From fae6f11f9069eceed75d003e6ebcd45038fc13ef Mon Sep 17 00:00:00 2001 From: Liang Hu Date: Fri, 31 Jul 2026 11:21:17 -0400 Subject: [PATCH 01/16] feat(agents): add Kimi Code CLI support Signed-off-by: Liang Hu --- README.md | 6 +++--- docker/Dockerfile | 2 +- electron/ipc/agents.test.ts | 4 ++++ electron/ipc/agents.ts | 9 +++++++++ electron/ipc/pty.test.ts | 1 + electron/ipc/pty.ts | 1 + electron/mcp/agent-args.test.ts | 11 ++++++++++ electron/mcp/agent-args.ts | 9 +++++++++ src/lib/agent-args.test.ts | 36 +++++++++++++++++++++++++++++++++ src/lib/agent-args.ts | 14 +++++++++++-- src/store/tasks.test.ts | 20 ++++++++++++++++++ src/store/tasks.ts | 8 +++++++- 12 files changed, 114 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index be4579225..b2d2a0c99 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@

- Works with Claude Code, Codex, and Gemini · Every change isolated in its own git worktree · Free, open source, no extra platform fee + Works with Claude Code, Codex, Gemini, and Kimi Code · Every change isolated in its own git worktree · Free, open source, no extra platform fee

