diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 335e21593..c59bebcaf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,5 +33,10 @@ jobs: - name: Test security rules run: npm run test:security-rules - - name: Test - run: npm test + - name: Test (unit with coverage floor, client) + run: npm run test:ci + + # The coordinator's prompt-delivery path against a real pty. Gated by + # env locally because it takes ~20s and needs a working shell. + - name: Test (coordinator against a real pty) + run: npm run test:coordinator-pty diff --git a/PRIVACY.md b/PRIVACY.md index 4226f6635..c6a2f9992 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -54,8 +54,8 @@ Unlike the AI CLIs above, the following network activity is initiated by Paralle - **Inline code Q&A** — by default, the inline Q&A feature uses the Claude Code CLI as a local subprocess. The prompt sent to that CLI includes your question, a fixed instruction, and the selection you asked about — either a code snippet with file path and line range, or a markdown snippet from the in-app plan viewer with its source heading and an approximate position within the document. When you enable the optional [MiniMax](https://www.minimax.io/) provider in Settings, MiniMax is called directly by Parallel Code rather than as a subprocess: Parallel Code itself makes an HTTPS request to `api.minimax.io` using your API key, with the same kind of selected context in the request body. Your MiniMax API key is held in the main process in memory only and is not written to disk. Data sent to MiniMax is governed by MiniMax's own privacy policy, not this one. - **Remote Access (mobile monitoring)** — when you start it from Settings, Parallel Code runs a local HTTP and WebSocket listener bound to all of your machine's network interfaces, so devices on your LAN — or any device that can reach an address shown by the app — can connect. No traffic is routed through infrastructure operated by the Parallel Code project. - **Transport.** Traffic is unencrypted HTTP, and the access token is included in the URL (e.g. in the QR code). **Treat that URL as a credential.** Anything that captures the URL has the token until you restart Remote Access — including a photo of the QR code (which may be auto-backed up by your phone), mobile browser history or cross-device sync, clipboard managers, screen-sharing or screen-recording tools, and corporate TLS-inspection proxies or appliances that log HTTP URLs. Treat the network it runs on as trusted (a private LAN or a Tailscale tailnet), and stop the server when you're done. - - **Tokens.** A fresh bearer token is generated each time the server starts and is not persisted by the desktop app; previous tokens become invalid on restart. The mobile client stores the token it receives in its browser `localStorage` so the same device can reconnect without rescanning; the token persists there in plaintext until you clear browser data on the device or restart Remote Access. - - **Capabilities.** An authenticated mobile client is **read-only** — it can see the list of running agents and their recent terminal output, but cannot send input or stop agents. + - **Tokens.** A fresh bearer token is generated each time the server starts and is not persisted by the desktop app; previous tokens become invalid on restart. The mobile client stores the token it receives in its browser `localStorage` so the same device can reconnect without rescanning; the token persists there in plaintext until you clear browser data on the device or restart Remote Access. Pairing mints a second token that the phone stores the same way; it, too, is invalidated by a restart and the phone must pair again. + - **Capabilities.** A client holding only the URL token is **read-only** — it can see the list of running agents, their recent terminal output, and task notes, but cannot send input, stop agents, edit notes, or create tasks. Typing into agents, editing notes, and creating tasks require **pairing**: entering a short-lived 6-digit code shown on the desktop, which mints a separate paired token on that phone. Resizing and stopping agents are never available to a phone. - **"Tailscale-like" detection.** Parallel Code labels an interface as Tailscale-like when it finds a non-internal IPv4 address beginning with `100.`; addresses starting with `172.` (often used by Docker bridges) are excluded from the displayed "WiFi" URL. The check is heuristic and does not verify the interface is actually Tailscale, so only treat that option as Tailscale if you know the address belongs to your tailnet. If your host has a VPN, virtualisation bridge, or unusual NIC, the URL shown may be reachable from a wider network than your LAN. - **Over Tailscale.** Traffic is carried by your tailnet — typically a direct WireGuard connection between your devices, but Tailscale's coordination service and (when direct connection is not possible) DERP relays may be involved per [Tailscale's network architecture](https://tailscale.com/kb/1257/connection-types). How Tailscale handles that traffic is governed by Tailscale's own policies, not this one. - **Sub-task coordinator (MCP)** — when sub-tasks run under a coordinator agent, Parallel Code starts a local token-protected HTTP/WebSocket server so sub-task agents can call back into the app (e.g. to signal completion). No traffic from this feature passes through infrastructure operated by the Parallel Code project. diff --git a/electron/remote/notes-route.test.ts b/electron/remote/notes-route.test.ts index 1b7d1b1f9..3a48ec35e 100644 --- a/electron/remote/notes-route.test.ts +++ b/electron/remote/notes-route.test.ts @@ -27,6 +27,7 @@ let port = 0; let mobileToken = ''; let coordinatorToken = ''; let subtaskToken = ''; +let generatePin: () => { pin: string; expiresAt: number }; let stop: () => Promise; let getTaskNotes: Mock<(taskId: string) => Promise>; let setTaskNotes: Mock<(taskId: string, notes: string) => Promise>; @@ -47,9 +48,18 @@ async function start(extra: Partial = {}): Promise { mobileToken = srv.mobileToken; coordinatorToken = srv.token; subtaskToken = srv.subtaskToken; + generatePin = srv.generatePairingPin; stop = srv.stop; } +/** Elevate the mobile token to a paired one via the desktop PIN. */ +async function pair(): Promise { + const { pin } = generatePin(); + const res = await request('POST', '/api/pair/verify', { token: mobileToken, body: { pin } }); + expect(res.status).toBe(201); + return (res.json as { token: string }).token; +} + beforeEach(() => { getTaskNotes = vi.fn(async (_taskId: string) => 'stored notes'); setTaskNotes = vi.fn(async (_taskId: string, _notes: string) => {}); @@ -160,9 +170,9 @@ describe('GET/PUT notes — malformed and dangerous task ids', () => { describe('PUT /api/mobile/notes/:taskId', () => { beforeEach(() => start()); - it('saves notes for a mobile token', async () => { + it('saves notes for a paired token', async () => { const res = await request('PUT', '/api/mobile/notes/task-1', { - token: mobileToken, + token: await pair(), body: { notes: 'hello world' }, }); expect(res.status).toBe(200); @@ -170,6 +180,16 @@ describe('PUT /api/mobile/notes/:taskId', () => { expect(setTaskNotes).toHaveBeenCalledWith('task-1', 'hello world'); }); + it('returns 403 for the read-only mobile token (writes need pairing)', async () => { + const res = await request('PUT', '/api/mobile/notes/task-1', { + token: mobileToken, + body: { notes: 'hello world' }, + }); + expect(res.status).toBe(403); + expect(res.json).toEqual({ error: 'pairing required' }); + expect(setTaskNotes).not.toHaveBeenCalled(); + }); + it('returns 403 for a coordinator token', async () => { const res = await request('PUT', '/api/mobile/notes/task-1', { token: coordinatorToken, @@ -181,7 +201,7 @@ describe('PUT /api/mobile/notes/:taskId', () => { it('rejects a non-string notes body with 400', async () => { const res = await request('PUT', '/api/mobile/notes/task-1', { - token: mobileToken, + token: await pair(), body: { notes: 123 }, }); expect(res.status).toBe(400); @@ -190,7 +210,7 @@ describe('PUT /api/mobile/notes/:taskId', () => { it('rejects notes larger than 100 KB with 400', async () => { const res = await request('PUT', '/api/mobile/notes/task-1', { - token: mobileToken, + token: await pair(), body: { notes: 'x'.repeat(100 * 1024 + 1) }, }); expect(res.status).toBe(400); @@ -200,7 +220,7 @@ describe('PUT /api/mobile/notes/:taskId', () => { it('accepts multi-line notes up to the limit', async () => { const notes = 'line\n'.repeat(1000); const res = await request('PUT', '/api/mobile/notes/task-1', { - token: mobileToken, + token: await pair(), body: { notes }, }); expect(res.status).toBe(200); @@ -218,7 +238,7 @@ describe('notes route without a renderer bridge', () => { it('returns 503 for PUT when notes are unavailable', async () => { const res = await request('PUT', '/api/mobile/notes/task-1', { - token: mobileToken, + token: await pair(), body: { notes: 'x' }, }); expect(res.status).toBe(503); diff --git a/electron/remote/server-ws.test.ts b/electron/remote/server-ws.test.ts index 2fde6a2a5..5b78b35b0 100644 --- a/electron/remote/server-ws.test.ts +++ b/electron/remote/server-ws.test.ts @@ -1,9 +1,14 @@ // WebSocket-level access control for the remote server. -// Mobile clients may type into agent terminals (input) but must not be able -// to resize the PTY or kill agents. +// The QR-code (mobile) token only watches: it can subscribe to output but +// must not type, resize, or kill. Typing needs the paired token (PIN entered +// on the phone); resize and kill stay coordinator-only. Browser pages from +// any other origin are refused at the upgrade, before any token is seen. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import WebSocket from 'ws'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; vi.mock('../ipc/pty.js', () => ({ writeToAgent: vi.fn(), @@ -19,13 +24,26 @@ vi.mock('../ipc/pty.js', () => ({ })); const pty = await import('../ipc/pty.js'); -const { startRemoteServer } = await import('./server.js'); +const { startRemoteServer, isBrowserOriginAllowed, buildRemoteCsp } = await import('./server.js'); let port = 0; let coordinatorToken = ''; let mobileToken = ''; +let generatePin: () => { pin: string; expiresAt: number }; let stop: () => Promise; +/** Elevate the mobile token to a paired one via the desktop PIN. */ +async function pair(): Promise { + const { pin } = generatePin(); + const res = await fetch(`http://127.0.0.1:${port}/api/pair/verify`, { + method: 'POST', + headers: { Authorization: `Bearer ${mobileToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ pin }), + }); + expect(res.status).toBe(201); + return ((await res.json()) as { token: string }).token; +} + beforeEach(async () => { const srv = await startRemoteServer({ port: 0, @@ -38,6 +56,7 @@ beforeEach(async () => { port = srv.port; coordinatorToken = srv.token; mobileToken = srv.mobileToken; + generatePin = srv.generatePairingPin; stop = srv.stop; vi.clearAllMocks(); }); @@ -47,9 +66,9 @@ afterEach(async () => { }); /** Connect and authenticate; resolves once the server replies (agents list). */ -function connectAndAuth(token: string): Promise { +function connectAndAuth(token: string, headers?: Record): Promise { return new Promise((resolve, reject) => { - const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`); + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`, { headers }); ws.on('open', () => ws.send(JSON.stringify({ type: 'auth', token }))); ws.once('message', () => resolve(ws)); ws.on('close', (code) => reject(new Error(`closed before auth ack: ${code}`))); @@ -70,27 +89,22 @@ function waitForClose(ws: WebSocket): Promise { } describe('mobile token over WebSocket', () => { - it('forwards input to the agent PTY', async () => { + it('authenticates and can subscribe to agent output', async () => { const ws = await connectAndAuth(mobileToken); - ws.send(JSON.stringify({ type: 'input', agentId: 'agent-1', data: 'hi' })); + ws.send(JSON.stringify({ type: 'subscribe', agentId: 'agent-1' })); await vi.waitFor(() => { - expect(pty.writeToAgent).toHaveBeenCalledWith('agent-1', 'hi'); + expect(pty.subscribeToAgent).toHaveBeenCalledWith('agent-1', expect.any(Function)); }); expect(ws.readyState).toBe(WebSocket.OPEN); ws.close(); }); - it('silently drops oversized input (>4096 chars) without closing', async () => { + it('rejects input with 4003 (pairing required) and never reaches the PTY', async () => { const ws = await connectAndAuth(mobileToken); - ws.send(JSON.stringify({ type: 'input', agentId: 'agent-1', data: 'x'.repeat(4097) })); - // Probe with a valid message to ensure the oversized one was processed first - ws.send(JSON.stringify({ type: 'input', agentId: 'agent-1', data: 'ok' })); - await vi.waitFor(() => { - expect(pty.writeToAgent).toHaveBeenCalledWith('agent-1', 'ok'); - }); - expect(pty.writeToAgent).not.toHaveBeenCalledWith('agent-1', 'x'.repeat(4097)); - expect(ws.readyState).toBe(WebSocket.OPEN); - ws.close(); + const closed = waitForClose(ws); + ws.send(JSON.stringify({ type: 'input', agentId: 'agent-1', data: 'rm -rf ~' })); + expect(await closed).toBe(4003); + expect(pty.writeToAgent).not.toHaveBeenCalled(); }); it('rejects resize with 4003 and does not resize the PTY', async () => { @@ -110,6 +124,200 @@ describe('mobile token over WebSocket', () => { }); }); +describe('paired token over WebSocket', () => { + it('forwards input to the agent PTY', async () => { + const ws = await connectAndAuth(await pair()); + ws.send(JSON.stringify({ type: 'input', agentId: 'agent-1', data: 'hi' })); + await vi.waitFor(() => { + expect(pty.writeToAgent).toHaveBeenCalledWith('agent-1', 'hi'); + }); + expect(ws.readyState).toBe(WebSocket.OPEN); + ws.close(); + }); + + it('silently drops oversized input (>4096 chars) without closing', async () => { + const ws = await connectAndAuth(await pair()); + ws.send(JSON.stringify({ type: 'input', agentId: 'agent-1', data: 'x'.repeat(4097) })); + // Probe with a valid message to ensure the oversized one was processed first + ws.send(JSON.stringify({ type: 'input', agentId: 'agent-1', data: 'ok' })); + await vi.waitFor(() => { + expect(pty.writeToAgent).toHaveBeenCalledWith('agent-1', 'ok'); + }); + expect(pty.writeToAgent).not.toHaveBeenCalledWith('agent-1', 'x'.repeat(4097)); + expect(ws.readyState).toBe(WebSocket.OPEN); + ws.close(); + }); + + it('still cannot resize or kill (4003)', async () => { + const paired = await pair(); + const ws1 = await connectAndAuth(paired); + const closed1 = waitForClose(ws1); + ws1.send(JSON.stringify({ type: 'resize', agentId: 'agent-1', cols: 80, rows: 24 })); + expect(await closed1).toBe(4003); + + const ws2 = await connectAndAuth(paired); + const closed2 = waitForClose(ws2); + ws2.send(JSON.stringify({ type: 'kill', agentId: 'agent-1' })); + expect(await closed2).toBe(4003); + + expect(pty.resizeAgent).not.toHaveBeenCalled(); + expect(pty.killAgent).not.toHaveBeenCalled(); + }); + + it('a paired token from a stopped server is refused (4001)', async () => { + const paired = await pair(); + await stop(); + const srv = await startRemoteServer({ + port: 0, + host: '127.0.0.1', + staticDir: '/nonexistent', + getTaskName: (id) => id, + getAgentStatus: () => ({ status: 'exited', exitCode: null, lastLine: '' }), + getCoordinator: () => null, + }); + port = srv.port; + stop = srv.stop; + await expect(connectAndAuth(paired)).rejects.toThrow('4001'); + }); +}); + +describe('browser Origin on the WebSocket upgrade', () => { + /** Attempt an upgrade with the given headers; resolves with the HTTP status when refused. */ + function upgradeStatus(headers: Record): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`, { headers }); + ws.on('unexpected-response', (_req, res) => { + resolve(res.statusCode ?? 0); + res.resume(); + ws.terminate(); + }); + ws.on('open', () => { + resolve('open'); + ws.close(); + }); + ws.on('error', (err) => { + // ws also emits 'error' after 'unexpected-response'; only surface it + // when nothing else resolved first. + reject(err); + }); + }); + } + + it('refuses an Origin that does not match the Host (403)', async () => { + expect(await upgradeStatus({ Origin: 'http://evil.example' })).toBe(403); + }); + + it('refuses an opaque "null" Origin (403)', async () => { + expect(await upgradeStatus({ Origin: 'null' })).toBe(403); + }); + + it('accepts the origin this server itself serves', async () => { + expect(await upgradeStatus({ Origin: `http://127.0.0.1:${port}` })).toBe('open'); + }); + + it('accepts non-browser clients that send no Origin', async () => { + expect(await upgradeStatus({})).toBe('open'); + }); + + it('a cross-origin page with a valid token still cannot type', async () => { + await expect(connectAndAuth(await pair(), { Origin: 'http://evil.example' })).rejects.toThrow(); + expect(pty.writeToAgent).not.toHaveBeenCalled(); + }); +}); + +describe('browser Origin on HTTP API routes', () => { + it('refuses a cross-origin fetch even with a valid token (403)', async () => { + const res = await fetch(`http://127.0.0.1:${port}/api/agents`, { + headers: { Authorization: `Bearer ${coordinatorToken}`, Origin: 'http://evil.example' }, + }); + expect(res.status).toBe(403); + }); + + it('serves a same-origin fetch', async () => { + const res = await fetch(`http://127.0.0.1:${port}/api/agents`, { + headers: { + Authorization: `Bearer ${coordinatorToken}`, + Origin: `http://127.0.0.1:${port}`, + }, + }); + expect(res.status).toBe(200); + }); +}); + +describe('isBrowserOriginAllowed', () => { + it('allows requests without an Origin (non-browser clients)', () => { + expect(isBrowserOriginAllowed({ host: '10.0.0.2:7777' })).toBe(true); + }); + + it('requires the Origin host to equal the Host header, case-insensitively', () => { + expect(isBrowserOriginAllowed({ host: '10.0.0.2:7777', origin: 'http://10.0.0.2:7777' })).toBe( + true, + ); + expect( + isBrowserOriginAllowed({ host: 'Desktop.local:7777', origin: 'http://desktop.local:7777' }), + ).toBe(true); + expect(isBrowserOriginAllowed({ host: '10.0.0.2:7777', origin: 'http://10.0.0.2:7778' })).toBe( + false, + ); + expect(isBrowserOriginAllowed({ host: '10.0.0.2:7777', origin: 'http://attacker.test' })).toBe( + false, + ); + }); + + it('refuses opaque, malformed, and non-http origins', () => { + expect(isBrowserOriginAllowed({ host: 'a:1', origin: 'null' })).toBe(false); + expect(isBrowserOriginAllowed({ host: 'a:1', origin: 'not a url' })).toBe(false); + expect(isBrowserOriginAllowed({ host: 'a:1', origin: 'file://a:1' })).toBe(false); + // Node joins duplicate Origin headers with ", " — not a URL, so refused. + expect(isBrowserOriginAllowed({ host: 'a:1', origin: 'http://a:1, http://b:1' })).toBe(false); + }); + + it('refuses an Origin when the request carries no Host', () => { + expect(isBrowserOriginAllowed({ origin: 'http://a:1' })).toBe(false); + }); +}); + +describe('buildRemoteCsp', () => { + it('pins scripts to the bundle and the socket to the requested host', () => { + const csp = buildRemoteCsp('10.0.0.2:7777'); + expect(csp).toContain("script-src 'self';"); + expect(csp).toContain("connect-src 'self' ws://10.0.0.2:7777 wss://10.0.0.2:7777"); + expect(csp).toContain("object-src 'none'"); + expect(csp).toContain("frame-ancestors 'none'"); + expect(csp).not.toContain('unsafe-eval'); + }); + + it('drops a Host that could inject directives', () => { + const csp = buildRemoteCsp("x; script-src 'unsafe-inline'"); + expect(csp).toContain("connect-src 'self';"); + expect(csp).not.toContain('x; script-src'); + expect(buildRemoteCsp(undefined)).toContain("connect-src 'self';"); + }); + + it('is sent with the mobile SPA static responses', async () => { + await stop(); + const staticDir = mkdtempSync(join(tmpdir(), 'pc-remote-static-')); + writeFileSync(join(staticDir, 'index.html'), 'x'); + const srv = await startRemoteServer({ + port: 0, + host: '127.0.0.1', + staticDir, + getTaskName: (id) => id, + getAgentStatus: () => ({ status: 'exited', exitCode: null, lastLine: '' }), + getCoordinator: () => null, + }); + port = srv.port; + stop = async () => { + await srv.stop(); + rmSync(staticDir, { recursive: true, force: true }); + }; + const res = await fetch(`http://127.0.0.1:${port}/`); + expect(res.status).toBe(200); + expect(res.headers.get('content-security-policy')).toBe(buildRemoteCsp(`127.0.0.1:${port}`)); + expect(res.headers.get('x-frame-options')).toBe('DENY'); + }); +}); + describe('unauthenticated WebSocket clients', () => { function connectRaw(): Promise { return new Promise((resolve, reject) => { diff --git a/electron/remote/server.ts b/electron/remote/server.ts index 85ac9691d..66745074c 100644 --- a/electron/remote/server.ts +++ b/electron/remote/server.ts @@ -82,6 +82,68 @@ export function getMCPLogs(): MCPLogEntry[] { return mcpLogs.slice(); } +interface OriginHeaders { + origin?: string; + host?: string; +} + +/** + * Browser clients send an `Origin` header on WebSocket upgrades and on + * cross-site fetches; non-browser clients (the MCP coordinator client, curl) + * send none. A page this server itself served has an Origin whose host equals + * the request's `Host`; any other Origin is some other site — a tab open on + * the LAN, a page that guessed the desktop's address — using the user's + * browser to reach the server. WebSockets are exempt from the same-origin + * policy, so without this a foreign page could at least open a socket and + * probe. (Auth still needs the token, which lives in the SPA's own + * localStorage and is unreadable cross-origin; that, not this check, is what + * defeats DNS rebinding.) No Origin is allowed: the token still gates every + * API route and socket. + */ +export function isBrowserOriginAllowed(headers: OriginHeaders): boolean { + const origin = headers.origin; + if (origin === undefined) return true; + const host = headers.host; + if (!host) return false; + let parsed: URL; + try { + parsed = new URL(origin); + } catch { + return false; // "null" (sandboxed/opaque origin) or garbage + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false; + return parsed.host.toLowerCase() === host.toLowerCase(); +} + +/** + * Content-Security-Policy for the mobile SPA. The SPA is a Solid bundle with + * inline `style` attributes, an xterm canvas, and one WebSocket back to this + * server; nothing loads from anywhere else. Scripts are restricted to the + * bundle so an injected string can never become code in the page that holds + * the terminal token. The socket target is derived from the request's Host so + * `connect-src` stays tight whichever IP the phone reached us on. + */ +export function buildRemoteCsp(host: string | undefined): string { + // Host lands inside a header value: only accept hostname/IP/port characters + // so a crafted Host cannot append directives. + const safeHost = host && /^[A-Za-z0-9.\-:[\]]{1,255}$/.test(host) ? host : null; + const socketSources = safeHost ? ` ws://${safeHost} wss://${safeHost}` : ''; + return [ + "default-src 'self'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob:", + "font-src 'self' data:", + `connect-src 'self'${socketSources}`, + "worker-src 'self' blob:", + "manifest-src 'self'", + "object-src 'none'", + "base-uri 'none'", + "frame-ancestors 'none'", + "form-action 'self'", + ].join('; '); +} + function parseLandSelfInput(body: Record): LandSelfInput | string { const summary = body.summary; if (summary !== undefined && typeof summary !== 'string') return 'summary must be a string'; @@ -697,6 +759,11 @@ export function startRemoteServer(opts: { // the server, consistent with the mobile/coordinator tokens above. One entry // per paired phone. const pairedTokenBufs: Buffer[] = []; + // Bound the credential set: pairing is a user action on the desktop, so a + // handful covers every phone a person owns; beyond that the oldest is + // dropped. Eviction only stops future authentication with that token — a + // socket it already opened stays authenticated until it reconnects. + const MAX_PAIRED_TOKENS = 8; // At most one pending PIN at a time — a fresh mint replaces any prior one. let pairing: { pinBuf: Buffer; expiresAt: number; attemptsLeft: number } | null = null; @@ -755,6 +822,9 @@ export function startRemoteServer(opts: { pairing = null; // single-use const pairedToken = randomBytes(24).toString('base64url'); pairedTokenBufs.push(Buffer.from(pairedToken)); + if (pairedTokenBufs.length > MAX_PAIRED_TOKENS) { + pairedTokenBufs.splice(0, pairedTokenBufs.length - MAX_PAIRED_TOKENS); + } return { ok: true, token: pairedToken }; } @@ -769,6 +839,11 @@ export function startRemoteServer(opts: { // --- API routes (require auth) --- if (url.pathname.startsWith('/api/')) { + if (!isBrowserOriginAllowed(req.headers)) { + res.writeHead(403, { ...SECURITY_HEADERS, 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'forbidden origin' })); + return; + } const tokenClass = classifyToken(req); if (tokenClass === null) { res.writeHead(401, { ...SECURITY_HEADERS, 'Content-Type': 'application/json' }); @@ -844,10 +919,11 @@ export function startRemoteServer(opts: { return jsonEnd(405, { error: 'method not allowed' }); } - // --- Task notes (mobile + paired) --- - // Read/write the notes textarea shown on the desktop task panel. Available - // to the read-only mobile token too: editing notes is low-risk and mirrors - // the "interact with your terminals" capability the mobile token already has. + // --- Task notes (read: mobile + paired; write: paired) --- + // The notes textarea shown on the desktop task panel. The QR-code mobile + // token may read notes; writing them (text that lands in the desktop UI + // and can be sent to an agent as a prompt) needs the paired token, like + // every other write. const notesMatch = url.pathname.match(/^\/api\/mobile\/notes\/([^/]+)$/); if (notesMatch) { if (tokenClass !== 'mobile' && tokenClass !== 'paired') @@ -878,6 +954,7 @@ export function startRemoteServer(opts: { } if (req.method === 'PUT') { + if (tokenClass !== 'paired') return jsonEnd(403, { error: 'pairing required' }); const setTaskNotes = opts.setTaskNotes; if (!setTaskNotes) return jsonEnd(503, { error: 'notes unavailable' }); // Cap the body generously above MAX_NOTES_BYTES so the precise byte @@ -1056,7 +1133,15 @@ export function startRemoteServer(opts: { const serveFile = (path: string, ct: string, cc: string) => { const stream = createReadStream(path); - res.writeHead(200, { ...SECURITY_HEADERS, 'Content-Type': ct, 'Cache-Control': cc }); + res.writeHead(200, { + ...SECURITY_HEADERS, + // The policy governs documents; assets only need to be served by one. + ...(ct.startsWith('text/html') + ? { 'Content-Security-Policy': buildRemoteCsp(req.headers.host) } + : {}), + 'Content-Type': ct, + 'Cache-Control': cc, + }); stream.pipe(res); stream.on('error', () => { if (!res.headersSent) { @@ -1099,6 +1184,10 @@ export function startRemoteServer(opts: { server, maxPayload: 64 * 1024, verifyClient: (info, cb) => { + if (!isBrowserOriginAllowed(info.req.headers)) { + cb(false, 403, 'Forbidden origin'); + return; + } if (wss.clients.size >= 10) { cb(false, 429, 'Too many connections'); return; @@ -1111,7 +1200,7 @@ export function startRemoteServer(opts: { const clientSubs = new WeakMap void>>(); const authenticatedClients = new Set(); - const clientTokenTypes = new Map(); + const clientTokenTypes = new Map(); const authTimers = new WeakMap>(); function broadcast(msg: ServerMessage): void { @@ -1157,10 +1246,12 @@ export function startRemoteServer(opts: { const list = buildAgentList(opts.getTaskName, opts.getAgentStatus, getTaskAttention); ws.send(JSON.stringify({ type: 'agents', list } satisfies ServerMessage)); } else { - // Close unauthenticated connections after 5 seconds + // Close unauthenticated connections after 5 seconds. Distinct code from + // 4001: the phone treats 4001 as "my token is stale" and discards it, + // which a slow network must not trigger. const authTimer = setTimeout(() => { if (!authenticatedClients.has(ws)) { - ws.close(4001, 'Auth timeout'); + ws.close(4002, 'Auth timeout'); } }, 5_000); authTimers.set(ws, authTimer); @@ -1170,11 +1261,12 @@ export function startRemoteServer(opts: { const msg = parseClientMessage(String(raw)); if (!msg) return; - // Handle first-message auth. Coordinator and mobile tokens grant WS - // access; subtask tokens are denied. + // Handle first-message auth. Coordinator, mobile, and paired tokens + // grant WS access (with different write rights, below); subtask tokens + // are denied. if (msg.type === 'auth') { const tokenType = classifyCandidate(msg.token); - if (tokenType === 'coordinator' || tokenType === 'mobile') { + if (tokenType === 'coordinator' || tokenType === 'mobile' || tokenType === 'paired') { authenticatedClients.add(ws); clientTokenTypes.set(ws, tokenType); const timer = authTimers.get(ws); @@ -1193,14 +1285,20 @@ export function startRemoteServer(opts: { return; } - // Mobile clients may type into agent terminals (`input`) but cannot - // resize the PTY (desktop owns the geometry) or kill agents — the - // mobile token travels in a QR-code URL, so keep its blast radius small. - if (clientTokenTypes.get(ws) === 'mobile') { - if (msg.type === 'resize' || msg.type === 'kill') { - ws.close(4003, 'Forbidden'); - return; - } + // Write rights by token class. The mobile token travels in a QR-code + // URL over plain HTTP, so it is view-only: anything that can capture + // that URL must not be able to type into a shell on this machine. + // Typing (`input`) needs the paired token — the phone proved it can + // read the pairing PIN off the desktop screen. Resize (desktop owns the + // geometry) and kill stay coordinator-only. + const tokenType = clientTokenTypes.get(ws); + if (msg.type === 'input' && tokenType !== 'coordinator' && tokenType !== 'paired') { + ws.close(4003, 'Pairing required'); + return; + } + if ((msg.type === 'resize' || msg.type === 'kill') && tokenType !== 'coordinator') { + ws.close(4003, 'Forbidden'); + return; } switch (msg.type) { @@ -1294,7 +1392,9 @@ export function startRemoteServer(opts: { }); const primaryIp = ips.wifi ?? ips.tailscale ?? '127.0.0.1'; - // url embeds the mobileToken — safe to surface in UI. Coordinator token never leaves the main process. + // url embeds the mobileToken — a view-only credential (see the WS write + // gate above), so it is safe to surface in the UI and the QR code. + // Coordinator token never leaves the main process. const url = `http://${primaryIp}:${opts.port}?token=${mobileToken}`; const result: RemoteServer = { diff --git a/electron/vite.config.electron.test.ts b/electron/vite.config.electron.test.ts index 85e9cc26c..2a551b8cc 100644 --- a/electron/vite.config.electron.test.ts +++ b/electron/vite.config.electron.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import config from './vite.config.electron'; +import config, { RENDERER_CSP } from './vite.config.electron'; describe('electron vite config', () => { it('ignores nested worktree directories in dev watch mode', () => { @@ -11,3 +11,35 @@ describe('electron vite config', () => { expect(patterns).toContain('**/.worktrees/**'); }); }); + +describe('renderer Content-Security-Policy', () => { + it('restricts scripts to the bundle and forbids plugins, framing, and base overrides', () => { + expect(RENDERER_CSP).toContain("script-src 'self' 'wasm-unsafe-eval'"); + expect(RENDERER_CSP).toContain("object-src 'none'"); + expect(RENDERER_CSP).toContain("base-uri 'none'"); + expect(RENDERER_CSP).toContain("frame-src 'none'"); + expect(RENDERER_CSP).not.toContain("'unsafe-eval'"); + expect(RENDERER_CSP).not.toMatch(/script-src[^;]*unsafe-inline/); + }); + + it('is injected into the built index.html, and only at build time', () => { + const plugins = (config.plugins ?? []).flat() as Array<{ + name?: string; + apply?: unknown; + transformIndexHtml?: unknown; + }>; + const csp = plugins.find((p) => p?.name === 'parallel-code:renderer-csp'); + expect(csp).toBeDefined(); + expect(csp?.apply).toBe('build'); + const hook = csp?.transformIndexHtml as + | (() => Array<{ tag: string; attrs: Record }>) + | undefined; + const tags = hook?.(); + expect(tags).toEqual([ + expect.objectContaining({ + tag: 'meta', + attrs: { 'http-equiv': 'Content-Security-Policy', content: RENDERER_CSP }, + }), + ]); + }); +}); diff --git a/electron/vite.config.electron.ts b/electron/vite.config.electron.ts index c5d29107c..c3a90fc16 100644 --- a/electron/vite.config.electron.ts +++ b/electron/vite.config.electron.ts @@ -1,13 +1,58 @@ import path from 'path'; -import { defineConfig } from 'vite'; +import { defineConfig, type Plugin } from 'vite'; import solid from 'vite-plugin-solid'; const rootDir = path.resolve(process.cwd()); const parentDir = path.resolve(rootDir, '..'); +/** + * Content-Security-Policy for the packaged renderer. The renderer can spawn + * processes through IPC, so any script injection in it is code execution on + * the user's machine; the policy makes sure only the bundle itself runs. + * + * - script-src: the bundle plus 'wasm-unsafe-eval' for shiki's oniguruma + * engine (WebAssembly instantiation is blocked without it). + * - style-src 'unsafe-inline': Solid `style={{}}` attributes plus the style + * elements xterm, Monaco, and mermaid inject. + * - img-src http(s): images linked from rendered markdown (notes, plans). + * - worker-src blob:: Monaco language workers. + * + * Applied at build time only: the dev server injects its own client and HMR + * socket, which this policy would block. + */ +export const RENDERER_CSP = [ + "default-src 'self'", + "script-src 'self' 'wasm-unsafe-eval'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob: http: https:", + "font-src 'self' data:", + "connect-src 'self'", + "worker-src 'self' blob:", + "media-src 'self' blob:", + "object-src 'none'", + "base-uri 'none'", + "frame-src 'none'", +].join('; '); + +function rendererCspPlugin(): Plugin { + return { + name: 'parallel-code:renderer-csp', + apply: 'build', + transformIndexHtml() { + return [ + { + tag: 'meta', + attrs: { 'http-equiv': 'Content-Security-Policy', content: RENDERER_CSP }, + injectTo: 'head-prepend', + }, + ]; + }, + }; +} + export default defineConfig({ base: './', - plugins: [solid()], + plugins: [solid(), rendererCspPlugin()], clearScreen: false, server: { port: 1421, diff --git a/package.json b/package.json index a68a43519..f533b8056 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "test:client": "vitest run --config vitest.client.config.ts", "test:coordinator-pty": "RUN_COORDINATOR_PTY_TEST=1 vitest run electron/mcp/coordinator-real-pty.integration.test.ts", "test:coverage": "vitest run --coverage", + "test:ci": "vitest run --coverage && npm run test:client", "check:coordinator-log": "node scripts/check-coordinator-run.mjs", "check": "npm run compile && npm run typecheck && npm run lint && npm run format:check", "check:static": "npm run typecheck && npm run lint && npm run lint:dead && npm run lint:arch", diff --git a/src/arena/BattleScreen.tsx b/src/arena/BattleScreen.tsx index a61de796b..1e74efa90 100644 --- a/src/arena/BattleScreen.tsx +++ b/src/arena/BattleScreen.tsx @@ -126,6 +126,7 @@ export function BattleScreen() { - Pair a device to create tasks + Pair a device to type and create tasks {pairingError()} @@ -560,16 +560,16 @@ export function ConnectPhoneModal(props: ConnectPhoneModalProps) { }} >
  • - Anyone on your network with the link can view your agent terminals and type into - running agents. + Anyone on your network with the link can watch your agent terminals. Typing into + agents, editing notes, and creating tasks require pairing with the code above.
  • The Wi-Fi connection is unencrypted. Prefer Tailscale, or only connect on a network you trust.
  • - A paired phone can create new tasks, which run code on this computer, until you - disconnect. + A paired phone can type into agents and create new tasks, which run code on this + computer, until you disconnect.
  • Disconnecting stops the server and revokes every connected and paired device.
  • diff --git a/src/components/MergeDialog.tsx b/src/components/MergeDialog.tsx index ab3e369b6..741628111 100644 --- a/src/components/MergeDialog.tsx +++ b/src/components/MergeDialog.tsx @@ -20,6 +20,7 @@ import { theme, bannerStyle } from '../lib/theme'; import type { CoverageComparison } from '../lib/coverage-comparison'; import type { Task } from '../store/types'; import type { ChangedFile, MergeStatus, WorktreeStatus } from '../ipc/types'; +import { errMessage } from '../lib/log'; interface MergeDialogProps { open: boolean; @@ -301,7 +302,7 @@ export function MergeDialog(props: MergeDialogProps) { refetchBranchLog(); refetchWorktreeStatus(); } catch (err) { - setRebaseError(String(err)); + setRebaseError(errMessage(err)); } finally { setRebasing(false); } @@ -584,8 +585,8 @@ export function MergeDialog(props: MergeDialogProps) { .then(() => { onDone(); }) - .catch((err) => { - setMergeError(String(err)); + .catch((err: unknown) => { + setMergeError(errMessage(err)); }) .finally(() => { setMerging(false); diff --git a/src/components/PushDialog.tsx b/src/components/PushDialog.tsx index 6b30cd210..c49eb6360 100644 --- a/src/components/PushDialog.tsx +++ b/src/components/PushDialog.tsx @@ -4,6 +4,7 @@ import { Channel } from '../lib/ipc'; import { Dialog } from './Dialog'; import { theme, bannerStyle } from '../lib/theme'; import type { Task } from '../store/types'; +import { errMessage } from '../lib/log'; interface PushDialogProps { open: boolean; @@ -57,8 +58,8 @@ export function PushDialog(props: PushDialogProps) { .then(() => { onDone(true); }) - .catch((err) => { - setPushError(String(err)); + .catch((err: unknown) => { + setPushError(errMessage(err)); onDone(false); }) .finally(() => { diff --git a/src/components/TaskAITerminal.tsx b/src/components/TaskAITerminal.tsx index 9d2be526b..29d457b6a 100644 --- a/src/components/TaskAITerminal.tsx +++ b/src/components/TaskAITerminal.tsx @@ -19,8 +19,7 @@ import { showNotification, toggleAITerminalLayout, } from '../store/store'; -import { markDirty, redrawTerminal } from '../lib/terminalFitManager'; -import { isMac } from '../lib/platform'; +import { markDirty } from '../lib/terminalFitManager'; import { warn as logWarn } from '../lib/log'; import { InfoBar } from './InfoBar'; import { TerminalView } from './TerminalView'; @@ -119,17 +118,14 @@ export function TaskAITerminal(props: TaskAITerminalProps) { // In tabs mode only the selected pane is shown; the others stay mounted but // hidden (visibility:hidden) so their pty sessions and scrollback survive the - // switch. As a pane becomes the visible tab, re-fit it (its container may have - // resized while hidden) and, on macOS, force a repaint: a backgrounded WebGL - // pane can return with a corrupt glyph atlas, and TerminalView's issue-#121 - // redraw keys off focus mode — which never toggles for a within-task tab - // switch — so it wouldn't fire here. + // switch. As a pane becomes the visible tab, re-fit it (its container may + // have resized while hidden). The repaint / WebGL reattach on that edge is + // TerminalView's job, driven by the `visible` prop passed below. createEffect(() => { if (!tabsMode()) return; const id = visibleAgentId(); if (!id) return; markDirty(id); - if (isMac) redrawTerminal(id); }); const infoBarStatus = () => { @@ -681,6 +677,7 @@ function AgentTerminalPane(props: {