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/session/workspaceContext/workspaceContextService.ts b/packages/agent-core-v2/src/session/workspaceContext/workspaceContextService.ts index 0af6483124b..c7ad6da5afa 100644 --- a/packages/agent-core-v2/src/session/workspaceContext/workspaceContextService.ts +++ b/packages/agent-core-v2/src/session/workspaceContext/workspaceContextService.ts @@ -1,8 +1,8 @@ import { isAbsolute, relative, resolve } from 'node:path'; import { Service } from '#/_base/di/service'; -import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; import { defineState } from '#/state/state'; import { ErrorCodes, Error2 } from '#/errors'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; 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..a67516d1bb7 --- /dev/null +++ b/packages/agent-core-v2/test/session/workspaceContext/workspaceContextService.test.ts @@ -0,0 +1,86 @@ +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 { Event } from '#/_base/event'; +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'; +import { ISessionWorkspaceInfo } from '#/session/workspaceInfo/workspaceInfo'; + +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; + let additionalDirs: string[]; + + beforeEach(() => { + disposables = new DisposableStore(); + additionalDirs = ['/extra']; + ix = createServices(disposables, { + additionalServices: (reg) => { + reg.defineInstance(ISessionContext, stubSessionContext()); + reg.defineInstance(ISessionStateService, new SessionStateService()); + reg.defineInstance(ISessionWorkspaceInfo, { + _serviceBrand: undefined, + ready: Promise.resolve(), + get additionalDirs() { + return additionalDirs; + }, + onDidChange: Event.None, + }); + 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.FS_PATH_ESCAPES); + expect(coded.message).toBe(`Path outside workspace (execute): ${escapingPath}`); + expect(coded.details).toEqual({ op: 'execute', path: escapingPath }); + } + }); + + it('allows paths inside additional dirs', () => { + const workspace = ix.get(ISessionWorkspaceContext); + + 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/test/terminals.test.ts b/packages/kap-server/test/terminals.test.ts index 6a51b2a188b..7d24c88af10 100644 --- a/packages/kap-server/test/terminals.test.ts +++ b/packages/kap-server/test/terminals.test.ts @@ -239,6 +239,7 @@ describe('server-v2 /api/v1/sessions/{sid}/terminals', () => { cwd: '../outside', }); expect(escaping.code).toBe(ErrorCode.FS_PATH_ESCAPES_SESSION); + expect(escaping.msg).toContain('outside'); const noSession = await get(`/api/v1/sessions/sess_missing/terminals`); expect(noSession.code).toBe(ErrorCode.SESSION_NOT_FOUND);