Skip to content

Commit a844a68

Browse files
brookscclaude
andcommitted
fix: address round-3 review (orphaned signal_done, per-agent MCP config, notification delay, semgrep)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b43a427 commit a844a68

8 files changed

Lines changed: 140 additions & 16 deletions

File tree

.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/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: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { getMcpConfigArgs } 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 empty for codex', () => {
13+
expect(getMcpConfigArgs('codex', '/tmp/config.json')).toEqual([]);
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+
});

electron/ipc/agents.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ interface AgentDef {
1414
description: string;
1515
available?: boolean;
1616
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
1718
}
1819

1920
const DEFAULT_AGENTS: AgentDef[] = [
@@ -25,6 +26,7 @@ const DEFAULT_AGENTS: AgentDef[] = [
2526
resume_args: ['--continue'],
2627
skip_permissions_args: ['--dangerously-skip-permissions'],
2728
description: "Anthropic's Claude Code CLI agent",
29+
mcp_config_flag: '--mcp-config',
2830
},
2931
{
3032
id: 'codex',
@@ -88,6 +90,13 @@ export function getSkipPermissionsArgs(command: string): string[] {
8890
return agent ? agent.skip_permissions_args : [];
8991
}
9092

93+
export function getMcpConfigArgs(command: string, configPath: string): string[] {
94+
const base = path.basename(command);
95+
const agent = DEFAULT_AGENTS.find((a) => a.command === base || a.command === command);
96+
if (!agent?.mcp_config_flag) return [];
97+
return [agent.mcp_config_flag, configPath];
98+
}
99+
91100
export async function listAgents(): Promise<AgentDef[]> {
92101
const now = Date.now();
93102
if (cachedAgents && now - cacheTime < AGENT_CACHE_TTL) {

electron/ipc/register.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1184,9 +1184,12 @@ export function registerAllHandlers(win: BrowserWindow): void {
11841184
// Stop the remote server when the last coordinator exits if:
11851185
// - MCP started the server and user hasn't separately requested manual access, OR
11861186
// - the user explicitly requested stop while coordinator was active (pendingStop)
1187+
// Also defer shutdown if orphaned child tasks still need the HTTP server
1188+
// (they call POST /api/tasks/{id}/done which requires it to be alive).
11871189
if (
11881190
remoteServer &&
11891191
!coordinator?.hasActiveCoordinator() &&
1192+
!coordinator?.hasOrphanedTasks() &&
11901193
(remoteServerPendingStop || (remoteServerStartedForMcp && !remoteServerRequestedManually))
11911194
) {
11921195
await remoteServer.stop();
@@ -1304,6 +1307,20 @@ export function registerAllHandlers(win: BrowserWindow): void {
13041307
const { Coordinator } = await import('../mcp/coordinator.js');
13051308
coordinator = new Coordinator();
13061309
coordinator.setWindow(win);
1310+
coordinator.setAllOrphanedDoneCallback(async () => {
1311+
if (
1312+
remoteServer &&
1313+
!coordinator?.hasActiveCoordinator() &&
1314+
!coordinator?.hasOrphanedTasks() &&
1315+
(remoteServerPendingStop || (remoteServerStartedForMcp && !remoteServerRequestedManually))
1316+
) {
1317+
await remoteServer.stop();
1318+
remoteServer = null;
1319+
remoteServerStartedForMcp = false;
1320+
remoteServerRequestedManually = false;
1321+
remoteServerPendingStop = false;
1322+
}
1323+
});
13071324
registerCoordinatorHandlers();
13081325
}
13091326

electron/mcp/coordinator.test.ts

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -973,20 +973,18 @@ describe('Coordinator deregisterCoordinator', () => {
973973
expect(() => coordinator.deregisterCoordinator('nonexistent')).not.toThrow();
974974
});
975975

976-
it('child tasks are removed from internal map after coordinator is deregistered', async () => {
976+
it('tasks become orphans after coordinator is deregistered', async () => {
977977
coordinator.registerCoordinator('coord-1', 'proj-1');
978978
await coordinator.createTask({ name: 'test', prompt: 'do', coordinatorTaskId: 'coord-1' });
979+
expect(coordinator.hasOrphanedTasks()).toBe(false);
979980
coordinator.deregisterCoordinator('coord-1');
980-
// markPromptDelivered is now a no-op — task was removed from this.tasks
981+
// Task remains in internal map as an orphan (not removed)
982+
expect(coordinator.hasOrphanedTasks()).toBe(true);
983+
// markPromptDelivered still works on orphaned tasks
981984
coordinator.markPromptDelivered('task-1');
982-
const outputCb = getOutputCb();
983-
mockNotifyRenderer.mockClear();
984-
outputCb(encode('Done ❯ '));
985-
// PTY output is silently dropped — no orphaned notification because the task entry is gone
986-
expect(mockNotifyRenderer).not.toHaveBeenCalledWith(
987-
'mcp_coordinator_orphaned_notification',
988-
expect.anything(),
989-
);
985+
// Close the orphaned task
986+
await coordinator.closeTask('task-1');
987+
expect(coordinator.hasOrphanedTasks()).toBe(false);
990988
});
991989

992990
it('clears staged notification when coordinator is deregistered', async () => {
@@ -1016,6 +1014,31 @@ describe('Coordinator deregisterCoordinator', () => {
10161014
expect(coordinator.hasActiveCoordinator()).toBe(false);
10171015
});
10181016

1017+
it('hasOrphanedTasks returns true after deregister with children, false after closeTask', async () => {
1018+
coordinator.registerCoordinator('coord-1', 'proj-1');
1019+
await coordinator.createTask({ name: 'test', prompt: 'do', coordinatorTaskId: 'coord-1' });
1020+
expect(coordinator.hasOrphanedTasks()).toBe(false);
1021+
1022+
coordinator.deregisterCoordinator('coord-1');
1023+
expect(coordinator.hasOrphanedTasks()).toBe(true);
1024+
1025+
await coordinator.closeTask('task-1');
1026+
expect(coordinator.hasOrphanedTasks()).toBe(false);
1027+
});
1028+
1029+
it('allOrphanedDoneCallback fires when last orphaned task is closed', async () => {
1030+
const cb = vi.fn();
1031+
coordinator.setAllOrphanedDoneCallback(cb);
1032+
coordinator.registerCoordinator('coord-1', 'proj-1');
1033+
await coordinator.createTask({ name: 'test', prompt: 'do', coordinatorTaskId: 'coord-1' });
1034+
1035+
coordinator.deregisterCoordinator('coord-1');
1036+
expect(cb).not.toHaveBeenCalled();
1037+
1038+
await coordinator.closeTask('task-1');
1039+
expect(cb).toHaveBeenCalledTimes(1);
1040+
});
1041+
10191042
it('deregister cleans up backend resource maps for child tasks', async () => {
10201043
const { unsubscribeFromAgent } =
10211044
await vi.importMock<typeof import('../ipc/pty.js')>('../ipc/pty.js');
@@ -1044,6 +1067,16 @@ describe('Coordinator deregisterCoordinator', () => {
10441067
expect(c.tailBuffers.has(agentId)).toBe(false);
10451068
expect(c.decoders.has(agentId)).toBe(false);
10461069
});
1070+
1071+
it('hasOrphanedTasks returns false after removeCoordinatedTask closes an orphaned task', async () => {
1072+
coordinator.registerCoordinator('coord-1', 'proj-1');
1073+
await coordinator.createTask({ name: 'test', prompt: 'do', coordinatorTaskId: 'coord-1' });
1074+
coordinator.deregisterCoordinator('coord-1');
1075+
expect(coordinator.hasOrphanedTasks()).toBe(true);
1076+
// Simulate UI closing the task
1077+
coordinator.removeCoordinatedTask('task-1');
1078+
expect(coordinator.hasOrphanedTasks()).toBe(false);
1079+
});
10471080
});
10481081

10491082
// ─── per-task projectRoot tests ───────────────────────────────────────────────

electron/mcp/coordinator.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import {
2929
const execAsync = promisify(execFile);
3030
import type { BrowserWindow } from 'electron';
3131
import { createTask as createBackendTask, deleteTask } from '../ipc/tasks.js';
32-
import { getSkipPermissionsArgs } from '../ipc/agents.js';
32+
import { getSkipPermissionsArgs, getMcpConfigArgs } from '../ipc/agents.js';
3333
import {
3434
spawnAgent,
3535
writeToAgent,
@@ -87,9 +87,11 @@ export class Coordinator {
8787
args: [],
8888
};
8989
private coordinators = new Map<string, CoordinatorState>();
90-
private notificationDelayMs = 30_000;
90+
private notificationDelayMs = 60_000;
9191
private readonly COORDINATOR_RESTAMP_DELAY_MS = 5 * 60_000;
9292
private readonly MAX_ACKED_BATCH_IDS = 64;
93+
private orphanedTaskIds = new Set<string>();
94+
private allOrphanedDoneCallback: (() => void) | null = null;
9395
// Serializes concurrent preamble writes to the same file path.
9496
private preambleWriteQueue = new Map<string, Promise<void>>();
9597
constructor() {
@@ -635,7 +637,9 @@ export class Coordinator {
635637
...agentArgs,
636638
...(coordinatorState.propagateSkipPermissions ? getSkipPermissionsArgs(agentCommand) : []),
637639
];
638-
const mcpArgs = subTaskMcpConfigPath ? ['--mcp-config', subTaskMcpConfigPath] : [];
640+
const mcpArgs = subTaskMcpConfigPath
641+
? getMcpConfigArgs(agentCommand, subTaskMcpConfigPath)
642+
: [];
639643
const agentFinalArgs = [...baseArgs, ...mcpArgs];
640644

641645
// In Docker coordinator mode, each sub-task gets its own `docker run` container
@@ -1011,6 +1015,11 @@ export class Coordinator {
10111015
this.tasks.delete(taskId);
10121016
this.blockedByHumanControl.delete(taskId);
10131017
this.controlMap.delete(taskId);
1018+
1019+
this.orphanedTaskIds.delete(taskId);
1020+
if (this.orphanedTaskIds.size === 0 && !this.hasActiveCoordinator()) {
1021+
this.allOrphanedDoneCallback?.();
1022+
}
10141023
}
10151024

10161025
private async cleanupTask(taskId: string): Promise<void> {
@@ -1096,6 +1105,11 @@ export class Coordinator {
10961105
this.blockedByHumanControl.delete(taskId);
10971106
this.closingTaskIds.delete(taskId);
10981107

1108+
this.orphanedTaskIds.delete(taskId);
1109+
if (this.orphanedTaskIds.size === 0 && !this.hasActiveCoordinator()) {
1110+
this.allOrphanedDoneCallback?.();
1111+
}
1112+
10991113
// Notify renderer
11001114
this.notifyRenderer(IPC.MCP_TaskClosed, { taskId });
11011115
}
@@ -1384,6 +1398,7 @@ export class Coordinator {
13841398
// Transfer control to human so the user can decide what to do with orphaned tasks
13851399
this.controlMap.set(taskId, 'human');
13861400
this.blockedByHumanControl.delete(taskId);
1401+
this.orphanedTaskIds.add(taskId);
13871402

13881403
if (task.mcpConfigPath) {
13891404
try {
@@ -1477,6 +1492,14 @@ export class Coordinator {
14771492
return this.coordinators.size > 0;
14781493
}
14791494

1495+
hasOrphanedTasks(): boolean {
1496+
return this.orphanedTaskIds.size > 0;
1497+
}
1498+
1499+
setAllOrphanedDoneCallback(cb: () => void): void {
1500+
this.allOrphanedDoneCallback = cb;
1501+
}
1502+
14801503
signalDone(taskId: string): boolean {
14811504
const task = this.tasks.get(taskId);
14821505
if (!task) return false;

src/components/PlanViewerDialog.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ function PlanViewerContent(props: PlanViewerContentProps) {
114114
if (!source) return;
115115
const id = `mermaid-plan-${Date.now()}-${i}`;
116116
mermaid.render(id, source).then(({ svg }) => {
117+
// semgrep-disable-next-line no-inner-html-without-sanitize -- mermaid.render() returns safe SVG
117118
el.innerHTML = svg;
118119
el.classList.add('mermaid-rendered');
119120
});

0 commit comments

Comments
 (0)