From bb75ffde39f05b457bf9b24598c19dd6c8c70491 Mon Sep 17 00:00:00 2001 From: mb Date: Mon, 7 Sep 2026 09:10:51 +0200 Subject: [PATCH 01/10] fix(cli): resume older working-directory sessions in /sessions and --continue --- .changeset/session-workdir-resume-fix.md | 5 +++++ apps/kimi-code/src/cli/v2/run-v2-print.ts | 4 +++- .../src/app/sessionIndex/sessionIndexService.ts | 9 ++++++++- .../test/app/sessionIndex/sessionIndex.test.ts | 11 +++++++++++ 4 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 .changeset/session-workdir-resume-fix.md diff --git a/.changeset/session-workdir-resume-fix.md b/.changeset/session-workdir-resume-fix.md new file mode 100644 index 00000000000..95dfe8237ec --- /dev/null +++ b/.changeset/session-workdir-resume-fix.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Restore older working-directory sessions in `/sessions` and `kimi --continue`. diff --git a/apps/kimi-code/src/cli/v2/run-v2-print.ts b/apps/kimi-code/src/cli/v2/run-v2-print.ts index 876f1cdaed8..59bf515811c 100644 --- a/apps/kimi-code/src/cli/v2/run-v2-print.ts +++ b/apps/kimi-code/src/cli/v2/run-v2-print.ts @@ -447,7 +447,9 @@ async function resolveNativeSession( if (opts.continue) { const page = await index.listRecent({}); - const previous = page.items.find((summary) => summary.cwd === workDir); + const previous = page.items.find( + (summary) => summary.cwd !== undefined && resolve(summary.cwd) === resolve(workDir), + ); if (previous !== undefined) { const session = await resumeById(previous.id); const agentContext = await ensureMainAgent(session); diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts index aba8842511a..4033738e9c5 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts @@ -297,7 +297,14 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { async listRecent(query: SessionListQuery): Promise> { return this.withReadModel( - (generation) => this.listRecentFromReadModel(generation, query), + async (generation) => { + const page = await this.listRecentFromReadModel(generation, query); + if (page.items.length === 0 && query.workspaceIds !== undefined) { + const legacy = await this.listLegacy(query); + if (legacy.items.length > 0) return legacy; + } + return page; + }, () => this.listLegacy(query), ); } diff --git a/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts b/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts index a9bc6181d0f..5da0e2e4fe1 100644 --- a/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts +++ b/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts @@ -533,6 +533,17 @@ describe('FileSessionIndex (read model)', () => { expect(fileStorage.listCalls).toBe(0); }); + it('coalesces a workspace-scoped miss onto the authoritative directory', async () => { + const store = build(); + await store.prepare(); + store.stopReconcileLoop(); + await seedSession('legacy', { workDir: WORK_DIR, createdAt: 1, updatedAt: 2 }); + + const page = await store.listRecent({ workspaceIds: [workspaceId] }); + expect(page.items.map((s) => s.id)).toEqual(['legacy']); + expect(page.items[0]).toMatchObject({ cwd: WORK_DIR }); + }); + it('paginates exactly through same-millisecond ties', async () => { const specs: [string, number][] = [ ['a', 100], From 158ad79281427321ca0749bac8c7a0bdc0fbe8b0 Mon Sep 17 00:00:00 2001 From: mb <5196346+creatiVision@users.noreply.github.com> Date: Sat, 12 Sep 2026 17:50:38 +0200 Subject: [PATCH 02/10] fix(cli): canonicalize working directory path comparison and validate cached cwd --- apps/kimi-code/src/cli/v2/run-v2-print.ts | 26 +++++++++++++++++-- .../app/sessionIndex/sessionIndexService.ts | 4 ++- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/apps/kimi-code/src/cli/v2/run-v2-print.ts b/apps/kimi-code/src/cli/v2/run-v2-print.ts index 5cb957f1c72..3a62527ed90 100644 --- a/apps/kimi-code/src/cli/v2/run-v2-print.ts +++ b/apps/kimi-code/src/cli/v2/run-v2-print.ts @@ -14,6 +14,7 @@ * `exit` / `drain` / `steer`) before exiting. */ +import { realpathSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { @@ -418,12 +419,30 @@ async function resolveNativeSession( }; }; + function normalizePathForComparison(p: string): string { + let resolved: string; + try { + resolved = realpathSync.native(p); + } catch { + resolved = resolve(p); + } + return process.platform === 'win32' ? resolved.toLowerCase() : resolved; + } + + function isSamePath(a: string, b: string): boolean { + return normalizePathForComparison(a) === normalizePathForComparison(b); + } + if (opts.session !== undefined) { const target = await index.get(opts.session); if (target === undefined) { throw new Error(`Session "${opts.session}" not found.`); } - if (target.cwd !== undefined && resolve(target.cwd) !== resolve(workDir)) { + if ( + typeof target.cwd === 'string' && + target.cwd.length > 0 && + !isSamePath(target.cwd, workDir) + ) { stderr.write( `Session "${opts.session}" was created under a different directory.\n` + ` cd "${target.cwd}" && kimi -r ${opts.session}\n\n`, @@ -449,7 +468,10 @@ async function resolveNativeSession( if (opts.continue) { const page = await index.listRecent({}); const previous = page.items.find( - (summary) => summary.cwd !== undefined && resolve(summary.cwd) === resolve(workDir), + (summary) => + typeof summary.cwd === 'string' && + summary.cwd.length > 0 && + isSamePath(summary.cwd, workDir), ); if (previous !== undefined) { const session = await resumeById(previous.id); diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts index cc90841abf0..2e0b8f49ec9 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts @@ -69,7 +69,9 @@ function isSessionSummaryShape(value: unknown): value is SessionSummary { typeof summary['workspaceId'] === 'string' && typeof summary['createdAt'] === 'number' && typeof summary['updatedAt'] === 'number' && - typeof summary['archived'] === 'boolean' + typeof summary['archived'] === 'boolean' && + (summary['cwd'] === undefined || + (typeof summary['cwd'] === 'string' && summary['cwd'].length > 0)) ); } From 031aebb76acada9a59dd1a04ac2e194d672ee9d3 Mon Sep 17 00:00:00 2001 From: mb <5196346+creatiVision@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:51:18 +0200 Subject: [PATCH 03/10] revert: remove accidental edits to compactionHandoff and runner.test --- .../src/agent/contextMemory/compactionHandoff.ts | 4 ++-- .../agent-core-v2/test/features/externalHooks/runner.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts b/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts index 9cb73276b23..2b92220b2c8 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts @@ -336,9 +336,9 @@ function truncateTextToTokensFromEnd(text: string, maxTokens: number): string { let start = text.length; for (let i = text.length - 1; i >= 0; i--) { let isAscii = false; - const code = text.codePointAt(i); + const code = text.charCodeAt(i); if (code >= 0xdc00 && code <= 0xdfff && i > 0) { - const high = text.codePointAt(i - 1); + const high = text.charCodeAt(i - 1); if (high >= 0xd800 && high <= 0xdbff) { i--; } diff --git a/packages/agent-core-v2/test/features/externalHooks/runner.test.ts b/packages/agent-core-v2/test/features/externalHooks/runner.test.ts index 8b16890c90a..434225c4f27 100644 --- a/packages/agent-core-v2/test/features/externalHooks/runner.test.ts +++ b/packages/agent-core-v2/test/features/externalHooks/runner.test.ts @@ -7,7 +7,7 @@ import { HostProcessService } from '#/os/backends/node-local/hostProcessService' const hostProcess = new HostProcessService(); function nodeCommand(source: string): string { - return `node -e ${JSON.stringify(source.replaceAll(/\s*\n\s*/g, ' '))}`; + return `node -e ${JSON.stringify(source.replace(/\s*\n\s*/g, ' '))}`; } describe('runHook process runner', () => { From 44a85c9d3edcd33ad9f53348a21df719c40eefb2 Mon Sep 17 00:00:00 2001 From: creatiVision Date: Sun, 11 Oct 2026 20:22:59 +0200 Subject: [PATCH 04/10] fix(cli): optimize workDir comparison and preserve native casing in print continue --- apps/kimi-code/src/cli/v2/run-v2-print.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/apps/kimi-code/src/cli/v2/run-v2-print.ts b/apps/kimi-code/src/cli/v2/run-v2-print.ts index 1e857a9358c..0ecb0fc686a 100644 --- a/apps/kimi-code/src/cli/v2/run-v2-print.ts +++ b/apps/kimi-code/src/cli/v2/run-v2-print.ts @@ -419,17 +419,19 @@ async function resolveNativeSession( }; function normalizePathForComparison(p: string): string { - let resolved: string; try { - resolved = realpathSync.native(p); + return realpathSync.native(p); } catch { - resolved = resolve(p); + return resolve(p); } - return process.platform === 'win32' ? resolved.toLowerCase() : resolved; + } + + function compareNormalizedPaths(a: string, b: string): boolean { + return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b; } function isSamePath(a: string, b: string): boolean { - return normalizePathForComparison(a) === normalizePathForComparison(b); + return compareNormalizedPaths(normalizePathForComparison(a), normalizePathForComparison(b)); } if (opts.session !== undefined) { @@ -466,11 +468,12 @@ async function resolveNativeSession( if (opts.continue) { const page = await index.listRecent({}); + const normalizedWorkDir = normalizePathForComparison(workDir); const previous = page.items.find( (summary) => typeof summary.cwd === 'string' && summary.cwd.length > 0 && - isSamePath(summary.cwd, workDir), + compareNormalizedPaths(normalizePathForComparison(summary.cwd), normalizedWorkDir), ); if (previous !== undefined) { const session = await resumeById(previous.id); From 9bec1587d8faa09d0bede659ab196f623152c64f Mon Sep 17 00:00:00 2001 From: creatiVision Date: Sun, 11 Oct 2026 20:53:13 +0200 Subject: [PATCH 05/10] fix(session-index): prevent cursor pagination truncation on malformed cwd --- .../app/sessionIndex/sessionIndexService.ts | 23 +++++++++++++++---- .../app/sessionIndex/sessionIndex.test.ts | 14 +++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts index 0d5e3929c7f..56b840f95c9 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts @@ -576,11 +576,26 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { > { const id = query.before ?? query.after; if (id === undefined) return { filter: {}, bounds: {} }; + let u: number | undefined; const storedValue: unknown = await this.queryStore.get(sessionCollection(generation), id); - const stored = isSessionSummaryShape(storedValue) ? storedValue : undefined; - const cursor = stored ?? this.mirror.pending().find((summary) => summary.id === id); - if (cursor === undefined) return undefined; - const u = cursor.updatedAt; + if (isSessionSummaryShape(storedValue)) { + u = storedValue.updatedAt; + } else { + const summary = + this.mirror.pending().find((item) => item.id === id) ?? + (await this.getLegacy(id)); + if (summary !== undefined) { + this.mirror.record(summary); + u = summary.updatedAt; + } else if ( + storedValue !== null && + typeof storedValue === 'object' && + typeof (storedValue as Record)['updatedAt'] === 'number' + ) { + u = (storedValue as Record)['updatedAt'] as number; + } + } + if (u === undefined) return undefined; if (query.before !== undefined) { return { bounds: { lte: u }, diff --git a/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts b/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts index e4dab800be8..f137f69ac7c 100644 --- a/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts +++ b/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts @@ -762,6 +762,20 @@ describe('FileSessionIndex (read model)', () => { expect(unknown.items).toEqual([]); }); + it('resolves a cursor even when the cached row has a malformed cwd', async () => { + await seedSession('a', { createdAt: 1, updatedAt: 2 }); + await seedSession('b', { createdAt: 2, updatedAt: 4, custom: { cwd: '' } }); + const store = build(); + await store.prepare(); + + const rest = await store.listRecent({ + workspaceIds: [workspaceId], + limit: 5, + before: 'b', + }); + expect(rest.items.map((s) => s.id)).toEqual(['a']); + }); + it('remove evicts a queued mirror entry so a deleted session stays unlisted', async () => { await seedSession('a', { createdAt: 1, updatedAt: 2 }); const store = build(); From 8aeb4fe7c9185e7ae7d9078e783401e5ab16bf52 Mon Sep 17 00:00:00 2001 From: creatiVision Date: Sun, 11 Oct 2026 21:14:46 +0200 Subject: [PATCH 06/10] test(session-index): inject malformed top-level cwd into cached row in cursor test --- .../test/app/sessionIndex/sessionIndex.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts b/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts index f137f69ac7c..bfe2f9c3fe7 100644 --- a/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts +++ b/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts @@ -764,9 +764,18 @@ describe('FileSessionIndex (read model)', () => { it('resolves a cursor even when the cached row has a malformed cwd', async () => { await seedSession('a', { createdAt: 1, updatedAt: 2 }); - await seedSession('b', { createdAt: 2, updatedAt: 4, custom: { cwd: '' } }); + await seedSession('b', { createdAt: 2, updatedAt: 4 }); const store = build(); await store.prepare(); + await queryStore.put(sessionCollection(1), 'b', { + id: 'b', + workspaceId, + title: 'b', + createdAt: 2, + updatedAt: 4, + archived: false, + cwd: '', + }); const rest = await store.listRecent({ workspaceIds: [workspaceId], From 0bef45c0e1cfa0179d7345c22991323ee7417bf4 Mon Sep 17 00:00:00 2001 From: mb <5196346+creatiVision@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:43:42 +0200 Subject: [PATCH 07/10] fix(agent-core-v2): confine session index fallback scans to valid workspace buckets --- .../app/sessionIndex/sessionIndexService.ts | 34 ++++++++++++++++--- .../app/sessionIndex/sessionIndex.test.ts | 17 ++++++++++ 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts index 56b840f95c9..8cef854927e 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts @@ -1,3 +1,4 @@ +import { isAbsolute, relative } from 'node:path'; import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; @@ -677,7 +678,7 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { private async getLegacy(id: string): Promise { const hinted = await this.workspaceHintFor(id); - if (hinted !== undefined) { + if (hinted !== undefined && this.isSafeBucketId(hinted)) { const summary = await readSessionSummary(this.docs, this.sessionsScope, hinted, id); if (summary !== undefined) return summary; } @@ -741,6 +742,11 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { private async collectAuthoritative( workspaceIds: readonly string[] | undefined, ): Promise { + const safeWorkspaceIds = + workspaceIds === undefined ? undefined : workspaceIds.filter((id) => this.isSafeBucketId(id)); + if (workspaceIds !== undefined && safeWorkspaceIds!.length === 0) { + return []; + } let collected: SessionSummary[]; if ( this.readModelEnabled() && @@ -748,11 +754,15 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { ) { const { summaries } = await this.projector.sharedScanForRead(); collected = - workspaceIds === undefined + safeWorkspaceIds === undefined ? summaries - : summaries.filter((summary) => workspaceIds.includes(summary.workspaceId)); + : summaries.filter((summary) => safeWorkspaceIds.includes(summary.workspaceId)); } else { - const ids = workspaceIds ?? (await listWorkspaceIds(this.storage, this.sessionsScope)); + const knownIds = new Set(await listWorkspaceIds(this.storage, this.sessionsScope)); + const ids = + safeWorkspaceIds === undefined + ? [...knownIds] + : safeWorkspaceIds.filter((id) => knownIds.has(id)); collected = []; for (const workspaceId of ids) { for (const sessionId of await listSessionIds(this.storage, this.sessionsScope, workspaceId)) { @@ -765,12 +775,26 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { if (pending.length === 0) return collected; const byId = new Map(collected.map((summary) => [summary.id, summary])); for (const summary of pending) { - if (workspaceIds !== undefined && !workspaceIds.includes(summary.workspaceId)) continue; + if (safeWorkspaceIds !== undefined && !safeWorkspaceIds.includes(summary.workspaceId)) continue; byId.set(summary.id, summary); } return [...byId.values()]; } + private isSafeBucketId(id: string): boolean { + if (!id || id === '.' || id === '..' || id.includes('/') || id.includes('\\') || id.includes('\0')) { + return false; + } + const root = this.storage.pathFor(this.sessionsScope, ''); + if (root !== undefined) { + const target = this.storage.pathFor(this.sessionsScope, id); + if (target === undefined) return false; + const rel = relative(root, target); + if (rel.startsWith('..') || isAbsolute(rel) || rel !== id) return false; + } + return true; + } + private readModelEnabled(): boolean { return databaseBaseEnabled(this.config); } diff --git a/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts b/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts index bfe2f9c3fe7..d7c09bb9a23 100644 --- a/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts +++ b/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts @@ -594,6 +594,23 @@ describe('FileSessionIndex (read model)', () => { expect(page.items[0]).toMatchObject({ cwd: WORK_DIR }); }); + it('does not scan outside the storage root when workspaceIds contains path traversal', async () => { + const outsideMetaDir = join(homeDir, 'outside', 'evil', 'session-meta'); + await fsp.mkdir(outsideMetaDir, { recursive: true }); + await fsp.writeFile( + join(outsideMetaDir, 'state.json'), + JSON.stringify({ workDir: '/tmp', createdAt: 1, updatedAt: 2 }), + ); + + const store = build(); + await store.prepare(); + store.stopReconcileLoop(); + + const page = await store.listRecent({ workspaceIds: ['../../outside'] }); + expect(page.items).toEqual([]); + expect(await store.count({ workspaceIds: ['../../outside'] })).toBe(0); + }); + it('paginates exactly through same-millisecond ties', async () => { const specs: [string, number][] = [ ['a', 100], From a289bd8ce9e36e13d684262caa5cfb9d88c63a78 Mon Sep 17 00:00:00 2001 From: mb <5196346+creatiVision@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:44:21 +0200 Subject: [PATCH 08/10] fix(agent-core-v2): reject path traversal session IDs and preserve cached cursor timestamp --- .../app/sessionIndex/sessionIndexService.ts | 40 +++++++++++++++---- .../app/sessionIndex/sessionIndex.test.ts | 26 +++++++++++- 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts index 8cef854927e..9790710b2ba 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts @@ -342,6 +342,7 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { } async get(id: string): Promise { + if (!this.isSafeSessionId(id)) return undefined; return this.withReadModel( (generation) => this.getFromReadModel(generation, id), () => this.getLegacy(id), @@ -577,10 +578,26 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { > { const id = query.before ?? query.after; if (id === undefined) return { filter: {}, bounds: {} }; + if (!this.isSafeSessionId(id)) return undefined; let u: number | undefined; const storedValue: unknown = await this.queryStore.get(sessionCollection(generation), id); - if (isSessionSummaryShape(storedValue)) { - u = storedValue.updatedAt; + const storedUpdatedAt = + storedValue !== null && + typeof storedValue === 'object' && + typeof (storedValue as Record)['updatedAt'] === 'number' + ? ((storedValue as Record)['updatedAt'] as number) + : undefined; + + if (storedUpdatedAt !== undefined) { + u = storedUpdatedAt; + if (!isSessionSummaryShape(storedValue)) { + const summary = + this.mirror.pending().find((item) => item.id === id) ?? + (await this.getLegacy(id)); + if (summary !== undefined) { + this.mirror.record(summary); + } + } } else { const summary = this.mirror.pending().find((item) => item.id === id) ?? @@ -588,12 +605,6 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { if (summary !== undefined) { this.mirror.record(summary); u = summary.updatedAt; - } else if ( - storedValue !== null && - typeof storedValue === 'object' && - typeof (storedValue as Record)['updatedAt'] === 'number' - ) { - u = (storedValue as Record)['updatedAt'] as number; } } if (u === undefined) return undefined; @@ -643,6 +654,7 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { private async listLegacy(query: SessionListQuery): Promise> { if (query.sessionId !== undefined) { + if (!this.isSafeSessionId(query.sessionId)) return { items: [] }; const summary = await this.getLegacy(query.sessionId); const items = summary !== undefined && (!summary.archived || query.includeArchived === true) @@ -677,6 +689,7 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { } private async getLegacy(id: string): Promise { + if (!this.isSafeSessionId(id)) return undefined; const hinted = await this.workspaceHintFor(id); if (hinted !== undefined && this.isSafeBucketId(hinted)) { const summary = await readSessionSummary(this.docs, this.sessionsScope, hinted, id); @@ -795,6 +808,17 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { return true; } + private isSafeSessionId(id: string): boolean { + return ( + Boolean(id) && + id !== '.' && + id !== '..' && + !id.includes('/') && + !id.includes('\\') && + !id.includes('\0') + ); + } + private readModelEnabled(): boolean { return databaseBaseEnabled(this.config); } diff --git a/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts b/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts index d7c09bb9a23..bc9d9f4a6e2 100644 --- a/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts +++ b/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts @@ -781,7 +781,7 @@ describe('FileSessionIndex (read model)', () => { it('resolves a cursor even when the cached row has a malformed cwd', async () => { await seedSession('a', { createdAt: 1, updatedAt: 2 }); - await seedSession('b', { createdAt: 2, updatedAt: 4 }); + await seedSession('b', { createdAt: 2, updatedAt: 100 }); const store = build(); await store.prepare(); await queryStore.put(sessionCollection(1), 'b', { @@ -802,6 +802,30 @@ describe('FileSessionIndex (read model)', () => { expect(rest.items.map((s) => s.id)).toEqual(['a']); }); + it('rejects path traversal in cursor id and session id queries', async () => { + const outsideMetaDir = join(homeDir, 'outside', 'evil', 'session-meta'); + await fsp.mkdir(outsideMetaDir, { recursive: true }); + await fsp.writeFile( + join(outsideMetaDir, 'state.json'), + JSON.stringify({ workDir: '/tmp', createdAt: 1, updatedAt: 2 }), + ); + + const store = build(); + await store.prepare(); + + expect(await store.get('../../outside/evil')).toBeUndefined(); + const pageBefore = await store.listRecent({ + workspaceIds: [workspaceId], + before: '../../outside/evil', + }); + expect(pageBefore.items).toEqual([]); + const pageAfter = await store.listRecent({ + workspaceIds: [workspaceId], + after: '../../outside/evil', + }); + expect(pageAfter.items).toEqual([]); + }); + it('remove evicts a queued mirror entry so a deleted session stays unlisted', async () => { await seedSession('a', { createdAt: 1, updatedAt: 2 }); const store = build(); From 7cf47b71b24618bc50bc9bf885af7979aaf591e4 Mon Sep 17 00:00:00 2001 From: mb <5196346+creatiVision@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:27:28 +0100 Subject: [PATCH 09/10] fix(session): reject unsafe custom IDs before persistence and exclude healed cursor from after page --- .../src/app/sessionIndex/sessionIndex.ts | 11 ++++++ .../app/sessionIndex/sessionIndexService.ts | 21 ++++------- .../sessionManager/sessionManagerService.ts | 17 ++++++++- .../sessionLifecycleService.ts | 10 ++++++ .../app/sessionIndex/sessionIndex.test.ts | 36 +++++++++++++++++++ .../sessionManagerService.test.ts | 16 +++++++++ packages/node-sdk/src/sdk-rpc-client-v2.ts | 16 +++++++++ 7 files changed, 111 insertions(+), 16 deletions(-) diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts index 58a182a8ba4..bc681375ea5 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts @@ -70,3 +70,14 @@ export interface ISessionIndexMirror { export const ISessionIndexMirror: ServiceIdentifier = createDecorator('sessionIndexMirror'); + +export function isSafeSessionId(id: string): boolean { + return ( + Boolean(id) && + id !== '.' && + id !== '..' && + !id.includes('/') && + !id.includes('\\') && + !id.includes('\0') + ); +} diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts index 9790710b2ba..11434fa1140 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts @@ -31,6 +31,7 @@ import { ISessionIndex, ISessionIndexMirror, PARENT_SESSION_ID_KEY, + isSafeSessionId, type SessionCountQuery, type SessionIndexStatus, type SessionIndexState, @@ -342,7 +343,7 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { } async get(id: string): Promise { - if (!this.isSafeSessionId(id)) return undefined; + if (!isSafeSessionId(id)) return undefined; return this.withReadModel( (generation) => this.getFromReadModel(generation, id), () => this.getLegacy(id), @@ -546,6 +547,7 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { .pending() .filter( (summary) => + (position === undefined || summary.id !== position.id) && (query.workspaceIds === undefined || query.workspaceIds.includes(summary.workspaceId)) && (query.includeArchived === true || !summary.archived) && summaryMatchesChildOf(summary, query.childOf) && @@ -578,7 +580,7 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { > { const id = query.before ?? query.after; if (id === undefined) return { filter: {}, bounds: {} }; - if (!this.isSafeSessionId(id)) return undefined; + if (!isSafeSessionId(id)) return undefined; let u: number | undefined; const storedValue: unknown = await this.queryStore.get(sessionCollection(generation), id); const storedUpdatedAt = @@ -654,7 +656,7 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { private async listLegacy(query: SessionListQuery): Promise> { if (query.sessionId !== undefined) { - if (!this.isSafeSessionId(query.sessionId)) return { items: [] }; + if (!isSafeSessionId(query.sessionId)) return { items: [] }; const summary = await this.getLegacy(query.sessionId); const items = summary !== undefined && (!summary.archived || query.includeArchived === true) @@ -689,7 +691,7 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { } private async getLegacy(id: string): Promise { - if (!this.isSafeSessionId(id)) return undefined; + if (!isSafeSessionId(id)) return undefined; const hinted = await this.workspaceHintFor(id); if (hinted !== undefined && this.isSafeBucketId(hinted)) { const summary = await readSessionSummary(this.docs, this.sessionsScope, hinted, id); @@ -808,17 +810,6 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { return true; } - private isSafeSessionId(id: string): boolean { - return ( - Boolean(id) && - id !== '.' && - id !== '..' && - !id.includes('/') && - !id.includes('\\') && - !id.includes('\0') - ); - } - private readModelEnabled(): boolean { return databaseBaseEnabled(this.config); } diff --git a/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts b/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts index 3b10f3a7c3b..17b61a7ddfd 100644 --- a/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts +++ b/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts @@ -4,7 +4,7 @@ import { Emitter, type Event, type IWaitUntil } from '#/_base/event'; import { ScopeActivation, registerScopedService, type ISessionScopeHandle } from '#/_base/di/scope'; import { LifecycleScope } from '#/app/scopes'; import { Error2, ErrorCodes, isError2, SessionErrors } from '#/errors'; -import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex/sessionIndex'; +import { ISessionIndex, isSafeSessionId, type SessionSummary } from '#/app/sessionIndex/sessionIndex'; import { IAgentCronService } from '#/features/cron/cronService'; import type { SessionMeta } from '#/session/sessionMetadata/sessionMetadata'; import { ISessionLockService, type SessionLockLease } from '#/persistence/interface/sessionLock'; @@ -69,6 +69,9 @@ export class SessionManager implements ISessionManager { ) {} async create(options: CreateManagedSessionOptions): Promise { + if (options.sessionId !== undefined && !isSafeSessionId(options.sessionId)) { + throw new Error2(ErrorCodes.REQUEST_INVALID, `Invalid session id "${options.sessionId}"`); + } const workspace = await this.workspaces.getOrCreate( options.workspaceId === undefined ? { root: options.workDir } @@ -236,6 +239,12 @@ export class SessionManager implements ISessionManager { } async fork(options: ForkSessionOptions): Promise { + if (!isSafeSessionId(options.sourceSessionId)) { + throw new Error2(ErrorCodes.REQUEST_INVALID, `Invalid session id "${options.sourceSessionId}"`); + } + if (options.newSessionId !== undefined && !isSafeSessionId(options.newSessionId)) { + throw new Error2(ErrorCodes.REQUEST_INVALID, `Invalid session id "${options.newSessionId}"`); + } return this.serializeLifecycleForKeys( this.lifecycleKeys(options.sourceSessionId, options.newSessionId), async () => { @@ -252,6 +261,12 @@ export class SessionManager implements ISessionManager { } async createChild(options: CreateChildSessionOptions): Promise { + if (!isSafeSessionId(options.sourceSessionId)) { + throw new Error2(ErrorCodes.REQUEST_INVALID, `Invalid session id "${options.sourceSessionId}"`); + } + if (options.newSessionId !== undefined && !isSafeSessionId(options.newSessionId)) { + throw new Error2(ErrorCodes.REQUEST_INVALID, `Invalid session id "${options.newSessionId}"`); + } return this.serializeLifecycleForKeys( this.lifecycleKeys(options.sourceSessionId, options.newSessionId), async () => { diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts index 7a89eee2fb5..24535f8a3c7 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts @@ -31,6 +31,7 @@ import { CHILD_SESSION_KIND, ISessionIndex, ISessionIndexMirror, PARENT_SESSION_ID_KEY, + isSafeSessionId, } from '#/app/sessionIndex/sessionIndex'; import { buildSessionSummary } from '#/app/sessionIndex/sessionIndexSource'; import type { SessionEndedEvent } from '#/app/telemetry/events'; @@ -209,6 +210,9 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec } async create(opts: CreateSessionOptions): Promise { + if (opts.sessionId !== undefined && !isSafeSessionId(opts.sessionId)) { + throw new Error2(ErrorCodes.REQUEST_INVALID, `Invalid session id "${opts.sessionId}"`); + } const sessionId = opts.sessionId ?? createSessionId(); await this.workspaceSkillCatalog .reloadSources(SESSION_CREATE_RELOAD_SKILL_SOURCES) @@ -523,6 +527,12 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec async fork(opts: ForkSessionOptions): Promise { const sourceId = opts.sourceSessionId; + if (!isSafeSessionId(sourceId)) { + throw new Error2(ErrorCodes.REQUEST_INVALID, `Invalid session id "${sourceId}"`); + } + if (opts.newSessionId !== undefined && !isSafeSessionId(opts.newSessionId)) { + throw new Error2(ErrorCodes.REQUEST_INVALID, `Invalid session id "${opts.newSessionId}"`); + } const sourceHandle = this.sessions.get(sourceId); const indexSummary = await this.index.get(sourceId); diff --git a/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts b/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts index bc9d9f4a6e2..976dc2e7b19 100644 --- a/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts +++ b/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts @@ -20,6 +20,7 @@ import { ITelemetryService, noopTelemetryService } from '#/app/telemetry/telemet import { ISessionIndex, ISessionIndexMirror, + isSafeSessionId, type SessionSummary, } from '#/app/sessionIndex/sessionIndex'; import { @@ -802,6 +803,41 @@ describe('FileSessionIndex (read model)', () => { expect(rest.items.map((s) => s.id)).toEqual(['a']); }); + it('excludes healed cursors from their own after page', async () => { + await seedSession('a', { createdAt: 1, updatedAt: 2 }); + await seedSession('b', { createdAt: 2, updatedAt: 100 }); + await seedSession('c', { createdAt: 3, updatedAt: 200 }); + const store = build(); + await store.prepare(); + await queryStore.put(sessionCollection(1), 'b', { + id: 'b', + workspaceId, + title: 'b', + createdAt: 2, + updatedAt: 4, + archived: false, + cwd: '', + }); + + const page = await store.listRecent({ + workspaceIds: [workspaceId], + limit: 5, + after: 'b', + }); + expect(page.items.map((s) => s.id)).toEqual(['c']); + expect(page.items.find((s) => s.id === 'b')).toBeUndefined(); + }); + + it('validates safe session ids', () => { + expect(isSafeSessionId('session-123')).toBe(true); + expect(isSafeSessionId('')).toBe(false); + expect(isSafeSessionId('.')).toBe(false); + expect(isSafeSessionId('..')).toBe(false); + expect(isSafeSessionId('a/b')).toBe(false); + expect(isSafeSessionId('a\\b')).toBe(false); + expect(isSafeSessionId('a\0b')).toBe(false); + }); + it('rejects path traversal in cursor id and session id queries', async () => { const outsideMetaDir = join(homeDir, 'outside', 'evil', 'session-meta'); await fsp.mkdir(outsideMetaDir, { recursive: true }); diff --git a/packages/agent-core-v2/test/app/sessionManager/sessionManagerService.test.ts b/packages/agent-core-v2/test/app/sessionManager/sessionManagerService.test.ts index 3c35dbaf6c1..0352afe6416 100644 --- a/packages/agent-core-v2/test/app/sessionManager/sessionManagerService.test.ts +++ b/packages/agent-core-v2/test/app/sessionManager/sessionManagerService.test.ts @@ -648,6 +648,22 @@ describe('SessionManager closeIfIdle', () => { expect(fake.manager.get('session-1')).toBeDefined(); }); + it('rejects unsafe custom IDs on create and fork before persistence', async () => { + const fake = setup(); + await expect(fake.manager.create({ sessionId: 'evil/id', workDir: '/tmp' })).rejects.toThrow( + /Invalid session id/, + ); + await expect(fake.manager.create({ sessionId: '..', workDir: '/tmp' })).rejects.toThrow( + /Invalid session id/, + ); + await expect( + fake.manager.fork({ sourceSessionId: 'session-1', newSessionId: 'evil/id' }), + ).rejects.toThrow(/Invalid session id/); + await expect( + fake.manager.fork({ sourceSessionId: 'evil/source', newSessionId: 'session-2' }), + ).rejects.toThrow(/Invalid session id/); + }); + it('serializes a reclaim with a racing resume', async () => { const fake = setup(); await create(fake); diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index 4051b61d054..ab63dc1bbf6 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -143,6 +143,7 @@ import { drainQueryStoreDisposals, drainSessionIndexMirror, ensureKimiHome, + isSafeSessionId, ensureMainAgent, getOrRestoreAgent, reloadOnStale, @@ -1384,6 +1385,12 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { private async doCreateSession(input: CreateSessionOptions): Promise { const workDir = normalizeRequiredWorkDir('createSession', input.workDir); if (input.id !== undefined) { + if (!isSafeSessionId(input.id)) { + throw new KimiError( + ErrorCodes.REQUEST_INVALID, + `Invalid session id "${input.id}"`, + ); + } const existing = this.liveSession(input.id) ?? (await this.engineAccessor.get(ISessionIndex).get(input.id)); @@ -1471,6 +1478,15 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { * identical results. */ override async forkSession(input: ForkSessionInput): Promise { + if (!isSafeSessionId(input.id)) { + throw SDKRpcClientV2.sessionNotFound(input.id); + } + if (input.forkId !== undefined && !isSafeSessionId(input.forkId)) { + throw new KimiError( + ErrorCodes.REQUEST_INVALID, + `Invalid session id "${input.forkId}"`, + ); + } // The source session's reads (metadata, wire flush) stay atomic against // its close/reload through the per-session queue; an explicit target id // takes a second (sorted) queue so fork(A→X) is also atomic against From b1b869e817b4154a87df9e229a58c7a66104a552 Mon Sep 17 00:00:00 2001 From: mb <5196346+creatiVision@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:05:44 +0100 Subject: [PATCH 10/10] fix(session): skip legacy scan for invalid or absent cursors and fix traversal fixture --- .../app/sessionIndex/sessionIndexService.ts | 8 ++- .../app/sessionIndex/sessionIndex.test.ts | 66 +++++++++++++------ 2 files changed, 53 insertions(+), 21 deletions(-) diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts index 11434fa1140..1e7e6733997 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts @@ -355,6 +355,12 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { async (generation) => { const page = await this.listRecentFromReadModel(generation, query); if (page.items.length === 0 && query.workspaceIds !== undefined) { + const cursorId = query.before ?? query.after; + if (cursorId !== undefined) { + if (!isSafeSessionId(cursorId)) return page; + const cursor = await this.get(cursorId); + if (cursor === undefined) return page; + } const legacy = await this.listLegacy(query); if (legacy.items.length > 0) return legacy; } @@ -558,10 +564,10 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { : summary.updatedAt > position.u || (summary.updatedAt === position.u && summary.id > position.id))), ); - if (pending.length === 0) return page; const merged = new Map(); for (const summary of pending) merged.set(summary.id, summary); for (const summary of page.items) { + if (position !== undefined && summary.id === position.id) continue; if (!merged.has(summary.id)) merged.set(summary.id, summary); } const items = [...merged.values()].toSorted(canonicalOrder); diff --git a/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts b/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts index 976dc2e7b19..b9ce0104a5f 100644 --- a/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts +++ b/packages/agent-core-v2/test/app/sessionIndex/sessionIndex.test.ts @@ -607,9 +607,25 @@ describe('FileSessionIndex (read model)', () => { await store.prepare(); store.stopReconcileLoop(); - const page = await store.listRecent({ workspaceIds: ['../../outside'] }); + const page = await store.listRecent({ workspaceIds: ['../outside'] }); expect(page.items).toEqual([]); - expect(await store.count({ workspaceIds: ['../../outside'] })).toBe(0); + expect(await store.count({ workspaceIds: ['../outside'] })).toBe(0); + + const rawStore = build(); + expect(await rawStore.count({ workspaceIds: ['../outside'] })).toBe(0); + expect((await rawStore.listRecent({ workspaceIds: ['../outside'] })).items).toEqual([]); + }); + + it('skips fallback when cursor is invalid or absent', async () => { + const store = build(); + await store.prepare(); + store.stopReconcileLoop(); + + const unsafePage = await store.listRecent({ workspaceIds: [workspaceId], before: '../../outside' }); + expect(unsafePage.items).toEqual([]); + + const missingPage = await store.listRecent({ workspaceIds: [workspaceId], before: 'non-existent' }); + expect(missingPage.items).toEqual([]); }); it('paginates exactly through same-millisecond ties', async () => { @@ -785,15 +801,20 @@ describe('FileSessionIndex (read model)', () => { await seedSession('b', { createdAt: 2, updatedAt: 100 }); const store = build(); await store.prepare(); - await queryStore.put(sessionCollection(1), 'b', { - id: 'b', - workspaceId, - title: 'b', - createdAt: 2, - updatedAt: 4, - archived: false, - cwd: '', - }); + await queryStore.put( + sessionCollection(1), + 'b', + { + id: 'b', + workspaceId, + title: 'b', + createdAt: 2, + updatedAt: 4, + archived: false, + cwd: '', + }, + { columns: { [recencyColumn(1)]: 4 } }, + ); const rest = await store.listRecent({ workspaceIds: [workspaceId], @@ -809,15 +830,20 @@ describe('FileSessionIndex (read model)', () => { await seedSession('c', { createdAt: 3, updatedAt: 200 }); const store = build(); await store.prepare(); - await queryStore.put(sessionCollection(1), 'b', { - id: 'b', - workspaceId, - title: 'b', - createdAt: 2, - updatedAt: 4, - archived: false, - cwd: '', - }); + await queryStore.put( + sessionCollection(1), + 'b', + { + id: 'b', + workspaceId, + title: 'b', + createdAt: 2, + updatedAt: 4, + archived: false, + cwd: '', + }, + { columns: { [recencyColumn(1)]: 4 } }, + ); const page = await store.listRecent({ workspaceIds: [workspaceId],