Skip to content

Commit 0d9f3e9

Browse files
brookscclaude
andcommitted
fix(security): address round-2 review — token scoping, per-task done tokens, atomic EXDEV, hydration gating
P1 security: - Require X-Coordinator-Id for coordinator-class task routes (no unscoped fallback) - Per-task X-Done-Token header for signal_done; shared subtaskToken no longer sufficient - atomicWriteFile: use dirname(filePath) not os.tmpdir() to avoid cross-filesystem EXDEV - TerminalView: install MCP ready-state watcher regardless of initial status (fix retry spawn) - hydrateTask: throw if coordinator not registered; gate child hydration on coordinator ready P2 correctness: - Forward dockerImage in both createTask and retryTaskMcpStartup StartMCPServer calls - StartRemoteServer: rebind loopback-only MCP server to 0.0.0.0 on explicit remote start - Preserve pre-existing .mcp.json parallel-code entry; restore on deregistration Minor: semgrep/gitleaks scripts check for installation, print instructions if missing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d32cd5c commit 0d9f3e9

13 files changed

Lines changed: 225 additions & 70 deletions

File tree

electron/ipc/register-mcp.test.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -462,10 +462,11 @@ describe('Layer 5 — Failure modes', () => {
462462
}
463463
});
464464

465-
it('remote server returns 200 with correct Bearer token', async () => {
465+
it('remote server returns 200 with correct Bearer token and X-Coordinator-Id', async () => {
466466
const port = await findFreePort();
467467
const mockCoordinator = {
468468
listTasks: () => [],
469+
isRegisteredCoordinator: () => true,
469470
};
470471
const srv = await startRemoteServer({
471472
port,
@@ -476,7 +477,7 @@ describe('Layer 5 — Failure modes', () => {
476477
});
477478
try {
478479
const res = await fetch(`http://127.0.0.1:${srv.port}/api/tasks`, {
479-
headers: { Authorization: `Bearer ${srv.token}` },
480+
headers: { Authorization: `Bearer ${srv.token}`, 'X-Coordinator-Id': 'test-coord' },
480481
});
481482
expect(res.status).toBe(200);
482483
} finally {
@@ -512,7 +513,7 @@ describe('Layer 5 — Failure modes', () => {
512513
describe('Layer 7 — Remote server bind address', () => {
513514
it('server bound to 127.0.0.1 is reachable via that address', async () => {
514515
const port = await findFreePort();
515-
const mockCoordinator = { listTasks: () => [] };
516+
const mockCoordinator = { listTasks: () => [], isRegisteredCoordinator: () => true };
516517
const srv = await startRemoteServer({
517518
port,
518519
staticDir: os.tmpdir(),
@@ -522,7 +523,7 @@ describe('Layer 7 — Remote server bind address', () => {
522523
});
523524
try {
524525
const res = await fetch(`http://127.0.0.1:${srv.port}/api/tasks`, {
525-
headers: { Authorization: `Bearer ${srv.token}` },
526+
headers: { Authorization: `Bearer ${srv.token}`, 'X-Coordinator-Id': 'test-coord' },
526527
});
527528
expect(res.status).toBe(200);
528529
} finally {
@@ -671,7 +672,10 @@ describe('Layer 6 — Log assertions (production startup messages)', () => {
671672
describe('Layer 8 — Coordinator routes available after late coordinator attach', () => {
672673
it('coordinator routes return 503 before coordinator is set, then 200 after', async () => {
673674
const port = await findFreePort();
674-
let currentCoordinator: { listTasks: () => unknown[] } | null = null;
675+
let currentCoordinator: {
676+
listTasks: () => unknown[];
677+
isRegisteredCoordinator?: () => boolean;
678+
} | null = null;
675679

676680
const srv = await startRemoteServer({
677681
port,
@@ -689,11 +693,11 @@ describe('Layer 8 — Coordinator routes available after late coordinator attach
689693
expect(res1.status).toBe(503);
690694

691695
// Simulate coordinator being attached later (e.g. StartMCPServer called after StartRemoteServer)
692-
currentCoordinator = { listTasks: () => [] };
696+
currentCoordinator = { listTasks: () => [], isRegisteredCoordinator: () => true };
693697

694-
// After coordinator is set: coordinator routes should work
698+
// After coordinator is set: coordinator routes should work (coordinator token requires X-Coordinator-Id)
695699
const res2 = await fetch(`http://127.0.0.1:${srv.port}/api/tasks`, {
696-
headers: { Authorization: `Bearer ${srv.token}` },
700+
headers: { Authorization: `Bearer ${srv.token}`, 'X-Coordinator-Id': 'test-coord' },
697701
});
698702
expect(res2.status).toBe(200);
699703
const body = (await res2.json()) as unknown[];

electron/ipc/register.ts

Lines changed: 42 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1024,21 +1024,11 @@ export function registerAllHandlers(win: BrowserWindow): void {
10241024
ipcMain.handle(IPC.StartRemoteServer, async (_e, args: { port?: number }) => {
10251025
remoteServerRequestedManually = true;
10261026
remoteServerPendingStop = false;
1027-
if (remoteServer)
1028-
return {
1029-
url: remoteServer.url,
1030-
wifiUrl: remoteServer.wifiUrl,
1031-
tailscaleUrl: remoteServer.tailscaleUrl,
1032-
port: remoteServer.port,
1033-
};
10341027

10351028
const thisDir = path.dirname(fileURLToPath(import.meta.url));
10361029
const distRemote = path.join(thisDir, '..', '..', 'dist-remote');
1037-
// Remote access is an explicit user action — bind to all interfaces so WiFi/Tailscale clients
1038-
// can reach the SPA. Coordinator MCP-only mode uses 127.0.0.1 by default.
1039-
remoteServer = await startRemoteServer({
1040-
port: args.port ?? 7777,
1041-
host: '0.0.0.0',
1030+
const remoteServerOpts = {
1031+
host: '0.0.0.0' as const,
10421032
staticDir: distRemote,
10431033
getTaskName: (taskId: string) => taskNames.get(taskId) ?? taskId,
10441034
getAgentStatus: (agentId: string) => {
@@ -1050,7 +1040,33 @@ export function registerAllHandlers(win: BrowserWindow): void {
10501040
};
10511041
},
10521042
getCoordinator: () => coordinator,
1053-
});
1043+
};
1044+
1045+
if (remoteServer) {
1046+
// If server was started for MCP-only (loopback), rebind to 0.0.0.0 so WiFi/Tailscale
1047+
// clients can reach it. Skip rebind while a coordinator is active — restarting the
1048+
// server would break ongoing MCP connections.
1049+
if (remoteServerStartedForMcp && !coordinator?.hasActiveCoordinator()) {
1050+
const prevPort = remoteServer.port;
1051+
await remoteServer.stop();
1052+
remoteServer = null;
1053+
remoteServerStartedForMcp = false;
1054+
remoteServer = await startRemoteServer({
1055+
port: args.port ?? prevPort,
1056+
...remoteServerOpts,
1057+
});
1058+
}
1059+
return {
1060+
url: remoteServer.url,
1061+
wifiUrl: remoteServer.wifiUrl,
1062+
tailscaleUrl: remoteServer.tailscaleUrl,
1063+
port: remoteServer.port,
1064+
};
1065+
}
1066+
1067+
// Remote access is an explicit user action — bind to all interfaces so WiFi/Tailscale clients
1068+
// can reach the SPA. Coordinator MCP-only mode uses 127.0.0.1 by default.
1069+
remoteServer = await startRemoteServer({ port: args.port ?? 7777, ...remoteServerOpts });
10541070
return {
10551071
url: remoteServer.url,
10561072
wifiUrl: remoteServer.wifiUrl,
@@ -1226,7 +1242,8 @@ export function registerAllHandlers(win: BrowserWindow): void {
12261242
if (args.baseBranch !== undefined) sharedValidateBranchName(args.baseBranch, 'baseBranch');
12271243
validatePath(args.worktreePath, 'worktreePath');
12281244
assertString(args.coordinatorTaskId, 'coordinatorTaskId');
1229-
coordinator?.hydrateTask({
1245+
if (!coordinator) throw new Error('coordinator mode not initialized');
1246+
coordinator.hydrateTask({
12301247
id: args.id,
12311248
name: args.name,
12321249
projectId: args.projectId,
@@ -1406,10 +1423,13 @@ export function registerAllHandlers(win: BrowserWindow): void {
14061423

14071424
// Merge mcpConfig into the pre-validated existingMcpContent (parsed above,
14081425
// before any coordinator state was mutated).
1426+
// Capture the previous parallel-code entry so deregistration can restore it instead of
1427+
// unconditionally deleting it (which would remove a user-owned entry).
1428+
const existingServers =
1429+
(existingMcpContent.mcpServers as Record<string, unknown> | undefined) ?? {};
1430+
const previousMcpParallelCode: unknown = existingServers['parallel-code'];
14091431
let mergedMcpJson: string | undefined;
14101432
if (mcpJsonDir && worktreeMcpPath) {
1411-
const existingServers =
1412-
(existingMcpContent.mcpServers as Record<string, unknown> | undefined) ?? {};
14131433
existingMcpContent.mcpServers = { ...existingServers, ...mcpConfig.mcpServers };
14141434
mergedMcpJson = JSON.stringify(existingMcpContent, null, 2);
14151435
}
@@ -1483,7 +1503,12 @@ export function registerAllHandlers(win: BrowserWindow): void {
14831503
// we created the file so deregisterCoordinator can clean up correctly.
14841504
if (mcpJsonDir && worktreeMcpPath && mergedMcpJson !== undefined) {
14851505
atomicWriteFileSync(worktreeMcpPath, mergedMcpJson, { mode: 0o600 });
1486-
coordinator.setMcpJsonInfo(args.coordinatorTaskId, worktreeMcpPath, !mcpFileExistedBefore);
1506+
coordinator.setMcpJsonInfo(
1507+
args.coordinatorTaskId,
1508+
worktreeMcpPath,
1509+
!mcpFileExistedBefore,
1510+
previousMcpParallelCode,
1511+
);
14871512

14881513
// Append to .git/info/exclude (local-only gitignore, not committed)
14891514
try {

electron/mcp/atomic.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { writeFileSync, renameSync, unlinkSync } from 'fs';
22
import { writeFile, rename, unlink } from 'fs/promises';
3-
import { join } from 'path';
4-
import os from 'os';
3+
import { join, dirname } from 'path';
54
import { randomUUID } from 'crypto';
65

76
/** Write `data` to `filePath` atomically: write to a temp file then rename.
@@ -11,7 +10,7 @@ export function atomicWriteFileSync(
1110
data: string,
1211
options?: { mode?: number },
1312
): void {
14-
const tmp = join(os.tmpdir(), `parallel-code-atomic-${randomUUID()}.tmp`);
13+
const tmp = join(dirname(filePath), `.parallel-code-atomic-${randomUUID()}.tmp`);
1514
try {
1615
writeFileSync(tmp, data, options);
1716
renameSync(tmp, filePath);
@@ -31,7 +30,7 @@ export async function atomicWriteFile(
3130
data: string,
3231
options?: { mode?: number },
3332
): Promise<void> {
34-
const tmp = join(os.tmpdir(), `parallel-code-atomic-${randomUUID()}.tmp`);
33+
const tmp = join(dirname(filePath), `.parallel-code-atomic-${randomUUID()}.tmp`);
3534
try {
3635
await writeFile(tmp, data, options);
3736
await rename(tmp, filePath);

electron/mcp/client.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export class MCPClient {
1616
private baseUrl: string,
1717
private token: string,
1818
private coordinatorId?: string,
19+
private doneToken?: string,
1920
) {}
2021

2122
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
@@ -105,7 +106,19 @@ export class MCPClient {
105106
}
106107

107108
async signalDone(taskId: string): Promise<void> {
108-
await this.request<unknown>('POST', `/api/tasks/${encodeURIComponent(taskId)}/done`, {});
109+
const url = `${this.baseUrl}/api/tasks/${encodeURIComponent(taskId)}/done`;
110+
const headers: Record<string, string> = {
111+
Authorization: `Bearer ${this.token}`,
112+
'Content-Type': 'application/json',
113+
};
114+
// Per-task done token is sent as X-Done-Token so the server can verify task ownership
115+
// without needing per-task bearer token classification.
116+
if (this.doneToken) headers['X-Done-Token'] = this.doneToken;
117+
const res = await fetch(url, { method: 'POST', headers, body: '{}' });
118+
if (!res.ok) {
119+
const text = await res.text().catch(() => '');
120+
throw new Error(`API POST /api/tasks/.../done failed (${res.status}): ${text}`);
121+
}
109122
}
110123

111124
async waitForSignalDone(

electron/mcp/coordinator.ts

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// Manages task lifecycle independently of the SolidJS renderer,
33
// using existing backend primitives (pty, git, tasks).
44

5-
import { randomUUID } from 'crypto';
5+
import { randomUUID, randomBytes } from 'crypto';
66
import { execFile } from 'child_process';
77
import { promisify } from 'util';
88
import { unlinkSync, readFileSync, existsSync } from 'fs';
@@ -223,13 +223,18 @@ export class Coordinator {
223223
for (const task of this.tasks.values()) {
224224
if (task.coordinatorTaskId !== coordinatorTaskId) continue;
225225
if (!task.mcpConfigPath) continue;
226+
// Preserve existing doneToken; generate a fresh one if not yet set (e.g. older persisted task).
227+
if (!task.doneToken) task.doneToken = randomBytes(24).toString('base64url');
226228
const mcpConfig = {
227229
mcpServers: {
228230
'parallel-code': {
229231
type: 'stdio' as const,
230232
command: 'node',
231233
args: [serverPath, '--url', serverUrl, '--task-id', task.id],
232-
env: { PARALLEL_CODE_MCP_TOKEN: subtaskToken },
234+
env: {
235+
PARALLEL_CODE_MCP_TOKEN: subtaskToken,
236+
PARALLEL_CODE_MCP_DONE_TOKEN: task.doneToken,
237+
},
233238
},
234239
},
235240
};
@@ -583,13 +588,18 @@ export class Coordinator {
583588
const mcpServerInfoForTask = coordinatorState.mcpServerInfo;
584589
if (mcpServerInfoForTask) {
585590
const { serverUrl, subtaskToken, serverPath } = mcpServerInfoForTask;
591+
const doneToken = randomBytes(24).toString('base64url');
592+
task.doneToken = doneToken;
586593
const mcpConfig = {
587594
mcpServers: {
588595
'parallel-code': {
589596
type: 'stdio' as const,
590597
command: 'node',
591598
args: [serverPath, '--url', serverUrl, '--task-id', task.id],
592-
env: { PARALLEL_CODE_MCP_TOKEN: subtaskToken },
599+
env: {
600+
PARALLEL_CODE_MCP_TOKEN: subtaskToken,
601+
PARALLEL_CODE_MCP_DONE_TOKEN: doneToken,
602+
},
593603
},
594604
},
595605
};
@@ -735,6 +745,10 @@ export class Coordinator {
735745
};
736746
}
737747

748+
getTaskDoneToken(taskId: string): string | null {
749+
return this.tasks.get(taskId)?.doneToken ?? null;
750+
}
751+
738752
async sendPrompt(taskId: string, prompt: string): Promise<void> {
739753
const task = this.tasks.get(taskId);
740754
if (!task) throw new Error(`Task not found: ${taskId}`);
@@ -1085,6 +1099,9 @@ export class Coordinator {
10851099
mcpConfigPath?: string;
10861100
preambleFileExistedBefore?: boolean;
10871101
}): void {
1102+
if (!this.coordinators.has(opts.coordinatorTaskId)) {
1103+
throw new Error(`coordinator ${opts.coordinatorTaskId} is not registered`);
1104+
}
10881105
if (this.tasks.has(opts.id)) return;
10891106
const task: CoordinatedTask = {
10901107
id: opts.id,
@@ -1130,13 +1147,17 @@ export class Coordinator {
11301147
// agent gets fresh credentials instead of the stale pre-restart values.
11311148
if (safeMcpConfigPath && serverInfo) {
11321149
const { serverUrl, subtaskToken, serverPath } = serverInfo;
1150+
if (!task.doneToken) task.doneToken = randomBytes(24).toString('base64url');
11331151
const mcpConfig = {
11341152
mcpServers: {
11351153
'parallel-code': {
11361154
type: 'stdio' as const,
11371155
command: 'node',
11381156
args: [serverPath, '--url', serverUrl, '--task-id', task.id],
1139-
env: { PARALLEL_CODE_MCP_TOKEN: subtaskToken },
1157+
env: {
1158+
PARALLEL_CODE_MCP_TOKEN: subtaskToken,
1159+
PARALLEL_CODE_MCP_DONE_TOKEN: task.doneToken,
1160+
},
11401161
},
11411162
},
11421163
};
@@ -1222,11 +1243,17 @@ export class Coordinator {
12221243
});
12231244
}
12241245

1225-
setMcpJsonInfo(coordinatorTaskId: string, mcpJsonPath: string, createdMcpJson: boolean): void {
1246+
setMcpJsonInfo(
1247+
coordinatorTaskId: string,
1248+
mcpJsonPath: string,
1249+
createdMcpJson: boolean,
1250+
previousMcpParallelCode?: unknown,
1251+
): void {
12261252
const state = this.coordinators.get(coordinatorTaskId);
12271253
if (state) {
12281254
state.mcpJsonPath = mcpJsonPath;
12291255
state.createdMcpJson = createdMcpJson;
1256+
state.previousMcpParallelCode = previousMcpParallelCode;
12301257
}
12311258
}
12321259

@@ -1246,17 +1273,23 @@ export class Coordinator {
12461273
});
12471274
}
12481275

1249-
// Clean up coordinator .mcp.json — remove only the parallel-code key.
1250-
// Always read current contents (user may have added keys while running),
1251-
// then delete the whole file only if nothing else remains.
1276+
// Clean up coordinator .mcp.json — restore or remove only the parallel-code key.
1277+
// Always read current contents (user may have added keys while running).
1278+
// If there was a pre-existing parallel-code entry, restore it; otherwise delete the key.
12521279
if (coordinator.mcpJsonPath) {
12531280
try {
12541281
const raw = existsSync(coordinator.mcpJsonPath)
12551282
? readFileSync(coordinator.mcpJsonPath, 'utf-8')
12561283
: null;
12571284
if (raw !== null) {
12581285
const content = JSON.parse(raw) as { mcpServers?: Record<string, unknown> };
1259-
if (content.mcpServers) delete content.mcpServers['parallel-code'];
1286+
if (content.mcpServers) {
1287+
if (coordinator.previousMcpParallelCode !== undefined) {
1288+
content.mcpServers['parallel-code'] = coordinator.previousMcpParallelCode;
1289+
} else {
1290+
delete content.mcpServers['parallel-code'];
1291+
}
1292+
}
12601293
const hasServers = Object.keys(content.mcpServers ?? {}).length > 0;
12611294
const hasOtherKeys = Object.keys(content).filter((k) => k !== 'mcpServers').length > 0;
12621295
if (!hasServers && !hasOtherKeys) {

electron/mcp/server.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ for (let i = 0; i < args.length; i++) {
2525
}
2626

2727
const token = process.env.PARALLEL_CODE_MCP_TOKEN ?? '';
28+
const doneToken = process.env.PARALLEL_CODE_MCP_DONE_TOKEN || undefined;
2829

2930
if (!url || !token) {
3031
console.error(
@@ -34,7 +35,7 @@ if (!url || !token) {
3435
process.exit(1);
3536
}
3637

37-
const client = new MCPClient(url, token, coordinatorId || undefined);
38+
const client = new MCPClient(url, token, coordinatorId || undefined, doneToken);
3839

3940
const server = new Server(
4041
{ name: 'parallel-code', version: '1.0.0' },

electron/mcp/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export interface CoordinatedTask {
1414
exitCode: number | null;
1515
pendingPrompt?: string;
1616
mcpConfigPath?: string; // path to per-task tmp config, deleted on cleanup
17+
doneToken?: string; // per-task token; only the owning sub-task may call /done
1718
preambleFileExistedBefore?: boolean; // true if the preamble file existed before injection (even if empty)
1819
signalDoneAt?: Date; // set when sub-task explicitly calls signal_done
1920
signalDoneConsumed?: boolean; // true after wait_for_signal_done returns this task's signal
@@ -77,6 +78,8 @@ export interface CoordinatorState {
7778
mcpJsonPath: string;
7879
/** True if Parallel Code created .mcp.json from scratch; false if it was pre-existing. */
7980
createdMcpJson: boolean;
81+
/** Previous value of mcpServers["parallel-code"] before this coordinator wrote its entry, if any. */
82+
previousMcpParallelCode?: unknown;
8083
}
8184

8285
// --- MCP tool input schemas ---

0 commit comments

Comments
 (0)