@@ -45,7 +45,7 @@ ## Why Parallel Code? -- **Use the AI coding tools you already trust** — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex CLI](https://github.com/openai/codex), [Gemini CLI](https://github.com/google-gemini/gemini-cli), and [Copilot CLI](https://docs.github.com/en/copilot/concepts/agents/about-copilot-cli) — all from one interface. +- **Use the AI coding tools you already trust** — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex CLI](https://github.com/openai/codex), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Kimi Code CLI](https://github.com/MoonshotAI/kimi-code), and [Copilot CLI](https://docs.github.com/en/copilot/concepts/agents/about-copilot-cli) — all from one interface. - **Free and open source** — no extra subscription required. MIT licensed. - **Keep every change isolated and reviewable** — each task gets its own git branch and worktree automatically. - **Run agents in parallel, not in sequence** — five agents on five features at the same time, zero conflicts. @@ -115,7 +115,7 @@ When you're happy with the result, merge the branch back to main from the sideba - **macOS** — `.dmg` (universal) - **Linux** — `.AppImage` or `.deb` -2. **Install at least one AI coding CLI:** [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex CLI](https://github.com/openai/codex), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Antigravity CLI](https://antigravity.google/), or [Copilot CLI](https://docs.github.com/en/copilot/concepts/agents/about-copilot-cli) +2. **Install at least one AI coding CLI:** [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex CLI](https://github.com/openai/codex), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Kimi Code CLI](https://github.com/MoonshotAI/kimi-code), [Antigravity CLI](https://antigravity.google/), or [Copilot CLI](https://docs.github.com/en/copilot/concepts/agents/about-copilot-cli) 3. **Open Parallel Code**, point it at a git repo, and start dispatching tasks. diff --git a/docker/Dockerfile b/docker/Dockerfile index f775de193..19ce8f453 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -50,7 +50,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ RUN ln -sf "$(command -v fdfind)" /usr/local/bin/fd 2>/dev/null || true # AI agent CLIs — must be present so Docker-mode tasks can execute them -RUN npm install -g @anthropic-ai/claude-code @openai/codex @google/gemini-cli opencode-ai +RUN npm install -g @anthropic-ai/claude-code @openai/codex @google/gemini-cli opencode-ai @moonshot-ai/kimi-code # Antigravity CLI (agy) — distributed as a Go binary via the official installer # (not on npm). The installer's `--dir` flag drops the binary straight into a diff --git a/electron/ipc/agents.test.ts b/electron/ipc/agents.test.ts index 566b276db..472d65208 100644 --- a/electron/ipc/agents.test.ts +++ b/electron/ipc/agents.test.ts @@ -8,4 +8,8 @@ describe('getSkipPermissionsArgs', () => { expect(getSkipPermissionsArgs('claude')).toEqual(['--dangerously-skip-permissions']); }); + + it('returns Kimi Code skip-permission args', () => { + expect(getSkipPermissionsArgs('kimi')).toEqual(['--yolo']); + }); }); diff --git a/electron/ipc/agents.ts b/electron/ipc/agents.ts index 36febbba5..fb021aafb 100644 --- a/electron/ipc/agents.ts +++ b/electron/ipc/agents.ts @@ -44,6 +44,15 @@ const DEFAULT_AGENTS: AgentDef[] = [ skip_permissions_args: ['--yolo'], description: "Google's Gemini CLI agent", }, + { + id: 'kimi', + name: 'Kimi Code CLI', + command: 'kimi', + args: [], + resume_args: ['--continue'], + skip_permissions_args: ['--yolo'], + description: "Moonshot AI's Kimi Code CLI agent", + }, { id: 'opencode', name: 'OpenCode', diff --git a/electron/ipc/pty.test.ts b/electron/ipc/pty.test.ts index 0a0b1e074..16cf7424a 100644 --- a/electron/ipc/pty.test.ts +++ b/electron/ipc/pty.test.ts @@ -380,6 +380,7 @@ describe('spawnAgent docker mode', () => { ['opencode', '.config/opencode'], ['copilot', '.config/github-copilot'], ['agy', '.gemini/antigravity-cli'], + ['kimi', '.kimi-code'], ])( '%s bind-mounts a user-owned host directory when shareDockerAgentAuth is enabled', (command, relDir) => { diff --git a/electron/ipc/pty.ts b/electron/ipc/pty.ts index b76fd8bc3..925d45ad1 100644 --- a/electron/ipc/pty.ts +++ b/electron/ipc/pty.ts @@ -714,6 +714,7 @@ const AGENT_CONFIG_DIRS: Record = { opencode: ['.config/opencode'], copilot: ['.config/github-copilot'], agy: ['.gemini/antigravity-cli'], + kimi: ['.kimi-code'], }; // Config files (not directories) each agent CLI uses for auth, relative to HOME. diff --git a/electron/mcp/agent-args.test.ts b/electron/mcp/agent-args.test.ts index c7fa90b6c..bab22dddd 100644 --- a/electron/mcp/agent-args.test.ts +++ b/electron/mcp/agent-args.test.ts @@ -6,6 +6,7 @@ import { isAntigravityCommand, isCodexCommand, isCopilotCommand, + isKimiCommand, } from './agent-args.js'; const config = { @@ -67,6 +68,16 @@ describe('MCP agent launch args', () => { expect(buildMcpLaunchArgs('agy', '/tmp/config.json', config)).toEqual([]); }); + it('detects Kimi commands by executable name', () => { + expect(isKimiCommand('kimi')).toBe(true); + expect(isKimiCommand('/home/agent/.local/bin/kimi')).toBe(true); + expect(isKimiCommand('claude')).toBe(false); + }); + + it('emits no --mcp-config for Kimi Code', () => { + expect(buildMcpLaunchArgs('kimi', '/tmp/config.json', config)).toEqual([]); + }); + it('detects copilot commands by executable name', () => { expect(isCopilotCommand('copilot')).toBe(true); expect(isCopilotCommand('/opt/homebrew/bin/copilot')).toBe(true); diff --git a/electron/mcp/agent-args.ts b/electron/mcp/agent-args.ts index 5add7eab3..437a3fc56 100644 --- a/electron/mcp/agent-args.ts +++ b/electron/mcp/agent-args.ts @@ -18,6 +18,10 @@ export function isAntigravityCommand(command: string): boolean { return command.split('/').pop() === 'agy'; } +export function isKimiCommand(command: string): boolean { + return command.split('/').pop() === 'kimi'; +} + export function isCopilotCommand(command: string): boolean { return command.split('/').pop() === 'copilot'; } @@ -53,6 +57,11 @@ export function buildMcpLaunchArgs( if (isAntigravityCommand(command)) { return []; } + // Kimi Code auto-discovers user and project MCP config files and does not + // accept the generic `--mcp-config` flag. + if (isKimiCommand(command)) { + return []; + } // Copilot has no `--mcp-config` flag — passing it makes Copilot exit immediately // with "unknown option" before the prompt is ever sent (#146). It accepts // `--additional-mcp-config <@file|json>` (and also auto-discovers a workspace diff --git a/src/lib/agent-args.test.ts b/src/lib/agent-args.test.ts index 652f516b0..5d4b67c26 100644 --- a/src/lib/agent-args.test.ts +++ b/src/lib/agent-args.test.ts @@ -32,6 +32,16 @@ const antigravityAgent = { skip_permissions_args: ['--dangerously-skip-permissions'], }; +const kimiAgent = { + id: 'kimi', + name: 'Kimi Code CLI', + description: 'Kimi Code agent', + command: 'kimi', + args: [], + resume_args: ['--continue'], + skip_permissions_args: ['--yolo'], +}; + const copilotAgent = { id: 'copilot', name: 'Copilot CLI', @@ -146,6 +156,32 @@ describe('buildTaskAgentArgs', () => { ).toEqual(['-c']); }); + it('does not fall back to --mcp-config for Kimi Code', () => { + expect( + buildTaskAgentArgs( + kimiAgent, + { + skipPermissions: false, + mcpConfigPath: '/tmp/mcp.json', + }, + false, + ), + ).toEqual([]); + }); + + it('passes Kimi Code resume and skip-permission flags without --mcp-config', () => { + expect( + buildTaskAgentArgs( + kimiAgent, + { + skipPermissions: true, + mcpConfigPath: '/tmp/mcp.json', + }, + true, + ), + ).toEqual(['--continue', '--yolo']); + }); + it('uses Copilot --additional-mcp-config fallback instead of the unsupported --mcp-config', () => { expect( buildTaskAgentArgs( diff --git a/src/lib/agent-args.ts b/src/lib/agent-args.ts index 9769a8831..96b906232 100644 --- a/src/lib/agent-args.ts +++ b/src/lib/agent-args.ts @@ -9,6 +9,10 @@ function isAntigravityCommand(command: string): boolean { return command.split('/').pop() === 'agy'; } +function isKimiCommand(command: string): boolean { + return command.split('/').pop() === 'kimi'; +} + function isCopilotCommand(command: string): boolean { return command.split('/').pop() === 'copilot'; } @@ -26,8 +30,14 @@ export function isResumeArgsFailure(command: string, lastOutput: string[]): bool } function legacyMcpConfigArgs(command: string, mcpConfigPath: string | undefined): string[] { - // Codex and Antigravity have no `--mcp-config` flag; passing it would break launch. - if (!mcpConfigPath || isCodexCommand(command) || isAntigravityCommand(command)) return []; + // Codex, Antigravity, and Kimi have no `--mcp-config` flag; passing it would break launch. + if ( + !mcpConfigPath || + isCodexCommand(command) || + isAntigravityCommand(command) || + isKimiCommand(command) + ) + return []; // Copilot has no `--mcp-config` flag either — it exits with "unknown option" (#146). // Use its `--additional-mcp-config <@file>` flag, which takes the same config shape. if (isCopilotCommand(command)) return ['--additional-mcp-config', `@${mcpConfigPath}`]; diff --git a/src/store/tasks.test.ts b/src/store/tasks.test.ts index 3763bf523..7c0efb87a 100644 --- a/src/store/tasks.test.ts +++ b/src/store/tasks.test.ts @@ -727,6 +727,26 @@ describe('MCP startup status transitions', () => { expect(mockTasks['coord-1'].mcpStartupError).toBeUndefined(); }); + it('allows Kimi coordinator MCP startup with persisted config path and no launch args', async () => { + mockTasks['coord-1'] = { + agentIds: ['agent-coord'], + shellAgentIds: [], + coordinatorMode: true, + projectId: 'proj-1', + gitIsolation: 'worktree', + worktreePath: '/repo/.worktrees/coord', + mcpConfigPath: '/tmp/coord.json', + }; + mockAgents['agent-coord'] = { def: { command: 'kimi', args: [] } }; + mockInvoke.mockResolvedValueOnce({ mcpLaunchArgs: [] }); + + markTaskMcpPending('coord-1'); + await retryTaskMcpStartup('coord-1'); + + expect(mockTasks['coord-1'].mcpStartupStatus).toBe('ready'); + expect(mockTasks['coord-1'].mcpStartupError).toBeUndefined(); + }); + it('missing MCP launch args leaves a Codex coordinated task in error', async () => { mockTasks['coord-1'] = { agentIds: [], diff --git a/src/store/tasks.ts b/src/store/tasks.ts index a3af3446c..bb4e5a10f 100644 --- a/src/store/tasks.ts +++ b/src/store/tasks.ts @@ -1386,13 +1386,19 @@ function isAntigravityCommand(command: string | undefined): boolean { return command?.split('/').pop() === 'agy'; } +function isKimiCommand(command: string | undefined): boolean { + return command?.split('/').pop() === 'kimi'; +} + function taskRequiresMcpLaunchArgs(taskId: string): boolean { const task = store.tasks[taskId]; if (!task) return true; const agentDef = task.agentIds[0] ? store.agents[task.agentIds[0]]?.def : undefined; return ( isCodexCommand(agentDef?.command) || - (Boolean(task.mcpConfigPath) && !isAntigravityCommand(agentDef?.command)) + (Boolean(task.mcpConfigPath) && + !isAntigravityCommand(agentDef?.command) && + !isKimiCommand(agentDef?.command)) ); } From e79bf5ee024f1c96663735c3db0d4f4719bfbb02 Mon Sep 17 00:00:00 2001 From: Liang Hu Date: Sat, 1 Aug 2026 11:12:51 -0400 Subject: [PATCH 02/16] fix(mcp): wire Kimi coordinator children Signed-off-by: Liang Hu --- electron/mcp/coordinator-test-harness.ts | 9 ++ electron/mcp/coordinator.test.ts | 142 +++++++++++++++++++++++ electron/mcp/coordinator.ts | 116 +++++++++++++++++- electron/mcp/types.ts | 6 + 4 files changed, 272 insertions(+), 1 deletion(-) diff --git a/electron/mcp/coordinator-test-harness.ts b/electron/mcp/coordinator-test-harness.ts index 31d84badd..d48a4dee1 100644 --- a/electron/mcp/coordinator-test-harness.ts +++ b/electron/mcp/coordinator-test-harness.ts @@ -36,6 +36,7 @@ const mocks = vi.hoisted(() => { const mockFsMkdir = vi.fn(); const mockAtomicWriteFileSync = vi.fn(); const mockAtomicWriteFile = vi.fn(); + const mockAppendGitInfoExcludeBlock = vi.fn(); const mockNotifyRenderer = vi.fn(); const mockLogInfo = vi.fn(); const mockLogWarn = vi.fn(); @@ -67,6 +68,7 @@ const mocks = vi.hoisted(() => { mockFsMkdir, mockAtomicWriteFileSync, mockAtomicWriteFile, + mockAppendGitInfoExcludeBlock, mockNotifyRenderer, mockLogInfo, mockLogWarn, @@ -111,6 +113,10 @@ vi.mock('./atomic.js', () => ({ atomicWriteFile: mocks.mockAtomicWriteFile, })); +vi.mock('../ipc/git-exclude.js', () => ({ + appendGitInfoExcludeBlock: mocks.mockAppendGitInfoExcludeBlock, +})); + vi.mock('../shared/prompt-detect.js', () => ({ stripAnsi: (s: string) => s.replace( @@ -226,6 +232,7 @@ export const { mockFsMkdir, mockAtomicWriteFileSync, mockAtomicWriteFile, + mockAppendGitInfoExcludeBlock, mockNotifyRenderer, mockLogInfo, mockLogWarn, @@ -299,6 +306,8 @@ export function resetCoordinatorMocks(): void { mockAtomicWriteFileSync.mockReset(); mockAtomicWriteFile.mockReset(); mockAtomicWriteFile.mockResolvedValue(undefined); + mockAppendGitInfoExcludeBlock.mockReset(); + mockAppendGitInfoExcludeBlock.mockReturnValue('appended'); mockNotifyRenderer.mockReset(); mockLogInfo.mockReset(); diff --git a/electron/mcp/coordinator.test.ts b/electron/mcp/coordinator.test.ts index 510eaf3c5..22fdddf49 100644 --- a/electron/mcp/coordinator.test.ts +++ b/electron/mcp/coordinator.test.ts @@ -17,8 +17,10 @@ import { mockFsAccess, mockAtomicWriteFileSync, mockAtomicWriteFile, + mockAppendGitInfoExcludeBlock, mockNotifyRenderer, mockLogInfo, + mockLogWarn, mockSpawnAgent, mockWriteToAgent, mockSubscribeToAgent, @@ -3127,6 +3129,146 @@ describe('Coordinator sub-task MCP config isolation', () => { expect(configPaths[0]).not.toBe(configPaths[1]); }); + + it('writes isolated Kimi child configs to each worktree for auto-discovery', async () => { + mockCreateBackendTask + .mockResolvedValueOnce({ id: 'task-a', branch_name: 'task/a', worktree_path: '/tmp/a' }) + .mockResolvedValueOnce({ id: 'task-b', branch_name: 'task/b', worktree_path: '/tmp/b' }); + coordinator.setCoordinatorSpawnDefaults('coord-1', 'kimi', []); + coordinator.setMCPServerInfo( + 'coord-1', + 'http://localhost:3001', + 'coordinator-tok', + 'subtask-tok', + '/path/server.js', + ); + + await coordinator.createTask({ name: 'task-a', prompt: 'do a', coordinatorTaskId: 'coord-1' }); + await coordinator.createTask({ name: 'task-b', prompt: 'do b', coordinatorTaskId: 'coord-1' }); + + const childWrites = mockAtomicWriteFileSync.mock.calls.filter( + ([configPath]) => configPath === '/tmp/a/.mcp.json' || configPath === '/tmp/b/.mcp.json', + ); + expect(childWrites).toHaveLength(2); + const childConfigs = childWrites.map( + ([, raw]) => + JSON.parse(raw as string) as { + mcpServers: { + 'parallel-code': { args: string[]; env: Record }; + }; + }, + ); + + expect(childConfigs[0].mcpServers['parallel-code'].args).toContain('task-a'); + expect(childConfigs[1].mcpServers['parallel-code'].args).toContain('task-b'); + expect(childConfigs[0].mcpServers['parallel-code'].env['PARALLEL_CODE_MCP_TOKEN']).toBe( + 'subtask-tok', + ); + expect( + childConfigs[0].mcpServers['parallel-code'].env['PARALLEL_CODE_MCP_DONE_TOKEN'], + ).not.toBe(childConfigs[1].mcpServers['parallel-code'].env['PARALLEL_CODE_MCP_DONE_TOKEN']); + expect(mockAppendGitInfoExcludeBlock).toHaveBeenCalledWith( + '/tmp/a', + '.mcp.json', + expect.stringContaining('.mcp.json'), + expect.any(Function), + ); + for (const [, spawnOpts] of mockSpawnAgent.mock.calls) { + expect(spawnOpts).toEqual( + expect.objectContaining({ + command: 'kimi', + args: expect.not.arrayContaining(['--mcp-config']), + }), + ); + } + }); + + it('restores a pre-existing Kimi child MCP entry when its coordinator deregisters', async () => { + const configPath = '/tmp/test/.mcp.json'; + const previousParallelCode = { command: 'user-owned-server' }; + let currentConfig = JSON.stringify({ + mcpServers: { + other: { command: 'other-server' }, + 'parallel-code': previousParallelCode, + }, + setting: true, + }); + mockExistsSync.mockImplementation((path) => path === configPath); + mockReadFileSync.mockImplementation((path) => + path === configPath ? currentConfig : '# existing\n', + ); + mockAtomicWriteFileSync.mockImplementation((path, raw) => { + if (path === configPath) currentConfig = raw as string; + }); + coordinator.setCoordinatorSpawnDefaults('coord-1', 'kimi', []); + coordinator.setMCPServerInfo( + 'coord-1', + 'http://localhost:3001', + 'coordinator-tok', + 'subtask-tok', + '/path/server.js', + ); + + await coordinator.createTask({ name: 'test', prompt: 'do', coordinatorTaskId: 'coord-1' }); + coordinator.deregisterCoordinator('coord-1'); + + const restored = JSON.parse(currentConfig) as { + mcpServers: Record; + setting: boolean; + }; + expect(restored.mcpServers['parallel-code']).toEqual(previousParallelCode); + expect(restored.mcpServers.other).toEqual({ command: 'other-server' }); + expect(restored.setting).toBe(true); + }); + + it('does not overwrite a Kimi child MCP entry changed after creation', async () => { + const configPath = '/tmp/test/.mcp.json'; + let configExists = false; + let currentConfig = ''; + mockExistsSync.mockImplementation((path) => path === configPath && configExists); + mockReadFileSync.mockImplementation((path) => + path === configPath ? currentConfig : '# existing\n', + ); + mockAtomicWriteFileSync.mockImplementation((path, raw) => { + if (path === configPath) { + configExists = true; + currentConfig = raw as string; + } + }); + coordinator.setCoordinatorSpawnDefaults('coord-1', 'kimi', []); + coordinator.setMCPServerInfo( + 'coord-1', + 'http://localhost:3001', + 'coordinator-tok', + 'subtask-tok', + '/path/server.js', + ); + await coordinator.createTask({ name: 'test', prompt: 'do', coordinatorTaskId: 'coord-1' }); + + const userEntry = { command: 'user-replacement' }; + const changed = JSON.parse(currentConfig) as { + mcpServers: Record; + }; + changed.mcpServers['parallel-code'] = userEntry; + currentConfig = JSON.stringify(changed); + mockAtomicWriteFileSync.mockClear(); + + coordinator.setMCPServerInfo( + 'coord-1', + 'http://localhost:3002', + 'new-coordinator-tok', + 'new-subtask-tok', + '/path/server.js', + ); + + expect(mockAtomicWriteFileSync.mock.calls.some(([path]) => path === configPath)).toBe(false); + expect(JSON.parse(currentConfig).mcpServers['parallel-code']).toEqual(userEntry); + expect(mockLogWarn).toHaveBeenCalledWith( + 'coordinator.kimi_mcp', + expect.stringContaining('refusing overwrite'), + expect.objectContaining({ taskId: 'task-1', configPath }), + ); + }); }); // ─── MCP config restart rewrite tests ──────────────────────────────────────── diff --git a/electron/mcp/coordinator.ts b/electron/mcp/coordinator.ts index 6a695453b..d4385f49b 100644 --- a/electron/mcp/coordinator.ts +++ b/electron/mcp/coordinator.ts @@ -4,6 +4,7 @@ import { randomUUID, randomBytes } from 'crypto'; import { execFile } from 'child_process'; +import { join } from 'path'; import { promisify } from 'util'; import { unlinkSync, readFileSync, existsSync } from 'fs'; import { unlink as fsUnlink } from 'fs/promises'; @@ -14,9 +15,10 @@ import { writeSubTaskMcpConfig, writeSubTaskMcpConfigSync, } from './config.js'; -import { buildMcpLaunchArgs } from './agent-args.js'; +import { buildMcpLaunchArgs, isKimiCommand } from './agent-args.js'; import { validateBranchName } from './validation.js'; import { atomicWriteFileSync } from './atomic.js'; +import { appendGitInfoExcludeBlock } from '../ipc/git-exclude.js'; import { ReplayCache } from './replay-cache.js'; import { detectPreambleFiles, @@ -90,6 +92,38 @@ const PREAMBLE_ARTIFACT_PATHS = new Set([ ]); const UNRESOLVED_LANDED_COMMIT = 'unresolved'; +type McpJsonContent = Record & { + mcpServers?: Record; +}; + +function readMcpJsonContent(configPath: string): McpJsonContent { + if (!existsSync(configPath)) return {}; + + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(configPath, 'utf-8')); + } catch { + throw new Error(`${configPath} contains invalid JSON`); + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`${configPath} must contain a JSON object`); + } + + const content = parsed as McpJsonContent; + const servers = content.mcpServers; + if ( + servers !== undefined && + (!servers || typeof servers !== 'object' || Array.isArray(servers)) + ) { + throw new Error(`${configPath} mcpServers must be a JSON object`); + } + return content; +} + +function mcpEntriesMatch(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + function pasteDelayMs(text: string): number { const lines = text.split('\n').length; return Math.min(500, Math.max(50, lines * 15)); @@ -608,6 +642,7 @@ export class Coordinator { doneToken: task.doneToken, }); writeSubTaskMcpConfigSync(mcpConfigPath, mcpConfig); + this.writeKimiAutoDiscoveredMcpConfig(task, mcpConfig); } } @@ -867,6 +902,7 @@ export class Coordinator { if (!this.win) throw new Error('No window set on coordinator'); const agentCommand = opts.agentCommand ?? coordinatorState.spawnDefaults.command; + task.agentCommand = agentCommand; const dockerContainerName = this.coordinators.get(task.coordinatorTaskId)?.dockerContainerName ?? null; @@ -902,6 +938,7 @@ export class Coordinator { await writeSubTaskMcpConfig(configPath, mcpConfig); subTaskMcpConfigPath = configPath; task.mcpConfigPath = configPath; + this.writeKimiAutoDiscoveredMcpConfig(task, mcpConfig); } const agentArgs = opts.agentArgs ?? coordinatorState.spawnDefaults.args; @@ -1508,6 +1545,78 @@ export class Coordinator { this.clearAgentBuffers(task.agentId); } + private writeKimiAutoDiscoveredMcpConfig( + task: CoordinatedTask, + mcpConfig: ReturnType, + ): void { + if (!task.agentCommand || !isKimiCommand(task.agentCommand)) return; + + const configPath = join(task.worktreePath, '.mcp.json'); + const writtenParallelCode = mcpConfig.mcpServers['parallel-code']; + const priorState = task.autoDiscoveredMcpConfig; + const content = readMcpJsonContent(configPath); + const servers = content.mcpServers ?? {}; + + if ( + priorState?.path === configPath && + !mcpEntriesMatch(servers['parallel-code'], priorState.writtenParallelCode) + ) { + logWarn('coordinator.kimi_mcp', 'auto-discovered MCP config changed; refusing overwrite', { + taskId: task.id, + configPath, + }); + return; + } + + const previousParallelCode = + priorState?.path === configPath ? priorState.previousParallelCode : servers['parallel-code']; + content.mcpServers = { ...servers, 'parallel-code': writtenParallelCode }; + atomicWriteFileSync(configPath, JSON.stringify(content, null, 2), { mode: 0o600 }); + task.autoDiscoveredMcpConfig = { + path: configPath, + previousParallelCode, + writtenParallelCode, + }; + + appendGitInfoExcludeBlock( + task.worktreePath, + '.mcp.json', + '# Parallel Code MCP config (contains ephemeral token)\n.mcp.json\n', + (err) => console.warn('[MCP] Could not git-exclude child .mcp.json:', err), + ); + } + + private restoreTaskAutoDiscoveredMcpConfig(task: CoordinatedTask): void { + const state = task.autoDiscoveredMcpConfig; + task.autoDiscoveredMcpConfig = undefined; + if (!state) return; + + try { + if (!existsSync(state.path)) return; + const content = readMcpJsonContent(state.path); + const servers = content.mcpServers ?? {}; + if (!mcpEntriesMatch(servers['parallel-code'], state.writtenParallelCode)) return; + + if (state.previousParallelCode !== undefined) { + servers['parallel-code'] = state.previousParallelCode; + } else { + delete servers['parallel-code']; + } + + const hasServers = Object.keys(servers).length > 0; + const hasOtherKeys = Object.keys(content).some((key) => key !== 'mcpServers'); + if (!hasServers && !hasOtherKeys) { + unlinkSync(state.path); + return; + } + if (hasServers) content.mcpServers = servers; + else delete content.mcpServers; + atomicWriteFileSync(state.path, JSON.stringify(content, null, 2), { mode: 0o600 }); + } catch { + // Best effort: malformed, concurrently removed, or inaccessible files are left untouched. + } + } + /** Best-effort removal of a task's per-sub-task MCP config file. */ private unlinkMcpConfigFile(path: string | undefined): void { if (!path) return; @@ -1519,6 +1628,7 @@ export class Coordinator { } private clearTaskMcpConfig(task: CoordinatedTask): void { + this.restoreTaskAutoDiscoveredMcpConfig(task); this.unlinkMcpConfigFile(task.mcpConfigPath); task.mcpConfigPath = undefined; } @@ -1907,6 +2017,7 @@ export class Coordinator { const existingTask = this.tasks.get(opts.id); if (existingTask) { + existingTask.agentCommand = opts.agentCommand ?? existingTask.agentCommand; if (safeMcpConfigPath) existingTask.mcpConfigPath = safeMcpConfigPath; const mcpLaunchArgs = this.rewriteHydratedSubtaskMcpConfig( existingTask, @@ -1940,6 +2051,7 @@ export class Coordinator { landingSummary: opts.landingSummary, landedMetadata: opts.landedMetadata, preambleFileExistedBefore: opts.preambleFileExistedBefore, + agentCommand: opts.agentCommand, }; this.tasks.set(task.id, task); if (opts.landedMetadata) { @@ -2012,6 +2124,8 @@ export class Coordinator { if (mcpConfigPath) { writeSubTaskMcpConfigSync(mcpConfigPath, mcpConfig); } + task.agentCommand = agentCommand ?? task.agentCommand ?? 'claude'; + this.writeKimiAutoDiscoveredMcpConfig(task, mcpConfig); return buildMcpLaunchArgs(agentCommand ?? 'claude', mcpConfigPath, mcpConfig); } diff --git a/electron/mcp/types.ts b/electron/mcp/types.ts index 1b0044b4e..7edb31e12 100644 --- a/electron/mcp/types.ts +++ b/electron/mcp/types.ts @@ -16,6 +16,12 @@ export interface CoordinatedTask { initialPrompt?: string; automationWriteInFlight?: boolean; mcpConfigPath?: string; // path to per-task tmp config, deleted on cleanup + autoDiscoveredMcpConfig?: { + path: string; + previousParallelCode?: unknown; + writtenParallelCode: unknown; + }; + agentCommand?: string; doneToken?: string; // per-task token; only the owning sub-task may call /done preambleFileExistedBefore?: boolean; // true if the preamble file existed before injection (even if empty) signalDoneAt?: Date; // set when sub-task explicitly calls signal_done From e6236f300c5193ed305d57fd243078c9794d35b0 Mon Sep 17 00:00:00 2001 From: Liang Hu Date: Sun, 2 Aug 2026 10:10:26 -0400 Subject: [PATCH 03/16] fix(mcp): preserve Kimi launch args on hydration --- electron/mcp/coordinator.test.ts | 30 ++++++++++++++++++++++++++++++ electron/mcp/coordinator.ts | 2 +- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/electron/mcp/coordinator.test.ts b/electron/mcp/coordinator.test.ts index 22fdddf49..7b0698f59 100644 --- a/electron/mcp/coordinator.test.ts +++ b/electron/mcp/coordinator.test.ts @@ -3551,6 +3551,36 @@ describe('Coordinator hydrateTask — restart hydration', () => { expect(task?.status).toBe('exited'); }); + it('hydrateTask keeps Kimi launch args empty when the existing task command is reused', async () => { + coordinator.setCoordinatorSpawnDefaults('coord-1', 'kimi', []); + coordinator.setMCPServerInfo( + 'coord-1', + 'http://localhost:3001', + 'coordinator-token', + 'subtask-token', + '/path/server.js', + ); + const task = await coordinator.createTask({ + name: 'kimi-task', + prompt: 'do', + coordinatorTaskId: 'coord-1', + }); + + const result = coordinator.hydrateTask({ + id: task.id, + name: task.name, + projectId: task.projectId, + projectRoot: task.projectRoot, + branchName: task.branchName, + worktreePath: task.worktreePath, + agentId: task.agentId, + coordinatorTaskId: task.coordinatorTaskId, + mcpConfigPath: task.mcpConfigPath, + }); + + expect(result.mcpLaunchArgs).toEqual([]); + }); + it('hydrateTask restores an undelivered initial prompt for backend delivery', () => { coordinator.hydrateTask({ id: 'hydrated-1', diff --git a/electron/mcp/coordinator.ts b/electron/mcp/coordinator.ts index d4385f49b..7d8d5def7 100644 --- a/electron/mcp/coordinator.ts +++ b/electron/mcp/coordinator.ts @@ -2126,7 +2126,7 @@ export class Coordinator { } task.agentCommand = agentCommand ?? task.agentCommand ?? 'claude'; this.writeKimiAutoDiscoveredMcpConfig(task, mcpConfig); - return buildMcpLaunchArgs(agentCommand ?? 'claude', mcpConfigPath, mcpConfig); + return buildMcpLaunchArgs(task.agentCommand, mcpConfigPath, mcpConfig); } isRegisteredCoordinator(coordinatorTaskId: string): boolean { From d4ae1a60d438956ed1cb3de32d1c03c384761ec0 Mon Sep 17 00:00:00 2001 From: Liang Hu Date: Tue, 4 Aug 2026 10:02:13 -0400 Subject: [PATCH 04/16] fix(mcp): preserve Kimi config restoration state --- electron/ipc/register.ts | 2 + electron/mcp/coordinator.test.ts | 140 +++++++++++++++++++++++++ electron/mcp/coordinator.ts | 170 +++++++++++++++++++++++-------- electron/mcp/types.ts | 12 ++- src/App.tsx | 8 +- src/store/autosave.ts | 1 + src/store/persistence.test.ts | 60 +++++++++++ src/store/persistence.ts | 3 + src/store/tasks.test.ts | 15 +++ src/store/tasks.ts | 42 +++++++- src/store/types.ts | 8 ++ 11 files changed, 407 insertions(+), 54 deletions(-) diff --git a/electron/ipc/register.ts b/electron/ipc/register.ts index 25be5e469..9e0c98091 100644 --- a/electron/ipc/register.ts +++ b/electron/ipc/register.ts @@ -1507,6 +1507,7 @@ export function registerAllHandlers(win: BrowserWindow): void { landingSummary?: string; landedMetadata?: import('../mcp/types.js').LandedMetadata; mcpConfigPath?: string; + autoDiscoveredMcpConfig?: import('../mcp/types.js').AutoDiscoveredMcpConfigState; agentCommand?: string; preambleFileExistedBefore?: boolean; initialPrompt?: string; @@ -1545,6 +1546,7 @@ export function registerAllHandlers(win: BrowserWindow): void { landingSummary: args.landingSummary, landedMetadata: args.landedMetadata, mcpConfigPath: args.mcpConfigPath, + autoDiscoveredMcpConfig: args.autoDiscoveredMcpConfig, agentCommand: args.agentCommand, preambleFileExistedBefore: args.preambleFileExistedBefore, initialPrompt: args.initialPrompt, diff --git a/electron/mcp/coordinator.test.ts b/electron/mcp/coordinator.test.ts index 7b0698f59..6efd45a4e 100644 --- a/electron/mcp/coordinator.test.ts +++ b/electron/mcp/coordinator.test.ts @@ -1523,6 +1523,74 @@ describe('Coordinator land_self', () => { ); }); + it('restores a tracked Kimi .mcp.json before checking and merging the worktree', async () => { + const configPath = '/tmp/test/.mcp.json'; + const previousParallelCode = { command: 'user-owned-server' }; + let currentConfig = JSON.stringify({ + mcpServers: { 'parallel-code': previousParallelCode }, + }); + mockExistsSync.mockImplementation((path) => path === configPath); + mockReadFileSync.mockImplementation((path) => + path === configPath ? currentConfig : '# existing\n', + ); + mockAtomicWriteFileSync.mockImplementation((path, raw) => { + if (path === configPath) currentConfig = raw as string; + }); + mockExecFile.mockImplementation( + ( + _cmd: string, + args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + if (args.join(' ') === 'rev-parse --abbrev-ref HEAD') { + cb(null, 'task/test\n', ''); + return; + } + if (args[0] === 'status') { + const config = JSON.parse(currentConfig) as { + mcpServers: Record; + }; + const restored = config.mcpServers['parallel-code']; + const isRestored = JSON.stringify(restored) === JSON.stringify(previousParallelCode); + cb(null, isRestored ? '' : ' M .mcp.json\n', ''); + return; + } + if (args.join(' ') === 'rev-parse HEAD') { + cb(null, 'landed-sha\n', ''); + return; + } + cb(null, '', ''); + }, + ); + + const kimiCoordinator = new Coordinator(); + kimiCoordinator.setWindow(mockWin); + kimiCoordinator.setDefaultProject('proj-1', '/tmp/project'); + kimiCoordinator.registerCoordinator('coord-kimi', 'proj-1', { + worktreePath: '/tmp/project', + }); + kimiCoordinator.setCoordinatorSpawnDefaults('coord-kimi', 'kimi', []); + kimiCoordinator.setMCPServerInfo( + 'coord-kimi', + 'http://localhost:3001', + 'coordinator-token', + 'subtask-token', + '/path/server.js', + ); + await kimiCoordinator.createTask({ + name: 'test', + prompt: 'do', + coordinatorTaskId: 'coord-kimi', + }); + + await kimiCoordinator.landSelf('task-1', { verification }); + + const restored = JSON.parse(currentConfig) as { mcpServers: Record }; + expect(restored.mcpServers['parallel-code']).toEqual(previousParallelCode); + expect(vi.mocked(mergeTask)).toHaveBeenCalled(); + }); + it('stages a landed notification so the coordinator hears about successful self-land', async () => { await coordinator.landSelf('task-1', { verification, summary: 'done' }); @@ -3221,6 +3289,78 @@ describe('Coordinator sub-task MCP config isolation', () => { expect(restored.setting).toBe(true); }); + it('preserves the original Kimi entry across restart hydration and deregistration', async () => { + const configPath = '/tmp/test/.mcp.json'; + const previousParallelCode = { command: 'user-owned-server' }; + let currentConfig = JSON.stringify({ + mcpServers: { + other: { command: 'other-server' }, + 'parallel-code': previousParallelCode, + }, + }); + mockExistsSync.mockImplementation((path) => path === configPath); + mockReadFileSync.mockImplementation((path) => + path === configPath ? currentConfig : '# existing\n', + ); + mockAtomicWriteFileSync.mockImplementation((path, raw) => { + if (path === configPath) currentConfig = raw as string; + }); + coordinator.setCoordinatorSpawnDefaults('coord-1', 'kimi', []); + coordinator.setMCPServerInfo( + 'coord-1', + 'http://localhost:3001', + 'old-coordinator-token', + 'old-subtask-token', + '/path/server.js', + ); + const task = await coordinator.createTask({ + name: 'test', + prompt: 'do', + coordinatorTaskId: 'coord-1', + }); + const persistedState = task.autoDiscoveredMcpConfig; + expect(persistedState?.previousParallelCode).toEqual(previousParallelCode); + + const restarted = new Coordinator(); + restarted.setWindow(mockWin); + restarted.setDefaultProject('proj-1', '/tmp/project'); + restarted.registerCoordinator('coord-1', 'proj-1'); + restarted.setMCPServerInfo( + 'coord-1', + 'http://localhost:3002', + 'new-coordinator-token', + 'new-subtask-token', + '/path/server.js', + ); + const result = restarted.hydrateTask({ + id: task.id, + name: task.name, + projectId: task.projectId, + projectRoot: task.projectRoot, + branchName: task.branchName, + worktreePath: task.worktreePath, + agentId: task.agentId, + coordinatorTaskId: task.coordinatorTaskId, + mcpConfigPath: task.mcpConfigPath, + autoDiscoveredMcpConfig: persistedState, + agentCommand: 'kimi', + }); + + expect(result.autoDiscoveredMcpConfig?.previousParallelCode).toEqual(previousParallelCode); + const refreshed = JSON.parse(currentConfig) as { + mcpServers: { 'parallel-code': { env: Record } }; + }; + expect(refreshed.mcpServers['parallel-code'].env['PARALLEL_CODE_MCP_TOKEN']).toBe( + 'new-subtask-token', + ); + + restarted.deregisterCoordinator('coord-1'); + + const restored = JSON.parse(currentConfig) as { mcpServers: Record }; + expect(restored.mcpServers['parallel-code']).toEqual(previousParallelCode); + expect(restored.mcpServers.other).toEqual({ command: 'other-server' }); + }); + it('does not overwrite a Kimi child MCP entry changed after creation', async () => { const configPath = '/tmp/test/.mcp.json'; let configExists = false; diff --git a/electron/mcp/coordinator.ts b/electron/mcp/coordinator.ts index 7d8d5def7..73e1b96a9 100644 --- a/electron/mcp/coordinator.ts +++ b/electron/mcp/coordinator.ts @@ -2,7 +2,7 @@ // Manages task lifecycle independently of the SolidJS renderer, // using existing backend primitives (pty, git, tasks). -import { randomUUID, randomBytes } from 'crypto'; +import { createHash, randomUUID, randomBytes } from 'crypto'; import { execFile } from 'child_process'; import { join } from 'path'; import { promisify } from 'util'; @@ -66,6 +66,7 @@ import type { ApiTaskDetail, ApiDiffResult, ApiLandSelfResult, + AutoDiscoveredMcpConfigState, LandSelfInput, LandingState, SubtaskVerification, @@ -120,8 +121,29 @@ function readMcpJsonContent(configPath: string): McpJsonContent { return content; } -function mcpEntriesMatch(left: unknown, right: unknown): boolean { - return JSON.stringify(left) === JSON.stringify(right); +function mcpEntryFingerprint(value: unknown): string { + return createHash('sha256') + .update(JSON.stringify(value) ?? 'undefined') + .digest('hex'); +} + +function validateAutoDiscoveredMcpConfigState( + value: unknown, + worktreePath: string, +): AutoDiscoveredMcpConfigState | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const state = value as Record; + if (state.path !== join(worktreePath, '.mcp.json')) return undefined; + if ( + typeof state.writtenParallelCodeFingerprint !== 'string' || + !/^[a-f0-9]{64}$/.test(state.writtenParallelCodeFingerprint) + ) + return undefined; + return { + path: state.path, + previousParallelCode: state.previousParallelCode, + writtenParallelCodeFingerprint: state.writtenParallelCodeFingerprint, + }; } function pasteDelayMs(text: string): number { @@ -1002,6 +1024,7 @@ export class Coordinator { agentId: task.agentId, coordinatorTaskId: task.coordinatorTaskId, mcpConfigPath: subTaskMcpConfigPath, + autoDiscoveredMcpConfig: task.autoDiscoveredMcpConfig, prompt: task.initialPrompt, preambleFileExistedBefore: task.preambleFileExistedBefore, agentCommand: agentCommand, @@ -1559,7 +1582,7 @@ export class Coordinator { if ( priorState?.path === configPath && - !mcpEntriesMatch(servers['parallel-code'], priorState.writtenParallelCode) + mcpEntryFingerprint(servers['parallel-code']) !== priorState.writtenParallelCodeFingerprint ) { logWarn('coordinator.kimi_mcp', 'auto-discovered MCP config changed; refusing overwrite', { taskId: task.id, @@ -1575,8 +1598,9 @@ export class Coordinator { task.autoDiscoveredMcpConfig = { path: configPath, previousParallelCode, - writtenParallelCode, + writtenParallelCodeFingerprint: mcpEntryFingerprint(writtenParallelCode), }; + this.syncAutoDiscoveredMcpConfig(task); appendGitInfoExcludeBlock( task.worktreePath, @@ -1586,16 +1610,25 @@ export class Coordinator { ); } - private restoreTaskAutoDiscoveredMcpConfig(task: CoordinatedTask): void { + private syncAutoDiscoveredMcpConfig(task: CoordinatedTask): void { + this.notifyRenderer(IPC.MCP_TaskStateSync, { + taskId: task.id, + autoDiscoveredMcpConfig: task.autoDiscoveredMcpConfig ?? null, + }); + } + + private restoreTaskAutoDiscoveredMcpConfig(task: CoordinatedTask): boolean { const state = task.autoDiscoveredMcpConfig; task.autoDiscoveredMcpConfig = undefined; - if (!state) return; + if (!state) return false; + this.syncAutoDiscoveredMcpConfig(task); try { - if (!existsSync(state.path)) return; + if (!existsSync(state.path)) return true; const content = readMcpJsonContent(state.path); const servers = content.mcpServers ?? {}; - if (!mcpEntriesMatch(servers['parallel-code'], state.writtenParallelCode)) return; + if (mcpEntryFingerprint(servers['parallel-code']) !== state.writtenParallelCodeFingerprint) + return false; if (state.previousParallelCode !== undefined) { servers['parallel-code'] = state.previousParallelCode; @@ -1607,13 +1640,31 @@ export class Coordinator { const hasOtherKeys = Object.keys(content).some((key) => key !== 'mcpServers'); if (!hasServers && !hasOtherKeys) { unlinkSync(state.path); - return; + return true; } if (hasServers) content.mcpServers = servers; else delete content.mcpServers; atomicWriteFileSync(state.path, JSON.stringify(content, null, 2), { mode: 0o600 }); + return true; } catch { // Best effort: malformed, concurrently removed, or inaccessible files are left untouched. + return false; + } + } + + private refreshTaskMcpConfigAfterLandingFailure(task: CoordinatedTask): void { + try { + this.rewriteHydratedSubtaskMcpConfig( + task, + task.coordinatorTaskId, + task.mcpConfigPath, + task.agentCommand, + ); + } catch (err) { + logWarn('coordinator.kimi_mcp', 'failed to restore MCP config after landing failure', { + taskId: task.id, + error: err instanceof Error ? err.message : String(err), + }); } } @@ -1704,9 +1755,11 @@ export class Coordinator { task.verification = input.verification; task.landingSummary = input.summary; + const shouldRefreshMcpConfig = this.restoreTaskAutoDiscoveredMcpConfig(task); try { await this.prepareCleanSelfLandingWorktree(task); } catch (err) { + if (shouldRefreshMcpConfig) this.refreshTaskMcpConfigAfterLandingFailure(task); const reason = err instanceof Error ? err.message : String(err); this.escalateLanding(task, 'landing_escalated', reason); throw err; @@ -1716,6 +1769,7 @@ export class Coordinator { try { mergeResult = await this.runGitMerge(task, { squash: false }); } catch (err) { + if (shouldRefreshMcpConfig) this.refreshTaskMcpConfigAfterLandingFailure(task); const reason = err instanceof Error ? err.message : String(err); const state = reason.toLowerCase().includes('conflict') || reason.includes('Merge failed') @@ -1797,42 +1851,51 @@ export class Coordinator { const task = this.tasks.get(taskId); if (!task) throw new Error(`Task not found: ${taskId}`); this.assertTaskCanBeMerged(task); + const shouldRefreshMcpConfig = this.restoreTaskAutoDiscoveredMcpConfig(task); - // Strip injected preamble files before staging so they don't land in history, - // then auto-commit any uncommitted changes in the task worktree before merging. - if (task.worktreePath) { - await stripPreambleFromBranch(task); - try { - await execAsync('git', ['add', '-A'], { cwd: task.worktreePath }); - await execAsync('git', ['commit', '-m', 'WIP: auto-commit before merge'], { - cwd: task.worktreePath, - }); - } catch { - // Commit failed — check if uncommitted changes still exist - const { stdout: statusOut } = await execAsync('git', ['status', '--porcelain'], { - cwd: task.worktreePath, - }); - if (statusOut.trim()) { - throw new Error( - `Auto-commit failed and the task worktree still has uncommitted changes. ` + - `Please commit or discard changes in ${task.worktreePath} before merging.`, - ); + try { + // Strip injected preamble files before staging so they don't land in history, + // then auto-commit any uncommitted changes in the task worktree before merging. + if (task.worktreePath) { + await stripPreambleFromBranch(task); + try { + await execAsync('git', ['add', '-A'], { cwd: task.worktreePath }); + await execAsync('git', ['commit', '-m', 'WIP: auto-commit before merge'], { + cwd: task.worktreePath, + }); + } catch { + // Commit failed — check if uncommitted changes still exist + const { stdout: statusOut } = await execAsync('git', ['status', '--porcelain'], { + cwd: task.worktreePath, + }); + if (statusOut.trim()) { + throw new Error( + `Auto-commit failed and the task worktree still has uncommitted changes. ` + + `Please commit or discard changes in ${task.worktreePath} before merging.`, + ); + } + // Nothing to commit — swallow silently } - // Nothing to commit — swallow silently } - } - const result = await this.runGitMerge(task, opts); + const result = await this.runGitMerge(task, opts); - if (opts?.cleanup) { - await this.cleanupTask(taskId); - } + if (opts?.cleanup) { + await this.cleanupTask(taskId); + } + if (this.tasks.has(taskId) && shouldRefreshMcpConfig) { + this.refreshTaskMcpConfigAfterLandingFailure(task); + } - return { - mainBranch: result.mainBranch, - linesAdded: result.linesAdded, - linesRemoved: result.linesRemoved, - }; + return { + mainBranch: result.mainBranch, + linesAdded: result.linesAdded, + linesRemoved: result.linesRemoved, + }; + } catch (err) { + if (shouldRefreshMcpConfig) this.refreshTaskMcpConfigAfterLandingFailure(task); + throw err; + } } private assertTaskCanBeMerged(task: CoordinatedTask): void { @@ -1995,12 +2058,16 @@ export class Coordinator { landingSummary?: string; landedMetadata?: CoordinatedTask['landedMetadata']; mcpConfigPath?: string; + autoDiscoveredMcpConfig?: AutoDiscoveredMcpConfigState; agentCommand?: string; preambleFileExistedBefore?: boolean; initialPrompt?: string; pendingPrompts?: string[]; assignedPromptDelivered?: boolean; - }): { mcpLaunchArgs?: string[] } { + }): { + mcpLaunchArgs?: string[]; + autoDiscoveredMcpConfig: AutoDiscoveredMcpConfigState | null; + } { const coordinatorState = this.coordinators.get(opts.coordinatorTaskId); if (!coordinatorState) { throw new Error(`coordinator ${opts.coordinatorTaskId} is not registered`); @@ -2019,13 +2086,22 @@ export class Coordinator { if (existingTask) { existingTask.agentCommand = opts.agentCommand ?? existingTask.agentCommand; if (safeMcpConfigPath) existingTask.mcpConfigPath = safeMcpConfigPath; + if (opts.autoDiscoveredMcpConfig !== undefined) { + existingTask.autoDiscoveredMcpConfig = validateAutoDiscoveredMcpConfigState( + opts.autoDiscoveredMcpConfig, + existingTask.worktreePath, + ); + } const mcpLaunchArgs = this.rewriteHydratedSubtaskMcpConfig( existingTask, opts.coordinatorTaskId, safeMcpConfigPath ?? existingTask.mcpConfigPath, opts.agentCommand, ); - return { mcpLaunchArgs }; + return { + mcpLaunchArgs, + autoDiscoveredMcpConfig: existingTask.autoDiscoveredMcpConfig ?? null, + }; } const task: CoordinatedTask = { @@ -2052,6 +2128,10 @@ export class Coordinator { landedMetadata: opts.landedMetadata, preambleFileExistedBefore: opts.preambleFileExistedBefore, agentCommand: opts.agentCommand, + autoDiscoveredMcpConfig: validateAutoDiscoveredMcpConfigState( + opts.autoDiscoveredMcpConfig, + opts.worktreePath, + ), }; this.tasks.set(task.id, task); if (opts.landedMetadata) { @@ -2093,12 +2173,16 @@ export class Coordinator { } catch { /* agent not yet spawned — onPtyEvent('spawn') will subscribe when it starts */ } - return { mcpLaunchArgs }; + return { + mcpLaunchArgs, + autoDiscoveredMcpConfig: task.autoDiscoveredMcpConfig ?? null, + }; } catch (err) { // Clean up partial map entries so the agentId doesn't linger in state. this.clearAgentBuffers(agentId); this.subscribers.delete(agentId); this.clearPromptDeliveryState(task.id); + this.clearTaskMcpConfig(task); this.tasks.delete(task.id); throw err; } diff --git a/electron/mcp/types.ts b/electron/mcp/types.ts index 7edb31e12..00f49e586 100644 --- a/electron/mcp/types.ts +++ b/electron/mcp/types.ts @@ -1,5 +1,11 @@ // Shared types for the MCP coordinating-agent system. +export interface AutoDiscoveredMcpConfigState { + path: string; + previousParallelCode?: unknown; + writtenParallelCodeFingerprint: string; +} + export interface CoordinatedTask { id: string; name: string; @@ -16,11 +22,7 @@ export interface CoordinatedTask { initialPrompt?: string; automationWriteInFlight?: boolean; mcpConfigPath?: string; // path to per-task tmp config, deleted on cleanup - autoDiscoveredMcpConfig?: { - path: string; - previousParallelCode?: unknown; - writtenParallelCode: unknown; - }; + autoDiscoveredMcpConfig?: AutoDiscoveredMcpConfigState; agentCommand?: string; doneToken?: string; // per-task token; only the owning sub-task may call /done preambleFileExistedBefore?: boolean; // true if the preamble file existed before injection (even if empty) diff --git a/src/App.tsx b/src/App.tsx index b062b29b7..8d92a492c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -65,7 +65,7 @@ import { markTaskMcpError, } from './store/store'; import { isGitHubUrl } from './lib/github-url'; -import type { PersistedWindowState } from './store/types'; +import type { PersistedWindowState, Task } from './store/types'; import { initShortcuts, registerFromRegistry, @@ -451,7 +451,10 @@ function App() { if (!projectRoot) continue; markTaskMcpPending(task.id); hydratePromises.push( - invoke<{ mcpLaunchArgs?: string[] }>(IPC.MCP_HydrateCoordinatedTask, { + invoke<{ + mcpLaunchArgs?: string[]; + autoDiscoveredMcpConfig?: Task['autoDiscoveredMcpConfig'] | null; + }>(IPC.MCP_HydrateCoordinatedTask, { id: task.id, name: task.name, projectId: task.projectId, @@ -470,6 +473,7 @@ function App() { landingSummary: task.landingSummary, landedMetadata: task.landedMetadata, mcpConfigPath: task.mcpConfigPath, + autoDiscoveredMcpConfig: task.autoDiscoveredMcpConfig, agentCommand: store.agents[task.agentIds[0]]?.def.command ?? 'claude', preambleFileExistedBefore: task.preambleFileExistedBefore, initialPrompt: task.initialPrompt, diff --git a/src/store/autosave.ts b/src/store/autosave.ts index 9b269c89a..e911800e7 100644 --- a/src/store/autosave.ts +++ b/src/store/autosave.ts @@ -66,6 +66,7 @@ export function persistedSnapshot(): string { coordinatedBy: t.coordinatedBy, coordinatorMode: t.coordinatorMode, mcpConfigPath: t.mcpConfigPath, + autoDiscoveredMcpConfig: t.autoDiscoveredMcpConfig, preambleFileExistedBefore: t.preambleFileExistedBefore, signalDoneReceived: t.signalDoneReceived, signalDoneAt: t.signalDoneAt, diff --git a/src/store/persistence.test.ts b/src/store/persistence.test.ts index 6ae209780..0c369ed9d 100644 --- a/src/store/persistence.test.ts +++ b/src/store/persistence.test.ts @@ -243,6 +243,66 @@ describe('landing state persistence', () => { }); }); +describe('Kimi auto-discovered MCP config persistence', () => { + const autoDiscoveredMcpConfig = { + path: '/repo/.worktrees/task-1/.mcp.json', + previousParallelCode: { command: 'user-owned-server' }, + writtenParallelCodeFingerprint: 'a'.repeat(64), + }; + + it('saves the restoration snapshot with a coordinated task', async () => { + setStore('taskOrder', ['task-1']); + setStore('tasks', { + 'task-1': { + id: 'task-1', + name: 'Task', + projectId: 'project-1', + branchName: 'task/task-1', + worktreePath: '/repo/.worktrees/task-1', + agentIds: [], + shellAgentIds: [], + notes: '', + lastPrompt: '', + gitIsolation: 'worktree', + coordinatedBy: 'coord-1', + autoDiscoveredMcpConfig, + }, + }); + mockInvoke.mockResolvedValueOnce(undefined); + + await saveState(); + + const saved = JSON.parse(mockInvoke.mock.calls[0][1].json); + expect(saved.tasks['task-1'].autoDiscoveredMcpConfig).toEqual(autoDiscoveredMcpConfig); + }); + + it('restores the snapshot for restart hydration', async () => { + const def = agentDef(); + mockInvoke.mockResolvedValueOnce( + JSON.stringify({ + projects: [{ id: 'project-1', name: 'Repo', path: '/repo', color: 'hsl(0, 70%, 75%)' }], + lastProjectId: 'project-1', + lastAgentId: null, + taskOrder: ['task-1'], + collapsedTaskOrder: [], + tasks: { + 'task-1': { + ...persistedTask(def), + coordinatedBy: 'coord-1', + autoDiscoveredMcpConfig, + }, + }, + activeTaskId: 'task-1', + sidebarVisible: true, + }), + ); + + await loadState(); + + expect(store.tasks['task-1'].autoDiscoveredMcpConfig).toEqual(autoDiscoveredMcpConfig); + }); +}); + describe('PR URL persistence', () => { it('persists task PR URLs', async () => { setStore('taskOrder', ['task-1']); diff --git a/src/store/persistence.ts b/src/store/persistence.ts index 330867348..fe860cff7 100644 --- a/src/store/persistence.ts +++ b/src/store/persistence.ts @@ -153,6 +153,7 @@ function toPersistedTask(task: Task, agentDefs: AgentDef[], collapsed?: boolean) coordinatedBy: task.coordinatedBy, controlledBy: task.controlledBy, mcpConfigPath: task.mcpConfigPath, + autoDiscoveredMcpConfig: task.autoDiscoveredMcpConfig, signalDoneReceived: task.signalDoneReceived, signalDoneAt: task.signalDoneAt, signalDoneConsumed: task.signalDoneConsumed, @@ -684,6 +685,7 @@ export async function loadState(): Promise { mcpStartupStatus: pt.coordinatorMode || pt.coordinatedBy ? ('pending' as const) : undefined, mcpConfigPath: pt.mcpConfigPath, + autoDiscoveredMcpConfig: pt.autoDiscoveredMcpConfig, signalDoneReceived: pt.signalDoneReceived, signalDoneAt: pt.signalDoneAt, signalDoneConsumed: pt.signalDoneConsumed, @@ -790,6 +792,7 @@ export async function loadState(): Promise { mcpStartupStatus: pt.coordinatorMode || pt.coordinatedBy ? ('pending' as const) : undefined, mcpConfigPath: pt.mcpConfigPath, + autoDiscoveredMcpConfig: pt.autoDiscoveredMcpConfig, signalDoneReceived: pt.signalDoneReceived, signalDoneAt: pt.signalDoneAt, signalDoneConsumed: pt.signalDoneConsumed, diff --git a/src/store/tasks.test.ts b/src/store/tasks.test.ts index 7c0efb87a..f646f5c27 100644 --- a/src/store/tasks.test.ts +++ b/src/store/tasks.test.ts @@ -1328,6 +1328,21 @@ describe('MCP_TaskStateSync listener', () => { expect(mockTasks['task-1'].automationWriteInFlight).toBe(true); }); + it('stores and clears the auto-discovered MCP restoration snapshot', () => { + const snapshot = { + path: '/repo/.worktrees/task-1/.mcp.json', + previousParallelCode: { command: 'user-owned-server' }, + writtenParallelCodeFingerprint: 'a'.repeat(64), + }; + + taskStateSyncHandler({ taskId: 'task-1', autoDiscoveredMcpConfig: snapshot }); + expect(mockTasks['task-1'].autoDiscoveredMcpConfig).toEqual(snapshot); + expect(mockSaveState).toHaveBeenCalled(); + + taskStateSyncHandler({ taskId: 'task-1', autoDiscoveredMcpConfig: null }); + expect(mockTasks['task-1'].autoDiscoveredMcpConfig).toBeUndefined(); + }); + it('stores landed pending-review and verification sync fields', () => { taskStateSyncHandler({ taskId: 'task-1', diff --git a/src/store/tasks.ts b/src/store/tasks.ts index bb4e5a10f..67b6ee64a 100644 --- a/src/store/tasks.ts +++ b/src/store/tasks.ts @@ -27,7 +27,13 @@ import type { StepEntry, } from '../ipc/types'; import { parseGitHubUrl, taskNameFromGitHubUrl } from '../lib/github-url'; -import type { Agent, Task, GitIsolationMode, AppStore } from './types'; +import type { + Agent, + AppStore, + AutoDiscoveredMcpConfigState, + GitIsolationMode, + Task, +} from './types'; import type { DockerSource } from '../lib/docker'; import { COORDINATOR_PREAMBLE } from './coordinator-preamble'; import { @@ -1092,6 +1098,7 @@ interface MCPTaskCreatedEvent { coordinatorTaskId: string; prompt?: string; mcpConfigPath?: string; + autoDiscoveredMcpConfig?: AutoDiscoveredMcpConfigState; preambleFileExistedBefore?: boolean; agentCommand?: string; agentArgs?: string[]; @@ -1126,6 +1133,7 @@ export function initMCPListeners(): () => void { // background sub-task panels may never mount a PromptInput. initialPrompt: evt.prompt, mcpConfigPath: evt.mcpConfigPath, + autoDiscoveredMcpConfig: evt.autoDiscoveredMcpConfig, mcpLaunchArgs: evt.mcpLaunchArgs, preambleFileExistedBefore: evt.preambleFileExistedBefore, skipPermissions: evt.skipPermissions ?? false, @@ -1299,6 +1307,7 @@ export function initMCPListeners(): () => void { controlledBy?: 'coordinator' | 'human' | null; automationWriteInFlight?: boolean; mcpConfigPath?: string | null; + autoDiscoveredMcpConfig?: AutoDiscoveredMcpConfigState | null; mcpStartupStatus?: 'pending' | 'ready' | 'error' | null; mcpStartupError?: string | null; }; @@ -1337,11 +1346,18 @@ export function initMCPListeners(): () => void { setStore('tasks', evt.taskId, 'automationWriteInFlight', evt.automationWriteInFlight); if (evt.mcpConfigPath !== undefined) setStore('tasks', evt.taskId, 'mcpConfigPath', evt.mcpConfigPath ?? undefined); + if (evt.autoDiscoveredMcpConfig !== undefined) + setStore( + 'tasks', + evt.taskId, + 'autoDiscoveredMcpConfig', + evt.autoDiscoveredMcpConfig ?? undefined, + ); if (evt.mcpStartupStatus !== undefined) setStore('tasks', evt.taskId, 'mcpStartupStatus', evt.mcpStartupStatus ?? undefined); if (evt.mcpStartupError !== undefined) setStore('tasks', evt.taskId, 'mcpStartupError', evt.mcpStartupError ?? undefined); - if (hasLandingStateUpdate) void saveState(); + if (hasLandingStateUpdate || evt.autoDiscoveredMcpConfig !== undefined) void saveState(); } }), window.electron.ipcRenderer.on(IPC.MCP_TaskHydrated, (data: unknown) => { @@ -1404,7 +1420,12 @@ function taskRequiresMcpLaunchArgs(taskId: string): boolean { export function applyTaskMcpLaunchResult( taskId: string, - result: { mcpLaunchArgs?: string[] } | undefined, + result: + | { + mcpLaunchArgs?: string[]; + autoDiscoveredMcpConfig?: AutoDiscoveredMcpConfigState | null; + } + | undefined, ): boolean { if (!store.tasks[taskId]) return false; const args = result?.mcpLaunchArgs; @@ -1413,6 +1434,15 @@ export function applyTaskMcpLaunchResult( return false; } if (Array.isArray(args)) setTaskMcpLaunchArgs(taskId, args); + if (result?.autoDiscoveredMcpConfig !== undefined) { + setStore( + 'tasks', + taskId, + 'autoDiscoveredMcpConfig', + result.autoDiscoveredMcpConfig ?? undefined, + ); + void saveState(); + } markTaskMcpReady(taskId); return true; } @@ -1474,7 +1504,10 @@ export function retryTaskMcpStartup(taskId: string): Promise { return Promise.resolve(); } const agentDef = task.agentIds[0] ? store.agents[task.agentIds[0]]?.def : undefined; - return invoke<{ mcpLaunchArgs?: string[] }>(IPC.MCP_HydrateCoordinatedTask, { + return invoke<{ + mcpLaunchArgs?: string[]; + autoDiscoveredMcpConfig?: AutoDiscoveredMcpConfigState | null; + }>(IPC.MCP_HydrateCoordinatedTask, { id: task.id, name: task.name, projectId: task.projectId, @@ -1493,6 +1526,7 @@ export function retryTaskMcpStartup(taskId: string): Promise { landingSummary: task.landingSummary, landedMetadata: task.landedMetadata, mcpConfigPath: task.mcpConfigPath, + autoDiscoveredMcpConfig: task.autoDiscoveredMcpConfig, agentCommand: agentDef?.command ?? 'claude', preambleFileExistedBefore: task.preambleFileExistedBefore, }) diff --git a/src/store/types.ts b/src/store/types.ts index e1040bafc..d4fa4c40d 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -9,6 +9,12 @@ export type KeybindingOverride = Partial> export type GitIsolationMode = 'worktree' | 'direct' | 'none'; +export interface AutoDiscoveredMcpConfigState { + path: string; + previousParallelCode?: unknown; + writtenParallelCodeFingerprint: string; +} + export interface StagedNotification { batchId: string; notificationIds: string[]; @@ -144,6 +150,7 @@ export interface Task { controlledBy?: 'coordinator' | 'human'; automationWriteInFlight?: boolean; mcpConfigPath?: string; + autoDiscoveredMcpConfig?: AutoDiscoveredMcpConfigState; mcpLaunchArgs?: string[]; preambleFileExistedBefore?: boolean; signalDoneReceived?: boolean; @@ -204,6 +211,7 @@ export interface PersistedTask { coordinatedBy?: string; controlledBy?: 'coordinator' | 'human'; mcpConfigPath?: string; + autoDiscoveredMcpConfig?: AutoDiscoveredMcpConfigState; preambleFileExistedBefore?: boolean; signalDoneReceived?: boolean; signalDoneAt?: string; From 0cb12851eb37311b69acb2096f9dd7d9b72ff26c Mon Sep 17 00:00:00 2001 From: Liang Hu Date: Wed, 5 Aug 2026 10:14:06 -0400 Subject: [PATCH 05/16] fix(mcp): fail closed on Kimi config restoration --- docker/Dockerfile | 2 +- electron/mcp/coordinator.test.ts | 98 +++++++++++++++++++++++++++++--- electron/mcp/coordinator.ts | 81 ++++++++++++++++++++------ electron/mcp/dockerfile.test.ts | 12 ++++ electron/mcp/types.ts | 1 + src/store/types.ts | 1 + 6 files changed, 169 insertions(+), 26 deletions(-) create mode 100644 electron/mcp/dockerfile.test.ts diff --git a/docker/Dockerfile b/docker/Dockerfile index 19ce8f453..e18c46a68 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -50,7 +50,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ RUN ln -sf "$(command -v fdfind)" /usr/local/bin/fd 2>/dev/null || true # AI agent CLIs — must be present so Docker-mode tasks can execute them -RUN npm install -g @anthropic-ai/claude-code @openai/codex @google/gemini-cli opencode-ai @moonshot-ai/kimi-code +RUN npm install -g @anthropic-ai/claude-code @openai/codex @google/gemini-cli opencode-ai @moonshot-ai/kimi-code@0.32.0 # Antigravity CLI (agy) — distributed as a Go binary via the official installer # (not on npm). The installer's `--dir` flag drops the binary straight into a diff --git a/electron/mcp/coordinator.test.ts b/electron/mcp/coordinator.test.ts index 6efd45a4e..9b0638290 100644 --- a/electron/mcp/coordinator.test.ts +++ b/electron/mcp/coordinator.test.ts @@ -1526,9 +1526,10 @@ describe('Coordinator land_self', () => { it('restores a tracked Kimi .mcp.json before checking and merging the worktree', async () => { const configPath = '/tmp/test/.mcp.json'; const previousParallelCode = { command: 'user-owned-server' }; - let currentConfig = JSON.stringify({ + const originalConfig = JSON.stringify({ mcpServers: { 'parallel-code': previousParallelCode }, }); + let currentConfig = originalConfig; mockExistsSync.mockImplementation((path) => path === configPath); mockReadFileSync.mockImplementation((path) => path === configPath ? currentConfig : '# existing\n', @@ -1548,12 +1549,7 @@ describe('Coordinator land_self', () => { return; } if (args[0] === 'status') { - const config = JSON.parse(currentConfig) as { - mcpServers: Record; - }; - const restored = config.mcpServers['parallel-code']; - const isRestored = JSON.stringify(restored) === JSON.stringify(previousParallelCode); - cb(null, isRestored ? '' : ' M .mcp.json\n', ''); + cb(null, currentConfig === originalConfig ? '' : ' M .mcp.json\n', ''); return; } if (args.join(' ') === 'rev-parse HEAD') { @@ -1588,9 +1584,97 @@ describe('Coordinator land_self', () => { const restored = JSON.parse(currentConfig) as { mcpServers: Record }; expect(restored.mcpServers['parallel-code']).toEqual(previousParallelCode); + expect(currentConfig).toBe(originalConfig); expect(vi.mocked(mergeTask)).toHaveBeenCalled(); }); + it('fails closed before self-landing when Kimi MCP restoration fingerprint mismatches', async () => { + const configPath = '/tmp/test/.mcp.json'; + let currentConfig = JSON.stringify({ + mcpServers: { 'parallel-code': { command: 'user-owned-server' } }, + }); + mockExistsSync.mockImplementation((path) => path === configPath); + mockReadFileSync.mockImplementation((path) => + path === configPath ? currentConfig : '# existing\n', + ); + mockAtomicWriteFileSync.mockImplementation((path, raw) => { + if (path === configPath) currentConfig = raw as string; + }); + coordinator.setCoordinatorSpawnDefaults('coord-1', 'kimi', []); + coordinator.setMCPServerInfo( + 'coord-1', + 'http://localhost:3001', + 'coordinator-token', + 'subtask-token', + '/path/server.js', + ); + await coordinator.createTask({ name: 'test', prompt: 'do', coordinatorTaskId: 'coord-1' }); + + currentConfig = JSON.stringify({ + mcpServers: { 'parallel-code': { command: 'changed-generated-entry' } }, + }); + mockExecFile.mockClear(); + + await expect(coordinator.landSelf('task-1', { verification })).rejects.toThrow( + 'Unable to restore managed Kimi MCP config', + ); + + expect(vi.mocked(mergeTask)).not.toHaveBeenCalled(); + expect(mockExecFile).not.toHaveBeenCalledWith( + 'git', + expect.arrayContaining(['status']), + expect.anything(), + expect.anything(), + ); + expect(coordinator.getTask('task-1')?.autoDiscoveredMcpConfig).toBeDefined(); + expect(coordinator.getTask('task-1')?.landingState).toBe('landing_escalated'); + }); + + it('fails closed before merge staging when Kimi MCP restoration fingerprint mismatches', async () => { + const configPath = '/tmp/test/.mcp.json'; + let currentConfig = JSON.stringify({ + mcpServers: { 'parallel-code': { command: 'user-owned-server' } }, + }); + mockExistsSync.mockImplementation((path) => path === configPath); + mockReadFileSync.mockImplementation((path) => + path === configPath ? currentConfig : '# existing\n', + ); + mockAtomicWriteFileSync.mockImplementation((path, raw) => { + if (path === configPath) currentConfig = raw as string; + }); + coordinator.setCoordinatorSpawnDefaults('coord-1', 'kimi', []); + coordinator.setMCPServerInfo( + 'coord-1', + 'http://localhost:3001', + 'coordinator-token', + 'subtask-token', + '/path/server.js', + ); + const task = await coordinator.createTask({ + name: 'test', + prompt: 'do', + coordinatorTaskId: 'coord-1', + }); + task.signalDoneAt = new Date(); + currentConfig = JSON.stringify({ + mcpServers: { 'parallel-code': { command: 'changed-generated-entry' } }, + }); + mockExecFile.mockClear(); + + await expect(coordinator.mergeTask('task-1')).rejects.toThrow( + 'Unable to restore managed Kimi MCP config', + ); + + expect(mockExecFile).not.toHaveBeenCalledWith( + 'git', + expect.arrayContaining(['add', '-A']), + expect.anything(), + expect.anything(), + ); + expect(vi.mocked(mergeTask)).not.toHaveBeenCalled(); + expect(coordinator.getTask('task-1')?.autoDiscoveredMcpConfig).toBeDefined(); + }); + it('stages a landed notification so the coordinator hears about successful self-land', async () => { await coordinator.landSelf('task-1', { verification, summary: 'done' }); diff --git a/electron/mcp/coordinator.ts b/electron/mcp/coordinator.ts index 73e1b96a9..c0b50d559 100644 --- a/electron/mcp/coordinator.ts +++ b/electron/mcp/coordinator.ts @@ -97,12 +97,10 @@ type McpJsonContent = Record & { mcpServers?: Record; }; -function readMcpJsonContent(configPath: string): McpJsonContent { - if (!existsSync(configPath)) return {}; - +function parseMcpJsonContent(configPath: string, raw: string): McpJsonContent { let parsed: unknown; try { - parsed = JSON.parse(readFileSync(configPath, 'utf-8')); + parsed = JSON.parse(raw); } catch { throw new Error(`${configPath} contains invalid JSON`); } @@ -121,6 +119,11 @@ function readMcpJsonContent(configPath: string): McpJsonContent { return content; } +function readMcpJsonContent(configPath: string): McpJsonContent { + if (!existsSync(configPath)) return {}; + return parseMcpJsonContent(configPath, readFileSync(configPath, 'utf-8')); +} + function mcpEntryFingerprint(value: unknown): string { return createHash('sha256') .update(JSON.stringify(value) ?? 'undefined') @@ -141,6 +144,7 @@ function validateAutoDiscoveredMcpConfigState( return undefined; return { path: state.path, + previousContent: typeof state.previousContent === 'string' ? state.previousContent : undefined, previousParallelCode: state.previousParallelCode, writtenParallelCodeFingerprint: state.writtenParallelCodeFingerprint, }; @@ -1577,7 +1581,9 @@ export class Coordinator { const configPath = join(task.worktreePath, '.mcp.json'); const writtenParallelCode = mcpConfig.mcpServers['parallel-code']; const priorState = task.autoDiscoveredMcpConfig; - const content = readMcpJsonContent(configPath); + const existingContent = existsSync(configPath) ? readFileSync(configPath, 'utf-8') : undefined; + const content = + existingContent === undefined ? {} : parseMcpJsonContent(configPath, existingContent); const servers = content.mcpServers ?? {}; if ( @@ -1593,10 +1599,13 @@ export class Coordinator { const previousParallelCode = priorState?.path === configPath ? priorState.previousParallelCode : servers['parallel-code']; + const previousContent = + priorState?.path === configPath ? priorState.previousContent : existingContent; content.mcpServers = { ...servers, 'parallel-code': writtenParallelCode }; atomicWriteFileSync(configPath, JSON.stringify(content, null, 2), { mode: 0o600 }); task.autoDiscoveredMcpConfig = { path: configPath, + previousContent, previousParallelCode, writtenParallelCodeFingerprint: mcpEntryFingerprint(writtenParallelCode), }; @@ -1617,18 +1626,33 @@ export class Coordinator { }); } - private restoreTaskAutoDiscoveredMcpConfig(task: CoordinatedTask): boolean { + private restoreTaskAutoDiscoveredMcpConfig( + task: CoordinatedTask, + ): 'none' | 'restored' | 'failed' { const state = task.autoDiscoveredMcpConfig; - task.autoDiscoveredMcpConfig = undefined; - if (!state) return false; - this.syncAutoDiscoveredMcpConfig(task); + if (!state) return 'none'; try { - if (!existsSync(state.path)) return true; + if (!existsSync(state.path)) { + if (state.previousContent !== undefined) { + atomicWriteFileSync(state.path, state.previousContent, { mode: 0o600 }); + } + task.autoDiscoveredMcpConfig = undefined; + this.syncAutoDiscoveredMcpConfig(task); + return 'restored'; + } + const content = readMcpJsonContent(state.path); const servers = content.mcpServers ?? {}; if (mcpEntryFingerprint(servers['parallel-code']) !== state.writtenParallelCodeFingerprint) - return false; + return 'failed'; + + if (state.previousContent !== undefined) { + atomicWriteFileSync(state.path, state.previousContent, { mode: 0o600 }); + task.autoDiscoveredMcpConfig = undefined; + this.syncAutoDiscoveredMcpConfig(task); + return 'restored'; + } if (state.previousParallelCode !== undefined) { servers['parallel-code'] = state.previousParallelCode; @@ -1640,15 +1664,23 @@ export class Coordinator { const hasOtherKeys = Object.keys(content).some((key) => key !== 'mcpServers'); if (!hasServers && !hasOtherKeys) { unlinkSync(state.path); - return true; + task.autoDiscoveredMcpConfig = undefined; + this.syncAutoDiscoveredMcpConfig(task); + return 'restored'; } if (hasServers) content.mcpServers = servers; else delete content.mcpServers; atomicWriteFileSync(state.path, JSON.stringify(content, null, 2), { mode: 0o600 }); - return true; - } catch { - // Best effort: malformed, concurrently removed, or inaccessible files are left untouched. - return false; + task.autoDiscoveredMcpConfig = undefined; + this.syncAutoDiscoveredMcpConfig(task); + return 'restored'; + } catch (err) { + logWarn('coordinator.kimi_mcp', 'failed to restore auto-discovered MCP config', { + taskId: task.id, + configPath: state.path, + error: err instanceof Error ? err.message : String(err), + }); + return 'failed'; } } @@ -1755,7 +1787,14 @@ export class Coordinator { task.verification = input.verification; task.landingSummary = input.summary; - const shouldRefreshMcpConfig = this.restoreTaskAutoDiscoveredMcpConfig(task); + const restoreMcpConfig = this.restoreTaskAutoDiscoveredMcpConfig(task); + if (restoreMcpConfig === 'failed') { + const reason = + 'Unable to restore managed Kimi MCP config before self-landing; refusing to validate or merge a worktree that may contain ephemeral MCP tokens.'; + this.escalateLanding(task, 'landing_escalated', reason); + throw new Error(reason); + } + const shouldRefreshMcpConfig = restoreMcpConfig === 'restored'; try { await this.prepareCleanSelfLandingWorktree(task); } catch (err) { @@ -1851,7 +1890,13 @@ export class Coordinator { const task = this.tasks.get(taskId); if (!task) throw new Error(`Task not found: ${taskId}`); this.assertTaskCanBeMerged(task); - const shouldRefreshMcpConfig = this.restoreTaskAutoDiscoveredMcpConfig(task); + const restoreMcpConfig = this.restoreTaskAutoDiscoveredMcpConfig(task); + if (restoreMcpConfig === 'failed') { + throw new Error( + 'Unable to restore managed Kimi MCP config before merge; refusing to stage or merge a worktree that may contain ephemeral MCP tokens.', + ); + } + const shouldRefreshMcpConfig = restoreMcpConfig === 'restored'; try { // Strip injected preamble files before staging so they don't land in history, diff --git a/electron/mcp/dockerfile.test.ts b/electron/mcp/dockerfile.test.ts new file mode 100644 index 000000000..6da7dc343 --- /dev/null +++ b/electron/mcp/dockerfile.test.ts @@ -0,0 +1,12 @@ +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import { describe, expect, it } from 'vitest'; + +describe('agent Dockerfile', () => { + it('pins Kimi Code below the workspace-trust-gated 0.33 line', () => { + const dockerfile = readFileSync(resolve(__dirname, '../../docker/Dockerfile'), 'utf8'); + + expect(dockerfile).toContain('@moonshot-ai/kimi-code@0.32.0'); + expect(dockerfile).not.toContain('@moonshot-ai/kimi-code '); + }); +}); diff --git a/electron/mcp/types.ts b/electron/mcp/types.ts index 00f49e586..451bda09d 100644 --- a/electron/mcp/types.ts +++ b/electron/mcp/types.ts @@ -2,6 +2,7 @@ export interface AutoDiscoveredMcpConfigState { path: string; + previousContent?: string; previousParallelCode?: unknown; writtenParallelCodeFingerprint: string; } diff --git a/src/store/types.ts b/src/store/types.ts index d4fa4c40d..b3e967d57 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -11,6 +11,7 @@ export type GitIsolationMode = 'worktree' | 'direct' | 'none'; export interface AutoDiscoveredMcpConfigState { path: string; + previousContent?: string; previousParallelCode?: unknown; writtenParallelCodeFingerprint: string; } From 3f91f459d723c34776508af4f2acf290a2f29f67 Mon Sep 17 00:00:00 2001 From: Liang Hu Date: Thu, 6 Aug 2026 10:18:18 -0400 Subject: [PATCH 06/16] fix(mcp): keep Kimi tokens out of persisted history --- electron/mcp/coordinator.test.ts | 81 ++++++++++++++++++---- electron/mcp/coordinator.ts | 114 +++++++++++++++++++++---------- electron/mcp/types.ts | 1 - src/store/persistence.test.ts | 2 +- src/store/tasks.test.ts | 2 +- src/store/types.ts | 1 - 6 files changed, 147 insertions(+), 54 deletions(-) diff --git a/electron/mcp/coordinator.test.ts b/electron/mcp/coordinator.test.ts index 9b0638290..073c27025 100644 --- a/electron/mcp/coordinator.test.ts +++ b/electron/mcp/coordinator.test.ts @@ -1523,8 +1523,8 @@ describe('Coordinator land_self', () => { ); }); - it('restores a tracked Kimi .mcp.json before checking and merging the worktree', async () => { - const configPath = '/tmp/test/.mcp.json'; + it('restores the Kimi auto-discovered config before checking and merging the worktree', async () => { + const configPath = '/tmp/test/.kimi-code/mcp.json'; const previousParallelCode = { command: 'user-owned-server' }; const originalConfig = JSON.stringify({ mcpServers: { 'parallel-code': previousParallelCode }, @@ -1549,7 +1549,7 @@ describe('Coordinator land_self', () => { return; } if (args[0] === 'status') { - cb(null, currentConfig === originalConfig ? '' : ' M .mcp.json\n', ''); + cb(null, '', ''); return; } if (args.join(' ') === 'rev-parse HEAD') { @@ -1584,12 +1584,55 @@ describe('Coordinator land_self', () => { const restored = JSON.parse(currentConfig) as { mcpServers: Record }; expect(restored.mcpServers['parallel-code']).toEqual(previousParallelCode); - expect(currentConfig).toBe(originalConfig); expect(vi.mocked(mergeTask)).toHaveBeenCalled(); }); + it('fails closed before self-landing when a managed Kimi token is already in Git history', async () => { + const configPath = '/tmp/test/.kimi-code/mcp.json'; + let currentConfig = JSON.stringify({ + mcpServers: { 'parallel-code': { command: 'user-owned-server' } }, + }); + mockExistsSync.mockImplementation((path) => path === configPath); + mockReadFileSync.mockImplementation((path) => + path === configPath ? currentConfig : '# existing\n', + ); + mockAtomicWriteFileSync.mockImplementation((path, raw) => { + if (path === configPath) currentConfig = raw as string; + }); + mockExecFile.mockImplementation( + ( + _cmd: string, + args: string[], + _opts: unknown, + cb: (err: Error | null, stdout: string, stderr: string) => void, + ) => { + if (args.join(' ').includes('-S subtask-token')) { + cb(null, 'secret-bearing-sha\n', ''); + return; + } + cb(null, '', ''); + }, + ); + coordinator.setCoordinatorSpawnDefaults('coord-1', 'kimi', []); + coordinator.setMCPServerInfo( + 'coord-1', + 'http://localhost:3001', + 'coordinator-token', + 'subtask-token', + '/path/server.js', + ); + await coordinator.createTask({ name: 'test', prompt: 'do', coordinatorTaskId: 'coord-1' }); + + await expect(coordinator.landSelf('task-1', { verification })).rejects.toThrow( + 'Managed Kimi MCP token was found in task Git history', + ); + + expect(vi.mocked(mergeTask)).not.toHaveBeenCalled(); + expect(coordinator.getTask('task-1')?.landingState).toBe('landing_escalated'); + }); + it('fails closed before self-landing when Kimi MCP restoration fingerprint mismatches', async () => { - const configPath = '/tmp/test/.mcp.json'; + const configPath = '/tmp/test/.kimi-code/mcp.json'; let currentConfig = JSON.stringify({ mcpServers: { 'parallel-code': { command: 'user-owned-server' } }, }); @@ -1631,7 +1674,7 @@ describe('Coordinator land_self', () => { }); it('fails closed before merge staging when Kimi MCP restoration fingerprint mismatches', async () => { - const configPath = '/tmp/test/.mcp.json'; + const configPath = '/tmp/test/.kimi-code/mcp.json'; let currentConfig = JSON.stringify({ mcpServers: { 'parallel-code': { command: 'user-owned-server' } }, }); @@ -3299,7 +3342,8 @@ describe('Coordinator sub-task MCP config isolation', () => { await coordinator.createTask({ name: 'task-b', prompt: 'do b', coordinatorTaskId: 'coord-1' }); const childWrites = mockAtomicWriteFileSync.mock.calls.filter( - ([configPath]) => configPath === '/tmp/a/.mcp.json' || configPath === '/tmp/b/.mcp.json', + ([configPath]) => + configPath === '/tmp/a/.kimi-code/mcp.json' || configPath === '/tmp/b/.kimi-code/mcp.json', ); expect(childWrites).toHaveLength(2); const childConfigs = childWrites.map( @@ -3321,8 +3365,8 @@ describe('Coordinator sub-task MCP config isolation', () => { ).not.toBe(childConfigs[1].mcpServers['parallel-code'].env['PARALLEL_CODE_MCP_DONE_TOKEN']); expect(mockAppendGitInfoExcludeBlock).toHaveBeenCalledWith( '/tmp/a', - '.mcp.json', - expect.stringContaining('.mcp.json'), + '.kimi-code/mcp.json', + expect.stringContaining('.kimi-code/mcp.json'), expect.any(Function), ); for (const [, spawnOpts] of mockSpawnAgent.mock.calls) { @@ -3336,7 +3380,7 @@ describe('Coordinator sub-task MCP config isolation', () => { }); it('restores a pre-existing Kimi child MCP entry when its coordinator deregisters', async () => { - const configPath = '/tmp/test/.mcp.json'; + const configPath = '/tmp/test/.kimi-code/mcp.json'; const previousParallelCode = { command: 'user-owned-server' }; let currentConfig = JSON.stringify({ mcpServers: { @@ -3362,19 +3406,26 @@ describe('Coordinator sub-task MCP config isolation', () => { ); await coordinator.createTask({ name: 'test', prompt: 'do', coordinatorTaskId: 'coord-1' }); + const concurrentlyEdited = JSON.parse(currentConfig) as { + mcpServers: Record; + setting: boolean | string; + }; + concurrentlyEdited.mcpServers.other = { command: 'edited-server' }; + concurrentlyEdited.setting = 'edited'; + currentConfig = JSON.stringify(concurrentlyEdited); coordinator.deregisterCoordinator('coord-1'); const restored = JSON.parse(currentConfig) as { mcpServers: Record; - setting: boolean; + setting: boolean | string; }; expect(restored.mcpServers['parallel-code']).toEqual(previousParallelCode); - expect(restored.mcpServers.other).toEqual({ command: 'other-server' }); - expect(restored.setting).toBe(true); + expect(restored.mcpServers.other).toEqual({ command: 'edited-server' }); + expect(restored.setting).toBe('edited'); }); it('preserves the original Kimi entry across restart hydration and deregistration', async () => { - const configPath = '/tmp/test/.mcp.json'; + const configPath = '/tmp/test/.kimi-code/mcp.json'; const previousParallelCode = { command: 'user-owned-server' }; let currentConfig = JSON.stringify({ mcpServers: { @@ -3446,7 +3497,7 @@ describe('Coordinator sub-task MCP config isolation', () => { }); it('does not overwrite a Kimi child MCP entry changed after creation', async () => { - const configPath = '/tmp/test/.mcp.json'; + const configPath = '/tmp/test/.kimi-code/mcp.json'; let configExists = false; let currentConfig = ''; mockExistsSync.mockImplementation((path) => path === configPath && configExists); diff --git a/electron/mcp/coordinator.ts b/electron/mcp/coordinator.ts index c0b50d559..5c5d82b1b 100644 --- a/electron/mcp/coordinator.ts +++ b/electron/mcp/coordinator.ts @@ -4,9 +4,9 @@ import { createHash, randomUUID, randomBytes } from 'crypto'; import { execFile } from 'child_process'; -import { join } from 'path'; +import { dirname, join } from 'path'; import { promisify } from 'util'; -import { unlinkSync, readFileSync, existsSync } from 'fs'; +import { mkdirSync, unlinkSync, readFileSync, existsSync } from 'fs'; import { unlink as fsUnlink } from 'fs/promises'; import { buildSubTaskMcpConfig, @@ -97,6 +97,11 @@ type McpJsonContent = Record & { mcpServers?: Record; }; +type RestoreMcpConfigResult = { + status: 'none' | 'restored' | 'failed'; + managedEntry?: unknown; +}; + function parseMcpJsonContent(configPath: string, raw: string): McpJsonContent { let parsed: unknown; try { @@ -136,7 +141,11 @@ function validateAutoDiscoveredMcpConfigState( ): AutoDiscoveredMcpConfigState | undefined { if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; const state = value as Record; - if (state.path !== join(worktreePath, '.mcp.json')) return undefined; + const allowedPaths = [ + join(worktreePath, '.mcp.json'), + join(worktreePath, '.kimi-code', 'mcp.json'), + ]; + if (typeof state.path !== 'string' || !allowedPaths.includes(state.path)) return undefined; if ( typeof state.writtenParallelCodeFingerprint !== 'string' || !/^[a-f0-9]{64}$/.test(state.writtenParallelCodeFingerprint) @@ -144,7 +153,6 @@ function validateAutoDiscoveredMcpConfigState( return undefined; return { path: state.path, - previousContent: typeof state.previousContent === 'string' ? state.previousContent : undefined, previousParallelCode: state.previousParallelCode, writtenParallelCodeFingerprint: state.writtenParallelCodeFingerprint, }; @@ -1578,7 +1586,7 @@ export class Coordinator { ): void { if (!task.agentCommand || !isKimiCommand(task.agentCommand)) return; - const configPath = join(task.worktreePath, '.mcp.json'); + const configPath = join(task.worktreePath, '.kimi-code', 'mcp.json'); const writtenParallelCode = mcpConfig.mcpServers['parallel-code']; const priorState = task.autoDiscoveredMcpConfig; const existingContent = existsSync(configPath) ? readFileSync(configPath, 'utf-8') : undefined; @@ -1599,13 +1607,11 @@ export class Coordinator { const previousParallelCode = priorState?.path === configPath ? priorState.previousParallelCode : servers['parallel-code']; - const previousContent = - priorState?.path === configPath ? priorState.previousContent : existingContent; content.mcpServers = { ...servers, 'parallel-code': writtenParallelCode }; + mkdirSync(dirname(configPath), { recursive: true }); atomicWriteFileSync(configPath, JSON.stringify(content, null, 2), { mode: 0o600 }); task.autoDiscoveredMcpConfig = { path: configPath, - previousContent, previousParallelCode, writtenParallelCodeFingerprint: mcpEntryFingerprint(writtenParallelCode), }; @@ -1613,9 +1619,9 @@ export class Coordinator { appendGitInfoExcludeBlock( task.worktreePath, - '.mcp.json', - '# Parallel Code MCP config (contains ephemeral token)\n.mcp.json\n', - (err) => console.warn('[MCP] Could not git-exclude child .mcp.json:', err), + '.kimi-code/mcp.json', + '# Parallel Code Kimi MCP config (contains ephemeral token)\n.kimi-code/mcp.json\n', + (err) => console.warn('[MCP] Could not git-exclude child Kimi MCP config:', err), ); } @@ -1626,33 +1632,22 @@ export class Coordinator { }); } - private restoreTaskAutoDiscoveredMcpConfig( - task: CoordinatedTask, - ): 'none' | 'restored' | 'failed' { + private restoreTaskAutoDiscoveredMcpConfig(task: CoordinatedTask): RestoreMcpConfigResult { const state = task.autoDiscoveredMcpConfig; - if (!state) return 'none'; + if (!state) return { status: 'none' }; try { if (!existsSync(state.path)) { - if (state.previousContent !== undefined) { - atomicWriteFileSync(state.path, state.previousContent, { mode: 0o600 }); - } task.autoDiscoveredMcpConfig = undefined; this.syncAutoDiscoveredMcpConfig(task); - return 'restored'; + return { status: 'restored' }; } const content = readMcpJsonContent(state.path); const servers = content.mcpServers ?? {}; - if (mcpEntryFingerprint(servers['parallel-code']) !== state.writtenParallelCodeFingerprint) - return 'failed'; - - if (state.previousContent !== undefined) { - atomicWriteFileSync(state.path, state.previousContent, { mode: 0o600 }); - task.autoDiscoveredMcpConfig = undefined; - this.syncAutoDiscoveredMcpConfig(task); - return 'restored'; - } + const managedEntry = servers['parallel-code']; + if (mcpEntryFingerprint(managedEntry) !== state.writtenParallelCodeFingerprint) + return { status: 'failed' }; if (state.previousParallelCode !== undefined) { servers['parallel-code'] = state.previousParallelCode; @@ -1666,21 +1661,50 @@ export class Coordinator { unlinkSync(state.path); task.autoDiscoveredMcpConfig = undefined; this.syncAutoDiscoveredMcpConfig(task); - return 'restored'; + return { status: 'restored', managedEntry }; } if (hasServers) content.mcpServers = servers; else delete content.mcpServers; atomicWriteFileSync(state.path, JSON.stringify(content, null, 2), { mode: 0o600 }); task.autoDiscoveredMcpConfig = undefined; this.syncAutoDiscoveredMcpConfig(task); - return 'restored'; + return { status: 'restored', managedEntry }; } catch (err) { logWarn('coordinator.kimi_mcp', 'failed to restore auto-discovered MCP config', { taskId: task.id, configPath: state.path, error: err instanceof Error ? err.message : String(err), }); - return 'failed'; + return { status: 'failed' }; + } + } + + private extractManagedMcpTokens(managedEntry: unknown): string[] { + if (!managedEntry || typeof managedEntry !== 'object' || Array.isArray(managedEntry)) return []; + const env = (managedEntry as { env?: unknown }).env; + if (!env || typeof env !== 'object' || Array.isArray(env)) return []; + return ['PARALLEL_CODE_MCP_TOKEN', 'PARALLEL_CODE_MCP_DONE_TOKEN'] + .map((key) => (env as Record)[key]) + .filter((value): value is string => typeof value === 'string' && value.length > 0); + } + + private async assertManagedMcpTokensAbsentFromGitHistory( + task: CoordinatedTask, + managedEntry: unknown, + ): Promise { + const tokens = this.extractManagedMcpTokens(managedEntry); + for (const token of tokens) { + const result = await execAsync( + 'git', + ['log', '--all', '--format=%H', '-S', token, '--', '.mcp.json', '.kimi-code/mcp.json'], + { cwd: task.worktreePath }, + ); + const matches = execStdout(result).trim(); + if (matches) { + throw new Error( + 'Managed Kimi MCP token was found in task Git history; refusing to land or merge until the token-bearing commit is removed.', + ); + } } } @@ -1788,13 +1812,24 @@ export class Coordinator { task.landingSummary = input.summary; const restoreMcpConfig = this.restoreTaskAutoDiscoveredMcpConfig(task); - if (restoreMcpConfig === 'failed') { + if (restoreMcpConfig.status === 'failed') { const reason = 'Unable to restore managed Kimi MCP config before self-landing; refusing to validate or merge a worktree that may contain ephemeral MCP tokens.'; this.escalateLanding(task, 'landing_escalated', reason); throw new Error(reason); } - const shouldRefreshMcpConfig = restoreMcpConfig === 'restored'; + if (restoreMcpConfig.managedEntry !== undefined) { + try { + await this.assertManagedMcpTokensAbsentFromGitHistory(task, restoreMcpConfig.managedEntry); + } catch (err) { + if (restoreMcpConfig.status === 'restored') + this.refreshTaskMcpConfigAfterLandingFailure(task); + const reason = err instanceof Error ? err.message : String(err); + this.escalateLanding(task, 'landing_escalated', reason); + throw err; + } + } + const shouldRefreshMcpConfig = restoreMcpConfig.status === 'restored'; try { await this.prepareCleanSelfLandingWorktree(task); } catch (err) { @@ -1891,12 +1926,21 @@ export class Coordinator { if (!task) throw new Error(`Task not found: ${taskId}`); this.assertTaskCanBeMerged(task); const restoreMcpConfig = this.restoreTaskAutoDiscoveredMcpConfig(task); - if (restoreMcpConfig === 'failed') { + if (restoreMcpConfig.status === 'failed') { throw new Error( 'Unable to restore managed Kimi MCP config before merge; refusing to stage or merge a worktree that may contain ephemeral MCP tokens.', ); } - const shouldRefreshMcpConfig = restoreMcpConfig === 'restored'; + if (restoreMcpConfig.managedEntry !== undefined) { + try { + await this.assertManagedMcpTokensAbsentFromGitHistory(task, restoreMcpConfig.managedEntry); + } catch (err) { + if (restoreMcpConfig.status === 'restored') + this.refreshTaskMcpConfigAfterLandingFailure(task); + throw err; + } + } + const shouldRefreshMcpConfig = restoreMcpConfig.status === 'restored'; try { // Strip injected preamble files before staging so they don't land in history, diff --git a/electron/mcp/types.ts b/electron/mcp/types.ts index 451bda09d..00f49e586 100644 --- a/electron/mcp/types.ts +++ b/electron/mcp/types.ts @@ -2,7 +2,6 @@ export interface AutoDiscoveredMcpConfigState { path: string; - previousContent?: string; previousParallelCode?: unknown; writtenParallelCodeFingerprint: string; } diff --git a/src/store/persistence.test.ts b/src/store/persistence.test.ts index 0c369ed9d..5d4dc3336 100644 --- a/src/store/persistence.test.ts +++ b/src/store/persistence.test.ts @@ -245,7 +245,7 @@ describe('landing state persistence', () => { describe('Kimi auto-discovered MCP config persistence', () => { const autoDiscoveredMcpConfig = { - path: '/repo/.worktrees/task-1/.mcp.json', + path: '/repo/.worktrees/task-1/.kimi-code/mcp.json', previousParallelCode: { command: 'user-owned-server' }, writtenParallelCodeFingerprint: 'a'.repeat(64), }; diff --git a/src/store/tasks.test.ts b/src/store/tasks.test.ts index f646f5c27..e79b25e6c 100644 --- a/src/store/tasks.test.ts +++ b/src/store/tasks.test.ts @@ -1330,7 +1330,7 @@ describe('MCP_TaskStateSync listener', () => { it('stores and clears the auto-discovered MCP restoration snapshot', () => { const snapshot = { - path: '/repo/.worktrees/task-1/.mcp.json', + path: '/repo/.worktrees/task-1/.kimi-code/mcp.json', previousParallelCode: { command: 'user-owned-server' }, writtenParallelCodeFingerprint: 'a'.repeat(64), }; diff --git a/src/store/types.ts b/src/store/types.ts index b3e967d57..d4fa4c40d 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -11,7 +11,6 @@ export type GitIsolationMode = 'worktree' | 'direct' | 'none'; export interface AutoDiscoveredMcpConfigState { path: string; - previousContent?: string; previousParallelCode?: unknown; writtenParallelCodeFingerprint: string; } From 15080187aea9f1fdb7b27867b93731e01c9258cb Mon Sep 17 00:00:00 2001 From: Liang Hu Date: Thu, 6 Aug 2026 12:34:29 -0400 Subject: [PATCH 07/16] fix(mcp): keep Kimi child credentials off tracked paths --- electron/mcp/coordinator-test-harness.ts | 6 + electron/mcp/coordinator.test.ts | 156 +++++++++++++++++++---- electron/mcp/coordinator.ts | 123 +++++++++++++----- electron/mcp/types.ts | 1 - src/store/persistence.test.ts | 1 - src/store/tasks.test.ts | 1 - src/store/types.ts | 1 - 7 files changed, 230 insertions(+), 59 deletions(-) diff --git a/electron/mcp/coordinator-test-harness.ts b/electron/mcp/coordinator-test-harness.ts index d48a4dee1..8251d6b21 100644 --- a/electron/mcp/coordinator-test-harness.ts +++ b/electron/mcp/coordinator-test-harness.ts @@ -24,6 +24,7 @@ const enoent = () => Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); const mocks = vi.hoisted(() => { const mockExecFile = vi.fn(); + const mockSpawnSync = vi.fn(); const mockWriteFileSync = vi.fn(); const mockReadFileSync = vi.fn(); const mockExistsSync = vi.fn(); @@ -56,6 +57,7 @@ const mocks = vi.hoisted(() => { return { mockExecFile, + mockSpawnSync, mockWriteFileSync, mockReadFileSync, mockExistsSync, @@ -90,6 +92,7 @@ const mocks = vi.hoisted(() => { vi.mock('child_process', () => ({ execFile: mocks.mockExecFile, + spawnSync: mocks.mockSpawnSync, })); vi.mock('fs', () => ({ @@ -220,6 +223,7 @@ vi.mock('../log.js', () => ({ export const { mockExecFile, + mockSpawnSync, mockWriteFileSync, mockReadFileSync, mockExistsSync, @@ -283,6 +287,8 @@ export function resetCoordinatorMocks(): void { return { on: vi.fn() }; }, ); + mockSpawnSync.mockReset(); + mockSpawnSync.mockReturnValue({ status: 1, error: undefined, stderr: Buffer.alloc(0) }); mockWriteFileSync.mockReset(); mockReadFileSync.mockReset(); diff --git a/electron/mcp/coordinator.test.ts b/electron/mcp/coordinator.test.ts index 073c27025..1b2a95211 100644 --- a/electron/mcp/coordinator.test.ts +++ b/electron/mcp/coordinator.test.ts @@ -10,6 +10,7 @@ import type { MCPClient } from './client.js'; import { setupCoordinatorHarness, mockExecFile, + mockSpawnSync, mockReadFileSync, mockExistsSync, mockUnlinkSync, @@ -1525,9 +1526,8 @@ describe('Coordinator land_self', () => { it('restores the Kimi auto-discovered config before checking and merging the worktree', async () => { const configPath = '/tmp/test/.kimi-code/mcp.json'; - const previousParallelCode = { command: 'user-owned-server' }; const originalConfig = JSON.stringify({ - mcpServers: { 'parallel-code': previousParallelCode }, + mcpServers: { other: { command: 'user-owned-server' } }, }); let currentConfig = originalConfig; mockExistsSync.mockImplementation((path) => path === configPath); @@ -1583,22 +1583,36 @@ describe('Coordinator land_self', () => { await kimiCoordinator.landSelf('task-1', { verification }); const restored = JSON.parse(currentConfig) as { mcpServers: Record }; - expect(restored.mcpServers['parallel-code']).toEqual(previousParallelCode); + expect(restored.mcpServers['parallel-code']).toBeUndefined(); + expect(restored.mcpServers.other).toEqual({ command: 'user-owned-server' }); expect(vi.mocked(mergeTask)).toHaveBeenCalled(); }); - it('fails closed before self-landing when a managed Kimi token is already in Git history', async () => { + it('fails closed on token-bearing history even when the discovery config was deleted', async () => { const configPath = '/tmp/test/.kimi-code/mcp.json'; + let autoConfigExists = true; + let taskConfig = ''; let currentConfig = JSON.stringify({ - mcpServers: { 'parallel-code': { command: 'user-owned-server' } }, + mcpServers: { other: { command: 'user-owned-server' } }, + }); + mockExistsSync.mockImplementation( + (path) => + (path === configPath && autoConfigExists) || + (typeof path === 'string' && path.includes('parallel-code-subtask-')), + ); + mockReadFileSync.mockImplementation((path) => { + if (path === configPath) return currentConfig; + if (typeof path === 'string' && path.includes('parallel-code-subtask-')) return taskConfig; + return '# existing\n'; }); - mockExistsSync.mockImplementation((path) => path === configPath); - mockReadFileSync.mockImplementation((path) => - path === configPath ? currentConfig : '# existing\n', - ); mockAtomicWriteFileSync.mockImplementation((path, raw) => { if (path === configPath) currentConfig = raw as string; }); + mockAtomicWriteFile.mockImplementation(async (path, raw) => { + if (typeof path === 'string' && path.includes('parallel-code-subtask-')) { + taskConfig = raw as string; + } + }); mockExecFile.mockImplementation( ( _cmd: string, @@ -1606,8 +1620,8 @@ describe('Coordinator land_self', () => { _opts: unknown, cb: (err: Error | null, stdout: string, stderr: string) => void, ) => { - if (args.join(' ').includes('-S subtask-token')) { - cb(null, 'secret-bearing-sha\n', ''); + if (args[0] === 'log') { + cb(null, '+ PARALLEL_CODE_MCP_TOKEN=subtask-token\n', ''); return; } cb(null, '', ''); @@ -1622,6 +1636,7 @@ describe('Coordinator land_self', () => { '/path/server.js', ); await coordinator.createTask({ name: 'test', prompt: 'do', coordinatorTaskId: 'coord-1' }); + autoConfigExists = false; await expect(coordinator.landSelf('task-1', { verification })).rejects.toThrow( 'Managed Kimi MCP token was found in task Git history', @@ -1634,7 +1649,7 @@ describe('Coordinator land_self', () => { it('fails closed before self-landing when Kimi MCP restoration fingerprint mismatches', async () => { const configPath = '/tmp/test/.kimi-code/mcp.json'; let currentConfig = JSON.stringify({ - mcpServers: { 'parallel-code': { command: 'user-owned-server' } }, + mcpServers: { other: { command: 'user-owned-server' } }, }); mockExistsSync.mockImplementation((path) => path === configPath); mockReadFileSync.mockImplementation((path) => @@ -1676,7 +1691,7 @@ describe('Coordinator land_self', () => { it('fails closed before merge staging when Kimi MCP restoration fingerprint mismatches', async () => { const configPath = '/tmp/test/.kimi-code/mcp.json'; let currentConfig = JSON.stringify({ - mcpServers: { 'parallel-code': { command: 'user-owned-server' } }, + mcpServers: { other: { command: 'user-owned-server' } }, }); mockExistsSync.mockImplementation((path) => path === configPath); mockReadFileSync.mockImplementation((path) => @@ -3258,6 +3273,8 @@ describe('Coordinator sub-task MCP config isolation', () => { beforeEach(() => { vi.clearAllMocks(); + mockSpawnSync.mockReset(); + mockSpawnSync.mockReturnValue({ status: 1, error: undefined, stderr: Buffer.alloc(0) }); mockExistsSync.mockReturnValue(false); coordinator = new Coordinator(); coordinator.setWindow(mockWin); @@ -3379,13 +3396,100 @@ describe('Coordinator sub-task MCP config isolation', () => { } }); - it('restores a pre-existing Kimi child MCP entry when its coordinator deregisters', async () => { + it('uses the alternate Kimi discovery path when the preferred path is tracked', async () => { + mockSpawnSync.mockImplementation((_command: string, args: string[]) => ({ + status: args[args.length - 1] === '.kimi-code/mcp.json' ? 0 : 1, + error: undefined, + stderr: Buffer.alloc(0), + })); + coordinator.setCoordinatorSpawnDefaults('coord-1', 'kimi', []); + coordinator.setMCPServerInfo( + 'coord-1', + 'http://localhost:3001', + 'coordinator-tok', + 'subtask-tok', + '/path/server.js', + ); + + const task = await coordinator.createTask({ + name: 'test', + prompt: 'do', + coordinatorTaskId: 'coord-1', + }); + + expect(task.autoDiscoveredMcpConfig?.path).toBe('/tmp/test/.mcp.json'); + expect(mockAtomicWriteFileSync).toHaveBeenCalledWith( + '/tmp/test/.mcp.json', + expect.stringContaining('subtask-tok'), + { mode: 0o600 }, + ); + }); + + it('fails task creation when both Kimi discovery paths are tracked', async () => { + mockSpawnSync.mockReturnValue({ status: 0, error: undefined, stderr: Buffer.alloc(0) }); + coordinator.setCoordinatorSpawnDefaults('coord-1', 'kimi', []); + coordinator.setMCPServerInfo( + 'coord-1', + 'http://localhost:3001', + 'coordinator-tok', + 'subtask-tok', + '/path/server.js', + ); + + await expect( + coordinator.createTask({ name: 'test', prompt: 'do', coordinatorTaskId: 'coord-1' }), + ).rejects.toThrow('both .kimi-code/mcp.json and .mcp.json are tracked by Git'); + + expect(mockAtomicWriteFileSync).not.toHaveBeenCalledWith( + expect.stringMatching(/(?:\.kimi-code\/mcp|\.mcp)\.json$/), + expect.anything(), + expect.anything(), + ); + expect(mockSpawnAgent).not.toHaveBeenCalled(); + }); + + it('fails task creation instead of persisting a pre-existing parallel-code entry', async () => { + const configPath = '/tmp/test/.kimi-code/mcp.json'; + mockExistsSync.mockImplementation((path) => path === configPath); + mockReadFileSync.mockImplementation((path) => + path === configPath + ? JSON.stringify({ + mcpServers: { + 'parallel-code': { + command: 'user-owned-server', + env: { API_KEY: 'must-not-be-persisted' }, + }, + }, + }) + : '# existing\n', + ); + coordinator.setCoordinatorSpawnDefaults('coord-1', 'kimi', []); + coordinator.setMCPServerInfo( + 'coord-1', + 'http://localhost:3001', + 'coordinator-tok', + 'subtask-tok', + '/path/server.js', + ); + + await expect( + coordinator.createTask({ name: 'test', prompt: 'do', coordinatorTaskId: 'coord-1' }), + ).rejects.toThrow('already defines mcpServers["parallel-code"]'); + + expect(JSON.stringify(coordinator.getTask('task-1')) ?? '').not.toContain( + 'must-not-be-persisted', + ); + expect(mockNotifyRenderer).not.toHaveBeenCalledWith( + 'mcp_task_created', + expect.objectContaining({ autoDiscoveredMcpConfig: expect.anything() }), + ); + }); + + it('preserves concurrent Kimi config edits while removing its managed entry', async () => { const configPath = '/tmp/test/.kimi-code/mcp.json'; - const previousParallelCode = { command: 'user-owned-server' }; let currentConfig = JSON.stringify({ mcpServers: { other: { command: 'other-server' }, - 'parallel-code': previousParallelCode, }, setting: true, }); @@ -3419,18 +3523,16 @@ describe('Coordinator sub-task MCP config isolation', () => { mcpServers: Record; setting: boolean | string; }; - expect(restored.mcpServers['parallel-code']).toEqual(previousParallelCode); + expect(restored.mcpServers['parallel-code']).toBeUndefined(); expect(restored.mcpServers.other).toEqual({ command: 'edited-server' }); expect(restored.setting).toBe('edited'); }); - it('preserves the original Kimi entry across restart hydration and deregistration', async () => { + it('persists only non-secret Kimi restoration metadata across restart hydration', async () => { const configPath = '/tmp/test/.kimi-code/mcp.json'; - const previousParallelCode = { command: 'user-owned-server' }; let currentConfig = JSON.stringify({ mcpServers: { other: { command: 'other-server' }, - 'parallel-code': previousParallelCode, }, }); mockExistsSync.mockImplementation((path) => path === configPath); @@ -3454,7 +3556,12 @@ describe('Coordinator sub-task MCP config isolation', () => { coordinatorTaskId: 'coord-1', }); const persistedState = task.autoDiscoveredMcpConfig; - expect(persistedState?.previousParallelCode).toEqual(previousParallelCode); + expect(persistedState).toEqual({ + path: configPath, + writtenParallelCodeFingerprint: expect.stringMatching(/^[a-f0-9]{64}$/), + }); + expect(JSON.stringify(persistedState)).not.toContain('old-subtask-token'); + expect(JSON.stringify(persistedState)).not.toContain('other-server'); const restarted = new Coordinator(); restarted.setWindow(mockWin); @@ -3481,7 +3588,10 @@ describe('Coordinator sub-task MCP config isolation', () => { agentCommand: 'kimi', }); - expect(result.autoDiscoveredMcpConfig?.previousParallelCode).toEqual(previousParallelCode); + expect(result.autoDiscoveredMcpConfig).toEqual({ + path: configPath, + writtenParallelCodeFingerprint: expect.stringMatching(/^[a-f0-9]{64}$/), + }); const refreshed = JSON.parse(currentConfig) as { mcpServers: { 'parallel-code': { env: Record } }; }; @@ -3492,7 +3602,7 @@ describe('Coordinator sub-task MCP config isolation', () => { restarted.deregisterCoordinator('coord-1'); const restored = JSON.parse(currentConfig) as { mcpServers: Record }; - expect(restored.mcpServers['parallel-code']).toEqual(previousParallelCode); + expect(restored.mcpServers['parallel-code']).toBeUndefined(); expect(restored.mcpServers.other).toEqual({ command: 'other-server' }); }); diff --git a/electron/mcp/coordinator.ts b/electron/mcp/coordinator.ts index 5c5d82b1b..e219c5f3c 100644 --- a/electron/mcp/coordinator.ts +++ b/electron/mcp/coordinator.ts @@ -3,7 +3,7 @@ // using existing backend primitives (pty, git, tasks). import { createHash, randomUUID, randomBytes } from 'crypto'; -import { execFile } from 'child_process'; +import { execFile, spawnSync } from 'child_process'; import { dirname, join } from 'path'; import { promisify } from 'util'; import { mkdirSync, unlinkSync, readFileSync, existsSync } from 'fs'; @@ -92,6 +92,7 @@ const PREAMBLE_ARTIFACT_PATHS = new Set([ '.claude/settings.local.json', ]); const UNRESOLVED_LANDED_COMMIT = 'unresolved'; +const KIMI_AUTO_DISCOVERED_MCP_PATHS = ['.kimi-code/mcp.json', '.mcp.json'] as const; type McpJsonContent = Record & { mcpServers?: Record; @@ -135,16 +136,26 @@ function mcpEntryFingerprint(value: unknown): string { .digest('hex'); } +function isTrackedGitPath(worktreePath: string, relativePath: string): boolean { + const result = spawnSync('git', ['ls-files', '--error-unmatch', '--', relativePath], { + cwd: worktreePath, + stdio: 'ignore', + }); + if (result.error) { + throw new Error(`Unable to verify whether ${relativePath} is tracked: ${result.error.message}`); + } + if (result.status === 0) return true; + if (result.status === 1) return false; + throw new Error(`Unable to verify whether ${relativePath} is tracked`); +} + function validateAutoDiscoveredMcpConfigState( value: unknown, worktreePath: string, ): AutoDiscoveredMcpConfigState | undefined { if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; const state = value as Record; - const allowedPaths = [ - join(worktreePath, '.mcp.json'), - join(worktreePath, '.kimi-code', 'mcp.json'), - ]; + const allowedPaths = KIMI_AUTO_DISCOVERED_MCP_PATHS.map((path) => join(worktreePath, path)); if (typeof state.path !== 'string' || !allowedPaths.includes(state.path)) return undefined; if ( typeof state.writtenParallelCodeFingerprint !== 'string' || @@ -153,7 +164,6 @@ function validateAutoDiscoveredMcpConfigState( return undefined; return { path: state.path, - previousParallelCode: state.previousParallelCode, writtenParallelCodeFingerprint: state.writtenParallelCodeFingerprint, }; } @@ -1586,12 +1596,43 @@ export class Coordinator { ): void { if (!task.agentCommand || !isKimiCommand(task.agentCommand)) return; - const configPath = join(task.worktreePath, '.kimi-code', 'mcp.json'); const writtenParallelCode = mcpConfig.mcpServers['parallel-code']; const priorState = task.autoDiscoveredMcpConfig; - const existingContent = existsSync(configPath) ? readFileSync(configPath, 'utf-8') : undefined; - const content = - existingContent === undefined ? {} : parseMcpJsonContent(configPath, existingContent); + const candidates = KIMI_AUTO_DISCOVERED_MCP_PATHS.map((relativePath) => { + const configPath = join(task.worktreePath, relativePath); + return { + relativePath, + configPath, + tracked: isTrackedGitPath(task.worktreePath, relativePath), + content: readMcpJsonContent(configPath), + }; + }); + + for (const candidate of candidates) { + const existingParallelCode = candidate.content.mcpServers?.['parallel-code']; + const isManagedCandidate = priorState?.path === candidate.configPath; + if (existingParallelCode !== undefined && !isManagedCandidate) { + throw new Error( + `Unable to create Kimi child MCP config: ${candidate.relativePath} already defines mcpServers["parallel-code"].`, + ); + } + } + + const candidate = priorState + ? candidates.find(({ configPath }) => configPath === priorState.path) + : candidates.find(({ tracked }) => !tracked); + if (!candidate) { + throw new Error( + 'Unable to create Kimi child MCP config: both .kimi-code/mcp.json and .mcp.json are tracked by Git.', + ); + } + if (candidate.tracked) { + throw new Error( + `Unable to create Kimi child MCP config: ${candidate.relativePath} is tracked by Git.`, + ); + } + + const { configPath, content, relativePath } = candidate; const servers = content.mcpServers ?? {}; if ( @@ -1605,22 +1646,19 @@ export class Coordinator { return; } - const previousParallelCode = - priorState?.path === configPath ? priorState.previousParallelCode : servers['parallel-code']; content.mcpServers = { ...servers, 'parallel-code': writtenParallelCode }; mkdirSync(dirname(configPath), { recursive: true }); atomicWriteFileSync(configPath, JSON.stringify(content, null, 2), { mode: 0o600 }); task.autoDiscoveredMcpConfig = { path: configPath, - previousParallelCode, writtenParallelCodeFingerprint: mcpEntryFingerprint(writtenParallelCode), }; this.syncAutoDiscoveredMcpConfig(task); appendGitInfoExcludeBlock( task.worktreePath, - '.kimi-code/mcp.json', - '# Parallel Code Kimi MCP config (contains ephemeral token)\n.kimi-code/mcp.json\n', + relativePath, + `# Parallel Code Kimi MCP config (contains ephemeral token)\n${relativePath}\n`, (err) => console.warn('[MCP] Could not git-exclude child Kimi MCP config:', err), ); } @@ -1632,15 +1670,37 @@ export class Coordinator { }); } + private readManagedMcpEntryFromTaskConfig( + task: CoordinatedTask, + state: AutoDiscoveredMcpConfigState, + ): unknown { + if (!task.mcpConfigPath || !existsSync(task.mcpConfigPath)) return undefined; + try { + const entry = readMcpJsonContent(task.mcpConfigPath).mcpServers?.['parallel-code']; + return mcpEntryFingerprint(entry) === state.writtenParallelCodeFingerprint + ? entry + : undefined; + } catch (err) { + logWarn('coordinator.kimi_mcp', 'failed to read per-task MCP config for history check', { + taskId: task.id, + configPath: task.mcpConfigPath, + error: err instanceof Error ? err.message : String(err), + }); + return undefined; + } + } + private restoreTaskAutoDiscoveredMcpConfig(task: CoordinatedTask): RestoreMcpConfigResult { const state = task.autoDiscoveredMcpConfig; if (!state) return { status: 'none' }; try { if (!existsSync(state.path)) { + const managedEntry = this.readManagedMcpEntryFromTaskConfig(task, state); + if (managedEntry === undefined) return { status: 'failed' }; task.autoDiscoveredMcpConfig = undefined; this.syncAutoDiscoveredMcpConfig(task); - return { status: 'restored' }; + return { status: 'restored', managedEntry }; } const content = readMcpJsonContent(state.path); @@ -1649,11 +1709,7 @@ export class Coordinator { if (mcpEntryFingerprint(managedEntry) !== state.writtenParallelCodeFingerprint) return { status: 'failed' }; - if (state.previousParallelCode !== undefined) { - servers['parallel-code'] = state.previousParallelCode; - } else { - delete servers['parallel-code']; - } + delete servers['parallel-code']; const hasServers = Object.keys(servers).length > 0; const hasOtherKeys = Object.keys(content).some((key) => key !== 'mcpServers'); @@ -1693,18 +1749,21 @@ export class Coordinator { managedEntry: unknown, ): Promise { const tokens = this.extractManagedMcpTokens(managedEntry); - for (const token of tokens) { - const result = await execAsync( - 'git', - ['log', '--all', '--format=%H', '-S', token, '--', '.mcp.json', '.kimi-code/mcp.json'], - { cwd: task.worktreePath }, + if (tokens.length === 0) { + throw new Error( + 'Unable to verify managed Kimi MCP tokens before landing or merge; refusing to continue.', + ); + } + const result = await execAsync( + 'git', + ['log', '--all', '-p', '--format=', '--', '.mcp.json', '.kimi-code/mcp.json'], + { cwd: task.worktreePath }, + ); + const history = execStdout(result); + if (tokens.some((token) => history.includes(token))) { + throw new Error( + 'Managed Kimi MCP token was found in task Git history; refusing to land or merge until the token-bearing commit is removed.', ); - const matches = execStdout(result).trim(); - if (matches) { - throw new Error( - 'Managed Kimi MCP token was found in task Git history; refusing to land or merge until the token-bearing commit is removed.', - ); - } } } diff --git a/electron/mcp/types.ts b/electron/mcp/types.ts index 00f49e586..1d0b0339e 100644 --- a/electron/mcp/types.ts +++ b/electron/mcp/types.ts @@ -2,7 +2,6 @@ export interface AutoDiscoveredMcpConfigState { path: string; - previousParallelCode?: unknown; writtenParallelCodeFingerprint: string; } diff --git a/src/store/persistence.test.ts b/src/store/persistence.test.ts index 5d4dc3336..4d7570f35 100644 --- a/src/store/persistence.test.ts +++ b/src/store/persistence.test.ts @@ -246,7 +246,6 @@ describe('landing state persistence', () => { describe('Kimi auto-discovered MCP config persistence', () => { const autoDiscoveredMcpConfig = { path: '/repo/.worktrees/task-1/.kimi-code/mcp.json', - previousParallelCode: { command: 'user-owned-server' }, writtenParallelCodeFingerprint: 'a'.repeat(64), }; diff --git a/src/store/tasks.test.ts b/src/store/tasks.test.ts index e79b25e6c..fee12d112 100644 --- a/src/store/tasks.test.ts +++ b/src/store/tasks.test.ts @@ -1331,7 +1331,6 @@ describe('MCP_TaskStateSync listener', () => { it('stores and clears the auto-discovered MCP restoration snapshot', () => { const snapshot = { path: '/repo/.worktrees/task-1/.kimi-code/mcp.json', - previousParallelCode: { command: 'user-owned-server' }, writtenParallelCodeFingerprint: 'a'.repeat(64), }; diff --git a/src/store/types.ts b/src/store/types.ts index d4fa4c40d..bffa04eec 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -11,7 +11,6 @@ export type GitIsolationMode = 'worktree' | 'direct' | 'none'; export interface AutoDiscoveredMcpConfigState { path: string; - previousParallelCode?: unknown; writtenParallelCodeFingerprint: string; } From 7fc70ae88a7a6e366235d0a4f9e1e1ea3e6aa17d Mon Sep 17 00:00:00 2001 From: Liang Hu Date: Fri, 7 Aug 2026 10:10:30 -0400 Subject: [PATCH 08/16] fix(mcp): write Kimi child preamble to AGENTS --- electron/mcp/preamble.test.ts | 25 +++++++++++++++++++++++++ electron/mcp/preamble.ts | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/electron/mcp/preamble.test.ts b/electron/mcp/preamble.test.ts index ce6e2c7cc..fccd40e1e 100644 --- a/electron/mcp/preamble.test.ts +++ b/electron/mcp/preamble.test.ts @@ -34,6 +34,31 @@ describe('sub-task preamble injection', () => { } }); + it('writes Kimi child preambles to AGENTS.md instead of Claude settings', async () => { + const dir = mkdtempSync(join(tmpdir(), 'parallel-code-preamble-test-')); + const agentsPath = join(dir, 'AGENTS.md'); + const settingsPath = join(dir, '.claude', 'settings.local.json'); + const queue = new Map>(); + + try { + const injected = await injectSubTaskPreamble({ + worktreePath: dir, + agentCommand: 'kimi-code --yolo', + queue, + }); + + expect(injected).toMatchObject({ + filePath: agentsPath, + existedBefore: false, + restoreOnFailure: true, + }); + expect(readFileSync(agentsPath, 'utf8')).toContain(''); + expect(existsSync(settingsPath)).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it('writes Claude settings.local.json without making it a failure-restore target', async () => { const dir = mkdtempSync(join(tmpdir(), 'parallel-code-preamble-test-')); const settingsPath = join(dir, '.claude', 'settings.local.json'); diff --git a/electron/mcp/preamble.ts b/electron/mcp/preamble.ts index 670ad27e7..f34a0a733 100644 --- a/electron/mcp/preamble.ts +++ b/electron/mcp/preamble.ts @@ -85,7 +85,7 @@ export async function injectSubTaskPreamble(args: { queue: PreambleWriteQueue; }): Promise { const agentCmd = args.agentCommand.toLowerCase(); - if (agentCmd.includes('codex') || agentCmd.includes('opencode')) { + if (agentCmd.includes('codex') || agentCmd.includes('opencode') || agentCmd.includes('kimi')) { return injectMarkdownPreamble(args.queue, join(args.worktreePath, 'AGENTS.md')); } if (agentCmd.includes('gemini')) { From 061ec6763fd69fd341767dead03eb8f853122dcd Mon Sep 17 00:00:00 2001 From: Liang Hu Date: Fri, 14 Aug 2026 10:07:50 -0400 Subject: [PATCH 09/16] fix(mcp): harden Kimi config refresh --- docker/Dockerfile | 1 + electron/mcp/coordinator.test.ts | 58 ++++++++++++++++++++++++++++++++ electron/mcp/coordinator.ts | 35 ++++++++++--------- electron/mcp/dockerfile.test.ts | 2 +- electron/mcp/preamble.test.ts | 2 +- 5 files changed, 81 insertions(+), 17 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index e18c46a68..1063cfad1 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -50,6 +50,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ RUN ln -sf "$(command -v fdfind)" /usr/local/bin/fd 2>/dev/null || true # AI agent CLIs — must be present so Docker-mode tasks can execute them +# Keep Kimi below 0.33: newer releases block fresh worktrees on workspace trust. RUN npm install -g @anthropic-ai/claude-code @openai/codex @google/gemini-cli opencode-ai @moonshot-ai/kimi-code@0.32.0 # Antigravity CLI (agy) — distributed as a Go binary via the official installer diff --git a/electron/mcp/coordinator.test.ts b/electron/mcp/coordinator.test.ts index 5cf77cd4f..abbd34615 100644 --- a/electron/mcp/coordinator.test.ts +++ b/electron/mcp/coordinator.test.ts @@ -3434,6 +3434,64 @@ describe('Coordinator sub-task MCP config isolation', () => { ); }); + it('only parses the selected Kimi discovery path', async () => { + mockSpawnSync.mockImplementation((_command: string, args: string[]) => ({ + status: args[args.length - 1] === '.mcp.json' ? 0 : 1, + error: undefined, + stderr: Buffer.alloc(0), + })); + mockExistsSync.mockImplementation((path) => path === '/tmp/test/.mcp.json'); + mockReadFileSync.mockImplementation((path) => { + if (path === '/tmp/test/.mcp.json') throw new Error('unused candidate must not be parsed'); + return '# existing\n'; + }); + coordinator.setCoordinatorSpawnDefaults('coord-1', 'kimi', []); + coordinator.setMCPServerInfo( + 'coord-1', + 'http://localhost:3001', + 'coordinator-tok', + 'subtask-tok', + '/path/server.js', + ); + + await expect( + coordinator.createTask({ name: 'test', prompt: 'do', coordinatorTaskId: 'coord-1' }), + ).resolves.toBeDefined(); + expect(mockReadFileSync).not.toHaveBeenCalledWith('/tmp/test/.mcp.json', 'utf-8'); + }); + + it('keeps restarting sibling Kimi configs after one task refresh fails', async () => { + coordinator.setCoordinatorSpawnDefaults('coord-1', 'kimi', []); + coordinator.setMCPServerInfo( + 'coord-1', + 'http://localhost:3001', + 'coordinator-tok', + 'subtask-tok', + '/path/server.js', + ); + await coordinator.createTask({ name: 'test', prompt: 'do', coordinatorTaskId: 'coord-1' }); + mockSpawnSync.mockImplementation(() => ({ + status: null, + error: new Error('worktree disappeared'), + stderr: Buffer.alloc(0), + })); + + expect(() => + coordinator.setMCPServerInfo( + 'coord-1', + 'http://localhost:3002', + 'coordinator-tok-2', + 'subtask-tok-2', + '/path/server.js', + ), + ).not.toThrow(); + expect(mockLogWarn).toHaveBeenCalledWith( + 'coordinator.kimi_mcp', + 'failed to refresh Kimi child MCP config', + expect.objectContaining({ taskId: 'task-1' }), + ); + }); + it('fails task creation when both Kimi discovery paths are tracked', async () => { mockSpawnSync.mockReturnValue({ status: 0, error: undefined, stderr: Buffer.alloc(0) }); coordinator.setCoordinatorSpawnDefaults('coord-1', 'kimi', []); diff --git a/electron/mcp/coordinator.ts b/electron/mcp/coordinator.ts index 88224f767..db7b5bf1e 100644 --- a/electron/mcp/coordinator.ts +++ b/electron/mcp/coordinator.ts @@ -687,7 +687,14 @@ export class Coordinator { doneToken: task.doneToken, }); writeSubTaskMcpConfigSync(mcpConfigPath, mcpConfig); - this.writeKimiAutoDiscoveredMcpConfig(task, mcpConfig); + try { + this.writeKimiAutoDiscoveredMcpConfig(task, mcpConfig); + } catch (err) { + logWarn('coordinator.kimi_mcp', 'failed to refresh Kimi child MCP config', { + taskId: task.id, + error: err instanceof Error ? err.message : String(err), + }); + } } } @@ -1614,20 +1621,9 @@ export class Coordinator { relativePath, configPath, tracked: isTrackedGitPath(task.worktreePath, relativePath), - content: readMcpJsonContent(configPath), }; }); - for (const candidate of candidates) { - const existingParallelCode = candidate.content.mcpServers?.['parallel-code']; - const isManagedCandidate = priorState?.path === candidate.configPath; - if (existingParallelCode !== undefined && !isManagedCandidate) { - throw new Error( - `Unable to create Kimi child MCP config: ${candidate.relativePath} already defines mcpServers["parallel-code"].`, - ); - } - } - const candidate = priorState ? candidates.find(({ configPath }) => configPath === priorState.path) : candidates.find(({ tracked }) => !tracked); @@ -1642,7 +1638,15 @@ export class Coordinator { ); } - const { configPath, content, relativePath } = candidate; + const { configPath, relativePath } = candidate; + const content = readMcpJsonContent(configPath); + const existingParallelCode = content.mcpServers?.['parallel-code']; + const isManagedCandidate = priorState?.path === configPath; + if (existingParallelCode !== undefined && !isManagedCandidate) { + throw new Error( + `Unable to create Kimi child MCP config: ${relativePath} already defines mcpServers["parallel-code"].`, + ); + } const servers = content.mcpServers ?? {}; if ( @@ -1764,10 +1768,11 @@ export class Coordinator { 'Unable to verify managed Kimi MCP tokens before landing or merge; refusing to continue.', ); } + const historyRange = task.baseBranch ? `${task.baseBranch}..HEAD` : 'HEAD'; const result = await execAsync( 'git', - ['log', '--all', '-p', '--format=', '--', '.mcp.json', '.kimi-code/mcp.json'], - { cwd: task.worktreePath }, + ['log', historyRange, '-p', '--format=', '--', '.mcp.json', '.kimi-code/mcp.json'], + { cwd: task.worktreePath, maxBuffer: 8 * 1024 * 1024 }, ); const history = execStdout(result); if (tokens.some((token) => history.includes(token))) { diff --git a/electron/mcp/dockerfile.test.ts b/electron/mcp/dockerfile.test.ts index 6da7dc343..2f20a11e8 100644 --- a/electron/mcp/dockerfile.test.ts +++ b/electron/mcp/dockerfile.test.ts @@ -6,7 +6,7 @@ describe('agent Dockerfile', () => { it('pins Kimi Code below the workspace-trust-gated 0.33 line', () => { const dockerfile = readFileSync(resolve(__dirname, '../../docker/Dockerfile'), 'utf8'); + expect(dockerfile).toContain('# Keep Kimi below 0.33'); expect(dockerfile).toContain('@moonshot-ai/kimi-code@0.32.0'); - expect(dockerfile).not.toContain('@moonshot-ai/kimi-code '); }); }); diff --git a/electron/mcp/preamble.test.ts b/electron/mcp/preamble.test.ts index fccd40e1e..3fde21a32 100644 --- a/electron/mcp/preamble.test.ts +++ b/electron/mcp/preamble.test.ts @@ -43,7 +43,7 @@ describe('sub-task preamble injection', () => { try { const injected = await injectSubTaskPreamble({ worktreePath: dir, - agentCommand: 'kimi-code --yolo', + agentCommand: 'kimi', queue, }); From c5cd4b24afc48bcb5ce21205f5ebac27729dee2d Mon Sep 17 00:00:00 2001 From: Liang Hu Date: Fri, 21 Aug 2026 18:24:49 -0400 Subject: [PATCH 10/16] fix(mcp): recover missing Kimi managed entries --- electron/mcp/coordinator.test.ts | 102 +++++++++++++++++++++++++++++++ electron/mcp/coordinator.ts | 7 +++ 2 files changed, 109 insertions(+) diff --git a/electron/mcp/coordinator.test.ts b/electron/mcp/coordinator.test.ts index abbd34615..3965753c6 100644 --- a/electron/mcp/coordinator.test.ts +++ b/electron/mcp/coordinator.test.ts @@ -1588,6 +1588,53 @@ describe('Coordinator land_self', () => { expect(vi.mocked(mergeTask)).toHaveBeenCalled(); }); + it('lands when the managed Kimi child MCP entry is already absent', async () => { + const configPath = '/tmp/test/.kimi-code/mcp.json'; + let currentConfig = JSON.stringify({ + mcpServers: { other: { command: 'user-owned-server' } }, + }); + mockExistsSync.mockImplementation((path) => path === configPath); + mockReadFileSync.mockImplementation((path) => + path === configPath ? currentConfig : '# existing\n', + ); + mockAtomicWriteFileSync.mockImplementation((path, raw) => { + if (path === configPath) currentConfig = raw as string; + }); + + const kimiCoordinator = new Coordinator(); + kimiCoordinator.setWindow(mockWin); + kimiCoordinator.setDefaultProject('proj-1', '/tmp/project'); + kimiCoordinator.registerCoordinator('coord-kimi', 'proj-1', { + worktreePath: '/tmp/project', + }); + kimiCoordinator.setCoordinatorSpawnDefaults('coord-kimi', 'kimi', []); + kimiCoordinator.setMCPServerInfo( + 'coord-kimi', + 'http://localhost:3001', + 'coordinator-token', + 'subtask-token', + '/path/server.js', + ); + await kimiCoordinator.createTask({ + name: 'test', + prompt: 'do', + coordinatorTaskId: 'coord-kimi', + }); + + const withoutManagedEntry = JSON.parse(currentConfig) as { + mcpServers: Record; + }; + delete withoutManagedEntry.mcpServers['parallel-code']; + currentConfig = JSON.stringify(withoutManagedEntry); + + await kimiCoordinator.landSelf('task-1', { verification }); + + expect(JSON.parse(currentConfig).mcpServers).toEqual({ + other: { command: 'user-owned-server' }, + }); + expect(vi.mocked(mergeTask)).toHaveBeenCalled(); + }); + it('fails closed on token-bearing history even when the discovery config was deleted', async () => { const configPath = '/tmp/test/.kimi-code/mcp.json'; let autoConfigExists = true; @@ -3721,6 +3768,61 @@ describe('Coordinator sub-task MCP config isolation', () => { expect.objectContaining({ taskId: 'task-1', configPath }), ); }); + + it('recreates a missing managed Kimi child MCP entry during refresh', async () => { + const configPath = '/tmp/test/.kimi-code/mcp.json'; + let currentConfig = JSON.stringify({ + mcpServers: { other: { command: 'other-server' } }, + }); + mockExistsSync.mockImplementation((path) => path === configPath); + mockReadFileSync.mockImplementation((path) => + path === configPath ? currentConfig : '# existing\n', + ); + mockAtomicWriteFileSync.mockImplementation((path, raw) => { + if (path === configPath) currentConfig = raw as string; + }); + coordinator.setCoordinatorSpawnDefaults('coord-1', 'kimi', []); + coordinator.setMCPServerInfo( + 'coord-1', + 'http://localhost:3001', + 'coordinator-tok', + 'subtask-tok', + '/path/server.js', + ); + await coordinator.createTask({ name: 'test', prompt: 'do', coordinatorTaskId: 'coord-1' }); + + const withoutManagedEntry = JSON.parse(currentConfig) as { + mcpServers: Record; + }; + delete withoutManagedEntry.mcpServers['parallel-code']; + currentConfig = JSON.stringify(withoutManagedEntry); + mockAtomicWriteFileSync.mockClear(); + mockLogWarn.mockClear(); + + coordinator.setMCPServerInfo( + 'coord-1', + 'http://localhost:3002', + 'new-coordinator-tok', + 'new-subtask-tok', + '/path/server.js', + ); + + const refreshed = JSON.parse(currentConfig) as { + mcpServers: { + other: { command: string }; + 'parallel-code': { env: Record }; + }; + }; + expect(refreshed.mcpServers.other).toEqual({ command: 'other-server' }); + expect(refreshed.mcpServers['parallel-code'].env['PARALLEL_CODE_MCP_TOKEN']).toBe( + 'new-subtask-tok', + ); + expect(mockLogWarn).not.toHaveBeenCalledWith( + 'coordinator.kimi_mcp', + expect.stringContaining('refusing overwrite'), + expect.anything(), + ); + }); }); // ─── MCP config restart rewrite tests ──────────────────────────────────────── diff --git a/electron/mcp/coordinator.ts b/electron/mcp/coordinator.ts index db7b5bf1e..5344ef1aa 100644 --- a/electron/mcp/coordinator.ts +++ b/electron/mcp/coordinator.ts @@ -1651,6 +1651,7 @@ export class Coordinator { if ( priorState?.path === configPath && + existingParallelCode !== undefined && mcpEntryFingerprint(servers['parallel-code']) !== priorState.writtenParallelCodeFingerprint ) { logWarn('coordinator.kimi_mcp', 'auto-discovered MCP config changed; refusing overwrite', { @@ -1720,6 +1721,12 @@ export class Coordinator { const content = readMcpJsonContent(state.path); const servers = content.mcpServers ?? {}; const managedEntry = servers['parallel-code']; + if (managedEntry === undefined) { + const historicalManagedEntry = this.readManagedMcpEntryFromTaskConfig(task, state); + task.autoDiscoveredMcpConfig = undefined; + this.syncAutoDiscoveredMcpConfig(task); + return { status: 'restored', managedEntry: historicalManagedEntry }; + } if (mcpEntryFingerprint(managedEntry) !== state.writtenParallelCodeFingerprint) return { status: 'failed' }; From 8293bc4f281fd9b35fec8724a47721d3ac935dd4 Mon Sep 17 00:00:00 2001 From: Liang Hu Date: Sat, 29 Aug 2026 10:07:50 -0400 Subject: [PATCH 11/16] fix(mcp): fail closed when managed entry is lost --- electron/mcp/coordinator.test.ts | 66 ++++++++++++++++++++++++++++++-- electron/mcp/coordinator.ts | 1 + 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/electron/mcp/coordinator.test.ts b/electron/mcp/coordinator.test.ts index 3965753c6..9432df8c4 100644 --- a/electron/mcp/coordinator.test.ts +++ b/electron/mcp/coordinator.test.ts @@ -1590,16 +1590,28 @@ describe('Coordinator land_self', () => { it('lands when the managed Kimi child MCP entry is already absent', async () => { const configPath = '/tmp/test/.kimi-code/mcp.json'; + let taskConfig = ''; let currentConfig = JSON.stringify({ mcpServers: { other: { command: 'user-owned-server' } }, }); - mockExistsSync.mockImplementation((path) => path === configPath); - mockReadFileSync.mockImplementation((path) => - path === configPath ? currentConfig : '# existing\n', + mockExistsSync.mockImplementation( + (path) => + path === configPath || + (typeof path === 'string' && path.includes('parallel-code-subtask-')), ); + mockReadFileSync.mockImplementation((path) => { + if (path === configPath) return currentConfig; + if (typeof path === 'string' && path.includes('parallel-code-subtask-')) return taskConfig; + return '# existing\n'; + }); mockAtomicWriteFileSync.mockImplementation((path, raw) => { if (path === configPath) currentConfig = raw as string; }); + mockAtomicWriteFile.mockImplementation(async (path, raw) => { + if (typeof path === 'string' && path.includes('parallel-code-subtask-')) { + taskConfig = raw as string; + } + }); const kimiCoordinator = new Coordinator(); kimiCoordinator.setWindow(mockWin); @@ -1635,6 +1647,54 @@ describe('Coordinator land_self', () => { expect(vi.mocked(mergeTask)).toHaveBeenCalled(); }); + it('fails closed when the managed Kimi child MCP entry cannot be recovered', async () => { + const configPath = '/tmp/test/.kimi-code/mcp.json'; + let currentConfig = JSON.stringify({ + mcpServers: { other: { command: 'user-owned-server' } }, + }); + mockExistsSync.mockImplementation((path) => path === configPath); + mockReadFileSync.mockImplementation((path) => + path === configPath ? currentConfig : '# existing\n', + ); + mockAtomicWriteFileSync.mockImplementation((path, raw) => { + if (path === configPath) currentConfig = raw as string; + }); + + const kimiCoordinator = new Coordinator(); + kimiCoordinator.setWindow(mockWin); + kimiCoordinator.setDefaultProject('proj-1', '/tmp/project'); + kimiCoordinator.registerCoordinator('coord-kimi', 'proj-1', { + worktreePath: '/tmp/project', + }); + kimiCoordinator.setCoordinatorSpawnDefaults('coord-kimi', 'kimi', []); + kimiCoordinator.setMCPServerInfo( + 'coord-kimi', + 'http://localhost:3001', + 'coordinator-token', + 'subtask-token', + '/path/server.js', + ); + await kimiCoordinator.createTask({ + name: 'test', + prompt: 'do', + coordinatorTaskId: 'coord-kimi', + }); + + const withoutManagedEntry = JSON.parse(currentConfig) as { + mcpServers: Record; + }; + delete withoutManagedEntry.mcpServers['parallel-code']; + currentConfig = JSON.stringify(withoutManagedEntry); + + await expect(kimiCoordinator.landSelf('task-1', { verification })).rejects.toThrow( + 'Unable to restore managed Kimi MCP config', + ); + + expect(vi.mocked(mergeTask)).not.toHaveBeenCalled(); + expect(kimiCoordinator.getTask('task-1')?.autoDiscoveredMcpConfig).toBeDefined(); + expect(kimiCoordinator.getTask('task-1')?.landingState).toBe('landing_escalated'); + }); + it('fails closed on token-bearing history even when the discovery config was deleted', async () => { const configPath = '/tmp/test/.kimi-code/mcp.json'; let autoConfigExists = true; diff --git a/electron/mcp/coordinator.ts b/electron/mcp/coordinator.ts index 5344ef1aa..bf5e492aa 100644 --- a/electron/mcp/coordinator.ts +++ b/electron/mcp/coordinator.ts @@ -1723,6 +1723,7 @@ export class Coordinator { const managedEntry = servers['parallel-code']; if (managedEntry === undefined) { const historicalManagedEntry = this.readManagedMcpEntryFromTaskConfig(task, state); + if (historicalManagedEntry === undefined) return { status: 'failed' }; task.autoDiscoveredMcpConfig = undefined; this.syncAutoDiscoveredMcpConfig(task); return { status: 'restored', managedEntry: historicalManagedEntry }; From 9645906856a7e7968b0422565a7c418df4e20fa2 Mon Sep 17 00:00:00 2001 From: Liang Hu <35699841+LarryHu0217@users.noreply.github.com> Date: Sun, 30 Aug 2026 10:22:34 -0400 Subject: [PATCH 12/16] fix(mcp): harden Kimi credential lifecycle --- PRIVACY.md | 2 +- README.md | 19 ++++++- electron/mcp/coordinator.test.ts | 50 +++++++++++++++++ electron/mcp/coordinator.ts | 94 +++++++++++++++++++++++++------- 4 files changed, 141 insertions(+), 24 deletions(-) diff --git a/PRIVACY.md b/PRIVACY.md index 4226f6635..bff8ef522 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -60,7 +60,7 @@ Unlike the AI CLIs above, the following network activity is initiated by Paralle - **Over Tailscale.** Traffic is carried by your tailnet — typically a direct WireGuard connection between your devices, but Tailscale's coordination service and (when direct connection is not possible) DERP relays may be involved per [Tailscale's network architecture](https://tailscale.com/kb/1257/connection-types). How Tailscale handles that traffic is governed by Tailscale's own policies, not this one. - **Sub-task coordinator (MCP)** — when sub-tasks run under a coordinator agent, Parallel Code starts a local token-protected HTTP/WebSocket server so sub-task agents can call back into the app (e.g. to signal completion). No traffic from this feature passes through infrastructure operated by the Parallel Code project. - **Bind address.** When MCP starts its own listener, it binds to `127.0.0.1` except on macOS Docker setups, where it binds to `0.0.0.0` so containers can reach it via `host.docker.internal` — this also makes the port reachable from other hosts on your LAN, though access still requires the token. If a Remote Access server is already running when a coordinator starts, the coordinator reuses that listener; because Remote Access binds to `0.0.0.0`, MCP routes inherit that LAN reach on any platform (including Linux), though access still requires the MCP token. - - **Where the token can land.** Token-bearing MCP data is written or passed in several places: a worktree `.mcp.json` when a worktree path is available, or a project-root `.mcp.json` otherwise, so the coordinator agent can auto-discover the server (Parallel Code also adds `.mcp.json` to your `.git/info/exclude` so it is not committed); a non-Docker coordinator config in your OS temp directory named `parallel-code-mcp-.json`; per-sub-task configs in your OS temp directory for host-mode sub-tasks (`parallel-code-subtask-.json`) or under the coordinator's `.parallel-code/` directory for Docker sub-tasks (`subtask-.json`); and short-lived `.parallel-code-atomic-.tmp` files written next to these configs during atomic-rename steps. These files are written with `0600` permissions where the platform supports it. + - **Where the token can land.** Token-bearing MCP data is written or passed in several places: a worktree `.mcp.json` when a worktree path is available, or a project-root `.mcp.json` otherwise, so the coordinator agent can auto-discover the server (Parallel Code also adds `.mcp.json` to your `.git/info/exclude` so it is not committed); an auto-discovered `.kimi-code/mcp.json` (or fallback `.mcp.json`) inside each Kimi sub-task worktree, with both that config and its adjacent `.parallel-code-atomic-*.tmp` files added to `.git/info/exclude` before credentials are written; a non-Docker coordinator config in your OS temp directory named `parallel-code-mcp-.json`; per-sub-task configs in your OS temp directory for host-mode sub-tasks (`parallel-code-subtask-.json`) or under the coordinator's `.parallel-code/` directory for Docker sub-tasks (`subtask-.json`); and short-lived `.parallel-code-atomic-.tmp` files written next to these configs during atomic-rename steps. These files are written with `0600` permissions where the platform supports it. - **Codex token in the command line.** For Codex specifically, the MCP token is passed as a literal command-line argument (`--config mcp_servers.parallel-code={... env = { PARALLEL_CODE_MCP_TOKEN = "..." }}`). Process command lines are visible to other processes — via `/proc//cmdline` on Linux or `ps` on macOS — so any local process that runs concurrently with a Codex sub-task can read that token and call back into the coordinator under its authority until the coordinator exits. Other agents receive the token through a token-protected file or env var instead. - **Docker task isolation** — when you enable Docker mode for a task, or when you opt coordinator sub-tasks into Docker-isolated mode, the agent launches in a container via `docker run --network host`. **Docker mode is not a security boundary.** It isolates the filesystem against the worktree, but does not isolate the network or credentials from the agent. - **Network.** `--network host` means the container shares the host's network namespace; its outbound reachability is the same as your host's, including loopback services and any LAN address your host can reach. diff --git a/README.md b/README.md index b2d2a0c99..b07a4830e 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@

