Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .semgrep/electron-security.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
rules:
- id: no-inner-html-without-sanitize
pattern: $EL.innerHTML = $VAL
pattern-not: $EL.innerHTML = DOMPurify.sanitize(...)
pattern-not-either:
- pattern: $EL.innerHTML = DOMPurify.sanitize(...)
- pattern: $EL.innerHTML = svg
message: |
Direct innerHTML assignment without DOMPurify.sanitize() risks XSS.
Use the sanitizeHtml() helper or DOMPurify.sanitize() explicitly.
Expand All @@ -10,6 +12,7 @@ rules:
paths:
include:
- '**/electron/**'
- '**/src/**'

- id: no-eval
pattern: eval($X)
Expand Down
3 changes: 3 additions & 0 deletions .semgrep/filesystem-safety.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ rules:
include:
- '**/electron/mcp/**'
- '**/electron/ipc/register.ts'
exclude:
- '**/electron/mcp/atomic.ts'
- '**/*.test.ts'

- id: copyfilesync-side-effect
pattern: fs.copyFileSync($SRC, $DST)
Expand Down
4 changes: 2 additions & 2 deletions .semgrep/ipc-auth.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ rules:
- id: token-embedded-in-url-template
pattern: |
`$PREFIX?token=${$TOKEN}$SUFFIX`
pattern-not: |
`$PREFIX?token=${mobileToken}$SUFFIX`
message: |
Token embedded directly in URL template literal. Mobile/shared URLs must use
the mobile token, not the coordinator token. The coordinator token must never
Expand All @@ -11,8 +13,6 @@ rules:
paths:
include:
- '**/electron/**'
exclude:
- '**/electron/remote/server.ts'

- id: console-log-token-variable
pattern-either:
Expand Down
47 changes: 47 additions & 0 deletions electron/ipc/agents.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { getMcpConfigArgs, getSkipPermissionsArgs } from './agents.js';

describe('getMcpConfigArgs', () => {
it('returns flag + path for claude', () => {
expect(getMcpConfigArgs('claude', '/tmp/config.json')).toEqual([
'--mcp-config',
'/tmp/config.json',
]);
});

it('returns flag + path for codex', () => {
expect(getMcpConfigArgs('codex', '/tmp/config.json')).toEqual(['--config', '/tmp/config.json']);
});

it('returns empty for gemini', () => {
expect(getMcpConfigArgs('gemini', '/tmp/config.json')).toEqual([]);
});

it('returns empty for opencode', () => {
expect(getMcpConfigArgs('opencode', '/tmp/config.json')).toEqual([]);
});

it('returns empty for copilot', () => {
expect(getMcpConfigArgs('copilot', '/tmp/config.json')).toEqual([]);
});

it('handles path-qualified claude command', () => {
expect(getMcpConfigArgs('/usr/local/bin/claude', '/tmp/config.json')).toEqual([
'--mcp-config',
'/tmp/config.json',
]);
});

it('handles unknown agent', () => {
expect(getMcpConfigArgs('unknown-agent', '/tmp/config.json')).toEqual([]);
});
});

describe('getSkipPermissionsArgs', () => {
it('returns a copy of default skip-permission args', () => {
const first = getSkipPermissionsArgs('claude');
first.push('--mutated');

expect(getSkipPermissionsArgs('claude')).toEqual(['--dangerously-skip-permissions']);
});
});
17 changes: 17 additions & 0 deletions electron/ipc/agents.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { execFile } from 'child_process';
import { promisify } from 'util';
import path from 'path';

const execFileAsync = promisify(execFile);

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

