diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index 220aa749f..84ad5897b 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -84,7 +84,8 @@ export async function runShell( // Experimental agent-core-v2 route (same master switch as `kimi -p`): the // harness is the SDK's v2-backed client, so the whole TUI runs on the // agent-core-v2 engine. - const harness = isKimiV2Enabled() + const engineV2 = isKimiV2Enabled(); + const harness = engineV2 ? createKimiHarnessV2(harnessOptions) : createKimiHarness(harnessOptions); log.info('kimi-code starting', { @@ -124,6 +125,7 @@ export async function runShell( startupNotice: configWarning, migrationPlan, migrateOnly: runOptions.migrateOnly, + engineV2, }); initializeCliTelemetry({ diff --git a/apps/kimi-code/src/tui/components/dialogs/trust-prompt.ts b/apps/kimi-code/src/tui/components/dialogs/trust-prompt.ts new file mode 100644 index 000000000..0ecca3732 --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/trust-prompt.ts @@ -0,0 +1,107 @@ +import { + Key, + matchesKey, + truncateToWidth, + wrapTextWithAnsi, + type Component, + type Focusable, +} from '@moonshot-ai/pi-tui'; + +import { SELECT_POINTER } from '#/tui/constant/symbols'; +import { currentTheme } from '#/tui/theme'; + +export type TrustPromptChoice = 'trust' | 'distrust'; + +export interface TrustPromptOptions { + readonly workDir: string; + /** Project-level MCP servers that trusting would enable; may be empty. */ + readonly gatedMcpServers: readonly string[]; + /** Esc resolves to 'distrust' as well. */ + readonly onSelect: (choice: TrustPromptChoice) => void; +} + +interface TrustPromptOption { + readonly value: TrustPromptChoice; + readonly label: string; + readonly description: string; +} + +const OPTIONS: readonly TrustPromptOption[] = [ + { + value: 'trust', + label: 'Trust this folder', + description: 'Enable project MCP servers. Remembered for this folder.', + }, + { + value: 'distrust', + label: "Don't trust", + description: 'Exit Kimi Code. Asked again next launch.', + }, +]; + +export class TrustPromptComponent implements Component, Focusable { + focused = false; + private selectedIndex = 0; + + constructor(private readonly opts: TrustPromptOptions) {} + + invalidate(): void {} + + handleInput(data: string): void { + if (matchesKey(data, Key.escape)) { + this.opts.onSelect('distrust'); + return; + } + if (matchesKey(data, Key.up)) { + this.selectedIndex = Math.max(0, this.selectedIndex - 1); + return; + } + if (matchesKey(data, Key.down)) { + this.selectedIndex = Math.min(OPTIONS.length - 1, this.selectedIndex + 1); + return; + } + if (matchesKey(data, Key.enter) || matchesKey(data, Key.space)) { + this.opts.onSelect(OPTIONS[this.selectedIndex]!.value); + } + } + + render(width: number): string[] { + const rule = currentTheme.fg('primary', '─'.repeat(width)); + const lines = [ + rule, + currentTheme.boldFg('primary', ' Trust this folder?'), + currentTheme.fg('textMuted', ' ↑↓ navigate · Enter select · Esc exit'), + '', + ...wrapTextWithAnsi(this.opts.workDir, Math.max(20, width - 2)).map( + (line) => ` ${currentTheme.fg('textStrong', line)}`, + ), + '', + ]; + + const notice = + this.opts.gatedMcpServers.length > 0 + ? `Kimi Code loads project-level MCP servers (.mcp.json, .kimi-code/mcp.json) only in trusted folders. They run as local processes on your machine. This folder defines: ${this.opts.gatedMcpServers.join(', ')}.` + : 'Kimi Code loads project-level MCP servers (.mcp.json, .kimi-code/mcp.json) only in trusted folders. They run as local processes on your machine.'; + for (const line of wrapTextWithAnsi(notice, Math.max(20, width - 2))) { + lines.push(` ${currentTheme.fg('textMuted', line)}`); + } + lines.push(''); + + for (let i = 0; i < OPTIONS.length; i += 1) { + const option = OPTIONS[i]!; + const selected = i === this.selectedIndex; + const pointer = selected ? SELECT_POINTER : ' '; + const label = selected + ? currentTheme.boldFg('primary', option.label) + : currentTheme.fg('text', option.label); + lines.push(currentTheme.fg(selected ? 'primary' : 'textDim', ` ${pointer} `) + label); + for (const line of wrapTextWithAnsi(option.description, Math.max(20, width - 4))) { + lines.push(` ${currentTheme.fg('textMuted', line)}`); + } + lines.push(''); + } + + lines.push(rule); + return lines.map((line) => truncateToWidth(line, width)); + } +} diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 59807b835..2061725f4 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -12,6 +12,7 @@ import type { PermissionMode, PromptPart, Session, + WorkspaceTrustInfo, } from '@moonshot-ai/kimi-code-sdk'; import type { MigrationPlan } from '@moonshot-ai/migration-legacy'; import { @@ -64,6 +65,7 @@ import { CompactionComponent } from './components/dialogs/compaction'; import { HelpPanelComponent } from './components/dialogs/help-panel'; import { QuestionDialogComponent } from './components/dialogs/question-dialog'; import { SessionPickerComponent, type SessionRow } from './components/dialogs/session-picker'; +import { TrustPromptComponent, type TrustPromptChoice } from './components/dialogs/trust-prompt'; import { FileMentionProvider, type SlashAutocompleteCommand, @@ -185,6 +187,8 @@ export interface KimiTUIStartupInput { readonly migrationPlan?: MigrationPlan | null; /** When true, run only the migration screen, then exit (the `kimi migrate` command). */ readonly migrateOnly?: boolean; + /** agent-core-v2 engine (KIMI_CODE_EXPERIMENTAL_FLAG); enables the startup workspace-trust prompt. */ + readonly engineV2?: boolean; } type EffectiveActivityPaneMode = ActivityPaneMode | 'idle' | 'session'; @@ -323,6 +327,7 @@ export class KimiTUI { private isShuttingDown = false; private readonly migrationPlan: MigrationPlan | null; private readonly migrateOnly: boolean; + private readonly engineV2: boolean; private startupNotice: string | undefined; private lastActivityMode: string | undefined; private currentLoadingTip: { kind: LoadingTipKind; tip: string | undefined } | undefined = @@ -396,6 +401,7 @@ export class KimiTUI { this.options = tuiOptions; this.migrationPlan = startupInput.migrationPlan ?? null; this.migrateOnly = startupInput.migrateOnly ?? false; + this.engineV2 = startupInput.engineV2 ?? false; this.startupNotice = startupInput.startupNotice; this.state = createTUIState(tuiOptions); this.uninstallRainbowDance = installRainbowDance(() => { @@ -555,8 +561,13 @@ export class KimiTUI { return; } + const trustPromptStartedLoop = await this.maybeRunWorkspaceTrustPrompt(); const shouldReplayHistory = await this.initMainTui(); - this.startEventLoop(); + // When the trust prompt already started the event loop, starting it + // again would re-run pi-tui's terminal.start() — stacking a second + // Kitty keyboard-protocol push (leaking CSI-u mode past exit) and + // duplicate stdin listeners. + if (!trustPromptStartedLoop) this.startEventLoop(); try { this.startBackgroundFdAutocomplete(); await this.finishStartup(shouldReplayHistory); @@ -2832,6 +2843,57 @@ export class KimiTUI { return result; } + /** + * agent-core-v2 startup gate: before any session is created, ask whether to + * trust this folder when the workspace is not trusted yet (project-level MCP + * servers stay disabled while untrusted). Best-effort throughout — a failed + * check or trust write never blocks startup. Choosing "don't trust" (or Esc) + * exits the program before any session is created; the prompt reappears on + * the next launch: the engine's untrusted state is indistinguishable from + * never-trusted. Returns true when the prompt started the event loop (the + * caller must not start it again). + */ + private async maybeRunWorkspaceTrustPrompt(): Promise { + if (!this.engineV2) return false; + const workDir = this.state.appState.workDir; + let info: WorkspaceTrustInfo; + try { + info = await this.harness.getWorkspaceTrustInfo(workDir); + } catch { + return false; + } + if (info.trusted) return false; + this.startEventLoop(); + const choice = await new Promise((resolve) => { + this.state.activeDialog = 'trust-prompt'; + this.mountEditorReplacement( + new TrustPromptComponent({ + workDir, + gatedMcpServers: info.gatedMcpServers, + onSelect: (c) => { + resolve(c); + }, + }), + ); + }); + this.state.activeDialog = null; + if (choice !== 'trust') { + // Declining trust exits the program (Claude Code's "No, exit" semantics): + // stop() runs the standard shutdown path and ends in process.exit. The + // editor is NOT restored first — its frame would linger as an orphaned + // input box above the exit message; the prompt stays as the last frame. + await this.stop(); + return true; + } + this.restoreEditor(); + try { + await this.harness.trustWorkspace(workDir); + } catch { + // A failed write leaves the workspace untrusted (re-asked next launch). + } + return true; + } + showHelpPanel(): void { this.state.activeDialog = 'help'; this.mountEditorReplacement( diff --git a/apps/kimi-code/src/tui/tui-state.ts b/apps/kimi-code/src/tui/tui-state.ts index d9665c9e3..34571eb36 100644 --- a/apps/kimi-code/src/tui/tui-state.ts +++ b/apps/kimi-code/src/tui/tui-state.ts @@ -48,7 +48,7 @@ export interface TUIState { sessions: SessionRow[]; loadingSessions: boolean; sessionsScope: 'cwd' | 'all'; - activeDialog: 'session-picker' | 'help' | null; + activeDialog: 'session-picker' | 'help' | 'trust-prompt' | null; tasksBrowser: TasksBrowserState | undefined; externalEditorRunning: boolean; queuedMessages: QueuedMessage[]; diff --git a/apps/kimi-code/test/tui/components/dialogs/trust-prompt.test.ts b/apps/kimi-code/test/tui/components/dialogs/trust-prompt.test.ts new file mode 100644 index 000000000..389b21149 --- /dev/null +++ b/apps/kimi-code/test/tui/components/dialogs/trust-prompt.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { TrustPromptComponent } from '#/tui/components/dialogs/trust-prompt'; + +const ANSI_SGR = /\[[0-9;]*m/g; + +function strip(text: string): string { + return text.replaceAll(ANSI_SGR, ''); +} + +function renderLines(gatedMcpServers: readonly string[] = []): string[] { + const prompt = new TrustPromptComponent({ + workDir: '/tmp/demo-workspace', + gatedMcpServers, + onSelect: vi.fn(), + }); + return prompt.render(100).map(strip); +} + +describe('TrustPromptComponent', () => { + it('renders the header vocabulary and the workspace path', () => { + const lines = renderLines(); + const titleIdx = lines.findIndex((l) => l.includes('Trust this folder?')); + expect(titleIdx).toBeGreaterThanOrEqual(0); + const hint = lines[titleIdx + 1]; + expect(hint).toContain('↑↓ navigate'); + expect(hint).toContain('Enter select'); + expect(hint).toContain('Esc exit'); + expect(lines.some((l) => l.includes('/tmp/demo-workspace'))).toBe(true); + }); + + it('lists the gated project MCP servers when present', () => { + const lines = renderLines(['nested-server', 'root-server']); + expect(lines.some((l) => l.includes('This folder defines'))).toBe(true); + expect(lines.some((l) => l.includes('nested-server'))).toBe(true); + expect(lines.some((l) => l.includes('root-server'))).toBe(true); + expect(renderLines().some((l) => l.includes('This folder defines'))).toBe(false); + }); + + it('selects trust on Enter with the default highlight', () => { + const onSelect = vi.fn(); + const prompt = new TrustPromptComponent({ + workDir: '/tmp/demo-workspace', + gatedMcpServers: [], + onSelect, + }); + prompt.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith('trust'); + }); + + it('selects distrust after moving the cursor down', () => { + const onSelect = vi.fn(); + const prompt = new TrustPromptComponent({ + workDir: '/tmp/demo-workspace', + gatedMcpServers: [], + onSelect, + }); + prompt.handleInput('\u001B[B'); + prompt.handleInput('\r'); + expect(onSelect).toHaveBeenCalledWith('distrust'); + }); + + it('treats Esc as distrust', () => { + const onSelect = vi.fn(); + const prompt = new TrustPromptComponent({ + workDir: '/tmp/demo-workspace', + gatedMcpServers: [], + onSelect, + }); + prompt.handleInput('\u001B'); + expect(onSelect).toHaveBeenCalledWith('distrust'); + }); +}); diff --git a/packages/node-sdk/src/kimi-harness.ts b/packages/node-sdk/src/kimi-harness.ts index 8197d9bac..f8b71fb27 100644 --- a/packages/node-sdk/src/kimi-harness.ts +++ b/packages/node-sdk/src/kimi-harness.ts @@ -33,6 +33,7 @@ import type { TelemetryContextPatch, TelemetryProperties, TestMcpServerOptions, + WorkspaceTrustInfo, } from '#/types'; export interface KimiHarnessRuntimeOptions { @@ -255,6 +256,20 @@ export class KimiHarness { return this.rpc.listWorkspaceSkills(workDir); } + /** + * Trust state of `workDir` (agent-core-v2 only; the v1 engine reports an + * always-trusted workspace). Querying may register the workDir as a + * workspace, which session creation would do anyway. + */ + async getWorkspaceTrustInfo(workDir: string): Promise { + return this.rpc.getWorkspaceTrustInfo(workDir); + } + + /** Mark `workDir` as trusted; project-level MCP servers connect live afterwards. */ + async trustWorkspace(workDir: string): Promise { + return this.rpc.trustWorkspace(workDir); + } + async getConfig(options: GetConfigOptions = {}): Promise { return this.rpc.getConfig(options); } diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index 42fc747ab..86624d3ae 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -59,6 +59,7 @@ import type { SkillSummary, PluginCommandDef, Unsubscribe, + WorkspaceTrustInfo, } from '#/types'; const MAIN_AGENT_ID = 'main'; @@ -220,6 +221,20 @@ export abstract class SDKRpcClientBase { return rpc.listWorkspaceSkills({ workDir }); } + /** + * Workspace-trust state for `workDir`. The v1 engine has no trust concept, + * so the base implementation reports an always-trusted workspace and the + * trust write is a no-op; only the v2 client overrides these. + */ + async getWorkspaceTrustInfo(workDir: string): Promise { + void workDir; + return { trusted: true, gatedMcpServers: [] }; + } + + async trustWorkspace(workDir: string): Promise { + void workDir; + } + async renameSession(input: RenameSessionInput): Promise { const rpc = await this.getRpc(); return rpc.renameSession({ diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index 66886ad70..009a59d07 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -150,6 +150,7 @@ import { createMcpOAuthStore } from '@moonshot-ai/agent-core-v2/app/mcpConfig/oa import { SECONDARY_MODEL_SECTION } from '@moonshot-ai/agent-core-v2/app/kosongConfig/configSection'; import { IAtomicDocumentStore } from '@moonshot-ai/agent-core-v2/persistence/interface/atomicDocumentStore'; import { wrapSubagentModelError } from '@moonshot-ai/agent-core-v2/session/subagent/configSection'; +import { loadMcpServers } from '@moonshot-ai/agent-core-v2/workspace/workspaceMcpConfig/internal/config-loader'; import { applyPromptMetadataUpdate, bootstrap, @@ -199,6 +200,7 @@ import { IWorkspaceDirs, ISessionLifecycleService, IWorkspaceLifecycleService, + IWorkspaceTrust, closeSessionById, followWorkspaceHandlers, getLiveSessionById, @@ -293,6 +295,7 @@ import type { SessionUsage, SkillSummary, TelemetryClient, + WorkspaceTrustInfo, } from '#/types'; import { diagnosticsToConfigDiagnostics, @@ -573,6 +576,50 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { return [...byName.values()]; } + /** + * klient has no workspace-trust facade; composed directly from the engine + * via {@link engineAccessor} — the same `handlerFor({ root })` path + * `createSession` takes (materializing the workspace handler is a no-op + * cost here: session creation does it anyway). The gated-server list is + * what the pure config loader sees with project files included vs skipped + * (the workspaceTrust gate inside the engine's `workspaceMcpConfig`), + * computed best-effort: an unreadable/invalid project file degrades to an + * empty list rather than failing the caller. + */ + override async getWorkspaceTrustInfo(workDir: string): Promise { + const handler = await this.engineAccessor + .get(IWorkspaceLifecycleService) + .handlerFor({ root: workDir }); + const trusted = await handler.accessor.get(IWorkspaceTrust).get(); + if (trusted) return { trusted: true, gatedMcpServers: [] }; + try { + const fs = this.engineAccessor.get(IHostFileSystem); + const [withProject, userOnly] = await Promise.all([ + loadMcpServers({ fs, cwd: workDir, homeDir: this.homeDir, includeProject: true }), + loadMcpServers({ fs, cwd: workDir, homeDir: this.homeDir, includeProject: false }), + ]); + const gatedMcpServers = Object.keys(withProject) + .filter((name) => !(name in userOnly)) + .toSorted(); + return { trusted: false, gatedMcpServers }; + } catch { + return { trusted: false, gatedMcpServers: [] }; + } + } + + /** + * klient has no workspace-trust facade; see {@link getWorkspaceTrustInfo}. + * The flip fires `IWorkspaceTrust.onDidChange`, which makes the engine's + * `workspaceMcpConfig` reload with project files included — project MCP + * servers connect live, no restart needed. + */ + override async trustWorkspace(workDir: string): Promise { + const handler = await this.engineAccessor + .get(IWorkspaceLifecycleService) + .handlerFor({ root: workDir }); + await handler.accessor.get(IWorkspaceTrust).trust(); + } + /** * v1 returns the whole config.toml document as one `KimiConfig`; v2 * resolves the same file per config domain. `getAll()` is the effective diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index d65e5d30c..e2b58129c 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -74,6 +74,17 @@ export type { ContentPart, Role, ThinkingEffort, ToolCall } from '@moonshot-ai/k export type PermissionMode = 'yolo' | 'manual' | 'auto'; +/** + * Trust state of a workspace directory. Only meaningful on the agent-core-v2 + * engine; the v1 engine has no workspace-trust concept and reports + * `{ trusted: true, gatedMcpServers: [] }`. + */ +export interface WorkspaceTrustInfo { + readonly trusted: boolean; + /** Names of project-level MCP servers that trusting the workspace would enable. */ + readonly gatedMcpServers: readonly string[]; +} + export interface CreateGoalInput { readonly objective: string; readonly replace?: boolean; diff --git a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts index 88a23c50c..85509fdc7 100644 --- a/packages/node-sdk/test/sdk-rpc-client-v2.test.ts +++ b/packages/node-sdk/test/sdk-rpc-client-v2.test.ts @@ -7,7 +7,7 @@ * Wiring: real v2 engine bootstrapped on a temp KIMI_CODE_HOME; no provider calls. * Run: pnpm exec vitest run test/sdk-rpc-client-v2.test.ts */ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -160,6 +160,64 @@ describe('SDKRpcClientV2 (agent-core-v2 wiring MVP)', () => { }); }); +describe('SDKRpcClientV2 workspace trust', () => { + it('reports an untrusted workspace with the project MCP servers it gates', async () => { + const { harness } = await makeHarness(); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); + tempDirs.push(workDir); + await writeFile( + join(workDir, '.mcp.json'), + JSON.stringify({ mcpServers: { 'root-server': { command: 'root-cmd' } } }), + 'utf-8', + ); + await mkdir(join(workDir, '.kimi-code'), { recursive: true }); + await writeFile( + join(workDir, '.kimi-code', 'mcp.json'), + JSON.stringify({ mcpServers: { 'nested-server': { command: 'nested-cmd' } } }), + 'utf-8', + ); + try { + const info = await harness.getWorkspaceTrustInfo(workDir); + expect(info.trusted).toBe(false); + expect(info.gatedMcpServers).toEqual(['nested-server', 'root-server']); + } finally { + await harness.close(); + } + }); + + it('degrades the gated-server list to empty on an invalid project mcp.json', async () => { + const { harness } = await makeHarness(); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); + tempDirs.push(workDir); + await writeFile(join(workDir, '.mcp.json'), '{not json', 'utf-8'); + try { + const info = await harness.getWorkspaceTrustInfo(workDir); + expect(info).toEqual({ trusted: false, gatedMcpServers: [] }); + } finally { + await harness.close(); + } + }); + + it('trustWorkspace flips the state and persists the marker in the kimi home', async () => { + const { harness, homeDir } = await makeHarness(); + const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-')); + tempDirs.push(workDir); + try { + await harness.trustWorkspace(workDir); + expect(await harness.getWorkspaceTrustInfo(workDir)).toEqual({ + trusted: true, + gatedMcpServers: [], + }); + // The trust marker lives in the kimi home, never in the checkout. + const markers = await readdir(join(homeDir, 'workspace-trust')); + expect(markers.length).toBe(1); + expect(await readdir(workDir)).not.toContain('workspace-trust'); + } finally { + await harness.close(); + } + }); +}); + describe('foldAgentWireReplay', () => { it('folds a journal into v1 replay records and the tool store', async () => { const dir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-fold-'));