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>
This commit is contained in:
claude-code-best
2026-06-20 12:09:11 +08:00
parent 0f2eec496c
commit 02d84bcab0
3 changed files with 96 additions and 28 deletions

View File

@@ -49,7 +49,11 @@ import { unlink } from 'node:fs/promises'
import type { Message } from '../../../types/message.js'
import { sanitizeTitle } from '../utils.js'
import { listSessionsImpl } from '../../../utils/listSessionsImpl.js'
import { resolveSessionFilePath } from '../../../utils/sessionStoragePortable.js'
import {
resolveSessionFilePath,
canonicalizePath,
} from '../../../utils/sessionStoragePortable.js'
import { getOriginalCwd } from '../../../bootstrap/state.js'
import type { AcpSession } from './sessionTypes.js'
// ── Agent class ───────────────────────────────────────────────────
@@ -190,13 +194,31 @@ export class AcpAgent implements Agent {
)
}
// Resolve the effective cwd: client-provided wins, fall back to the
// agent's current working directory (set by the most recent session/new
// or session/load). Standard ACP clients (e.g. Goose) call session/list
// with empty params and no cwd — without a fallback, listSessionsImpl
// treats undefined dir as "all projects" and returns every session on
// disk, which is unrelated to the workspace the user actually has open.
const requestedCwd = params.cwd || getOriginalCwd()
const canonicalRequested = await canonicalizePath(requestedCwd)
const candidates = await listSessionsImpl({
dir: params.cwd ?? undefined,
dir: requestedCwd,
})
const sessions = []
for (const candidate of candidates) {
if (!candidate.cwd) continue
// Per session-list.mdx: "Only sessions with a matching cwd are
// returned." listSessionsImpl filters by which project directory
// the file lives in, but a project directory can hold sessions
// whose stored cwd points elsewhere (e.g. a session created in
// env_A whose file ended up in the parent repo's project dir via
// session/load's worktree fallback). Apply a strict canonical-cwd
// filter so the list reflects what the spec promises.
const canonicalCandidate = await canonicalizePath(candidate.cwd)
if (canonicalCandidate !== canonicalRequested) continue
// Only include title when non-empty; schema allows null/omitted title.
const title = sanitizeTitle(candidate.summary ?? '')
sessions.push({

View File

@@ -9,7 +9,6 @@
*/
import { type UUID } from 'node:crypto'
import { dirname } from 'node:path'
import * as path from 'node:path'
import type {
NewSessionRequest,
NewSessionResponse,
@@ -22,11 +21,7 @@ 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,
readSessionLite,
extractJsonStringField,
} from '../../../utils/sessionStoragePortable.js'
import { resolveSessionFilePath } from '../../../utils/sessionStoragePortable.js'
import { AcpAgent } from './AcpAgent.js'
import type { AcpSession } from './sessionTypes.js'
import { isPermissionMode } from './permissionMode.js'
@@ -89,28 +84,14 @@ async function getOrCreateSession(
await this.teardownSession(params.sessionId)
}
// Locate the session file by sessionId across all project directories.
// params.cwd may not match the project directory where the session was
// originally created (e.g. client sends a subdirectory path), so we
// search by sessionId first and fall back to cwd-based lookup.
// 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
// Per session-setup.mdx "Working Directory": the cwd MUST be the absolute
// path used for the session regardless of where the Agent was spawned.
// Reject cross-project loads where the persisted session's original cwd
// does not match the requested cwd, otherwise the client could load a
// session belonging to project B while passing project A's cwd.
if (resolved) {
const lite = await readSessionLite(resolved.filePath)
const originalCwd = lite && extractJsonStringField(lite.head, 'cwd')
if (originalCwd && path.resolve(originalCwd) !== path.resolve(params.cwd)) {
throw new Error(
`Session cwd mismatch: session belongs to ${originalCwd}, requested ${params.cwd}`,
)
}
}
switchSession(params.sessionId as SessionId, projectDir)
setOriginalCwd(params.cwd)