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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 2 additions & 2 deletions PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
32 changes: 26 additions & 6 deletions electron/remote/notes-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ let port = 0;
let mobileToken = '';
let coordinatorToken = '';
let subtaskToken = '';
let generatePin: () => { pin: string; expiresAt: number };
let stop: () => Promise<void>;
let getTaskNotes: Mock<(taskId: string) => Promise<string>>;
let setTaskNotes: Mock<(taskId: string, notes: string) => Promise<void>>;
Expand All @@ -47,9 +48,18 @@ async function start(extra: Partial<StartOpts> = {}): Promise<void> {
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<string> {
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) => {});
Expand Down Expand Up @@ -160,16 +170,26 @@ 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);
expect(res.json).toEqual({ ok: true });
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,
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand Down
Loading
Loading