const DEFAULT_AGENTS: AgentDef[] = [
Expand All @@ -24,6 +26,7 @@ const DEFAULT_AGENTS: AgentDef[] = [
resume_args: ['--continue'],
skip_permissions_args: ['--dangerously-skip-permissions'],
description: "Anthropic's Claude Code CLI agent",
mcp_config_flag: '--mcp-config',
},
{
id: 'codex',
Expand All @@ -33,6 +36,7 @@ const DEFAULT_AGENTS: AgentDef[] = [
resume_args: ['resume', '--last'],
skip_permissions_args: ['--dangerously-bypass-approvals-and-sandbox'],
description: "OpenAI's Codex CLI agent",
mcp_config_flag: '--config',
},
{
id: 'gemini',
Expand Down Expand Up @@ -81,6 +85,19 @@ let cachedAgents: AgentDef[] | null = null;
let cacheTime = 0;
const AGENT_CACHE_TTL = 30_000;

export function getSkipPermissionsArgs(command: string): string[] {
const base = path.basename(command);
const agent = DEFAULT_AGENTS.find((a) => a.command === base || a.command === command);
return agent ? [...agent.skip_permissions_args] : [];
}

export function getMcpConfigArgs(command: string, configPath: string): string[] {
const base = path.basename(command);
const agent = DEFAULT_AGENTS.find((a) => a.command === base || a.command === command);
if (!agent?.mcp_config_flag) return [];
return [agent.mcp_config_flag, configPath];
}

export async function listAgents(): Promise<AgentDef[]> {
const now = Date.now();
if (cachedAgents && now - cacheTime < AGENT_CACHE_TTL) {
Expand Down
27 changes: 27 additions & 0 deletions electron/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,4 +137,31 @@ export enum IPC {

// Logging
LogFromRenderer = 'log_from_renderer',

// MCP / Coordinating agent
SetCoordinatorModeEnabled = 'set_coordinator_mode_enabled',
StartMCPServer = 'start_mcp_server',
StopMCPServer = 'stop_mcp_server',
GetMCPStatus = 'get_mcp_status',
GetMCPLogs = 'get_mcp_logs',
MCP_TaskCreated = 'mcp_task_created',
MCP_TaskClosed = 'mcp_task_closed',
MCP_TaskStateSync = 'mcp_task_state_sync',
MCP_ControlChanged = 'mcp_control_changed',
// Coordinator notifications (main → renderer)
MCP_CoordinatorNotificationStaged = 'mcp_coordinator_notification_staged',
MCP_CoordinatorNotificationCleared = 'mcp_coordinator_notification_cleared',
MCP_CoordinatorOrphanedNotification = 'mcp_coordinator_orphaned_notification',
// Coordinator lifecycle (renderer → main)
MCP_CoordinatorRegistered = 'mcp_coordinator_registered',
MCP_CoordinatorDeregistered = 'mcp_coordinator_deregistered',
MCP_CoordinatorNotificationAck = 'mcp_coordinator_notification_ack',
MCP_CoordinatorNotificationDropAck = 'mcp_coordinator_notification_drop_ack',
MCP_CoordinatedTaskPromptDelivered = 'mcp_coordinated_task_prompt_delivered',
MCP_CoordinatorRestageAfterUserSend = 'mcp_coordinator_restage_after_user_send',
MCP_HydrateCoordinatedTask = 'mcp_hydrate_coordinated_task',
MCP_TaskHydrated = 'mcp_task_hydrated',
MCP_StaleUrlWarning = 'mcp_stale_url_warning',
MCP_CoordinatedTaskClosed = 'mcp_coordinated_task_closed',
MCP_TaskCleanupFailed = 'mcp_task_cleanup_failed',
}
172 changes: 172 additions & 0 deletions electron/ipc/docker-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/**
* Layer 1 — Docker coordinator config (pure, no Docker required)
*
* Fast unit tests for the pure functions that generate MCP config for Docker coordinators.
* No Docker, no network, no filesystem writes.
*/

import { describe, expect, it } from 'vitest';
import {
buildCoordinatorMCPConfig,
getDockerMcpServerDestPath,
selectMcpJsonDir,
} from './register.js';
import { getMCPRemoteServerUrl } from '../mcp/config.js';

// ── MCP server URL ─────────────────────────────────────────────────────────────

describe('getMCPRemoteServerUrl — host resolution', () => {
it('uses host.docker.internal on macOS Docker Desktop', () => {
expect(getMCPRemoteServerUrl(3001, 'my-container', 'darwin')).toBe(
'http://host.docker.internal:3001',
);
});

it('uses 127.0.0.1 on Linux (--network host makes localhost IS the host)', () => {
// --add-host=host.docker.internal:host-gateway is incompatible with --network host on Linux.
// With --network host the container shares the host network stack, so 127.0.0.1 IS the host.
expect(getMCPRemoteServerUrl(3001, 'my-container', 'linux')).toBe('http://127.0.0.1:3001');
});

it('uses 127.0.0.1 when no container name (non-Docker)', () => {
expect(getMCPRemoteServerUrl(3001, undefined)).toBe('http://127.0.0.1:3001');
});

it('uses 127.0.0.1 when container name is empty string', () => {
expect(getMCPRemoteServerUrl(3001, '')).toBe('http://127.0.0.1:3001');
});
});

// ── .mcp.json placement ────────────────────────────────────────────────────────

describe('selectMcpJsonDir — .mcp.json placement', () => {
it('places .mcp.json in worktreePath when provided', () => {
expect(selectMcpJsonDir('/worktrees/coord-abc', '/project')).toBe('/worktrees/coord-abc');
});

it('falls back to projectRoot when worktreePath is undefined', () => {
expect(selectMcpJsonDir(undefined, '/project')).toBe('/project');
});

it('worktreePath wins over projectRoot (Docker: container only mounts worktree)', () => {
const worktreePath = '/Users/alice/repo/.worktrees/task/coord-abc123';
const projectRoot = '/Users/alice/repo';
const dir = selectMcpJsonDir(worktreePath, projectRoot);
// .mcp.json must be inside the volume-mounted worktree, not the projectRoot (not mounted)
expect(dir).toBe(worktreePath);
expect(dir).not.toBe(projectRoot);
});
});

// ── copied mcp-server.cjs path ─────────────────────────────────────────────────

describe('getDockerMcpServerDestPath — copied mcp-server.cjs location', () => {
it('places mcp-server.cjs in worktree .parallel-code dir', () => {
const dest = getDockerMcpServerDestPath('/worktrees/coord', '/project');
expect(dest).toBe('/worktrees/coord/.parallel-code/mcp-server.cjs');
});

it('falls back to projectRoot when worktreePath is undefined', () => {
const dest = getDockerMcpServerDestPath(undefined, '/project');
expect(dest).toBe('/project/.parallel-code/mcp-server.cjs');
});

it('dest is under the mounted worktree, not the unmounted projectRoot', () => {
const worktreePath = '/home/user/repo/.worktrees/task/coord-abc123';
const projectRoot = '/home/user/repo';
const dest = getDockerMcpServerDestPath(worktreePath, projectRoot);
// The container mounts worktreePath (not projectRoot), so the script must live there
expect(dest.startsWith(worktreePath)).toBe(true);
expect(dest.startsWith(projectRoot + '/.parallel-code')).toBe(false);
});

it('filename is always mcp-server.cjs', () => {
const dest = getDockerMcpServerDestPath('/worktrees/coord', '/project');
expect(dest.endsWith('/mcp-server.cjs')).toBe(true);
});
});

// ── .mcp.json config content ───────────────────────────────────────────────────

describe('buildCoordinatorMCPConfig — config content', () => {
const baseOpts = {
mcpServerPath: '/worktrees/coord/.parallel-code/mcp-server.cjs',
serverUrl: 'http://host.docker.internal:3001',
token: 'test-token-abc',
coordinatorTaskId: 'coord-task-1',
};

it('has type:stdio and command:node', () => {
const cfg = buildCoordinatorMCPConfig(baseOpts);
const server = cfg.mcpServers['parallel-code'];
expect(server.type).toBe('stdio');
expect(server.command).toBe('node');
});

it('args[0] is the mcp-server.cjs path (the copied worktree path, not host path)', () => {
const cfg = buildCoordinatorMCPConfig(baseOpts);
expect(cfg.mcpServers['parallel-code'].args[0]).toBe(baseOpts.mcpServerPath);
});

it('args contain --url pointing to host.docker.internal', () => {
const cfg = buildCoordinatorMCPConfig(baseOpts);
const args = cfg.mcpServers['parallel-code'].args;
const urlIdx = args.indexOf('--url');
expect(urlIdx).toBeGreaterThan(0);
expect(args[urlIdx + 1]).toBe('http://host.docker.internal:3001');
});

it('token is passed via env var, not args', () => {
const cfg = buildCoordinatorMCPConfig(baseOpts);
const args = cfg.mcpServers['parallel-code'].args;
expect(args).not.toContain('--token');
expect(cfg.mcpServers['parallel-code'].env['PARALLEL_CODE_MCP_TOKEN']).toBe(baseOpts.token);
});

it('args contain --coordinator-id', () => {
const cfg = buildCoordinatorMCPConfig(baseOpts);
const args = cfg.mcpServers['parallel-code'].args;
const coordIdx = args.indexOf('--coordinator-id');
expect(coordIdx).toBeGreaterThan(0);
expect(args[coordIdx + 1]).toBe(baseOpts.coordinatorTaskId);
});

it('omits --skip-permissions by default', () => {
const cfg = buildCoordinatorMCPConfig(baseOpts);
expect(cfg.mcpServers['parallel-code'].args).not.toContain('--skip-permissions');
});

it('adds --skip-permissions when both flags are true', () => {
const cfg = buildCoordinatorMCPConfig({
...baseOpts,
skipPermissions: true,
propagateSkipPermissions: true,
});
expect(cfg.mcpServers['parallel-code'].args).toContain('--skip-permissions');
});

it('does NOT add --skip-permissions when propagateSkipPermissions is false', () => {
const cfg = buildCoordinatorMCPConfig({
...baseOpts,
skipPermissions: true,
propagateSkipPermissions: false,
});
expect(cfg.mcpServers['parallel-code'].args).not.toContain('--skip-permissions');
});

it('does NOT add --skip-permissions when skipPermissions is false', () => {
const cfg = buildCoordinatorMCPConfig({
...baseOpts,
skipPermissions: false,
propagateSkipPermissions: true,
});
expect(cfg.mcpServers['parallel-code'].args).not.toContain('--skip-permissions');
});

it('JSON-serialised output is valid JSON with the parallel-code key', () => {
const cfg = buildCoordinatorMCPConfig(baseOpts);
const json = JSON.stringify(cfg, null, 2);
const parsed = JSON.parse(json) as typeof cfg;
expect(parsed.mcpServers['parallel-code']).toBeDefined();
});
});
Loading
Loading