mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-14 11:16:19 +00:00
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:
parent
071d56940f
commit
32d693f644
10 changed files with 394 additions and 4 deletions
|
|
@ -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({
|
||||
|
|
|
|||
107
apps/kimi-code/src/tui/components/dialogs/trust-prompt.ts
Normal file
107
apps/kimi-code/src/tui/components/dialogs/trust-prompt.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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-'));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue