From 57eb6531f977d98d7317f541f5b1fad9da5e9970 Mon Sep 17 00:00:00 2001 From: MarkXian Date: Tue, 28 Jul 2026 10:24:39 +0800 Subject: [PATCH] fix: code workspace path escape errors --- .changeset/clear-terminals-workspace-error.md | 5 ++ packages/agent-core-v2/src/errors.ts | 3 + .../src/session/workspaceContext/errors.ts | 13 ++++ .../workspaceContextService.ts | 5 +- .../workspaceContextService.test.ts | 75 +++++++++++++++++++ packages/kap-server/src/routes/terminals.ts | 11 +-- packages/kap-server/test/terminals.test.ts | 1 + 7 files changed, 104 insertions(+), 9 deletions(-) create mode 100644 .changeset/clear-terminals-workspace-error.md create mode 100644 packages/agent-core-v2/src/session/workspaceContext/errors.ts create mode 100644 packages/agent-core-v2/test/session/workspaceContext/workspaceContextService.test.ts diff --git a/.changeset/clear-terminals-workspace-error.md b/.changeset/clear-terminals-workspace-error.md new file mode 100644 index 00000000000..83be5746b4b --- /dev/null +++ b/.changeset/clear-terminals-workspace-error.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix terminal path-escape errors so the CLI reports a consistent workspace message. diff --git a/packages/agent-core-v2/src/errors.ts b/packages/agent-core-v2/src/errors.ts index a3907352774..541b40fd9b8 100644 --- a/packages/agent-core-v2/src/errors.ts +++ b/packages/agent-core-v2/src/errors.ts @@ -34,6 +34,7 @@ import { StorageErrors } from '#/persistence/interface/storage'; import { TerminalErrors } from '#/os/interface/terminalErrors'; import { UsageErrors } from '#/agent/usage/errors'; import { WireErrors } from '#/wire/errors'; +import { WorkspaceContextErrors } from '#/session/workspaceContext/errors'; import { WorkspaceErrors } from '#/app/workspace/errors'; export * from '#/_base/errors/codes'; @@ -67,6 +68,7 @@ export { StorageErrors } from '#/persistence/interface/storage'; export { TerminalErrors } from '#/os/interface/terminalErrors'; export { UsageErrors } from '#/agent/usage/errors'; export { WireErrors } from '#/wire/errors'; +export { WorkspaceContextErrors } from '#/session/workspaceContext/errors'; export { WorkspaceErrors } from '#/app/workspace/errors'; export const ErrorCodes = { @@ -97,6 +99,7 @@ export const ErrorCodes = { ...TerminalErrors.codes, ...UsageErrors.codes, ...WireErrors.codes, + ...WorkspaceContextErrors.codes, ...WorkspaceErrors.codes, } as const; diff --git a/packages/agent-core-v2/src/session/workspaceContext/errors.ts b/packages/agent-core-v2/src/session/workspaceContext/errors.ts new file mode 100644 index 00000000000..ca0891f7f48 --- /dev/null +++ b/packages/agent-core-v2/src/session/workspaceContext/errors.ts @@ -0,0 +1,13 @@ +/** + * `workspaceContext` domain error codes. + */ + +import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; + +export const WorkspaceContextErrors = { + codes: { + WORKSPACE_CONTEXT_PATH_ESCAPES: 'workspace_context.path_escapes', + }, +} as const satisfies ErrorDomain; + +registerErrorDomain(WorkspaceContextErrors); diff --git a/packages/agent-core-v2/src/session/workspaceContext/workspaceContextService.ts b/packages/agent-core-v2/src/session/workspaceContext/workspaceContextService.ts index 4ca6b418dea..2e63f974292 100644 --- a/packages/agent-core-v2/src/session/workspaceContext/workspaceContextService.ts +++ b/packages/agent-core-v2/src/session/workspaceContext/workspaceContextService.ts @@ -12,6 +12,7 @@ import { isAbsolute, relative, resolve } from 'node:path'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/_base/state/stateRegistry'; +import { Error2, ErrorCodes } from '#/errors'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionStateService } from '#/session/state/sessionState'; @@ -85,7 +86,9 @@ export class SessionWorkspaceContextService implements ISessionWorkspaceContext assertAllowed(absPath: string, op: PathAccessOperation): string { const target = this.resolve(absPath); if (!this.isWithin(target)) { - throw new Error(`Path outside workspace (${op}): ${target}`); + throw new Error2(ErrorCodes.WORKSPACE_CONTEXT_PATH_ESCAPES, 'Path outside workspace', { + details: { path: target, op }, + }); } return target; } diff --git a/packages/agent-core-v2/test/session/workspaceContext/workspaceContextService.test.ts b/packages/agent-core-v2/test/session/workspaceContext/workspaceContextService.test.ts new file mode 100644 index 00000000000..a7976001af0 --- /dev/null +++ b/packages/agent-core-v2/test/session/workspaceContext/workspaceContextService.test.ts @@ -0,0 +1,75 @@ +import { isAbsolute, resolve } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { DisposableStore } from '#/_base/di/lifecycle'; +import { createServices, type TestInstantiationService } from '#/_base/di/test'; +import { ErrorCodes, Error2 } from '#/errors'; +import { makeSessionContext, ISessionContext } from '#/session/sessionContext/sessionContext'; +import { ISessionStateService } from '#/session/state/sessionState'; +import { SessionStateService } from '#/session/state/sessionStateService'; +import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import { SessionWorkspaceContextService } from '#/session/workspaceContext/workspaceContextService'; + +function stubSessionContext(cwd: string = '/repo'): ISessionContext { + return makeSessionContext({ + sessionId: 's1', + workspaceId: 'w1', + sessionDir: '/repo/.session', + sessionScope: 'session:s1', + cwd, + }); +} + +describe('SessionWorkspaceContextService', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + + beforeEach(() => { + disposables = new DisposableStore(); + ix = createServices(disposables, { + additionalServices: (reg) => { + reg.defineInstance(ISessionContext, stubSessionContext()); + reg.defineInstance(ISessionStateService, new SessionStateService()); + reg.define(ISessionWorkspaceContext, SessionWorkspaceContextService); + }, + }); + }); + + afterEach(() => disposables.dispose()); + + it('resolves relative paths against the session work dir', () => { + const workspace = ix.get(ISessionWorkspaceContext); + + expect(workspace.resolve('src/index.ts')).toBe(resolve('/repo/src/index.ts')); + expect(workspace.assertAllowed('src/index.ts', 'read')).toBe(resolve('/repo/src/index.ts')); + }); + + it('throws a coded error when a path escapes the workspace', () => { + const workspace = ix.get(ISessionWorkspaceContext); + const escapingPath = resolve('/repo/../outside'); + + expect(() => workspace.assertAllowed('../outside', 'execute')).toThrow(Error2); + + try { + workspace.assertAllowed('../outside', 'execute'); + throw new Error('expected assertAllowed to throw'); + } catch (err) { + expect(err).toBeInstanceOf(Error2); + const coded = err as Error2; + expect(coded.code).toBe(ErrorCodes.WORKSPACE_CONTEXT_PATH_ESCAPES); + expect(coded.message).toBe('Path outside workspace'); + expect(coded.details).toEqual({ path: escapingPath, op: 'execute' }); + } + }); + + it('allows paths inside additional dirs', () => { + const workspace = ix.get(ISessionWorkspaceContext); + workspace.setAdditionalDirs(['/extra']); + + const target = workspace.assertAllowed('/extra/file.txt', 'read'); + + expect(isAbsolute(target)).toBe(true); + expect(target).toBe(resolve('/extra/file.txt')); + }); +}); diff --git a/packages/kap-server/src/routes/terminals.ts b/packages/kap-server/src/routes/terminals.ts index 8a0ce8b5c1a..1a7dd31aca0 100644 --- a/packages/kap-server/src/routes/terminals.ts +++ b/packages/kap-server/src/routes/terminals.ts @@ -241,15 +241,10 @@ function sendMappedError( case ErrorCodes.TERMINAL_NOT_FOUND: reply.send(errEnvelope(ErrorCode.TERMINAL_NOT_FOUND, err.message, requestId, err.stack)); return; + case ErrorCodes.WORKSPACE_CONTEXT_PATH_ESCAPES: + reply.send(errEnvelope(ErrorCode.FS_PATH_ESCAPES_SESSION, err.message, requestId, err.stack)); + return; } } - // `ISessionWorkspaceContext.assertAllowed` throws a plain (uncoded) Error when a cwd - // escapes the workspace — map it to the same wire code v1 uses for path - // escapes. TODO: push a coded error into `assertAllowed` so this branch can - // be folded into the `isError2` switch above. - if (err instanceof Error && err.message.startsWith('Path outside workspace')) { - reply.send(errEnvelope(ErrorCode.FS_PATH_ESCAPES_SESSION, err.message, requestId, err.stack)); - return; - } throw err; } diff --git a/packages/kap-server/test/terminals.test.ts b/packages/kap-server/test/terminals.test.ts index 916d87f6296..6f00488cb78 100644 --- a/packages/kap-server/test/terminals.test.ts +++ b/packages/kap-server/test/terminals.test.ts @@ -235,6 +235,7 @@ describe('server-v2 /api/v1/sessions/{sid}/terminals', () => { cwd: '../outside', }); expect(escaping.code).toBe(ErrorCode.FS_PATH_ESCAPES_SESSION); + expect(escaping.msg).toBe('Path outside workspace'); const noSession = await get(`/api/v1/sessions/sess_missing/terminals`); expect(noSession.code).toBe(ErrorCode.SESSION_NOT_FOUND);