Skip to content

Commit 02d84bc

Browse files
claude-code-bestglm-5.2
andcommitted
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>
1 parent 0f2eec4 commit 02d84bc

3 files changed

Lines changed: 96 additions & 28 deletions

File tree

src/services/acp/__tests__/agent.test.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,13 @@ mockModulePreservingExports('../../../utils/config.ts', {
7171

7272
const mockSwitchSession = mock(() => {})
7373

74+
const mockGetOriginalCwd = mock(() => '/current/working/dir')
7475
mockModulePreservingExports('../../../bootstrap/state.ts', {
7576
setOriginalCwd: mock(() => {}),
7677
switchSession: mockSwitchSession,
7778
addSlowOperation: mock(() => {}),
79+
getOriginalCwd: mockGetOriginalCwd,
80+
getSessionProjectDir: mock(() => null),
7881
})
7982

8083
const mockGetDefaultAppState = mock(() => ({
@@ -116,8 +119,9 @@ mockModulePreservingExports('../bridge.ts', {
116119
})),
117120
})
118121

122+
const mockListSessionsImpl = mock(async () => [])
119123
mockModulePreservingExports('../../../utils/listSessionsImpl.ts', {
120-
listSessionsImpl: mock(async () => []),
124+
listSessionsImpl: mockListSessionsImpl,
121125
})
122126

123127
const mockResolveSessionFilePath = mock(async () => ({
@@ -241,6 +245,10 @@ describe('AcpAgent', () => {
241245
mockGetDefaultAppState.mockClear()
242246
mockGetSettings.mockReset()
243247
mockGetSettings.mockImplementation(() => ({}))
248+
mockListSessionsImpl.mockReset()
249+
mockListSessionsImpl.mockImplementation(async () => [])
250+
mockGetOriginalCwd.mockReset()
251+
mockGetOriginalCwd.mockImplementation(() => '/current/working/dir')
244252
;(forwardSessionUpdates as ReturnType<typeof mock>).mockReset()
245253
;(forwardSessionUpdates as ReturnType<typeof mock>).mockImplementation(
246254
async () => ({ stopReason: 'end_turn' as const }),
@@ -1320,6 +1328,63 @@ describe('AcpAgent', () => {
13201328
})
13211329
})
13221330

1331+
describe('listSessions', () => {
1332+
test('passes params.cwd through to listSessionsImpl when provided', async () => {
1333+
const agent = new AcpAgent(makeConn())
1334+
await agent.listSessions({ cwd: '/explicit/path' } as any)
1335+
expect(mockListSessionsImpl).toHaveBeenCalledWith({
1336+
dir: '/explicit/path',
1337+
})
1338+
})
1339+
1340+
test('falls back to current working dir when client omits cwd', async () => {
1341+
// Standard clients (Goose, possibly others) call session/list with
1342+
// empty params. Without a fallback, listSessionsImpl treats undefined
1343+
// dir as "all projects" and returns every session on disk.
1344+
mockGetOriginalCwd.mockImplementation(() => '/active/project')
1345+
const agent = new AcpAgent(makeConn())
1346+
await agent.listSessions({} as any)
1347+
expect(mockListSessionsImpl).toHaveBeenCalledWith({
1348+
dir: '/active/project',
1349+
})
1350+
})
1351+
1352+
test('falls back to current working dir when client sends null cwd', async () => {
1353+
mockGetOriginalCwd.mockImplementation(() => '/active/project')
1354+
const agent = new AcpAgent(makeConn())
1355+
await agent.listSessions({ cwd: null } as any)
1356+
expect(mockListSessionsImpl).toHaveBeenCalledWith({
1357+
dir: '/active/project',
1358+
})
1359+
})
1360+
1361+
test('rejects client-supplied cursor (pagination not implemented)', async () => {
1362+
const agent = new AcpAgent(makeConn())
1363+
await expect(
1364+
agent.listSessions({ cursor: 'page2' } as any),
1365+
).rejects.toThrow(/Pagination cursor not supported/)
1366+
})
1367+
1368+
test('filters out candidates without a cwd field', async () => {
1369+
mockListSessionsImpl.mockImplementation(
1370+
async () =>
1371+
[
1372+
{
1373+
sessionId: 'with-cwd',
1374+
cwd: '/p',
1375+
summary: 'Has cwd',
1376+
lastModified: 0,
1377+
},
1378+
{ sessionId: 'no-cwd', summary: 'No cwd', lastModified: 0 },
1379+
] as any,
1380+
)
1381+
const agent = new AcpAgent(makeConn())
1382+
const res = await agent.listSessions({ cwd: '/p' } as any)
1383+
expect(res.sessions).toHaveLength(1)
1384+
expect(res.sessions[0].sessionId).toBe('with-cwd')
1385+
})
1386+
})
1387+
13231388
describe('sessionId alignment with global state', () => {
13241389
test('newSession calls switchSession with the generated sessionId', async () => {
13251390
const agent = new AcpAgent(makeConn())

src/services/acp/agent/AcpAgent.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,11 @@ import { unlink } from 'node:fs/promises'
4949
import type { Message } from '../../../types/message.js'
5050
import { sanitizeTitle } from '../utils.js'
5151
import { listSessionsImpl } from '../../../utils/listSessionsImpl.js'
52-
import { resolveSessionFilePath } from '../../../utils/sessionStoragePortable.js'
52+
import {
53+
resolveSessionFilePath,
54+
canonicalizePath,
55+
} from '../../../utils/sessionStoragePortable.js'
56+
import { getOriginalCwd } from '../../../bootstrap/state.js'
5357
import type { AcpSession } from './sessionTypes.js'
5458

5559
// ── Agent class ───────────────────────────────────────────────────
@@ -190,13 +194,31 @@ export class AcpAgent implements Agent {
190194
)
191195
}
192196

197+
// Resolve the effective cwd: client-provided wins, fall back to the
198+
// agent's current working directory (set by the most recent session/new
199+
// or session/load). Standard ACP clients (e.g. Goose) call session/list
200+
// with empty params and no cwd — without a fallback, listSessionsImpl
201+
// treats undefined dir as "all projects" and returns every session on
202+
// disk, which is unrelated to the workspace the user actually has open.
203+
const requestedCwd = params.cwd || getOriginalCwd()
204+
const canonicalRequested = await canonicalizePath(requestedCwd)
205+
193206
const candidates = await listSessionsImpl({
194-
dir: params.cwd ?? undefined,
207+
dir: requestedCwd,
195208
})
196209

197210
const sessions = []
198211
for (const candidate of candidates) {
199212
if (!candidate.cwd) continue
213+
// Per session-list.mdx: "Only sessions with a matching cwd are
214+
// returned." listSessionsImpl filters by which project directory
215+
// the file lives in, but a project directory can hold sessions
216+
// whose stored cwd points elsewhere (e.g. a session created in
217+
// env_A whose file ended up in the parent repo's project dir via
218+
// session/load's worktree fallback). Apply a strict canonical-cwd
219+
// filter so the list reflects what the spec promises.
220+
const canonicalCandidate = await canonicalizePath(candidate.cwd)
221+
if (canonicalCandidate !== canonicalRequested) continue
200222
// Only include title when non-empty; schema allows null/omitted title.
201223
const title = sanitizeTitle(candidate.summary ?? '')
202224
sessions.push({

src/services/acp/agent/sessionLifecycle.ts

Lines changed: 6 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
*/
1010
import { type UUID } from 'node:crypto'
1111
import { dirname } from 'node:path'
12-
import * as path from 'node:path'
1312
import type {
1413
NewSessionRequest,
1514
NewSessionResponse,
@@ -22,11 +21,7 @@ import { setOriginalCwd, switchSession } from '../../../bootstrap/state.js'
2221
import type { SessionId } from '../../../types/ids.js'
2322
import { replayHistoryMessages } from '../bridge.js'
2423
import { computeSessionFingerprint } from '../utils.js'
25-
import {
26-
resolveSessionFilePath,
27-
readSessionLite,
28-
extractJsonStringField,
29-
} from '../../../utils/sessionStoragePortable.js'
24+
import { resolveSessionFilePath } from '../../../utils/sessionStoragePortable.js'
3025
import { AcpAgent } from './AcpAgent.js'
3126
import type { AcpSession } from './sessionTypes.js'
3227
import { isPermissionMode } from './permissionMode.js'
@@ -89,28 +84,14 @@ async function getOrCreateSession(
8984
await this.teardownSession(params.sessionId)
9085
}
9186

92-
// Locate the session file by sessionId across all project directories.
93-
// params.cwd may not match the project directory where the session was
94-
// originally created (e.g. client sends a subdirectory path), so we
95-
// search by sessionId first and fall back to cwd-based lookup.
87+
// Locate the session file by sessionId. resolveSessionFilePath searches
88+
// the requested cwd's project dir first, then falls back to sibling git
89+
// worktrees — sessions created inside a repo (including from subdirectories
90+
// or ephemeral test envs nested in the repo) all persist under the same
91+
// parent project dir.
9692
const resolved = await resolveSessionFilePath(params.sessionId, params.cwd)
9793
const projectDir = resolved ? dirname(resolved.filePath) : null
9894

99-
// Per session-setup.mdx "Working Directory": the cwd MUST be the absolute
100-
// path used for the session regardless of where the Agent was spawned.
101-
// Reject cross-project loads where the persisted session's original cwd
102-
// does not match the requested cwd, otherwise the client could load a
103-
// session belonging to project B while passing project A's cwd.
104-
if (resolved) {
105-
const lite = await readSessionLite(resolved.filePath)
106-
const originalCwd = lite && extractJsonStringField(lite.head, 'cwd')
107-
if (originalCwd && path.resolve(originalCwd) !== path.resolve(params.cwd)) {
108-
throw new Error(
109-
`Session cwd mismatch: session belongs to ${originalCwd}, requested ${params.cwd}`,
110-
)
111-
}
112-
}
113-
11495
switchSession(params.sessionId as SessionId, projectDir)
11596
setOriginalCwd(params.cwd)
11697

0 commit comments

Comments
 (0)