feat(tui): ask for workspace trust on startup with the v2 engine (#2453)

* feat(node-sdk): expose workspace trust state and trust grant on the v2 client

* feat(tui): ask for workspace trust on startup with the v2 engine
This commit is contained in:
liruifengv 2026-07-31 14:21:04 +08:00 committed by GitHub
parent 071d56940f
commit 32d693f644
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 394 additions and 4 deletions

View file

@ -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({

View file

@ -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));
}
}

View file

@ -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<boolean> {
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<TrustPromptChoice>((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(

View file

@ -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[];

View file

@ -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');
});
});

View file

@ -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<WorkspaceTrustInfo> {
return this.rpc.getWorkspaceTrustInfo(workDir);
}
/** Mark `workDir` as trusted; project-level MCP servers connect live afterwards. */
async trustWorkspace(workDir: string): Promise<void> {
return this.rpc.trustWorkspace(workDir);
}
async getConfig(options: GetConfigOptions = {}): Promise<KimiConfig> {
return this.rpc.getConfig(options);
}

View file

@ -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<WorkspaceTrustInfo> {
void workDir;
return { trusted: true, gatedMcpServers: [] };
}
async trustWorkspace(workDir: string): Promise<void> {
void workDir;
}
async renameSession(input: RenameSessionInput): Promise<void> {
const rpc = await this.getRpc();
return rpc.renameSession({

View file

@ -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<WorkspaceTrustInfo> {
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<void> {
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

View file

@ -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;

View file

@ -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-'));