- Works with Claude Code, Codex, Gemini, and Kimi Code · Every change isolated in its own git worktree · Free, open source, no extra platform fee + Works with Claude Code, Codex, Gemini, and Docker-pinned Kimi Code · Every change isolated in its own git worktree · Free, open source, no extra platform fee

@@ -45,7 +45,7 @@ ## Why Parallel Code? -- **Use the AI coding tools you already trust** — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex CLI](https://github.com/openai/codex), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Kimi Code CLI](https://github.com/MoonshotAI/kimi-code), and [Copilot CLI](https://docs.github.com/en/copilot/concepts/agents/about-copilot-cli) — all from one interface. +- **Use the AI coding tools you already trust** — [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex CLI](https://github.com/openai/codex), [Gemini CLI](https://github.com/google-gemini/gemini-cli), Docker-pinned [Kimi Code CLI](https://github.com/MoonshotAI/kimi-code), and [Copilot CLI](https://docs.github.com/en/copilot/concepts/agents/about-copilot-cli) — all from one interface. - **Free and open source** — no extra subscription required. MIT licensed. - **Keep every change isolated and reviewable** — each task gets its own git branch and worktree automatically. - **Run agents in parallel, not in sequence** — five agents on five features at the same time, zero conflicts. @@ -115,10 +115,23 @@ When you're happy with the result, merge the branch back to main from the sideba - **macOS** — `.dmg` (universal) - **Linux** — `.AppImage` or `.deb` -2. **Install at least one AI coding CLI:** [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex CLI](https://github.com/openai/codex), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Kimi Code CLI](https://github.com/MoonshotAI/kimi-code), [Antigravity CLI](https://antigravity.google/), or [Copilot CLI](https://docs.github.com/en/copilot/concepts/agents/about-copilot-cli) +2. **Install at least one AI coding CLI:** [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Codex CLI](https://github.com/openai/codex), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Antigravity CLI](https://antigravity.google/), or [Copilot CLI](https://docs.github.com/en/copilot/concepts/agents/about-copilot-cli). Kimi Code is currently supported through the bundled Docker image instead of a native installation. 3. **Open Parallel Code**, point it at a git repo, and start dispatching tasks. +

