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 f36ab6aa064..7d909543d67 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 { @@ -456,12 +457,32 @@ async function resolveNativeSession( return previous; }; + function normalizePathForComparison(p: string): string { + try { + return realpathSync.native(p); + } catch { + return resolve(p); + } + } + + 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 compareNormalizedPaths(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`, @@ -487,7 +508,13 @@ async function resolveNativeSession( if (opts.continue) { const page = await index.listRecent({}); - const previous = page.items.find((summary) => summary.cwd === workDir); + const normalizedWorkDir = normalizePathForComparison(workDir); + const previous = page.items.find( + (summary) => + typeof summary.cwd === 'string' && + summary.cwd.length > 0 && + compareNormalizedPaths(normalizePathForComparison(summary.cwd), normalizedWorkDir), + ); if (previous !== undefined) { const session = await resumeById(previous.id); const agentContext = await ensureMainAgent(session); 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 dad84d6cb26..1e7e6733997 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'; @@ -30,6 +31,7 @@ import { ISessionIndex, ISessionIndexMirror, PARENT_SESSION_ID_KEY, + isSafeSessionId, type SessionCountQuery, type SessionIndexStatus, type SessionIndexState, @@ -73,7 +75,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)) ); } @@ -339,6 +343,7 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { } async get(id: string): Promise { + if (!isSafeSessionId(id)) return undefined; return this.withReadModel( (generation) => this.getFromReadModel(generation, id), () => this.getLegacy(id), @@ -347,7 +352,20 @@ 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 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; + } + return page; + }, () => this.listLegacy(query), ); } @@ -535,6 +553,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) && @@ -545,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); @@ -567,11 +586,36 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { > { const id = query.before ?? query.after; if (id === undefined) return { filter: {}, bounds: {} }; + if (!isSafeSessionId(id)) return undefined; + 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; + 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) ?? + (await this.getLegacy(id)); + if (summary !== undefined) { + this.mirror.record(summary); + u = summary.updatedAt; + } + } + if (u === undefined) return undefined; if (query.before !== undefined) { return { bounds: { lte: u }, @@ -618,6 +662,7 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { private async listLegacy(query: SessionListQuery): Promise> { if (query.sessionId !== undefined) { + if (!isSafeSessionId(query.sessionId)) return { items: [] }; const summary = await this.getLegacy(query.sessionId); const items = summary !== undefined && (!summary.archived || query.includeArchived === true) @@ -652,8 +697,9 @@ export class FileSessionIndex extends Disposable implements ISessionIndex { } private async getLegacy(id: string): Promise { + if (!isSafeSessionId(id)) return undefined; 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; } @@ -717,6 +763,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() && @@ -724,11 +775,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)) { @@ -741,12 +796,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/src/app/sessionManager/sessionManagerService.ts b/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts index add4f842d8b..3bd629d15bc 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'; @@ -73,6 +73,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 create = async () => { const entry = await this.controllerForWorkspace( options.workspaceId === undefined @@ -260,6 +263,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 () => { @@ -276,6 +285,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 9faec31f72e..b9ce0104a5f 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 { @@ -583,6 +584,50 @@ 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('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); + + 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 () => { const specs: [string, number][] = [ ['a', 100], @@ -751,6 +796,98 @@ 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: 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: '', + }, + { columns: { [recencyColumn(1)]: 4 } }, + ); + + const rest = await store.listRecent({ + workspaceIds: [workspaceId], + limit: 5, + before: 'b', + }); + 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: '', + }, + { columns: { [recencyColumn(1)]: 4 } }, + ); + + 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 }); + 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(); 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 4a80b5cee44..52424265afc 100644 --- a/packages/agent-core-v2/test/app/sessionManager/sessionManagerService.test.ts +++ b/packages/agent-core-v2/test/app/sessionManager/sessionManagerService.test.ts @@ -642,6 +642,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/kap-server/src/routes/fs.ts b/packages/kap-server/src/routes/fs.ts index 11618713edf..798b41520c9 100644 --- a/packages/kap-server/src/routes/fs.ts +++ b/packages/kap-server/src/routes/fs.ts @@ -421,8 +421,8 @@ export function registerFsRoutes(app: FsRouteHost, core: Scope): void { await handleReveal(runtimeFs.fs, req, reply); return; } - } catch (err) { - sendMappedError(reply, req, err); + } catch (error) { + sendMappedError(reply, req, error); } finally { if (runtimeFs !== undefined) releaseRuntimeFs(runtimeFs); } @@ -467,8 +467,8 @@ export function registerFsRoutes(app: FsRouteHost, core: Scope): void { } const data = await runtimeFs.fs.search(searchRequest); reply.send(okEnvelope(data, req.id)); - } catch (err) { - sendMappedError(reply, req, err); + } catch (error) { + sendMappedError(reply, req, error); } finally { if (runtimeFs !== undefined) releaseRuntimeFs(runtimeFs); } @@ -512,8 +512,8 @@ export function registerFsRoutes(app: FsRouteHost, core: Scope): void { } const data = await runtimeFs.fs.suggest(suggestRequest); reply.send(okEnvelope(data, req.id)); - } catch (err) { - sendMappedError(reply, req, err); + } catch (error) { + sendMappedError(reply, req, error); } finally { if (runtimeFs !== undefined) releaseRuntimeFs(runtimeFs); } @@ -576,15 +576,15 @@ export function registerFsRoutes(app: FsRouteHost, core: Scope): void { } const data = await runtimeFs.fs.suggest(suggestRequest); reply.send(okEnvelope(data, req.id)); - } catch (err) { - if (err instanceof RuntimeError) { - const code = err.code === 'runtime.not_found' + } catch (error) { + if (error instanceof RuntimeError) { + const code = error.code === 'runtime.not_found' ? ErrorCode.RUNTIME_NOT_FOUND : ErrorCode.RUNTIME_UNAVAILABLE; - reply.send(errEnvelope(code, err.message, req.id)); + reply.send(errEnvelope(code, error.message, req.id)); return; } - sendMappedError(reply, req, err); + sendMappedError(reply, req, error); } finally { if (runtimeFs !== undefined) releaseRuntimeFs(runtimeFs); } @@ -644,9 +644,9 @@ export function registerFsRoutes(app: FsRouteHost, core: Scope): void { try { runtimeFs = await acquireSessionFs(core, session_id, req.query.runtime_id ?? 'local', ['fs']); resolved = await runtimeFs.fs.resolveDownload(relPath); - } catch (err) { + } catch (error) { if (runtimeFs !== undefined) releaseRuntimeFs(runtimeFs); - sendMappedError(reply, req, err); + sendMappedError(reply, req, error); return; } @@ -893,15 +893,15 @@ async function handleOpenIn(fs: IWorkspaceFsService, sessionId: string, req: Req isDirectory: resolved.isDirectory, }), ); - } catch (err) { + } catch (error) { requestLog(req)?.warn( - { session_id: sessionId, app_id: body.app_id, err }, + { session_id: sessionId, app_id: body.app_id, error }, 'fs open-in launch failed', ); reply.send( errEnvelope( ErrorCode.INTERNAL_ERROR, - `failed to open in ${body.app_id}: ${err instanceof Error ? err.message : String(err)}`, + `failed to open in ${body.app_id}: ${error instanceof Error ? error.message : String(error)}`, req.id, ), ); @@ -1003,6 +1003,6 @@ function buildValidationEnvelope( function sanitizeFilename(rel: string): string { const segs = rel.split('/'); - const base = segs[segs.length - 1] ?? rel; - return base.replace(/"/g, '\\"'); + const base = segs.at(-1) ?? rel; + return base.replaceAll(/"/g, '\\"'); } diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index 3f2d2c43f5f..56b88b2cba7 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, @@ -1400,6 +1401,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)); @@ -1487,6 +1494,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