Skip to content

Commit 0c6afe1

Browse files
committed
fix: scope MCP tool handlers to coordinator ID to prevent cross-contamination
1 parent 2f95e21 commit 0c6afe1

6 files changed

Lines changed: 421 additions & 6 deletions

File tree

electron/mcp/client.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export class MCPClient {
1414
constructor(
1515
private baseUrl: string,
1616
private token: string,
17+
private coordinatorId?: string,
1718
) {}
1819

1920
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
@@ -22,6 +23,9 @@ export class MCPClient {
2223
Authorization: `Bearer ${this.token}`,
2324
'Content-Type': 'application/json',
2425
};
26+
if (this.coordinatorId) {
27+
headers['X-Coordinator-Id'] = this.coordinatorId;
28+
}
2529

2630
const res = await fetch(url, {
2731
method,

electron/mcp/server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ if (!url || !token) {
3737
process.exit(1);
3838
}
3939

40-
const client = new MCPClient(url, token);
40+
const client = new MCPClient(url, token, coordinatorId || undefined);
4141

4242
const server = new Server(
4343
{ name: 'parallel-code', version: '1.0.0' },
Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
// Integration tests for coordinator-scoped task access in the remote HTTP server.
2+
// Verifies that a coordinator can only see and control its own sub-tasks.
3+
4+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
5+
import http from 'http';
6+
import type { Coordinator } from '../mcp/coordinator.js';
7+
import type { ApiTaskSummary, ApiTaskDetail } from '../mcp/types.js';
8+
9+
vi.mock('../ipc/pty.js', () => ({
10+
writeToAgent: vi.fn(),
11+
resizeAgent: vi.fn(),
12+
killAgent: vi.fn(),
13+
subscribeToAgent: vi.fn(),
14+
unsubscribeFromAgent: vi.fn(),
15+
getAgentScrollback: vi.fn(() => null),
16+
getActiveAgentIds: vi.fn(() => []),
17+
getAgentMeta: vi.fn(() => null),
18+
getAgentCols: vi.fn(() => 80),
19+
onPtyEvent: vi.fn(() => vi.fn()), // returns an unsubscribe fn
20+
}));
21+
22+
vi.mock('./protocol.js', () => ({
23+
parseClientMessage: vi.fn(() => null),
24+
}));
25+
26+
const { startRemoteServer } = await import('./server.js');
27+
28+
// --- Minimal task fixtures ---
29+
30+
const COORD_A = 'coordinator-a';
31+
const COORD_B = 'coordinator-b';
32+
33+
const taskA: ApiTaskDetail = {
34+
id: 'task-a-1',
35+
name: 'Task A',
36+
branchName: 'task/task-a',
37+
worktreePath: '/tmp/task-a',
38+
projectId: 'proj-1',
39+
agentId: 'agent-a',
40+
status: 'idle',
41+
coordinatorTaskId: COORD_A,
42+
exitCode: null,
43+
};
44+
45+
const taskB: ApiTaskDetail = {
46+
id: 'task-b-1',
47+
name: 'Task B',
48+
branchName: 'task/task-b',
49+
worktreePath: '/tmp/task-b',
50+
projectId: 'proj-1',
51+
agentId: 'agent-b',
52+
status: 'idle',
53+
coordinatorTaskId: COORD_B,
54+
exitCode: null,
55+
};
56+
57+
const summaryA: ApiTaskSummary = {
58+
id: taskA.id,
59+
name: taskA.name,
60+
branchName: taskA.branchName,
61+
status: taskA.status,
62+
coordinatorTaskId: taskA.coordinatorTaskId,
63+
};
64+
65+
const summaryB: ApiTaskSummary = {
66+
id: taskB.id,
67+
name: taskB.name,
68+
branchName: taskB.branchName,
69+
status: taskB.status,
70+
coordinatorTaskId: taskB.coordinatorTaskId,
71+
};
72+
73+
function makeMockCoordinator(): Coordinator {
74+
const tasks = new Map<string, ApiTaskDetail>([
75+
[taskA.id, taskA],
76+
[taskB.id, taskB],
77+
]);
78+
79+
return {
80+
listTasks: () => [summaryA, summaryB],
81+
getTaskStatus: (id: string) => tasks.get(id) ?? null,
82+
sendPrompt: vi.fn().mockResolvedValue(undefined),
83+
waitForIdle: vi.fn().mockResolvedValue({ reason: 'idle' }),
84+
getTaskDiff: vi.fn().mockResolvedValue({ files: [], diff: '' }),
85+
getTaskOutput: vi.fn().mockReturnValue('output'),
86+
mergeTask: vi.fn().mockResolvedValue({ mainBranch: 'main', linesAdded: 0, linesRemoved: 0 }),
87+
closeTask: vi.fn().mockResolvedValue(undefined),
88+
reviewAndMergeTask: vi.fn().mockResolvedValue({
89+
diff: { files: [], diff: '' },
90+
merge: { mainBranch: 'main', linesAdded: 0, linesRemoved: 0 },
91+
}),
92+
createTask: vi.fn().mockResolvedValue(taskA),
93+
signalDone: vi.fn().mockReturnValue(true),
94+
waitForSignalDone: vi.fn().mockResolvedValue({
95+
taskId: taskA.id,
96+
name: taskA.name,
97+
status: 'idle',
98+
signalDoneAt: new Date().toISOString(),
99+
remaining: 0,
100+
}),
101+
} as unknown as Coordinator;
102+
}
103+
104+
// --- Test helpers ---
105+
106+
let serverToken = '';
107+
let serverPort = 0;
108+
let serverStop: () => Promise<void>;
109+
110+
async function startServer(coordinator: Coordinator) {
111+
const srv = await startRemoteServer({
112+
port: 0, // random port
113+
host: '0.0.0.0',
114+
staticDir: '/nonexistent',
115+
getTaskName: (id) => id,
116+
getAgentStatus: () => ({ status: 'exited', exitCode: null, lastLine: '' }),
117+
coordinator,
118+
});
119+
serverToken = srv.token;
120+
serverPort = srv.port;
121+
serverStop = srv.stop;
122+
return srv;
123+
}
124+
125+
function httpRequest(
126+
method: string,
127+
path: string,
128+
body?: unknown,
129+
coordinatorId?: string,
130+
): Promise<{ status: number; json: () => Promise<unknown> }> {
131+
return new Promise((resolve, reject) => {
132+
const bodyStr = body !== undefined ? JSON.stringify(body) : undefined;
133+
const headers: Record<string, string> = {
134+
Authorization: `Bearer ${serverToken}`,
135+
'Content-Type': 'application/json',
136+
};
137+
if (coordinatorId) headers['X-Coordinator-Id'] = coordinatorId;
138+
if (bodyStr) headers['Content-Length'] = String(Buffer.byteLength(bodyStr));
139+
140+
const req = http.request(
141+
{ hostname: '127.0.0.1', port: serverPort, path, method, headers },
142+
(res) => {
143+
const chunks: Buffer[] = [];
144+
res.on('data', (c: Buffer) => chunks.push(c));
145+
res.on('end', () => {
146+
const raw = Buffer.concat(chunks).toString();
147+
resolve({
148+
status: res.statusCode ?? 0,
149+
json: () => Promise.resolve(JSON.parse(raw) as unknown),
150+
});
151+
});
152+
},
153+
);
154+
req.on('error', reject);
155+
if (bodyStr) req.write(bodyStr);
156+
req.end();
157+
});
158+
}
159+
160+
const get = (path: string, coordinatorId?: string) =>
161+
httpRequest('GET', path, undefined, coordinatorId);
162+
const post = (path: string, body: unknown, coordinatorId?: string) =>
163+
httpRequest('POST', path, body, coordinatorId);
164+
const del = (path: string, coordinatorId?: string) =>
165+
httpRequest('DELETE', path, undefined, coordinatorId);
166+
167+
// --- Tests ---
168+
169+
describe('coordinator scoping', () => {
170+
let mockCoord: Coordinator;
171+
172+
beforeEach(async () => {
173+
mockCoord = makeMockCoordinator();
174+
await startServer(mockCoord);
175+
});
176+
177+
afterEach(async () => {
178+
await serverStop();
179+
});
180+
181+
describe('list_tasks', () => {
182+
it('returns all tasks when no X-Coordinator-Id header', async () => {
183+
const res = await get('/api/tasks');
184+
expect(res.status).toBe(200);
185+
const tasks = (await res.json()) as ApiTaskSummary[];
186+
expect(tasks.map((t) => t.id).sort()).toEqual([taskA.id, taskB.id].sort());
187+
});
188+
189+
it('returns only coordinator A tasks when X-Coordinator-Id is coordinator-a', async () => {
190+
const res = await get('/api/tasks', COORD_A);
191+
expect(res.status).toBe(200);
192+
const tasks = (await res.json()) as ApiTaskSummary[];
193+
expect(tasks).toHaveLength(1);
194+
expect(tasks[0].id).toBe(taskA.id);
195+
});
196+
197+
it('returns only coordinator B tasks when X-Coordinator-Id is coordinator-b', async () => {
198+
const res = await get('/api/tasks', COORD_B);
199+
expect(res.status).toBe(200);
200+
const tasks = (await res.json()) as ApiTaskSummary[];
201+
expect(tasks).toHaveLength(1);
202+
expect(tasks[0].id).toBe(taskB.id);
203+
});
204+
});
205+
206+
describe('get_task_status', () => {
207+
it('allows coordinator A to access its own task', async () => {
208+
const res = await get(`/api/tasks/${taskA.id}`, COORD_A);
209+
expect(res.status).toBe(200);
210+
});
211+
212+
it('returns 403 when coordinator A accesses coordinator B task', async () => {
213+
const res = await get(`/api/tasks/${taskB.id}`, COORD_A);
214+
expect(res.status).toBe(403);
215+
});
216+
217+
it('allows unscoped caller to access any task', async () => {
218+
const resA = await get(`/api/tasks/${taskA.id}`);
219+
expect(resA.status).toBe(200);
220+
const resB = await get(`/api/tasks/${taskB.id}`);
221+
expect(resB.status).toBe(200);
222+
});
223+
});
224+
225+
describe('send_prompt', () => {
226+
it('allows coordinator A to send prompt to its own task', async () => {
227+
const res = await post(`/api/tasks/${taskA.id}/prompt`, { prompt: 'hello' }, COORD_A);
228+
expect(res.status).toBe(200);
229+
});
230+
231+
it('returns 403 when coordinator A sends prompt to coordinator B task', async () => {
232+
const res = await post(`/api/tasks/${taskB.id}/prompt`, { prompt: 'hello' }, COORD_A);
233+
expect(res.status).toBe(403);
234+
});
235+
});
236+
237+
describe('get_task_diff', () => {
238+
it('allows coordinator A to get diff of its own task', async () => {
239+
const res = await get(`/api/tasks/${taskA.id}/diff`, COORD_A);
240+
expect(res.status).toBe(200);
241+
});
242+
243+
it('returns 403 when coordinator A gets diff of coordinator B task', async () => {
244+
const res = await get(`/api/tasks/${taskB.id}/diff`, COORD_A);
245+
expect(res.status).toBe(403);
246+
});
247+
});
248+
249+
describe('get_task_output', () => {
250+
it('allows coordinator A to get output of its own task', async () => {
251+
const res = await get(`/api/tasks/${taskA.id}/output`, COORD_A);
252+
expect(res.status).toBe(200);
253+
});
254+
255+
it('returns 403 when coordinator A gets output of coordinator B task', async () => {
256+
const res = await get(`/api/tasks/${taskB.id}/output`, COORD_A);
257+
expect(res.status).toBe(403);
258+
});
259+
});
260+
261+
describe('merge_task', () => {
262+
it('allows coordinator A to merge its own task', async () => {
263+
const res = await post(`/api/tasks/${taskA.id}/merge`, {}, COORD_A);
264+
expect(res.status).toBe(200);
265+
});
266+
267+
it('returns 403 when coordinator A merges coordinator B task', async () => {
268+
const res = await post(`/api/tasks/${taskB.id}/merge`, {}, COORD_A);
269+
expect(res.status).toBe(403);
270+
});
271+
});
272+
273+
describe('close_task', () => {
274+
it('allows coordinator A to close its own task', async () => {
275+
const res = await del(`/api/tasks/${taskA.id}`, COORD_A);
276+
expect(res.status).toBe(200);
277+
});
278+
279+
it('returns 403 when coordinator A closes coordinator B task', async () => {
280+
const res = await del(`/api/tasks/${taskB.id}`, COORD_A);
281+
expect(res.status).toBe(403);
282+
});
283+
});
284+
285+
describe('review_and_merge_task', () => {
286+
it('allows coordinator A to review-merge its own task', async () => {
287+
const res = await post(`/api/tasks/${taskA.id}/review-merge`, {}, COORD_A);
288+
expect(res.status).toBe(200);
289+
});
290+
291+
it('returns 403 when coordinator A review-merges coordinator B task', async () => {
292+
const res = await post(`/api/tasks/${taskB.id}/review-merge`, {}, COORD_A);
293+
expect(res.status).toBe(403);
294+
});
295+
});
296+
297+
describe('signal_done (called by sub-tasks, no coordinator header)', () => {
298+
it('allows signal_done without coordinator header (sub-task flow)', async () => {
299+
const res = await post(`/api/tasks/${taskA.id}/done`, {});
300+
expect(res.status).toBe(200);
301+
});
302+
303+
it('allows signal_done for any task when no coordinator header', async () => {
304+
const res = await post(`/api/tasks/${taskB.id}/done`, {});
305+
expect(res.status).toBe(200);
306+
});
307+
});
308+
});

0 commit comments

Comments
 (0)