From 7515d99ed56704606cac32b51198efa839b1916b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 22:49:28 +0000 Subject: [PATCH 1/5] fix(remote): make the QR-code token view-only; lock down origins and add CSP The mobile token travels in the QR-code URL over plain HTTP, yet it could type into any agent terminal, which is code execution on the desktop for anyone who captured that URL. PRIVACY.md already promised the token was read-only; the code now matches: - WebSocket `input` requires the paired token (PIN entered on the phone) or the coordinator token. The mobile token may only subscribe. Resize and kill stay coordinator-only. Paired tokens can now authenticate the socket; the phone reconnects with its paired token after pairing and falls back to the QR-code token when the paired one goes stale. - Notes PUT requires the paired token; GET stays readable. - Browser `Origin` must match the request `Host` on the WebSocket upgrade and on every API route. Non-browser clients (no Origin) are unaffected. WebSockets are exempt from the same-origin policy, so this was the only missing gate against a cross-site page replaying the token. - Content-Security-Policy on the mobile SPA (header, socket pinned to the request host) and on the packaged desktop renderer (build-time meta tag; scripts limited to the bundle plus wasm for shiki). Verified the built renderer loads with zero policy violations. - Paired-token set is capped so pairing cannot grow it unbounded. The phone UI detours to the pairing screen when the user tries to type or save notes without a paired token, and returns to the agent afterwards. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016V9zaFQrv8aoUY2CuGuRJH --- PRIVACY.md | 2 +- electron/remote/notes-route.test.ts | 32 +++- electron/remote/server-ws.test.ts | 245 ++++++++++++++++++++++++-- electron/remote/server.ts | 129 ++++++++++++-- electron/vite.config.electron.test.ts | 34 +++- electron/vite.config.electron.ts | 49 +++++- src/components/ConnectPhoneModal.tsx | 10 +- src/remote/AgentDetail.tsx | 21 ++- src/remote/App.tsx | 32 +++- src/remote/PairScreen.tsx | 10 +- src/remote/api.ts | 6 +- src/remote/ws.ts | 50 +++++- 12 files changed, 553 insertions(+), 67 deletions(-) diff --git a/PRIVACY.md b/PRIVACY.md index 4226f6635..ef50f4370 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -55,7 +55,7 @@ Unlike the AI CLIs above, the following network activity is initiated by Paralle - **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. + - **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..1c942c31e 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,201 @@ 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); + 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..ac4035775 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 | string[]; + host?: string; + /** Sent instead of `origin` by very old (hybi-08) WebSocket clients. */ + 'sec-websocket-origin'?: string | 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`, so anything else is some other site — a DNS-rebinding + * page, or any tab on the LAN — borrowing the user's network position to reach + * the server. WebSockets are exempt from the same-origin policy, so this check + * is the only browser-side gate before the bearer token. No Origin is allowed: + * the token still gates every API route and socket. + */ +export function isBrowserOriginAllowed(headers: OriginHeaders): boolean { + const origin = headers.origin ?? headers['sec-websocket-origin']; + if (origin === undefined) return true; + if (Array.isArray(origin)) return false; + 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,9 @@ 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. + 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 +820,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 +837,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 +917,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 +952,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 +1131,12 @@ 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, + 'Content-Security-Policy': buildRemoteCsp(req.headers.host), + 'Content-Type': ct, + 'Cache-Control': cc, + }); stream.pipe(res); stream.on('error', () => { if (!res.headersSent) { @@ -1099,6 +1179,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 +1195,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 { @@ -1170,11 +1254,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 +1278,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 +1385,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..dc37d25e9 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 https:: images in 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: 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/src/components/ConnectPhoneModal.tsx b/src/components/ConnectPhoneModal.tsx index 97d89315e..45bcbc5a4 100644 --- a/src/components/ConnectPhoneModal.tsx +++ b/src/components/ConnectPhoneModal.tsx @@ -473,7 +473,7 @@ export function ConnectPhoneModal(props: ConnectPhoneModalProps) { 'font-weight': '500', }} > - 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/remote/AgentDetail.tsx b/src/remote/AgentDetail.tsx index c039f60ab..1f2fff4ad 100644 --- a/src/remote/AgentDetail.tsx +++ b/src/remote/AgentDetail.tsx @@ -3,7 +3,8 @@ import { Terminal } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; import { TERMINAL_SCROLL_OPTIONS, base64ToUint8Array } from '../lib/terminalConstants'; import { createTerminalHttpLinkHandler } from '../lib/terminalLinks'; -import { fetchNotes, saveNotes } from './api'; +import { fetchNotes, saveNotes, ApiError } from './api'; +import { getPairedToken, clearPairedToken } from './auth'; import { agentStatusDisplay } from './attention'; import { subscribeAgent, @@ -44,6 +45,8 @@ interface AgentDetailProps { agentId: string; taskName: string; onBack: () => void; + /** Typing and saving notes need the paired token; ask the user to pair. */ + onNeedsPairing: () => void; } const openRemoteHttpLink = createTerminalHttpLinkHandler({ @@ -172,6 +175,13 @@ export function AgentDetail(props: AgentDetailProps) { setNotesSaved(true); setTimeout(() => setNotesSaved(false), 1500); } catch (e) { + // 401/403: no paired token, or a stale one (desktop restarted). Pairing + // again is the fix, so send the user there instead of showing an error. + if (e instanceof ApiError && (e.status === 401 || e.status === 403)) { + clearPairedToken(); + props.onNeedsPairing(); + return; + } setNotesError(e instanceof Error ? e.message : String(e)); } finally { setNotesSaving(false); @@ -335,9 +345,17 @@ export function AgentDetail(props: AgentDetailProps) { // the latest invocation sends the delayed \r. let lastSendId = 0; + /** Typing needs the paired token; without one, detour to the pairing screen. */ + function ensurePairedForInput(): boolean { + if (getPairedToken()) return true; + props.onNeedsPairing(); + return false; + } + function handleSend() { const text = inputText(); if (!text) return; + if (!ensurePairedForInput()) return; // Keep the typed text while disconnected — send() silently drops // messages on a non-open socket, so clearing here would lose input. if (status() !== 'connected') return; @@ -352,6 +370,7 @@ export function AgentDetail(props: AgentDetailProps) { } function handleQuickAction(data: string) { + if (!ensurePairedForInput()) return; sendInput(props.agentId, data); } diff --git a/src/remote/App.tsx b/src/remote/App.tsx index 4a865b480..430db2fce 100644 --- a/src/remote/App.tsx +++ b/src/remote/App.tsx @@ -1,6 +1,6 @@ import { createSignal, onMount, Show, Switch, Match } from 'solid-js'; import { initAuth, getPairedToken } from './auth'; -import { connect } from './ws'; +import { connect, reconnect } from './ws'; import { AgentList } from './AgentList'; import { AgentDetail } from './AgentDetail'; import { ConnectScreen } from './ConnectScreen'; @@ -17,6 +17,9 @@ export function App() { const [view, setView] = createSignal('list'); const [detailAgentId, setDetailAgentId] = createSignal(''); const [detailTaskName, setDetailTaskName] = createSignal(''); + // Where to land after pairing: the New Task form, or back to the agent the + // user was about to type into. + const [afterPairing, setAfterPairing] = createSignal('newtask'); function selectAgent(id: string, name: string) { setDetailAgentId(id); @@ -27,9 +30,25 @@ export function App() { // Creating a task needs the elevated paired token; pair first if we don't // have one yet. function startNewTask() { + setAfterPairing('newtask'); setView(getPairedToken() ? 'newtask' : 'pair'); } + // Typing into a terminal (or saving notes) needs the paired token too. The + // socket reconnects with it after pairing (see ws.ts), so returning to the + // detail view is enough. + function pairForDetail() { + setAfterPairing('detail'); + setView('pair'); + } + + // A fresh paired token must also reach the socket, which authenticated with + // whichever token it had at connect time. + function onPaired() { + reconnect(); + setView(afterPairing()); + } + function onConnected() { setAuthed(true); connect(); @@ -48,16 +67,23 @@ export function App() { agentId={detailAgentId()} taskName={detailTaskName()} onBack={() => setView('list')} + onNeedsPairing={pairForDetail} /> - setView('newtask')} onCancel={() => setView('list')} /> + setView(afterPairing() === 'detail' ? 'detail' : 'list')} + /> setView('list')} onCancel={() => setView('list')} - onNeedsPairing={() => setView('pair')} + onNeedsPairing={() => { + setAfterPairing('newtask'); + setView('pair'); + }} /> diff --git a/src/remote/PairScreen.tsx b/src/remote/PairScreen.tsx index 28699121a..c6b0350ef 100644 --- a/src/remote/PairScreen.tsx +++ b/src/remote/PairScreen.tsx @@ -8,8 +8,9 @@ interface PairScreenProps { } /** - * Shown before a phone may create tasks. The user opens Connect Phone on the - * desktop, taps "Pair a device", and types the 6-digit code shown there. + * Shown before a phone may type into a terminal, save notes, or create tasks. + * The user opens Connect Phone on the desktop, taps "Pair a device", and types + * the 6-digit code shown there. */ export function PairScreen(props: PairScreenProps) { const [pin, setPin] = createSignal(''); @@ -56,8 +57,9 @@ export function PairScreen(props: PairScreenProps) { Pair this device

    - On your computer, open Connect Phone and tap{' '} - Pair a device to create tasks. Enter the 6-digit code below. + Typing into agents, saving notes, and creating tasks need a paired phone. On your + computer, open Connect Phone and tap Pair a device. + Enter the 6-digit code below.

    diff --git a/src/remote/api.ts b/src/remote/api.ts index d771e813b..7aac901f8 100644 --- a/src/remote/api.ts +++ b/src/remote/api.ts @@ -87,10 +87,10 @@ export async function fetchNotes(taskId: string): Promise { return r.notes; } -/** Save the notes for a task. Works with the base connection token. */ +/** Save the notes for a task. Requires a paired token (it is a write). */ export async function saveNotes(taskId: string, notes: string): Promise { - const token = getToken(); - if (!token) throw new ApiError('Not connected', 401); + const token = getPairedToken(); + if (!token) throw new ApiError('Not paired', 401); await request<{ ok: boolean }>(`/api/mobile/notes/${encodeURIComponent(taskId)}`, { method: 'PUT', body: { notes }, diff --git a/src/remote/ws.ts b/src/remote/ws.ts index 02cc8095a..f6cff2501 100644 --- a/src/remote/ws.ts +++ b/src/remote/ws.ts @@ -1,5 +1,5 @@ import { createSignal } from 'solid-js'; -import { getToken, clearToken } from './auth'; +import { getToken, clearToken, getPairedToken, clearPairedToken } from './auth'; import type { ServerMessage, RemoteAgent } from '../../electron/remote/protocol'; export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected'; @@ -14,6 +14,18 @@ const scrollbackListeners = new Map>(); let ws: WebSocket | null = null; let reconnectTimer: ReturnType | null = null; +// Which credential the open socket authenticated with. The paired token +// (minted by entering the desktop PIN) is preferred because it is the one +// that may type into terminals; the QR-code token only watches. +let authTokenKind: 'paired' | 'mobile' = 'mobile'; + +/** Pick the credential for the next socket: paired if this phone has one. */ +function selectAuthToken(): { token: string; kind: 'paired' | 'mobile' } | null { + const paired = getPairedToken(); + if (paired) return { token: paired, kind: 'paired' }; + const mobile = getToken(); + return mobile ? { token: mobile, kind: 'mobile' } : null; +} export { agents, status }; @@ -26,8 +38,9 @@ export function connect(): void { ws = null; } - const token = getToken(); - if (!token) return; + const auth = selectAuthToken(); + if (!auth) return; + authTokenKind = auth.kind; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const url = `${protocol}//${window.location.host}/ws`; @@ -38,7 +51,7 @@ export function connect(): void { ws.onopen = () => { // Authenticate via first message instead of URL query to avoid // token leaking in proxy logs or browser history. - send({ type: 'auth', token }); + send({ type: 'auth', token: auth.token }); setStatus('connected'); if (reconnectTimer) { clearTimeout(reconnectTimer); @@ -89,8 +102,17 @@ export function connect(): void { ws.onclose = (event) => { ws = null; setStatus('disconnected'); - // 4001 = server rejected auth — token is stale, reload to re-auth + // 4001 = server rejected auth — the token is stale (the desktop restarted + // Remote Access, which rotates every token). A stale paired token falls + // back to the QR-code token so the phone keeps watching and only loses + // typing rights until it pairs again; a stale QR-code token means + // reconnecting from scratch. if (event.code === 4001) { + if (authTokenKind === 'paired') { + clearPairedToken(); + reconnectTimer = setTimeout(connect, 0); + return; + } clearToken(); window.location.reload(); return; @@ -104,6 +126,24 @@ export function connect(): void { }; } +/** Drop the current socket and connect again with the best available token. */ +export function reconnect(): void { + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + if (ws) { + // Detach the handlers first: a manual close must not trigger the + // auto-reconnect path (that would race the connect below). + ws.onclose = null; + ws.onerror = null; + ws.close(); + ws = null; + } + setStatus('disconnected'); + connect(); +} + export function send(msg: Record): void { if (ws?.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(msg)); From 35eb5735c0a4f7e62eeded19878c19bedbe32794 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 22:49:29 +0000 Subject: [PATCH 2/5] fix(store): bound autosave latency and stop swallowing save, merge, push failures - Autosave was a pure trailing debounce: every change reset the 1s timer, so continuous typing (notes panel) postponed the write indefinitely and a crash lost the whole session. Add a 5s maximum wait per burst and flush pending work when the effect's owner is disposed. - A failed state write only reached the console; tasks, projects and settings silently stopped persisting. Surface it as a longer-lived toast, rate-limited to once a minute while the cause (full disk, permissions) persists. - mergeTask/pushTask returned silently on precondition failures (task gone, being closed, direct mode, project folder missing), which read as "Merge did nothing". They now throw with a reason, and the merge and push dialogs show the message rather than a raw stringified error. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016V9zaFQrv8aoUY2CuGuRJH --- src/components/MergeDialog.tsx | 5 +- src/components/PushDialog.tsx | 5 +- src/store/autosave.client.test.tsx | 87 ++++++++++++++++++++++++++++++ src/store/autosave.ts | 37 +++++++++++-- src/store/notification.ts | 8 ++- src/store/persistence.test.ts | 18 +++++++ src/store/persistence.ts | 24 +++++++-- src/store/tasks.test.ts | 73 +++++++++++++++++++++++++ src/store/tasks.ts | 14 +++-- 9 files changed, 253 insertions(+), 18 deletions(-) create mode 100644 src/store/autosave.client.test.tsx diff --git a/src/components/MergeDialog.tsx b/src/components/MergeDialog.tsx index ab3e369b6..3324b87e7 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; @@ -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/store/autosave.client.test.tsx b/src/store/autosave.client.test.tsx new file mode 100644 index 000000000..877c7c9d2 --- /dev/null +++ b/src/store/autosave.client.test.tsx @@ -0,0 +1,87 @@ +// Scheduling behaviour of the autosave effect. Lives in the client (happy-dom) +// config: the node config compiles solid-js for SSR, where createEffect is a +// no-op, so the effect under test would never run there. +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createRoot } from 'solid-js'; +import { store, setStore } from './core'; +import { setupAutosave, AUTOSAVE_DEBOUNCE_MS, AUTOSAVE_MAX_WAIT_MS } from './autosave'; + +const { mockSaveState } = vi.hoisted(() => ({ mockSaveState: vi.fn(async () => {}) })); +vi.mock('./persistence', async (importOriginal) => ({ + ...(await importOriginal()), + saveState: mockSaveState, +})); +describe('setupAutosave scheduling', () => { + // Real solid-js reactivity over the real store; only the write is mocked. + beforeEach(() => { + vi.useFakeTimers(); + mockSaveState.mockClear(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + function withAutosave(run: () => void): void { + // Effects created inside createRoot run when the root's setup returns, so + // the scenario must execute after that. The initial effect pass schedules + // one startup save (as it always has); let it land and discard it so each + // scenario starts from a quiet, saved state. + const dispose = createRoot((d) => { + setupAutosave(); + return d; + }); + vi.advanceTimersByTime(AUTOSAVE_DEBOUNCE_MS); + expect(mockSaveState).toHaveBeenCalledTimes(1); + mockSaveState.mockClear(); + try { + run(); + } finally { + dispose(); + } + } + + it('debounces a burst of changes into one save after the quiet period', () => { + withAutosave(() => { + setStore('showSidebarTips', !store.showSidebarTips); + vi.advanceTimersByTime(AUTOSAVE_DEBOUNCE_MS - 1); + setStore('showSidebarTips', !store.showSidebarTips); + vi.advanceTimersByTime(AUTOSAVE_DEBOUNCE_MS - 1); + expect(mockSaveState).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(mockSaveState).toHaveBeenCalledTimes(1); + }); + }); + + it('never postpones a save past the max wait while changes keep arriving', () => { + withAutosave(() => { + // Simulate continuous typing: a change every 500ms for 12 seconds. + const step = 500; + let saves = 0; + for (let elapsed = 0; elapsed < 12_000; elapsed += step) { + setStore('showSidebarTips', !store.showSidebarTips); + vi.advanceTimersByTime(step); + saves = mockSaveState.mock.calls.length; + // Before the max wait elapses no save is forced... + if (elapsed + step < AUTOSAVE_MAX_WAIT_MS) expect(saves).toBe(0); + } + // ...but over 12s of nonstop edits at least two saves were written. + expect(saves).toBeGreaterThanOrEqual(2); + }); + }); + + it('does not save when nothing persisted changed', () => { + withAutosave(() => { + setStore('notification', 'transient toast'); + vi.advanceTimersByTime(AUTOSAVE_MAX_WAIT_MS * 2); + expect(mockSaveState).not.toHaveBeenCalled(); + }); + }); + + it('flushes a pending save when the owner is disposed', () => { + withAutosave(() => { + setStore('showSidebarTips', !store.showSidebarTips); + expect(mockSaveState).not.toHaveBeenCalled(); + }); + expect(mockSaveState).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/store/autosave.ts b/src/store/autosave.ts index 9cb53fe6f..84927926b 100644 --- a/src/store/autosave.ts +++ b/src/store/autosave.ts @@ -95,9 +95,25 @@ export function persistedSnapshot(): string { }); } +/** Quiet period after the last change before a save is written. */ +export const AUTOSAVE_DEBOUNCE_MS = 1000; +/** Upper bound on how long a save may be postponed by a continuous stream of + * changes (typing in the notes panel, for example). Without this the trailing + * debounce never fires while the user keeps typing, and a crash or force-quit + * loses the whole session. */ +export const AUTOSAVE_MAX_WAIT_MS = 5000; + export function setupAutosave(): void { - let timer: number | undefined; + let timer: ReturnType | undefined; let lastSnapshot: string | undefined; + // When the first unsaved change of the current burst happened. + let pendingSince: number | undefined; + + const flush = () => { + timer = undefined; + pendingSince = undefined; + void saveState(); + }; createEffect(() => { const snapshot = persistedSnapshot(); @@ -106,9 +122,22 @@ export function setupAutosave(): void { if (snapshot === lastSnapshot) return; lastSnapshot = snapshot; - clearTimeout(timer); - timer = window.setTimeout(() => saveState(), 1000); + const now = Date.now(); + pendingSince ??= now; + if (timer !== undefined) clearTimeout(timer); + // Trailing debounce, but never later than MAX_WAIT after the burst began. + const delay = Math.max( + 0, + Math.min(AUTOSAVE_DEBOUNCE_MS, pendingSince + AUTOSAVE_MAX_WAIT_MS - now), + ); + timer = setTimeout(flush, delay); + }); - onCleanup(() => clearTimeout(timer)); + // Owner disposal (app teardown): write what is pending rather than drop it. + onCleanup(() => { + if (timer !== undefined) { + clearTimeout(timer); + flush(); + } }); } diff --git a/src/store/notification.ts b/src/store/notification.ts index cd9bd71c3..21a396d5e 100644 --- a/src/store/notification.ts +++ b/src/store/notification.ts @@ -2,13 +2,17 @@ import { setStore } from './core'; let notificationTimer: ReturnType | null = null; -export function showNotification(message: string): void { +export const NOTIFICATION_DEFAULT_MS = 3000; +/** Errors the user must act on stay up longer than a passing status message. */ +export const NOTIFICATION_ERROR_MS = 10_000; + +export function showNotification(message: string, opts?: { durationMs?: number }): void { if (notificationTimer) clearTimeout(notificationTimer); setStore('notification', message); notificationTimer = setTimeout(() => { setStore('notification', null); notificationTimer = null; - }, 3000); + }, opts?.durationMs ?? NOTIFICATION_DEFAULT_MS); } export function clearNotification(): void { diff --git a/src/store/persistence.test.ts b/src/store/persistence.test.ts index b9714cd90..24c637ae9 100644 --- a/src/store/persistence.test.ts +++ b/src/store/persistence.test.ts @@ -1044,3 +1044,21 @@ describe('showSteps → defaultStepsEnabled migration', () => { expect(saved.defaultStepsEnabled).toBe(true); }); }); + +describe('saveState failure reporting', () => { + it('tells the user when the state file could not be written', async () => { + setStore('notification', null); + mockInvoke.mockImplementation((channel: string) => + channel === IPC.SaveAppState + ? Promise.reject(new Error('ENOSPC: no space left on device')) + : Promise.resolve(undefined), + ); + await saveState(); + expect(store.notification).toContain("Couldn't save app state"); + expect(store.notification).toContain('ENOSPC'); + // Rate-limited: a second failure right away does not replace the toast. + setStore('notification', null); + await saveState(); + expect(store.notification).toBeNull(); + }); +}); diff --git a/src/store/persistence.ts b/src/store/persistence.ts index 16b127333..4ccf0c7fb 100644 --- a/src/store/persistence.ts +++ b/src/store/persistence.ts @@ -28,6 +28,8 @@ import { isLookPreset } from '../lib/look'; import { validateCustomTheme, parseThemeCss, themeToCss } from '../lib/custom-theme'; import type { CustomTheme } from '../lib/custom-theme'; import { syncTerminalCounter } from './terminals'; +import { showNotification, NOTIFICATION_ERROR_MS } from './notification'; +import { errMessage } from '../lib/log'; const RESTORED_AGENT_SPAWN_STAGGER_MS = 1_000; @@ -285,9 +287,25 @@ export async function saveState(): Promise { persisted.terminals[id] = { id: terminal.id, name: terminal.name }; } - await invoke(IPC.SaveAppState, { json: JSON.stringify(persisted) }).catch((e) => - console.warn('Failed to save state:', e), - ); + await invoke(IPC.SaveAppState, { json: JSON.stringify(persisted) }).catch((e: unknown) => { + console.warn('Failed to save state:', e); + notifySaveFailure(e); + }); +} + +/** Don't nag on every autosave tick while the cause (full disk, permissions) persists. */ +const SAVE_FAILURE_NOTIFY_INTERVAL_MS = 60_000; +let lastSaveFailureNotifiedAt = 0; + +/** A failed state write means tasks, projects, and settings are silently no + * longer persisting — the user needs to know before they quit. */ +function notifySaveFailure(err: unknown): void { + const now = Date.now(); + if (now - lastSaveFailureNotifiedAt < SAVE_FAILURE_NOTIFY_INTERVAL_MS) return; + lastSaveFailureNotifiedAt = now; + showNotification(`Couldn't save app state: ${errMessage(err)}`, { + durationMs: NOTIFICATION_ERROR_MS, + }); } /** 20_000 px is ~10× the largest plausible monitor axis and big enough to let diff --git a/src/store/tasks.test.ts b/src/store/tasks.test.ts index 904b876df..88f81875a 100644 --- a/src/store/tasks.test.ts +++ b/src/store/tasks.test.ts @@ -155,6 +155,7 @@ import { collapseTask, closeTask, mergeTask, + pushTask, sendPrompt, pasteDelayMs, markTaskUserActivity, @@ -1464,3 +1465,75 @@ describe('pasteDelayMs', () => { expect(pasteDelayMs(text)).toBe(500); }); }); + +describe('mergeTask / pushTask preconditions', () => { + beforeEach(() => { + const harness = expectDefined(core.harness, 'mock store harness'); + harness.reset(harness.state()); + mockInvoke.mockReset(); + vi.mocked(getProjectPath).mockReset(); + }); + + it('mergeTask throws instead of silently returning for a missing task', async () => { + await expect(mergeTask('nope')).rejects.toThrow('Task no longer exists'); + expect(mockInvoke).not.toHaveBeenCalled(); + }); + + it('mergeTask refuses a task that is being closed', async () => { + mockTasks['task-1'] = { + agentIds: [], + shellAgentIds: [], + gitIsolation: 'worktree', + closingStatus: 'removing', + projectId: 'proj-1', + }; + await expect(mergeTask('task-1')).rejects.toThrow('being closed'); + expect(mockInvoke).not.toHaveBeenCalled(); + }); + + it('mergeTask refuses direct-mode tasks with a reason', async () => { + mockTasks['task-1'] = { + agentIds: [], + shellAgentIds: [], + gitIsolation: 'direct', + projectId: 'proj-1', + }; + await expect(mergeTask('task-1')).rejects.toThrow('worktree'); + expect(mockInvoke).not.toHaveBeenCalled(); + }); + + it('mergeTask reports a missing project folder', async () => { + mockTasks['task-1'] = { + agentIds: [], + shellAgentIds: [], + gitIsolation: 'worktree', + projectId: 'proj-gone', + }; + vi.mocked(getProjectPath).mockReturnValue(undefined); + await expect(mergeTask('task-1')).rejects.toThrow('Project folder not found'); + expect(mockInvoke).not.toHaveBeenCalled(); + }); + + it('pushTask throws for a missing task, a direct-mode task, and a missing project', async () => { + const channel = { onmessage: undefined } as unknown as Parameters[1]; + await expect(pushTask('nope', channel)).rejects.toThrow('Task no longer exists'); + + mockTasks['task-1'] = { + agentIds: [], + shellAgentIds: [], + gitIsolation: 'direct', + projectId: 'p', + }; + await expect(pushTask('task-1', channel)).rejects.toThrow('worktree'); + + mockTasks['task-2'] = { + agentIds: [], + shellAgentIds: [], + gitIsolation: 'worktree', + projectId: 'p', + }; + vi.mocked(getProjectPath).mockReturnValue(undefined); + await expect(pushTask('task-2', channel)).rejects.toThrow('Project folder not found'); + expect(mockInvoke).not.toHaveBeenCalled(); + }); +}); diff --git a/src/store/tasks.ts b/src/store/tasks.ts index 5f1f3e31a..e82c6e07b 100644 --- a/src/store/tasks.ts +++ b/src/store/tasks.ts @@ -621,12 +621,15 @@ export async function mergeTask( taskId: string, options?: { squash?: boolean; message?: string; cleanup?: boolean }, ): Promise { + // Precondition failures throw so the merge dialog can say why nothing + // happened; a silent return here reads as "Merge did nothing" to the user. const task = store.tasks[taskId]; - if (!task || task.closingStatus === 'removing') return; - if (task.gitIsolation !== 'worktree') return; + if (!task) throw new Error('Task no longer exists'); + if (task.closingStatus === 'removing') throw new Error('Task is being closed'); + if (task.gitIsolation !== 'worktree') throw new Error('Only worktree tasks can be merged'); const projectRoot = getProjectPath(task.projectId); - if (!projectRoot) return; + if (!projectRoot) throw new Error('Project folder not found — relink the project first'); const agentIds = [...task.agentIds]; const shellAgentIds = [...task.shellAgentIds]; @@ -669,10 +672,11 @@ export async function mergeTask( export async function pushTask(taskId: string, onOutput: Channel): Promise { const task = store.tasks[taskId]; - if (!task || task.gitIsolation !== 'worktree') return; + if (!task) throw new Error('Task no longer exists'); + if (task.gitIsolation !== 'worktree') throw new Error('Only worktree tasks can be pushed'); const projectRoot = getProjectPath(task.projectId); - if (!projectRoot) return; + if (!projectRoot) throw new Error('Project folder not found — relink the project first'); await invoke(IPC.PushTask, { projectRoot, From ba07193f143b5bfa0221fcd631b1672b6328f0a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 22:49:29 +0000 Subject: [PATCH 3/5] perf(terminal): hold WebGL contexts only for panes that are on screen Every task's terminals stay mounted, so each pane kept a live WebGL context for the life of the app; with many tasks that blew past Chromium's active-context cap (raised to 64 in main.ts) and contexts were evicted in rotation, the churn the webglContextLoss policy brakes on. TerminalView now detaches the WebGL addon a second after a pane leaves the screen (focus mode inactive task, tiling task scrolled fully out of view, unselected tab within a task) and reattaches on the visible edge. Hidden panes render through xterm's DOM renderer meanwhile; panes scrolled off screen are already paused by xterm's own IntersectionObserver. The glyph atlas is ref-counted per terminal inside xterm, so disposing one pane's addon leaves the others' intact. A fresh attach repaints the pane in full, which also covers the macOS corrupt-atlas case (issue #121) that the old visible-edge redraw handled; that redraw is kept only for a pane that returns before its detach delay elapses. TaskAITerminal passes tab visibility down instead of repainting itself. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016V9zaFQrv8aoUY2CuGuRJH --- src/components/TaskAITerminal.tsx | 13 ++- src/components/TerminalView.tsx | 106 +++++++++++++++++++------ src/lib/terminalPaneVisibility.test.ts | 29 +++++++ src/lib/terminalPaneVisibility.ts | 40 ++++++++++ src/lib/webglContextLoss.ts | 8 +- 5 files changed, 159 insertions(+), 37 deletions(-) create mode 100644 src/lib/terminalPaneVisibility.test.ts create mode 100644 src/lib/terminalPaneVisibility.ts 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: {