Files
claude-code/src/services/acp/agent/sessionLifecycle.ts
claude-code-best 02d84bcab0 fix: listSessions 严格按 cwd 过滤并移除 session/load 过严校验
- listSessions: 客户端省略 cwd 时回退到 getOriginalCwd(),并对每个候选会话的
  存储 cwd 做 canonicalizePath 规范化后与请求 cwd 严格匹配,确保只返回真正属
  于当前工作区的会话(符合 session-list.mdx "Only sessions with a matching
  cwd are returned")
- sessionLifecycle: 移除 getOrCreateSession 中审计 2.2 添加的 cwd 一致性校验,
  它会拒绝 resolveSessionFilePath worktree fallback 找到的合法会话加载
- 补充 listSessions 的 5 个测试用例覆盖 cwd 透传/fallback/分页拒绝/无 cwd 过滤

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
2026-06-20 12:38:43 +08:00

267 lines
8.9 KiB
TypeScript

/**
* Session-lifecycle methods for AcpAgent (excluding createSession, which
* lives in ./createSessionMethod.ts), attached to the prototype via
* Object.assign. The barrel (./index.ts) imports this module for its side
* effect so the prototype is populated before any instance is built.
*
* Methods attached here: getOrCreateSession, teardownSession,
* replaySessionHistory, applySessionMode, updateConfigOption.
*/
import { type UUID } from 'node:crypto'
import { dirname } from 'node:path'
import type {
NewSessionRequest,
NewSessionResponse,
} from '@agentclientprotocol/sdk'
import type { Message } from '../../../types/message.js'
import { deserializeMessages } from '../../../utils/conversationRecovery.js'
import { getLastSessionLog } from '../../../utils/sessionStorage.js'
import type { PermissionMode } from '../../../types/permissions.js'
import { setOriginalCwd, switchSession } from '../../../bootstrap/state.js'
import type { SessionId } from '../../../types/ids.js'
import { replayHistoryMessages } from '../bridge.js'
import { computeSessionFingerprint } from '../utils.js'
import { resolveSessionFilePath } from '../../../utils/sessionStoragePortable.js'
import { AcpAgent } from './AcpAgent.js'
import type { AcpSession } from './sessionTypes.js'
import { isPermissionMode } from './permissionMode.js'
import {
getConnection,
readClientCapabilities,
syncSessionConfigState,
} from './internalAccessors.js'
// ── getOrCreateSession ───────────────────────────────────────────
async function getOrCreateSession(
this: AcpAgent,
params: {
sessionId: string
cwd: string
mcpServers?: NewSessionRequest['mcpServers']
_meta?: NewSessionRequest['_meta']
// replay:true (default, session/load) streams the conversation history back
// to the client via session/update. replay:false (session/resume) only
// restores the in-process context — per session-setup.mdx the Agent MUST
// NOT replay history when resuming.
replay?: boolean
},
): Promise<NewSessionResponse> {
const shouldReplay = params.replay !== false
const existingSession = this.sessions.get(params.sessionId)
if (existingSession) {
const fingerprint = computeSessionFingerprint({
cwd: params.cwd,
mcpServers: params.mcpServers as
| Array<{ name: string; [key: string]: unknown }>
| undefined,
})
if (fingerprint === existingSession.sessionFingerprint) {
const resolved = await resolveSessionFilePath(
params.sessionId,
params.cwd,
)
switchSession(
params.sessionId as SessionId,
resolved ? dirname(resolved.filePath) : null,
)
setOriginalCwd(params.cwd)
if (shouldReplay) {
await this.replaySessionHistory(params)
}
return {
sessionId: params.sessionId,
modes: existingSession.modes,
// Carry models over on reconnect so the client keeps its model selector
// populated (standard clients gate supportsModelSelection on this field).
models: existingSession.models,
configOptions: existingSession.configOptions,
}
}
await this.teardownSession(params.sessionId)
}
// Locate the session file by sessionId. resolveSessionFilePath searches
// the requested cwd's project dir first, then falls back to sibling git
// worktrees — sessions created inside a repo (including from subdirectories
// or ephemeral test envs nested in the repo) all persist under the same
// parent project dir.
const resolved = await resolveSessionFilePath(params.sessionId, params.cwd)
const projectDir = resolved ? dirname(resolved.filePath) : null
switchSession(params.sessionId as SessionId, projectDir)
setOriginalCwd(params.cwd)
let initialMessages: Message[] | undefined
if (resolved) {
try {
const log = await getLastSessionLog(params.sessionId as UUID)
if (log && log.messages.length > 0) {
initialMessages = deserializeMessages(log.messages)
}
} catch (err) {
console.error('[ACP] Failed to load session history:', err)
}
}
const response = await this.createSession(
{
cwd: params.cwd,
mcpServers: params.mcpServers ?? [],
_meta: params._meta,
},
{ sessionId: params.sessionId, initialMessages },
)
// Replay history to client if loaded. session/resume skips this block.
if (shouldReplay && initialMessages && initialMessages.length > 0) {
const session = this.sessions.get(params.sessionId)
if (session) {
await replayHistoryMessages(
params.sessionId,
initialMessages as unknown as Array<Record<string, unknown>>,
getConnection(this),
session.toolUseCache,
readClientCapabilities(this),
session.cwd,
)
}
}
return {
sessionId: response.sessionId,
modes: response.modes,
// createSession already returns models; pass it through. Same reason as above.
models: response.models,
configOptions: response.configOptions,
}
}
// ── teardownSession ──────────────────────────────────────────────
async function teardownSession(
this: AcpAgent,
sessionId: string,
): Promise<void> {
const session = this.sessions.get(sessionId)
if (!session) return
await this.cancel({ sessionId })
this.sessions.delete(sessionId)
}
// ── replaySessionHistory ─────────────────────────────────────────
/**
* Load session history from disk and replay it to the ACP client.
* Used when switching back to a session that is already in memory
* (the client needs the conversation replayed to display it).
*/
async function replaySessionHistory(
this: AcpAgent,
params: {
sessionId: string
cwd: string
},
): Promise<void> {
try {
const log = await getLastSessionLog(params.sessionId as UUID)
if (!log || log.messages.length === 0) return
const messages = deserializeMessages(log.messages)
if (messages.length === 0) return
const session = this.sessions.get(params.sessionId)
if (!session) return
await replayHistoryMessages(
params.sessionId,
messages as unknown as Array<Record<string, unknown>>,
getConnection(this),
session.toolUseCache,
readClientCapabilities(this),
session.cwd,
)
} catch (err) {
console.error('[ACP] Failed to replay session history:', err)
}
}
// ── applySessionMode ─────────────────────────────────────────────
function applySessionMode(
this: AcpAgent,
sessionId: string,
modeId: string,
): void {
if (!isPermissionMode(modeId)) {
throw new Error(`Invalid mode: ${modeId}`)
}
const session = this.sessions.get(sessionId)
if (session) {
if (
modeId === 'bypassPermissions' &&
!session.appState.toolPermissionContext.isBypassPermissionsModeAvailable
) {
throw new Error(`Mode not available: ${modeId}`)
}
const isAvailable = session.modes.availableModes.some(
mode => mode.id === modeId,
)
if (!isAvailable) {
throw new Error(`Mode not available: ${modeId}`)
}
session.modes = { ...session.modes, currentModeId: modeId }
// Sync mode to appState so the permission pipeline sees the correct mode
session.appState.toolPermissionContext = {
...session.appState.toolPermissionContext,
mode: modeId as PermissionMode,
}
}
}
// ── updateConfigOption ───────────────────────────────────────────
async function updateConfigOption(
this: AcpAgent,
sessionId: string,
configId: string,
value: string,
): Promise<void> {
const session = this.sessions.get(sessionId)
if (!session) return
// Delegate to the shell's private syncSessionConfigState via a typed cast.
// The shell declares syncSessionConfigState as a private method; it is not
// part of the merged public interface, so we access it through the shared
// internal accessor to preserve exact original behavior.
syncSessionConfigState(this, session, configId, value)
session.configOptions = session.configOptions.map(o =>
o.id === configId && typeof o.currentValue === 'string'
? { ...o, currentValue: value }
: o,
)
await getConnection(this).sessionUpdate({
sessionId,
update: {
sessionUpdate: 'config_option_update',
configOptions: session.configOptions,
},
})
}
// ── Prototype attachment ─────────────────────────────────────────
Object.assign(AcpAgent.prototype, {
getOrCreateSession,
teardownSession,
replaySessionHistory,
applySessionMode,
updateConfigOption,
})