From fae6f11f9069eceed75d003e6ebcd45038fc13ef Mon Sep 17 00:00:00 2001 From: Liang Hu Date: Fri, 31 Jul 2026 11:21:17 -0400 Subject: [PATCH 1/9] 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 be457922..b2d2a0c9 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 f775de19..19ce8f45 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 566b276d..472d6520 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 36febbba..fb021aaf 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 0a0b1e07..16cf7424 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 b76fd8bc..925d45ad 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 c7fa90b6..bab22ddd 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 5add7eab..437a3fc5 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 652f516b..5d4b67c2 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 9769a883..96b90623 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 3763bf52..7c0efb87 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 a3af3446..bb4e5a10 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 2/9] 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 31d84bad..d48a4dee 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 510eaf3c..22fdddf4 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 6a695453..d4385f49 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 1b0044b4..7edb31e1 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 3/9] 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 22fdddf4..7b0698f5 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 d4385f49..7d8d5def 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 4/9] 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 25be5e46..9e0c9809 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 7b0698f5..6efd45a4 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 7d8d5def..73e1b96a 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 7edb31e1..00f49e58 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 b062b29b..8d92a492 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 9b269c89..e911800e 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 6ae20978..0c369ed9 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 33086734..fe860cff 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 7c0efb87..f646f5c2 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 bb4e5a10..67b6ee64 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 e1040baf..d4fa4c40 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 5/9] 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 19ce8f45..e18c46a6 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 6efd45a4..9b063829 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 73e1b96a..c0b50d55 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 00000000..6da7dc34 --- /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 00f49e58..451bda09 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 d4fa4c40..b3e967d5 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 6/9] 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 9b063829..073c2702 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 c0b50d55..5c5d82b1 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 451bda09..00f49e58 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 0c369ed9..5d4dc333 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 f646f5c2..e79b25e6 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 b3e967d5..d4fa4c40 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 7/9] 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 d48a4dee..8251d6b2 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 073c2702..1b2a9521 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 5c5d82b1..e219c5f3 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 00f49e58..1d0b0339 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 5d4dc333..4d7570f3 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 e79b25e6..fee12d11 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 d4fa4c40..bffa04ee 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 8/9] 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 ce6e2c7c..fccd40e1 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 670ad27e..f34a0a73 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 9/9] 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 e18c46a6..1063cfad 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 5cf77cd4..abbd3461 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 88224f76..db7b5bf1 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 6da7dc34..2f20a11e 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 fccd40e1..3fde21a3 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, });