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
7 changes: 7 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 Down Expand Up @@ -81,6 +82,12 @@ 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 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();
});
});
80 changes: 80 additions & 0 deletions electron/ipc/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import {
getUncommittedChangedFiles,
checkMergeStatus,
listImportableWorktrees,
mergeTask,
} from './git.js';

type ExecFileCallback = (err: Error | null, stdout: string, stderr: string) => void;
Expand Down Expand Up @@ -1499,3 +1500,82 @@ describe('checkMergeStatus (cherry-pick filtered ahead count)', () => {
expect(result.conflicting_files).toEqual(['src/foo.ts', 'src/bar.ts']);
});
});

// ---------------------------------------------------------------------------
// mergeTask — mergeWorktreePath skips checkout and routes ops to that path
// ---------------------------------------------------------------------------

describe('mergeTask (mergeWorktreePath)', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('skips git checkout and routes status/merge ops through mergeWorktreePath', async () => {
type CallRecord = { args: string[]; cwd: string | undefined };
const callRecords: CallRecord[] = [];

vi.mocked(execFile).mockImplementation(((
_cmd: string,
args: string[],
opts: unknown,
cb: ExecFileCallback,
) => {
callRecords.push({ args, cwd: (opts as { cwd?: string } | null)?.cwd });

const [cmd] = args;
// Repo lock key
if (cmd === 'rev-parse' && args.includes('--git-common-dir')) return cb(null, '.git\n', '');
// localBranchExists('main') → true; all other --verify refs → false
if (cmd === 'rev-parse' && args[1] === '--verify' && args[2] === 'refs/heads/main')
return cb(null, 'sha\n', '');
if (cmd === 'rev-parse' && args[1] === '--verify') return cb(new Error('no ref'), '', '');
// No remote HEAD
if (cmd === 'symbolic-ref') return cb(new Error('no remote'), '', '');
// merge-base for diff-base detection
if (cmd === 'merge-base') return cb(null, 'mergebase000\n', '');
// cherry-pick unique-commit list — empty means fully merged (→ no rev-list call)
if (cmd === 'log' && args.includes('--cherry-pick')) return cb(null, '', '');
// diff stats
if (cmd === 'diff') return cb(null, '', '');
// status --porcelain — clean working tree
if (cmd === 'status') return cb(null, '', '');
// merge
if (cmd === 'merge') return cb(null, '', '');
return cb(null, '', '');
}) as unknown as typeof execFile);

const projectRoot = uniqueRepoPath();
const mergeWorktreePath = uniqueRepoPath();
const branchName = 'feature/test-branch';

await mergeTask(
projectRoot,
branchName,
false, // squash
null, // message
false, // cleanup
'main', // baseBranch
undefined,
mergeWorktreePath,
);

// git checkout must never be called when mergeWorktreePath is supplied
expect(callRecords.some((r) => r.args[0] === 'checkout')).toBe(false);

// git merge must be called, and every such call must use mergeWorktreePath as cwd
const mergeCalls = callRecords.filter((r) => r.args[0] === 'merge');
expect(mergeCalls.length).toBeGreaterThan(0);
for (const r of mergeCalls) {
expect(r.cwd).toBe(mergeWorktreePath);
}

// git status --porcelain must be called with mergeWorktreePath as cwd
const statusCalls = callRecords.filter(
(r) => r.args[0] === 'status' && r.args.includes('--porcelain'),
);
expect(statusCalls.length).toBeGreaterThan(0);
for (const r of statusCalls) {
expect(r.cwd).toBe(mergeWorktreePath);
}
});
});
Loading
Loading