+Kimi Code: use Docker mode + +Parallel Code's Kimi integration writes an auto-discovered project MCP config into each fresh +task worktree. Kimi Code 0.33 added a workspace-trust prompt, and 0.36 defaults to declining +project MCP launch targets in that prompt. A current native Kimi installation can therefore +pause on every new worktree instead of starting the task unattended. + +Use Docker-isolated Kimi tasks for now. The bundled image pins Kimi Code 0.32, before the +workspace-trust gate. Native Kimi support is not currently claimed. + +
+
Antigravity CLI: run natively, not in Docker isolation diff --git a/electron/mcp/coordinator.test.ts b/electron/mcp/coordinator.test.ts index 9432df8c4..18306af8c 100644 --- a/electron/mcp/coordinator.test.ts +++ b/electron/mcp/coordinator.test.ts @@ -1751,6 +1751,8 @@ describe('Coordinator land_self', () => { expect(vi.mocked(mergeTask)).not.toHaveBeenCalled(); expect(coordinator.getTask('task-1')?.landingState).toBe('landing_escalated'); + const historyCall = mockExecFile.mock.calls.find(([, args]) => args[0] === 'log'); + expect(historyCall?.[1]).toEqual(expect.arrayContaining(['-m', '--text', '--no-textconv'])); }); it('fails closed before self-landing when Kimi MCP restoration fingerprint mismatches', async () => { @@ -3502,6 +3504,12 @@ describe('Coordinator sub-task MCP config isolation', () => { expect.stringContaining('.kimi-code/mcp.json'), expect.any(Function), ); + expect(mockAppendGitInfoExcludeBlock).toHaveBeenCalledWith( + '/tmp/a', + '.kimi-code/.parallel-code-atomic-*.tmp', + expect.stringContaining('.kimi-code/.parallel-code-atomic-*.tmp'), + expect.any(Function), + ); for (const [, spawnOpts] of mockSpawnAgent.mock.calls) { expect(spawnOpts).toEqual( expect.objectContaining({ @@ -4195,6 +4203,48 @@ describe('Coordinator hydrateTask — restart hydration', () => { expect(result.mcpLaunchArgs).toEqual([]); }); + it('hydrateTask preserves live Kimi MCP state when persisted state is invalid', async () => { + coordinator.setCoordinatorSpawnDefaults('coord-1', 'kimi', []); + coordinator.setMCPServerInfo( + 'coord-1', + 'http://localhost:3001', + 'coordinator-token', + 'subtask-token', + '/path/server.js', + ); + const task = await coordinator.createTask({ + name: 'kimi-task', + prompt: 'do', + coordinatorTaskId: 'coord-1', + }); + const liveState = task.autoDiscoveredMcpConfig; + expect(liveState).toBeDefined(); + + const result = coordinator.hydrateTask({ + id: task.id, + name: task.name, + projectId: task.projectId, + projectRoot: task.projectRoot, + branchName: task.branchName, + worktreePath: task.worktreePath, + agentId: task.agentId, + coordinatorTaskId: task.coordinatorTaskId, + mcpConfigPath: task.mcpConfigPath, + agentCommand: task.agentCommand, + autoDiscoveredMcpConfig: { + path: '/tmp/not-this-task/.kimi-code/mcp.json', + writtenParallelCodeFingerprint: 'a'.repeat(64), + }, + }); + + expect(result.autoDiscoveredMcpConfig?.path).toBe(liveState?.path); + expect(mockLogWarn).toHaveBeenCalledWith( + 'coordinator.kimi_mcp', + 'ignored invalid persisted Kimi MCP state; preserving live state', + { taskId: task.id }, + ); + }); + it('hydrateTask restores an undelivered initial prompt for backend delivery', () => { coordinator.hydrateTask({ id: 'hydrated-1', diff --git a/electron/mcp/coordinator.ts b/electron/mcp/coordinator.ts index bf5e492aa..8bc4dc63f 100644 --- a/electron/mcp/coordinator.ts +++ b/electron/mcp/coordinator.ts @@ -140,6 +140,7 @@ function isTrackedGitPath(worktreePath: string, relativePath: string): boolean { const result = spawnSync('git', ['ls-files', '--error-unmatch', '--', relativePath], { cwd: worktreePath, stdio: 'ignore', + timeout: 3000, }); if (result.error) { throw new Error(`Unable to verify whether ${relativePath} is tracked: ${result.error.message}`); @@ -1615,18 +1616,33 @@ export class Coordinator { const writtenParallelCode = mcpConfig.mcpServers['parallel-code']; const priorState = task.autoDiscoveredMcpConfig; - const candidates = KIMI_AUTO_DISCOVERED_MCP_PATHS.map((relativePath) => { - const configPath = join(task.worktreePath, relativePath); - return { - relativePath, - configPath, - tracked: isTrackedGitPath(task.worktreePath, relativePath), - }; + type Candidate = { + relativePath: (typeof KIMI_AUTO_DISCOVERED_MCP_PATHS)[number]; + configPath: string; + tracked: boolean; + }; + const toCandidate = ( + relativePath: (typeof KIMI_AUTO_DISCOVERED_MCP_PATHS)[number], + ): Candidate => ({ + relativePath, + configPath: join(task.worktreePath, relativePath), + tracked: isTrackedGitPath(task.worktreePath, relativePath), }); - - const candidate = priorState - ? candidates.find(({ configPath }) => configPath === priorState.path) - : candidates.find(({ tracked }) => !tracked); + let candidate: Candidate | undefined; + if (priorState) { + const relativePath = KIMI_AUTO_DISCOVERED_MCP_PATHS.find( + (path) => join(task.worktreePath, path) === priorState.path, + ); + if (relativePath) candidate = toCandidate(relativePath); + } else { + for (const relativePath of KIMI_AUTO_DISCOVERED_MCP_PATHS) { + const current = toCandidate(relativePath); + if (!current.tracked) { + candidate = current; + break; + } + } + } if (!candidate) { throw new Error( 'Unable to create Kimi child MCP config: both .kimi-code/mcp.json and .mcp.json are tracked by Git.', @@ -1661,6 +1677,31 @@ export class Coordinator { return; } + const relativeDir = relativePath.includes('/') + ? relativePath.slice(0, relativePath.lastIndexOf('/') + 1) + : ''; + const atomicTmpPattern = `${relativeDir}.parallel-code-atomic-*.tmp`; + const excludePatterns = [ + { + marker: relativePath, + block: `# Parallel Code Kimi MCP config (contains ephemeral token)\n${relativePath}\n`, + }, + { + marker: atomicTmpPattern, + block: `${atomicTmpPattern}\n`, + }, + ]; + for (const { marker, block } of excludePatterns) { + const result = appendGitInfoExcludeBlock(task.worktreePath, marker, block, (err) => + console.warn('[MCP] Could not git-exclude child Kimi MCP config:', err), + ); + if (result !== 'appended' && result !== 'present') { + throw new Error( + `Unable to git-exclude Kimi child MCP credential path ${marker}; refusing to write it.`, + ); + } + } + content.mcpServers = { ...servers, 'parallel-code': writtenParallelCode }; mkdirSync(dirname(configPath), { recursive: true }); atomicWriteFileSync(configPath, JSON.stringify(content, null, 2), { mode: 0o600 }); @@ -1669,13 +1710,6 @@ export class Coordinator { writtenParallelCodeFingerprint: mcpEntryFingerprint(writtenParallelCode), }; this.syncAutoDiscoveredMcpConfig(task); - - appendGitInfoExcludeBlock( - task.worktreePath, - relativePath, - `# Parallel Code Kimi MCP config (contains ephemeral token)\n${relativePath}\n`, - (err) => console.warn('[MCP] Could not git-exclude child Kimi MCP config:', err), - ); } private syncAutoDiscoveredMcpConfig(task: CoordinatedTask): void { @@ -1779,7 +1813,18 @@ export class Coordinator { const historyRange = task.baseBranch ? `${task.baseBranch}..HEAD` : 'HEAD'; const result = await execAsync( 'git', - ['log', historyRange, '-p', '--format=', '--', '.mcp.json', '.kimi-code/mcp.json'], + [ + 'log', + historyRange, + '-m', + '--text', + '--no-textconv', + '-p', + '--format=', + '--', + '.mcp.json', + '.kimi-code/mcp.json', + ], { cwd: task.worktreePath, maxBuffer: 8 * 1024 * 1024 }, ); const history = execStdout(result); @@ -2258,10 +2303,19 @@ export class Coordinator { existingTask.agentCommand = opts.agentCommand ?? existingTask.agentCommand; if (safeMcpConfigPath) existingTask.mcpConfigPath = safeMcpConfigPath; if (opts.autoDiscoveredMcpConfig !== undefined) { - existingTask.autoDiscoveredMcpConfig = validateAutoDiscoveredMcpConfigState( + const restoredState = validateAutoDiscoveredMcpConfigState( opts.autoDiscoveredMcpConfig, existingTask.worktreePath, ); + if (restoredState) { + existingTask.autoDiscoveredMcpConfig = restoredState; + } else if (existingTask.autoDiscoveredMcpConfig) { + logWarn( + 'coordinator.kimi_mcp', + 'ignored invalid persisted Kimi MCP state; preserving live state', + { taskId: existingTask.id }, + ); + } } const mcpLaunchArgs = this.rewriteHydratedSubtaskMcpConfig( existingTask, From f7f3caee35acaed8d89f3d27bcda87410f384040 Mon Sep 17 00:00:00 2001 From: Liang Hu Date: Mon, 7 Sep 2026 21:05:58 -0400 Subject: [PATCH 13/16] refactor(mcp): unify missing Kimi config recovery --- electron/mcp/coordinator.test.ts | 12 ++++++++---- electron/mcp/coordinator.ts | 8 -------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/electron/mcp/coordinator.test.ts b/electron/mcp/coordinator.test.ts index a01ded066..629e95b7a 100644 --- a/electron/mcp/coordinator.test.ts +++ b/electron/mcp/coordinator.test.ts @@ -1594,15 +1594,16 @@ describe('Coordinator land_self', () => { expect(vi.mocked(mergeTask)).toHaveBeenCalled(); }); - it('lands when the managed Kimi child MCP entry is already absent', async () => { + it.each(['file', 'entry'])('recovers missing Kimi %s', async (missing) => { const configPath = '/tmp/test/.kimi-code/mcp.json'; + let configExists = true; let taskConfig = ''; let currentConfig = JSON.stringify({ mcpServers: { other: { command: 'user-owned-server' } }, }); mockExistsSync.mockImplementation( (path) => - path === configPath || + (path === configPath && configExists) || (typeof path === 'string' && path.includes('parallel-code-subtask-')), ); mockReadFileSync.mockImplementation((path) => { @@ -1644,6 +1645,7 @@ describe('Coordinator land_self', () => { }; delete withoutManagedEntry.mcpServers['parallel-code']; currentConfig = JSON.stringify(withoutManagedEntry); + if (missing === 'file') configExists = false; await kimiCoordinator.landSelf('task-1', { verification }); @@ -1653,12 +1655,13 @@ describe('Coordinator land_self', () => { expect(vi.mocked(mergeTask)).toHaveBeenCalled(); }); - it('fails closed when the managed Kimi child MCP entry cannot be recovered', async () => { + it.each(['file', 'entry'])('rejects unrecoverable Kimi %s', async (missing) => { const configPath = '/tmp/test/.kimi-code/mcp.json'; + let configExists = true; let currentConfig = JSON.stringify({ mcpServers: { other: { command: 'user-owned-server' } }, }); - mockExistsSync.mockImplementation((path) => path === configPath); + mockExistsSync.mockImplementation((path) => path === configPath && configExists); mockReadFileSync.mockImplementation((path) => path === configPath ? currentConfig : '# existing\n', ); @@ -1691,6 +1694,7 @@ describe('Coordinator land_self', () => { }; delete withoutManagedEntry.mcpServers['parallel-code']; currentConfig = JSON.stringify(withoutManagedEntry); + if (missing === 'file') configExists = false; await expect(kimiCoordinator.landSelf('task-1', { verification })).rejects.toThrow( 'Unable to restore managed Kimi MCP config', diff --git a/electron/mcp/coordinator.ts b/electron/mcp/coordinator.ts index 525a6e872..a3c22d8ac 100644 --- a/electron/mcp/coordinator.ts +++ b/electron/mcp/coordinator.ts @@ -1976,14 +1976,6 @@ export class Coordinator { if (!state) return { status: 'none' }; try { - if (!existsSync(state.path)) { - const managedEntry = this.readManagedMcpEntryFromTaskConfig(task, state); - if (managedEntry === undefined) return { status: 'failed' }; - task.autoDiscoveredMcpConfig = undefined; - this.syncAutoDiscoveredMcpConfig(task); - return { status: 'restored', managedEntry }; - } - const content = readMcpJsonContent(state.path); const servers = content.mcpServers ?? {}; const managedEntry = servers['parallel-code']; From 5b0a24bf94fa715f7ac99d6fa0f3908a7a6fdda6 Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 8 Sep 2026 20:55:53 +0200 Subject: [PATCH 14/16] fix(mcp): anchor Kimi git-exclude patterns to the worktree root A slashless .git/info/exclude pattern matches at any depth, so the .mcp.json candidate also hid a user's nested .mcp.json and every .parallel-code-atomic-*.tmp in the worktree from git status. Claude-Session: https://claude.ai/code/session_01LbmrtsEKtwHHhDy2JmnAeq --- electron/mcp/coordinator.test.ts | 22 ++++++++++++++++++---- electron/mcp/coordinator.ts | 10 +++++++--- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/electron/mcp/coordinator.test.ts b/electron/mcp/coordinator.test.ts index 629e95b7a..f0db11ee4 100644 --- a/electron/mcp/coordinator.test.ts +++ b/electron/mcp/coordinator.test.ts @@ -3718,14 +3718,14 @@ describe('Coordinator sub-task MCP config isolation', () => { ).not.toBe(childConfigs[1].mcpServers['parallel-code'].env['PARALLEL_CODE_MCP_DONE_TOKEN']); expect(mockAppendGitInfoExcludeBlock).toHaveBeenCalledWith( '/tmp/a', - '.kimi-code/mcp.json', - expect.stringContaining('.kimi-code/mcp.json'), + '/.kimi-code/mcp.json', + expect.stringContaining('/.kimi-code/mcp.json'), expect.any(Function), ); expect(mockAppendGitInfoExcludeBlock).toHaveBeenCalledWith( '/tmp/a', - '.kimi-code/.parallel-code-atomic-*.tmp', - expect.stringContaining('.kimi-code/.parallel-code-atomic-*.tmp'), + '/.kimi-code/.parallel-code-atomic-*.tmp', + expect.stringContaining('/.kimi-code/.parallel-code-atomic-*.tmp'), expect.any(Function), ); for (const [, spawnOpts] of mockSpawnAgent.mock.calls) { @@ -3765,6 +3765,20 @@ describe('Coordinator sub-task MCP config isolation', () => { expect.stringContaining('subtask-tok'), { mode: 0o600 }, ); + // Root-level patterns carry no slash of their own, so they must be anchored + // explicitly or git would also match a user's nested `.mcp.json`. + expect(mockAppendGitInfoExcludeBlock).toHaveBeenCalledWith( + '/tmp/test', + '/.mcp.json', + expect.stringContaining('/.mcp.json'), + expect.any(Function), + ); + expect(mockAppendGitInfoExcludeBlock).toHaveBeenCalledWith( + '/tmp/test', + '/.parallel-code-atomic-*.tmp', + expect.stringContaining('/.parallel-code-atomic-*.tmp'), + expect.any(Function), + ); }); it('only parses the selected Kimi discovery path', async () => { diff --git a/electron/mcp/coordinator.ts b/electron/mcp/coordinator.ts index a3c22d8ac..066708e2e 100644 --- a/electron/mcp/coordinator.ts +++ b/electron/mcp/coordinator.ts @@ -1912,11 +1912,15 @@ export class Coordinator { const relativeDir = relativePath.includes('/') ? relativePath.slice(0, relativePath.lastIndexOf('/') + 1) : ''; - const atomicTmpPattern = `${relativeDir}.parallel-code-atomic-*.tmp`; + // Leading slash anchors both patterns to the worktree root. Without it a + // slashless pattern such as `.mcp.json` matches at any depth and would hide + // a user's nested config from git status. + const configPattern = `/${relativePath}`; + const atomicTmpPattern = `/${relativeDir}.parallel-code-atomic-*.tmp`; const excludePatterns = [ { - marker: relativePath, - block: `# Parallel Code Kimi MCP config (contains ephemeral token)\n${relativePath}\n`, + marker: configPattern, + block: `# Parallel Code Kimi MCP config (contains ephemeral token)\n${configPattern}\n`, }, { marker: atomicTmpPattern, From 6c19f6817f94711fc2764bbcf4e8ca3119fa3e2c Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 8 Sep 2026 20:56:05 +0200 Subject: [PATCH 15/16] fix(mcp): re-arm the Kimi child MCP config when restoration fails closed landSelf and mergeTask escalate without re-writing the child's config when restoreTaskAutoDiscoveredMcpConfig returns 'failed'. In the case that reaches that branch the managed entry is already gone from the worktree, so the child was left with no parallel-code server and could not report the escalation back to the coordinator until an app restart. Re-arm before escalating, as every other failure exit in both methods already does. The write path refuses to overwrite an entry it did not fingerprint, so a tampered entry stays untouched, and landing still fails closed either way. Claude-Session: https://claude.ai/code/session_01LbmrtsEKtwHHhDy2JmnAeq --- electron/mcp/coordinator.test.ts | 57 ++++++++++++++++++++++++++++++++ electron/mcp/coordinator.ts | 7 ++++ 2 files changed, 64 insertions(+) diff --git a/electron/mcp/coordinator.test.ts b/electron/mcp/coordinator.test.ts index f0db11ee4..5db15d676 100644 --- a/electron/mcp/coordinator.test.ts +++ b/electron/mcp/coordinator.test.ts @@ -1703,6 +1703,63 @@ describe('Coordinator land_self', () => { expect(vi.mocked(mergeTask)).not.toHaveBeenCalled(); expect(kimiCoordinator.getTask('task-1')?.autoDiscoveredMcpConfig).toBeDefined(); expect(kimiCoordinator.getTask('task-1')?.landingState).toBe('landing_escalated'); + // Landing failed closed, but the child keeps a working parallel-code server + // so it can report the escalation instead of going silent. + const rearmed = JSON.parse(currentConfig) as { + mcpServers: Record }>; + }; + expect(rearmed.mcpServers['parallel-code'].env['PARALLEL_CODE_MCP_TOKEN']).toBe( + 'subtask-token', + ); + }); + + it('leaves a foreign parallel-code entry alone when landing fails closed', async () => { + const configPath = '/tmp/test/.kimi-code/mcp.json'; + let currentConfig = JSON.stringify({ + mcpServers: { other: { command: 'user-owned-server' } }, + }); + mockExistsSync.mockImplementation((path) => path === configPath); + mockReadFileSync.mockImplementation((path) => + path === configPath ? currentConfig : '# existing\n', + ); + mockAtomicWriteFileSync.mockImplementation((path, raw) => { + if (path === configPath) currentConfig = raw as string; + }); + + const kimiCoordinator = new Coordinator(); + kimiCoordinator.setWindow(mockWin); + kimiCoordinator.setDefaultProject('proj-1', '/tmp/project'); + kimiCoordinator.registerCoordinator('coord-kimi', 'proj-1', { + worktreePath: '/tmp/project', + }); + kimiCoordinator.setCoordinatorSpawnDefaults('coord-kimi', 'kimi', []); + kimiCoordinator.setMCPServerInfo( + 'coord-kimi', + 'http://localhost:3001', + 'coordinator-token', + 'subtask-token', + '/path/server.js', + ); + await kimiCoordinator.createTask({ + name: 'test', + prompt: 'do', + coordinatorTaskId: 'coord-kimi', + }); + + const tampered = JSON.parse(currentConfig) as { mcpServers: Record }; + tampered.mcpServers['parallel-code'] = { command: 'not-ours' }; + currentConfig = JSON.stringify(tampered); + + await expect(kimiCoordinator.landSelf('task-1', { verification })).rejects.toThrow( + 'Unable to restore managed Kimi MCP config', + ); + + expect(vi.mocked(mergeTask)).not.toHaveBeenCalled(); + expect( + (JSON.parse(currentConfig) as { mcpServers: Record }).mcpServers[ + 'parallel-code' + ], + ).toEqual({ command: 'not-ours' }); }); it('fails closed on token-bearing history even when the discovery config was deleted', async () => { diff --git a/electron/mcp/coordinator.ts b/electron/mcp/coordinator.ts index 066708e2e..5444a6377 100644 --- a/electron/mcp/coordinator.ts +++ b/electron/mcp/coordinator.ts @@ -2168,6 +2168,12 @@ export class Coordinator { const restoreMcpConfig = this.restoreTaskAutoDiscoveredMcpConfig(task); if (restoreMcpConfig.status === 'failed') { + // Re-arm before escalating. In the case that gets us here the managed + // entry is already gone from the worktree, so without this the child is + // left with no parallel-code server and cannot report the escalation + // back. The write refuses to touch an entry it does not own, so a + // fingerprint mismatch stays untouched; landing fails closed either way. + this.refreshTaskMcpConfigAfterLandingFailure(task); const reason = 'Unable to restore managed Kimi MCP config before self-landing; refusing to validate or merge a worktree that may contain ephemeral MCP tokens.'; this.escalateLanding(task, 'landing_escalated', reason); @@ -2288,6 +2294,7 @@ export class Coordinator { this.assertTaskCanBeMerged(task); const restoreMcpConfig = this.restoreTaskAutoDiscoveredMcpConfig(task); if (restoreMcpConfig.status === 'failed') { + this.refreshTaskMcpConfigAfterLandingFailure(task); throw new Error( 'Unable to restore managed Kimi MCP config before merge; refusing to stage or merge a worktree that may contain ephemeral MCP tokens.', ); From a986a6f6afda2eb5581fe1176c9898bbc8ae1bd6 Mon Sep 17 00:00:00 2001 From: Liang Hu Date: Thu, 10 Sep 2026 12:27:41 -0400 Subject: [PATCH 16/16] fix(agents): enforce Docker-only Kimi launches --- README.md | 3 + electron/ipc/pty.test.ts | 40 +++++++++ electron/ipc/pty.ts | 4 + electron/shared/agent-support.test.ts | 15 ++++ electron/shared/agent-support.ts | 4 + src/components/AgentSelector.client.test.tsx | 86 ++++++++++++++++++++ src/components/AgentSelector.tsx | 30 +++++-- src/components/ImportWorktreesDialog.tsx | 4 +- src/components/NewTaskDialog.tsx | 7 ++ src/components/TaskAITerminal.tsx | 17 +++- 10 files changed, 201 insertions(+), 9 deletions(-) create mode 100644 electron/shared/agent-support.test.ts create mode 100644 electron/shared/agent-support.ts create mode 100644 src/components/AgentSelector.client.test.tsx diff --git a/README.md b/README.md index b07a4830e..f4c33bed5 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,9 @@ pause on every new worktree instead of starting the task unattended. Use Docker-isolated Kimi tasks for now. The bundled image pins Kimi Code 0.32, before the workspace-trust gate. Native Kimi support is not currently claimed. +Kimi is hidden from native task agent pickers, and native launches (including saved/custom +`kimi` definitions) are rejected with an actionable Docker-mode error. Existing running +terminals can still reattach after a renderer reload.
diff --git a/electron/ipc/pty.test.ts b/electron/ipc/pty.test.ts index 3abe1d863..71ad71c3a 100644 --- a/electron/ipc/pty.test.ts +++ b/electron/ipc/pty.test.ts @@ -863,6 +863,46 @@ describe('spawnAgent session reattach', () => { }); }); +describe('Kimi Docker-only support', () => { + it.each(['kimi', '/opt/bin/kimi'])('rejects a native %s before spawning', (command) => { + expect(() => + spawnAgent(createMockWindow(), buildSpawnArgs({ command, dockerMode: false })), + ).toThrow('Kimi Code requires Docker mode'); + expect(mockPtySpawn).not.toHaveBeenCalled(); + expect(mockExecFileSync).not.toHaveBeenCalled(); + }); + + it('allows a Docker Kimi launch without a host Kimi installation', () => { + spawnAgent(createMockWindow(), buildSpawnArgs({ command: 'kimi' })); + expect(getLastSpawnCall().command).toBe('docker'); + expect(getLastSpawnCall().args).toContain('kimi'); + expect(mockExecFileSync).not.toHaveBeenCalledWith('which', ['kimi'], expect.anything()); + }); + + it('does not kill an existing PTY when a native Kimi replacement is rejected', () => { + const win = createMockWindow(); + const args = buildSpawnArgs(); + spawnAgent(win, args); + const proc = mockPtySpawn.mock.results[0].value; + expect(() => spawnAgent(win, { ...args, command: 'kimi', dockerMode: false })).toThrow( + 'Kimi Code requires Docker mode', + ); + expect(proc.kill).not.toHaveBeenCalled(); + }); + + it('reattaches before applying new-launch eligibility checks', () => { + const win = createMockWindow(); + const args = buildSpawnArgs({ command: 'kimi' }); + spawnAgent(win, args); + const proc = mockPtySpawn.mock.results[0].value; + expect(() => + spawnAgent(win, { ...args, dockerMode: false, attachExisting: true }), + ).not.toThrow(); + expect(mockPtySpawn).toHaveBeenCalledTimes(1); + expect(proc.resume).toHaveBeenCalled(); + }); +}); + describe('validateCommand', () => { it('does not throw for a command found in PATH', () => { expect(() => validateCommand('/bin/sh')).not.toThrow(); diff --git a/electron/ipc/pty.ts b/electron/ipc/pty.ts index 64d38dd11..429d13a13 100644 --- a/electron/ipc/pty.ts +++ b/electron/ipc/pty.ts @@ -18,6 +18,7 @@ import { loadEnvFile } from './env-file.js'; import { HOOK_PTY_ENV_KEYS } from '../agent-hooks/hook-script.js'; import { isClaudeCommand, withClaudeHookSettings } from '../agent-hooks/launch-args.js'; import { debug as logDebug } from '../log.js'; +import { isAgentSupportedInMode } from '../shared/agent-support.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -548,6 +549,9 @@ export function spawnAgent(win: BrowserWindow, args: SpawnAgentArgs): void { } // In Docker mode, we validate `docker` exists rather than the inner command + if (!isAgentSupportedInMode(command, args.dockerMode)) { + throw new Error('Kimi Code requires Docker mode. Enable Docker isolation to start this agent.'); + } if (!args.dockerMode) { validateCommand(command); } else { diff --git a/electron/shared/agent-support.test.ts b/electron/shared/agent-support.test.ts new file mode 100644 index 000000000..164f5af88 --- /dev/null +++ b/electron/shared/agent-support.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { isAgentSupportedInMode } from './agent-support.js'; + +describe('isAgentSupportedInMode', () => { + it.each(['kimi', '/opt/bin/kimi'])('requires Docker for %s', (command) => { + expect(isAgentSupportedInMode(command)).toBe(false); + expect(isAgentSupportedInMode(command, false)).toBe(false); + expect(isAgentSupportedInMode(command, true)).toBe(true); + }); + + it.each(['claude', 'codex', 'gemini', '/bin/zsh', 'kimi-wrapper'])( + 'preserves native support for %s', + (command) => expect(isAgentSupportedInMode(command)).toBe(true), + ); +}); diff --git a/electron/shared/agent-support.ts b/electron/shared/agent-support.ts new file mode 100644 index 000000000..a794e67a1 --- /dev/null +++ b/electron/shared/agent-support.ts @@ -0,0 +1,4 @@ +/** Kimi's project MCP/trust flow is only supported by the pinned Docker image. */ +export function isAgentSupportedInMode(command: string, dockerMode = false): boolean { + return dockerMode || command.split('/').pop() !== 'kimi'; +} diff --git a/src/components/AgentSelector.client.test.tsx b/src/components/AgentSelector.client.test.tsx new file mode 100644 index 000000000..5f20b107c --- /dev/null +++ b/src/components/AgentSelector.client.test.tsx @@ -0,0 +1,86 @@ +import { createSignal } from 'solid-js'; +import { render } from 'solid-js/web'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { AgentSelector } from './AgentSelector'; +import type { AgentDef } from '../ipc/types'; + +vi.mock('../store/store', () => ({ store: { themePreset: 'dark' } })); +vi.mock('../lib/theme', () => ({ theme: {} })); + +const agents: AgentDef[] = ['claude', 'kimi', 'codex'].map((command) => ({ + id: command, + command, + name: command, + args: [], + resume_args: [], + skip_permissions_args: [], + description: command, +})); +const disposers: Array<() => void> = []; +afterEach(() => { + disposers.splice(0).forEach((dispose) => dispose()); + document.body.replaceChildren(); +}); + +function mount(docker = false, selected = agents[0]) { + const [dockerMode, setDockerMode] = createSignal(docker); + const [selectedAgent, setSelectedAgent] = createSignal(selected); + const container = document.createElement('div'); + document.body.append(container); + disposers.push( + render( + () => ( + + ), + container, + ), + ); + return { container, selectedAgent, setDockerMode }; +} + +describe('AgentSelector Docker-only eligibility', () => { + it('hides Kimi from native selection and explains Docker support', () => { + const { container } = mount(); + expect( + Array.from(container.querySelectorAll('[role=radio]')).map((b) => b.textContent), + ).toEqual(['claude', 'codex']); + expect(container.textContent).toContain('Kimi Code is available in Docker mode only.'); + }); + + it('retains Kimi in Docker mode', () => { + const { container } = mount(true); + expect(container.querySelectorAll('[role=radio]')).toHaveLength(3); + expect(container.textContent).not.toContain('Docker mode only'); + }); + + it('switches away from Kimi when Docker is disabled', () => { + const { selectedAgent, setDockerMode, container } = mount(true, agents[1]); + setDockerMode(false); + expect(selectedAgent().command).toBe('claude'); + expect(container.querySelector('[aria-checked=true]')?.textContent).toBe('claude'); + }); + + it('skips hidden agents during keyboard navigation', () => { + const { selectedAgent, container } = mount(); + container + .querySelector('button') + ?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); + expect(selectedAgent().command).toBe('codex'); + expect(document.activeElement?.textContent).toBe('codex'); + }); + + it('keeps keyboard focus correct after the Docker-only button is removed', () => { + const { selectedAgent, container, setDockerMode } = mount(true); + setDockerMode(false); + container + .querySelector('button') + ?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); + expect(selectedAgent().command).toBe('codex'); + expect(document.activeElement?.textContent).toBe('codex'); + }); +}); diff --git a/src/components/AgentSelector.tsx b/src/components/AgentSelector.tsx index e458083f3..fa48294fd 100644 --- a/src/components/AgentSelector.tsx +++ b/src/components/AgentSelector.tsx @@ -1,4 +1,5 @@ -import { For, Show } from 'solid-js'; +import { createEffect, For, Show } from 'solid-js'; +import { isAgentSupportedInMode } from '../../electron/shared/agent-support'; import { store } from '../store/store'; import { theme } from '../lib/theme'; import type { AgentDef } from '../ipc/types'; @@ -8,6 +9,7 @@ interface AgentSelectorProps { selectedAgent: AgentDef | null; onSelect: (agent: AgentDef) => void; wrap?: boolean; + dockerMode?: boolean; } /** @@ -15,11 +17,22 @@ interface AgentSelectorProps { * Only the selected agent is in the Tab order; Arrow keys move between agents. */ export function AgentSelector(props: AgentSelectorProps) { - const btnRefs: HTMLButtonElement[] = []; + const btnRefs = new Map(); const allowWrap = () => props.wrap ?? true; + const supportedAgents = () => + props.agents.filter((agent) => isAgentSupportedInMode(agent.command, props.dockerMode)); + + // Switching Docker off must not leave a hidden, unsupported agent selected. + createEffect(() => { + const selected = props.selectedAgent; + if (selected && !isAgentSupportedInMode(selected.command, props.dockerMode)) { + const fallback = supportedAgents()[0]; + if (fallback) props.onSelect(fallback); + } + }); function handleKeyDown(e: KeyboardEvent, idx: number) { - const agents = props.agents; + const agents = supportedAgents(); let nextIdx: number | null = null; if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { @@ -32,7 +45,7 @@ export function AgentSelector(props: AgentSelectorProps) { if (nextIdx !== null) { props.onSelect(agents[nextIdx]); - btnRefs[nextIdx]?.focus(); + btnRefs.get(agents[nextIdx].id)?.focus(); } } @@ -59,12 +72,12 @@ export function AgentSelector(props: AgentSelectorProps) { 'padding-bottom': allowWrap() ? undefined : '2px', }} > - + {(agent, i) => { const isSelected = () => props.selectedAgent?.id === agent.id; return (