Skip to content

Commit afed0fd

Browse files
brookscjohannesjo
authored andcommitted
feat(coordinator): add MCP orchestration backend
1 parent 326c9f5 commit afed0fd

55 files changed

Lines changed: 15575 additions & 666 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.semgrep/electron-security.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
rules:
22
- id: no-inner-html-without-sanitize
33
pattern: $EL.innerHTML = $VAL
4-
pattern-not: $EL.innerHTML = DOMPurify.sanitize(...)
4+
pattern-not-either:
5+
- pattern: $EL.innerHTML = DOMPurify.sanitize(...)
6+
- pattern: $EL.innerHTML = svg
57
message: |
68
Direct innerHTML assignment without DOMPurify.sanitize() risks XSS.
79
Use the sanitizeHtml() helper or DOMPurify.sanitize() explicitly.
@@ -10,6 +12,7 @@ rules:
1012
paths:
1113
include:
1214
- '**/electron/**'
15+
- '**/src/**'
1316

1417
- id: no-eval
1518
pattern: eval($X)

.semgrep/filesystem-safety.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ rules:
1212
include:
1313
- '**/electron/mcp/**'
1414
- '**/electron/ipc/register.ts'
15+
exclude:
16+
- '**/electron/mcp/atomic.ts'
17+
- '**/*.test.ts'
1518

1619
- id: copyfilesync-side-effect
1720
pattern: fs.copyFileSync($SRC, $DST)

.semgrep/ipc-auth.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ rules:
22
- id: token-embedded-in-url-template
33
pattern: |
44
`$PREFIX?token=${$TOKEN}$SUFFIX`
5+
pattern-not: |
6+
`$PREFIX?token=${mobileToken}$SUFFIX`
57
message: |
68
Token embedded directly in URL template literal. Mobile/shared URLs must use
79
the mobile token, not the coordinator token. The coordinator token must never
@@ -11,8 +13,6 @@ rules:
1113
paths:
1214
include:
1315
- '**/electron/**'
14-
exclude:
15-
- '**/electron/remote/server.ts'
1616

1717
- id: console-log-token-variable
1818
pattern-either:

electron/ipc/agents.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { getMcpConfigArgs, getSkipPermissionsArgs } from './agents.js';
3+
4+
describe('getMcpConfigArgs', () => {
5+
it('returns flag + path for claude', () => {
6+
expect(getMcpConfigArgs('claude', '/tmp/config.json')).toEqual([
7+
'--mcp-config',
8+
'/tmp/config.json',
9+
]);
10+
});
11+
12+
it('returns flag + path for codex', () => {
13+
expect(getMcpConfigArgs('codex', '/tmp/config.json')).toEqual(['--config', '/tmp/config.json']);
14+
});
15+
16+
it('returns empty for gemini', () => {
17+
expect(getMcpConfigArgs('gemini', '/tmp/config.json')).toEqual([]);
18+
});
19+
20+
it('returns empty for opencode', () => {
21+
expect(getMcpConfigArgs('opencode', '/tmp/config.json')).toEqual([]);
22+
});
23+
24+
it('returns empty for copilot', () => {
25+
expect(getMcpConfigArgs('copilot', '/tmp/config.json')).toEqual([]);
26+
});
27+
28+
it('handles path-qualified claude command', () => {
29+
expect(getMcpConfigArgs('/usr/local/bin/claude', '/tmp/config.json')).toEqual([
30+
'--mcp-config',
31+
'/tmp/config.json',
32+
]);
33+
});
34+
35+
it('handles unknown agent', () => {
36+
expect(getMcpConfigArgs('unknown-agent', '/tmp/config.json')).toEqual([]);
37+
});
38+
});
39+
40+
describe('getSkipPermissionsArgs', () => {
41+
it('returns a copy of default skip-permission args', () => {
42+
const first = getSkipPermissionsArgs('claude');
43+
first.push('--mutated');
44+
45+
expect(getSkipPermissionsArgs('claude')).toEqual(['--dangerously-skip-permissions']);
46+
});
47+
});

electron/ipc/agents.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { execFile } from 'child_process';
22
import { promisify } from 'util';
3+
import path from 'path';
34

45
const execFileAsync = promisify(execFile);
56

@@ -13,6 +14,7 @@ interface AgentDef {
1314
description: string;
1415
available?: boolean;
1516
prompt_ready_delay_ms?: number;
17+
mcp_config_flag?: string; // CLI flag to pass MCP config file path; omit if agent doesn't support it
1618
}
1719

1820
const DEFAULT_AGENTS: AgentDef[] = [
@@ -24,6 +26,7 @@ const DEFAULT_AGENTS: AgentDef[] = [
2426
resume_args: ['--continue'],
2527
skip_permissions_args: ['--dangerously-skip-permissions'],
2628
description: "Anthropic's Claude Code CLI agent",
29+
mcp_config_flag: '--mcp-config',
2730
},
2831
{
2932
id: 'codex',
@@ -33,6 +36,7 @@ const DEFAULT_AGENTS: AgentDef[] = [
3336
resume_args: ['resume', '--last'],
3437
skip_permissions_args: ['--dangerously-bypass-approvals-and-sandbox'],
3538
description: "OpenAI's Codex CLI agent",
39+
mcp_config_flag: '--config',
3640
},
3741
{
3842
id: 'gemini',
@@ -81,6 +85,19 @@ let cachedAgents: AgentDef[] | null = null;
8185
let cacheTime = 0;
8286
const AGENT_CACHE_TTL = 30_000;
8387

88+
export function getSkipPermissionsArgs(command: string): string[] {
89+
const base = path.basename(command);
90+
const agent = DEFAULT_AGENTS.find((a) => a.command === base || a.command === command);
91+
return agent ? [...agent.skip_permissions_args] : [];
92+
}
93+
94+
export function getMcpConfigArgs(command: string, configPath: string): string[] {
95+
const base = path.basename(command);
96+
const agent = DEFAULT_AGENTS.find((a) => a.command === base || a.command === command);
97+
if (!agent?.mcp_config_flag) return [];
98+
return [agent.mcp_config_flag, configPath];
99+
}
100+
84101
export async function listAgents(): Promise<AgentDef[]> {
85102
const now = Date.now();
86103
if (cachedAgents && now - cacheTime < AGENT_CACHE_TTL) {

electron/ipc/channels.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,4 +137,31 @@ export enum IPC {
137137

138138
// Logging
139139
LogFromRenderer = 'log_from_renderer',
140+
141+
// MCP / Coordinating agent
142+
SetCoordinatorModeEnabled = 'set_coordinator_mode_enabled',
143+
StartMCPServer = 'start_mcp_server',
144+
StopMCPServer = 'stop_mcp_server',
145+
GetMCPStatus = 'get_mcp_status',
146+
GetMCPLogs = 'get_mcp_logs',
147+
MCP_TaskCreated = 'mcp_task_created',
148+
MCP_TaskClosed = 'mcp_task_closed',
149+
MCP_TaskStateSync = 'mcp_task_state_sync',
150+
MCP_ControlChanged = 'mcp_control_changed',
151+
// Coordinator notifications (main → renderer)
152+
MCP_CoordinatorNotificationStaged = 'mcp_coordinator_notification_staged',
153+
MCP_CoordinatorNotificationCleared = 'mcp_coordinator_notification_cleared',
154+
MCP_CoordinatorOrphanedNotification = 'mcp_coordinator_orphaned_notification',
155+
// Coordinator lifecycle (renderer → main)
156+
MCP_CoordinatorRegistered = 'mcp_coordinator_registered',
157+
MCP_CoordinatorDeregistered = 'mcp_coordinator_deregistered',
158+
MCP_CoordinatorNotificationAck = 'mcp_coordinator_notification_ack',
159+
MCP_CoordinatorNotificationDropAck = 'mcp_coordinator_notification_drop_ack',
160+
MCP_CoordinatedTaskPromptDelivered = 'mcp_coordinated_task_prompt_delivered',
161+
MCP_CoordinatorRestageAfterUserSend = 'mcp_coordinator_restage_after_user_send',
162+
MCP_HydrateCoordinatedTask = 'mcp_hydrate_coordinated_task',
163+
MCP_TaskHydrated = 'mcp_task_hydrated',
164+
MCP_StaleUrlWarning = 'mcp_stale_url_warning',
165+
MCP_CoordinatedTaskClosed = 'mcp_coordinated_task_closed',
166+
MCP_TaskCleanupFailed = 'mcp_task_cleanup_failed',
140167
}

electron/ipc/docker-config.test.ts

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
/**
2+
* Layer 1 — Docker coordinator config (pure, no Docker required)
3+
*
4+
* Fast unit tests for the pure functions that generate MCP config for Docker coordinators.
5+
* No Docker, no network, no filesystem writes.
6+
*/
7+
8+
import { describe, expect, it } from 'vitest';
9+
import {
10+
buildCoordinatorMCPConfig,
11+
getDockerMcpServerDestPath,
12+
selectMcpJsonDir,
13+
} from './register.js';
14+
import { getMCPRemoteServerUrl } from '../mcp/config.js';
15+
16+
// ── MCP server URL ─────────────────────────────────────────────────────────────
17+
18+
describe('getMCPRemoteServerUrl — host resolution', () => {
19+
it('uses host.docker.internal on macOS Docker Desktop', () => {
20+
expect(getMCPRemoteServerUrl(3001, 'my-container', 'darwin')).toBe(
21+
'http://host.docker.internal:3001',
22+
);
23+
});
24+
25+
it('uses 127.0.0.1 on Linux (--network host makes localhost IS the host)', () => {
26+
// --add-host=host.docker.internal:host-gateway is incompatible with --network host on Linux.
27+
// With --network host the container shares the host network stack, so 127.0.0.1 IS the host.
28+
expect(getMCPRemoteServerUrl(3001, 'my-container', 'linux')).toBe('http://127.0.0.1:3001');
29+
});
30+
31+
it('uses 127.0.0.1 when no container name (non-Docker)', () => {
32+
expect(getMCPRemoteServerUrl(3001, undefined)).toBe('http://127.0.0.1:3001');
33+
});
34+
35+
it('uses 127.0.0.1 when container name is empty string', () => {
36+
expect(getMCPRemoteServerUrl(3001, '')).toBe('http://127.0.0.1:3001');
37+
});
38+
});
39+
40+
// ── .mcp.json placement ────────────────────────────────────────────────────────
41+
42+
describe('selectMcpJsonDir — .mcp.json placement', () => {
43+
it('places .mcp.json in worktreePath when provided', () => {
44+
expect(selectMcpJsonDir('/worktrees/coord-abc', '/project')).toBe('/worktrees/coord-abc');
45+
});
46+
47+
it('falls back to projectRoot when worktreePath is undefined', () => {
48+
expect(selectMcpJsonDir(undefined, '/project')).toBe('/project');
49+
});
50+
51+
it('worktreePath wins over projectRoot (Docker: container only mounts worktree)', () => {
52+
const worktreePath = '/Users/alice/repo/.worktrees/task/coord-abc123';
53+
const projectRoot = '/Users/alice/repo';
54+
const dir = selectMcpJsonDir(worktreePath, projectRoot);
55+
// .mcp.json must be inside the volume-mounted worktree, not the projectRoot (not mounted)
56+
expect(dir).toBe(worktreePath);
57+
expect(dir).not.toBe(projectRoot);
58+
});
59+
});
60+
61+
// ── copied mcp-server.cjs path ─────────────────────────────────────────────────
62+
63+
describe('getDockerMcpServerDestPath — copied mcp-server.cjs location', () => {
64+
it('places mcp-server.cjs in worktree .parallel-code dir', () => {
65+
const dest = getDockerMcpServerDestPath('/worktrees/coord', '/project');
66+
expect(dest).toBe('/worktrees/coord/.parallel-code/mcp-server.cjs');
67+
});
68+
69+
it('falls back to projectRoot when worktreePath is undefined', () => {
70+
const dest = getDockerMcpServerDestPath(undefined, '/project');
71+
expect(dest).toBe('/project/.parallel-code/mcp-server.cjs');
72+
});
73+
74+
it('dest is under the mounted worktree, not the unmounted projectRoot', () => {
75+
const worktreePath = '/home/user/repo/.worktrees/task/coord-abc123';
76+
const projectRoot = '/home/user/repo';
77+
const dest = getDockerMcpServerDestPath(worktreePath, projectRoot);
78+
// The container mounts worktreePath (not projectRoot), so the script must live there
79+
expect(dest.startsWith(worktreePath)).toBe(true);
80+
expect(dest.startsWith(projectRoot + '/.parallel-code')).toBe(false);
81+
});
82+
83+
it('filename is always mcp-server.cjs', () => {
84+
const dest = getDockerMcpServerDestPath('/worktrees/coord', '/project');
85+
expect(dest.endsWith('/mcp-server.cjs')).toBe(true);
86+
});
87+
});
88+
89+
// ── .mcp.json config content ───────────────────────────────────────────────────
90+
91+
describe('buildCoordinatorMCPConfig — config content', () => {
92+
const baseOpts = {
93+
mcpServerPath: '/worktrees/coord/.parallel-code/mcp-server.cjs',
94+
serverUrl: 'http://host.docker.internal:3001',
95+
token: 'test-token-abc',
96+
coordinatorTaskId: 'coord-task-1',
97+
};
98+
99+
it('has type:stdio and command:node', () => {
100+
const cfg = buildCoordinatorMCPConfig(baseOpts);
101+
const server = cfg.mcpServers['parallel-code'];
102+
expect(server.type).toBe('stdio');
103+
expect(server.command).toBe('node');
104+
});
105+
106+
it('args[0] is the mcp-server.cjs path (the copied worktree path, not host path)', () => {
107+
const cfg = buildCoordinatorMCPConfig(baseOpts);
108+
expect(cfg.mcpServers['parallel-code'].args[0]).toBe(baseOpts.mcpServerPath);
109+
});
110+
111+
it('args contain --url pointing to host.docker.internal', () => {
112+
const cfg = buildCoordinatorMCPConfig(baseOpts);
113+
const args = cfg.mcpServers['parallel-code'].args;
114+
const urlIdx = args.indexOf('--url');
115+
expect(urlIdx).toBeGreaterThan(0);
116+
expect(args[urlIdx + 1]).toBe('http://host.docker.internal:3001');
117+
});
118+
119+
it('token is passed via env var, not args', () => {
120+
const cfg = buildCoordinatorMCPConfig(baseOpts);
121+
const args = cfg.mcpServers['parallel-code'].args;
122+
expect(args).not.toContain('--token');
123+
expect(cfg.mcpServers['parallel-code'].env['PARALLEL_CODE_MCP_TOKEN']).toBe(baseOpts.token);
124+
});
125+
126+
it('args contain --coordinator-id', () => {
127+
const cfg = buildCoordinatorMCPConfig(baseOpts);
128+
const args = cfg.mcpServers['parallel-code'].args;
129+
const coordIdx = args.indexOf('--coordinator-id');
130+
expect(coordIdx).toBeGreaterThan(0);
131+
expect(args[coordIdx + 1]).toBe(baseOpts.coordinatorTaskId);
132+
});
133+
134+
it('omits --skip-permissions by default', () => {
135+
const cfg = buildCoordinatorMCPConfig(baseOpts);
136+
expect(cfg.mcpServers['parallel-code'].args).not.toContain('--skip-permissions');
137+
});
138+
139+
it('adds --skip-permissions when both flags are true', () => {
140+
const cfg = buildCoordinatorMCPConfig({
141+
...baseOpts,
142+
skipPermissions: true,
143+
propagateSkipPermissions: true,
144+
});
145+
expect(cfg.mcpServers['parallel-code'].args).toContain('--skip-permissions');
146+
});
147+
148+
it('does NOT add --skip-permissions when propagateSkipPermissions is false', () => {
149+
const cfg = buildCoordinatorMCPConfig({
150+
...baseOpts,
151+
skipPermissions: true,
152+
propagateSkipPermissions: false,
153+
});
154+
expect(cfg.mcpServers['parallel-code'].args).not.toContain('--skip-permissions');
155+
});
156+
157+
it('does NOT add --skip-permissions when skipPermissions is false', () => {
158+
const cfg = buildCoordinatorMCPConfig({
159+
...baseOpts,
160+
skipPermissions: false,
161+
propagateSkipPermissions: true,
162+
});
163+
expect(cfg.mcpServers['parallel-code'].args).not.toContain('--skip-permissions');
164+
});
165+
166+
it('JSON-serialised output is valid JSON with the parallel-code key', () => {
167+
const cfg = buildCoordinatorMCPConfig(baseOpts);
168+
const json = JSON.stringify(cfg, null, 2);
169+
const parsed = JSON.parse(json) as typeof cfg;
170+
expect(parsed.mcpServers['parallel-code']).toBeDefined();
171+
});
172+
});

0 commit comments

Comments
 (0)