diff --git a/AGENTS.md b/AGENTS.md index 5d094bec20..66fd19cf6e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -151,6 +151,7 @@ claude --plugin-dir ./apps/hook |----------|-------------| | `PLANNOTATOR_REMOTE` | Set to `1` / `true` for remote mode, `0` / `false` for local mode, or leave unset for SSH auto-detection. Uses a fixed port in remote mode; browser-opening behavior depends on the environment. Remote ready messages also render a terminal QR code of the advertised URL when stderr is a TTY and the advertised host is overridden away from localhost (a QR of a localhost URL scans to nowhere), so another device can join without retyping the URL. | | `PLANNOTATOR_AGENT_TERMINAL_REMOTE` | Set to `1` / `true` to enable the annotate-mode agent terminal while `PLANNOTATOR_REMOTE` is active or the session is published with `--tailscale`. Off by default in both cases because the session is reachable by network peers and the PTY token is not an auth boundary. | +| `PLANNOTATOR_INBOX_PHONES` | Switch for the Plannotator Inbox's whole phone surface (see "Phones" under "Plannotator Inbox"): pairing, the device door `/api/inbox/device/*`, the tailnet and Wi-Fi listeners, the relay connection and Settings' Phones block. **Default: off**, until the iPhone app and the relay ship. Off, the Inbox does nothing phone-related: every pairing, device and reach-switch route answers the unknown-route `404 { code: "not_found" }`, no LAN or door listener opens, nothing connects to the relay, no certificate is made and no `openssl`, `tailscale` or Bonjour process runs, even when `inbox.json`, `relay.json` or `devices.jsonl` hold state from a run that had it on (that state is kept for the next run with it on). `1` / `true` / `on` turn it on, `0` / `false` / `off` / `disabled` off; empty or unrecognized counts as unset. Also `~/.plannotator/config.json` `{ "inboxPhones": true }`; the env var wins (`resolveInboxPhones` in `packages/shared/config.ts`). Read once when the Inbox starts (`startInboxServer`'s `phones` option overrides it); `GET /api/inbox/settings` carries it as `phones`, which is how the window decides to draw the block. The iPhone app's proofs (`apps/ios/scripts/proof.ts`, `workspaces-proof.ts`), the relay proof, `tests/e2e/inbox-phones.spec.ts`, the phone server tests (`createInboxWorld(..., { phones: true })`, `phones: true`) and the DAST target set it; `packages/server/inbox-phones-gate.test.ts` proves both states. Not in the public docs. | | `PLANNOTATOR_RELAY_URL` | The Inbox relay a new mailbox is made at (`packages/server/inbox-relay.ts`). Default `https://relay.plannotator.ai`. Proofs point it at a `wrangler dev` relay; tests at a closed loopback port. A mailbox, once made, stays at the URL `inbox/relay.json` names. | | `PLANNOTATOR_PORT` | Fixed port to use. Default: random locally, `19432` for remote sessions. The Plannotator Inbox ignores it (and `PLANNOTATOR_REMOTE`): it always binds `127.0.0.1` on its last port or a random one; see "Plannotator Inbox". | | `PLANNOTATOR_URL_HOST` | Display-only hostname for advertised session URLs (issue #657), e.g. a Tailscale MagicDNS name or tailnet IP, so remote-mode links are reachable from another device instead of `http://localhost:`. Host only — bare hostname, IPv4, or bracketed IPv6 (`[fd7a::1]`); the runtime-chosen port is always appended, and anything carrying a scheme, port, path, credentials, or whitespace warns once on stderr and falls back to `localhost`. Strictly display-only and remote-only: binding stays governed by `PLANNOTATOR_REMOTE`; a local session ignores the override (localhost is advertised and opened, since only loopback is bound) with a once-per-process stderr warning to set `PLANNOTATOR_REMOTE=1`, and spawned agent-review jobs keep a pinned `http://127.0.0.1:` API URL so a tailnet-only hostname cannot break local jobs. The sentinel `auto` resolves the host from Tailscale once per process, at first use in a remote session: `tailscale status --json` → `Self.DNSName` (trailing dot stripped), falling back to the single `tailscale ip -4` CGNAT (100.64.0.0/10) address; detection failure warns once on stderr and falls back to `localhost`, and detection never changes binding — `auto` is as display-only as any explicit host. Can also be set via `~/.plannotator/config.json` (`{ "urlHost": "host" }` or `{ "urlHost": "auto" }`); the env var takes precedence, and an empty-but-set env var (`PLANNOTATOR_URL_HOST=`) suppresses a config-file `urlHost`. Default: unset (`localhost`). | @@ -2047,6 +2048,7 @@ Sorting and the rest: **On a phone**: `adr/implementation/inbox-mobile.md` is the wire contract the iPhone app and its server steps build against: the pairing offer, the device token door `/api/inbox/device/*`, the LAN listener, the relay, the surface bridge and the idempotency rule. Built (P1, P2, R1, R2): pairing, the door, the tailnet path, the LAN listener, and the relay's mailboxes, push and carriage; the iPhone app `apps/ios` (M1: pairing by QR or typed address and six digits, the list, threads, picks, Send, Resolve, Delete, Settings with the tailnet row; M2: attachments full screen in the bundled surface with comments by touch, the "N annotations" sheet, the changed line, Share, and annotations riding the next Send; M3: the decision switch opening the decision card as a sheet, the Decisions tab per project, and New message from the reply bar to a live session, a menu when several are live, the desktop's "not running" words when none is; M4: a guided review full screen in the surface, its sections with their reviewed ticks and Continue, one section per screen with Previous, Reviewed and Next, the bar's title and back following the surface's `section` messages, the wrap button, each tick saved through the door; its proof is `bun apps/ios/scripts/proof.ts --binary `, run by `.github/workflows/ios.yml`, whose New message steps drive live Claude Code sessions made of the mod's own code (`apps/hook/hooks/mod/testing/claude-session.ts`) and which also runs the relay under `wrangler dev`; M5: push registration from Settings' Allow notifications, the notification service extension, the lock-screen answer as one Send with the pick inside it, through the relay's command path when the computer cannot be reached; M7: Workspaces as a second source behind the `WORKSPACES_HOST` build setting (Debug and TestFlight on, Release off), see `apps/ios/README.md`; M6: the paths of 9.2 in order, the Wi-Fi listener from the QR's `lan` and `fp` with its certificate pinned, the tailnet, then the relay as a reading and answering path (down items applied to the threads the app keeps, the list, threads, attachments and HTML assets read as GET commands while the computer is online, commands sealed under the up key with the door's idempotency keys, a Send held for an offline computer shown as "Sent. Waiting for your computer" until it lands or is refused), the relay switch on from pairing (Allow notifications registers the push token only)). The Inbox writes no event when a thread is deleted: a phone learns it at its next read of that thread. Not built: Bonjour discovery in 1.3 (a Wi-Fi address that moves is followed only by scanning the code again). **Phones** (`packages/server/inbox-devices.ts`, records in `packages/shared/inbox/devices.ts`; contract sections 1, 2 and 6). +- Off unless `PLANNOTATOR_INBOX_PHONES=1` (or config.json `inboxPhones: true`) is set when the Inbox starts: until the iPhone app ships, a released Inbox has no pairing, no door, no reach switches, no relay and no Phones block, and every one of those routes answers the unknown-route 404 (see the env table). Everything below describes the Inbox with the switch on. - Pairing: Settings' "Pair a phone" calls `POST /api/inbox/pairing`, which opens one offer: a `plannotator://pair` link (drawn as a QR code with `uqr`, `packages/inbox/qr.ts`) carrying the computer's name, the tailnet address when it is on, the LAN address and the certificate's SHA-256 (`lan`, `fp`) when the Wi-Fi is on, a 32-byte secret and six digits. One offer is open at a time, in memory only. It closes when redeemed, when the window makes another, 10 minutes after it was made, or after 5 wrong codes: the two limits pairing needs, and the only ones. The phone redeems the secret or the digits at `POST /api/inbox/device/pair` for its own `tok_` bearer token, returned once. - Records under `inbox/`: `devices.jsonl` (the store's line rules; `dev_` id, name, platform, the token's SHA-256 and never the token, created, last seen, revoked, `carriage`), `device-secrets/` (the pairing secret, 0600 in a 0700 folder, for the relay's keys; deleted on revoke) and `device-commands.jsonl` (the door's answers by key). Last seen is kept in memory on each request and written when its UTC day changes. - The relay (`packages/server/inbox-relay.ts`; the Worker in `apps/relay`; contract section 4): diff --git a/apps/ios/scripts/proof.ts b/apps/ios/scripts/proof.ts index 30f989b861..6ec59370de 100644 --- a/apps/ios/scripts/proof.ts +++ b/apps/ios/scripts/proof.ts @@ -65,7 +65,8 @@ const env: Record = { PATH: process.env.PATH ?? '', HOME: join(tmp, 'home'), PLANNOTATOR_DATA_DIR: dataDir, - PLANNOTATOR_BROWSER: 'none', + PLANNOTATOR_BROWSER: 'none', // Phones are hidden in releases until the iPhone app ships; the app's proofs turn them on. + PLANNOTATOR_INBOX_PHONES: '1', }; // Every plannotator process this script starts gets exactly this env: a temp data dir, never ~/.plannotator. if (!env.PLANNOTATOR_DATA_DIR.startsWith(tmpdir()) || env.PLANNOTATOR_DATA_DIR.includes('/.plannotator')) { diff --git a/apps/ios/scripts/workspaces-proof.ts b/apps/ios/scripts/workspaces-proof.ts index c4948ad5dd..2135a52106 100644 --- a/apps/ios/scripts/workspaces-proof.ts +++ b/apps/ios/scripts/workspaces-proof.ts @@ -61,7 +61,8 @@ const env: Record = { PATH: process.env.PATH ?? '', HOME: join(tmp, 'home'), PLANNOTATOR_DATA_DIR: join(tmp, 'data'), - PLANNOTATOR_BROWSER: 'none', + PLANNOTATOR_BROWSER: 'none', // Phones are hidden in releases until the iPhone app ships; the app's proofs turn them on. + PLANNOTATOR_INBOX_PHONES: '1', }; // The Inbox runs on a temp data dir, never the person's own (row 5109). if (!env.PLANNOTATOR_DATA_DIR!.startsWith(tmpdir()) || env.PLANNOTATOR_DATA_DIR!.includes('.plannotator')) throw new Error('not a temp data dir'); diff --git a/apps/relay/test/relay.test.ts b/apps/relay/test/relay.test.ts index 7509d4f77b..3927afb55f 100644 --- a/apps/relay/test/relay.test.ts +++ b/apps/relay/test/relay.test.ts @@ -278,7 +278,7 @@ describe("the relay with an Inbox, under wrangler dev", () => { apple = await fakeApns(key.publicKey); relay = await startRelay("inbox", { APNS_KEY: key.pkcs8, APNS_KEY_ID: "KEY0000000", APNS_TEAM_ID: "TEAM000000", APNS_ORIGIN: `http://127.0.0.1:${apple.port}` }); stubs = stubBuiltHtml(); - w = createInboxWorld("plannotator-relay-", "relay", "relay"); + w = createInboxWorld("plannotator-relay-", "relay", "relay", { phones: true }); env = { ...(process.env as Record), ...worldEnv(w), PLANNOTATOR_RELAY_URL: relay.url }; startTheInbox(); agent = await SimAgent.connect({ binary: join(w.bin, "plannotator"), env, name: "Claude Code", host: "claude-code", cwd: w.project }); @@ -604,7 +604,7 @@ describe("the carriage: a phone reads and answers the Inbox through the relay al beforeAll(async () => { relay = await startRelay("carriage", {}); stubs = stubBuiltHtml(); - w = createInboxWorld("plannotator-relay-carriage-", "carriage", "R2"); + w = createInboxWorld("plannotator-relay-carriage-", "carriage", "R2", { phones: true }); if (proofDir) mkdirSync(proofDir, { recursive: true }); env = { ...(process.env as Record), ...worldEnv(w), PLANNOTATOR_RELAY_URL: relay.url }; // The files an agent attaches: a plan, and an HTML page with an image beside it (1.4 MB: its envelope passes one storage row). diff --git a/packages/inbox/api.ts b/packages/inbox/api.ts index 936b63a67e..c225872c62 100644 --- a/packages/inbox/api.ts +++ b/packages/inbox/api.ts @@ -95,6 +95,8 @@ export interface SettingsModel { data_dir: string; inbox_tool: { hosts: Record; env: boolean | null }; notifications: NotificationSettings; + /** Whether this Inbox serves phones (PLANNOTATOR_INBOX_PHONES): the Phones block shows only then. */ + phones: boolean; store: { dir: string; bytes: number; diff --git a/packages/inbox/components/Settings.tsx b/packages/inbox/components/Settings.tsx index 0140cc1436..884f8f6da4 100644 --- a/packages/inbox/components/Settings.tsx +++ b/packages/inbox/components/Settings.tsx @@ -222,10 +222,13 @@ export function SettingsPage({

Connect an agent

-

Agents on this computer reach the Inbox at 127.0.0.1, and it keeps its port between runs when it can. Phones reach it only on a path you turn on below.

+

+ Agents on this computer reach the Inbox at 127.0.0.1, and it keeps its port between runs when it can. + {settings?.phones && ' Phones reach it only on a path you turn on below.'} +

{context && }
- {settings && } + {settings?.phones && } {settings && (

Stored on this machine

diff --git a/packages/server/inbox-devices.test.ts b/packages/server/inbox-devices.test.ts index 66d37a811b..23c08b51a1 100644 --- a/packages/server/inbox-devices.test.ts +++ b/packages/server/inbox-devices.test.ts @@ -105,7 +105,7 @@ describe("the device door, on the binary", () => { beforeAll(async () => { stubs = stubBuiltHtml(); - w = createInboxWorld("plannotator-inbox-devices-", "device-door", "device-door"); + w = createInboxWorld("plannotator-inbox-devices-", "device-door", "device-door", { phones: true }); const entry = startInbox(w); base = `http://127.0.0.1:${entry.port}`; serverSession = ((await (await fetch(`${base}/api/inbox/health`)).json()) as Json).serverSession; @@ -538,7 +538,7 @@ describe("the device door, in process", () => { const dataDir = mkdtempSync(join(tmpdir(), "plannotator-inbox-devices-clock-")); roots.push(dataDir); let clock = Date.parse("2026-10-08T10:00:00.000Z"); - const inbox = await startInboxServer({ dataDir, binaryPath: null, now: () => new Date(clock) }); + const inbox = await startInboxServer({ dataDir, binaryPath: null, phones: true, now: () => new Date(clock) }); try { const base = `http://127.0.0.1:${inbox.port}`; const made = (await (await fetch(`${base}/api/inbox/pairing`, { method: "POST", body: "{}" })).json()) as Json; @@ -582,7 +582,7 @@ describe("the device door, in process", () => { }; const refused = async (response: Response) => [response.status, ((await response.json()) as Json).code]; try { - const inbox = await startInboxServer({ dataDir, binaryPath: null, tailscale }); + const inbox = await startInboxServer({ dataDir, binaryPath: null, phones: true, tailscale }); const main = `http://127.0.0.1:${inbox.port}`; const on = await fetch(`${main}/api/inbox/tailnet`, { method: "POST", body: JSON.stringify({ on: true }) }); expect(((await on.json()) as Json).tailnet).toMatchObject({ on: true, address: "macbook-pro.tail0000.ts.net:8443" }); @@ -649,7 +649,7 @@ describe("the device door, in process", () => { expect(serve.has(8443)).toBe(false); expect(JSON.parse(readFileSync(join(dataDir, "inbox", "inbox.json"), "utf8")).tailnet.https_port).toBe(8443); let atRestart: string | undefined = "not called"; - const next = await startInboxServer({ dataDir, binaryPath: null, tailscale, onRestartRequested: () => (atRestart = serve.get(8443)) }); + const next = await startInboxServer({ dataDir, binaryPath: null, phones: true, tailscale, onRestartRequested: () => (atRestart = serve.get(8443)) }); expect(serve.get(8443)).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); expect(serve.get(8443)).not.toBe(`http://127.0.0.1:${next.port}`); // Restart to update takes the mapping down before the new run starts (no race with its publish). @@ -658,13 +658,13 @@ describe("the device door, in process", () => { expect(atRestart).toBeUndefined(); // After a crash (kill -9): the mapping still points at the dead run's door listener. - const crashed = await startInboxServer({ dataDir, binaryPath: null, tailscale }); + const crashed = await startInboxServer({ dataDir, binaryPath: null, phones: true, tailscale }); const deadDoor = serve.get(8443)!; crashed.stop(); serve.set(8443, deadDoor); // The next start that cannot publish takes that leftover down rather than leave it. down.serve = true; - const unpublished = await startInboxServer({ dataDir, binaryPath: null, tailscale }); + const unpublished = await startInboxServer({ dataDir, binaryPath: null, phones: true, tailscale }); expect(serve.has(8443)).toBe(false); unpublished.stop(); down.serve = false; @@ -676,7 +676,7 @@ describe("the device door, in process", () => { expect(takeDownInboxTailnet(dataDir, tailscale)).toBe("none"); // A mapping someone else put on 8443 is never taken down. - const third = await startInboxServer({ dataDir, binaryPath: null, tailscale }); + const third = await startInboxServer({ dataDir, binaryPath: null, phones: true, tailscale }); serve.set(8443, "http://127.0.0.1:3000"); third.stop(); expect(serve.get(8443)).toBe("http://127.0.0.1:3000"); diff --git a/packages/server/inbox-lan.test.ts b/packages/server/inbox-lan.test.ts index bf6d4dfb71..252460a952 100644 --- a/packages/server/inbox-lan.test.ts +++ b/packages/server/inbox-lan.test.ts @@ -95,7 +95,7 @@ describe("Reach from this Wi-Fi, on the binary", () => { beforeAll(async () => { stubs = stubBuiltHtml(); - w = createInboxWorld("plannotator-inbox-lan-", "lan-listener", "lan-listener"); + w = createInboxWorld("plannotator-inbox-lan-", "lan-listener", "lan-listener", { phones: true }); await restart(); computerName = ((await (await win("/api/inbox/pairing", { serverSession })).json()) as Json).computer.name; }, 60_000); @@ -344,7 +344,7 @@ describe("Reach from this Wi-Fi, in process", () => { chmodSync(join(bin, "openssl"), 0o755); const path = process.env.PATH; process.env.PATH = `${bin}:${path ?? ""}`; - const inbox = await startInboxServer({ dataDir: join(root, "data"), binaryPath: null }); + const inbox = await startInboxServer({ dataDir: join(root, "data"), binaryPath: null, phones: true }); try { const main = `http://127.0.0.1:${inbox.port}`; const on = await fetch(`${main}/api/inbox/lan`, { method: "POST", body: JSON.stringify({ on: true }) }); @@ -365,7 +365,7 @@ describe("Reach from this Wi-Fi, in process", () => { test("a listener that cannot open at start shows the switch on with the reason, and turning it off works", async () => { const root = mkdtempSync(join(tmpdir(), "plannotator-inbox-lan-startfail-")); const dataDir = join(root, "data"); - const first = await startInboxServer({ dataDir, binaryPath: null }); + const first = await startInboxServer({ dataDir, binaryPath: null, phones: true }); const on = await fetch(`http://127.0.0.1:${first.port}/api/inbox/lan`, { method: "POST", body: JSON.stringify({ on: true }) }); const port = Number((((await on.json()) as Json).lan.address ?? ":0").split(":")[1]); first.stop(); @@ -377,7 +377,7 @@ describe("Reach from this Wi-Fi, in process", () => { chmodSync(join(bin, "openssl"), 0o755); const path = process.env.PATH; process.env.PATH = `${bin}:${path ?? ""}`; - const inbox = await startInboxServer({ dataDir, binaryPath: null }); + const inbox = await startInboxServer({ dataDir, binaryPath: null, phones: true }); try { const main = `http://127.0.0.1:${inbox.port}`; const state = ((await (await fetch(`${main}/api/inbox/lan`)).json()) as Json).lan; @@ -396,7 +396,7 @@ describe("Reach from this Wi-Fi, in process", () => { test("the switch's route keeps the window's guards: a foreign page and a stale tab are refused", async () => { const root = mkdtempSync(join(tmpdir(), "plannotator-inbox-lan-guards-")); - const inbox = await startInboxServer({ dataDir: join(root, "data"), binaryPath: null }); + const inbox = await startInboxServer({ dataDir: join(root, "data"), binaryPath: null, phones: true }); try { const main = `http://127.0.0.1:${inbox.port}`; const foreign = await fetch(`${main}/api/inbox/lan`, { method: "POST", headers: { Origin: "https://evil.example" }, body: JSON.stringify({ on: true }) }); diff --git a/packages/server/inbox-phones-gate.test.ts b/packages/server/inbox-phones-gate.test.ts new file mode 100644 index 0000000000..7a576f8f99 --- /dev/null +++ b/packages/server/inbox-phones-gate.test.ts @@ -0,0 +1,189 @@ +/** + * Phones stay hidden until the iPhone app ships: PLANNOTATOR_INBOX_PHONES (or + * config.json `inboxPhones`) turns the whole phone surface on, and it is off + * by default. Proved on `plannotator inbox --background` (the CLI from source, + * or the compiled binary when PLANNOTATOR_INBOX_TEST_BINARY names one) under + * a temp data dir, with a fake relay on loopback and `openssl` / `tailscale` + * stand-ins on PATH that record every call: + * + * 1. On: pairing works as before, and the paired Inbox holds a socket to the relay. + * 2. Off, over the state that run left (relay.json, a paired phone) plus the + * Wi-Fi and tailnet switches seeded on: every phone route answers the + * unknown-route 404, the settings payload says `phones: false`, nothing + * reaches the relay, openssl and tailscale never run, and no listener + * opens beyond the window's port. + * 3. On again over the same state: the relay socket, openssl and tailscale + * all come back, so step 2's silence is the switch's doing. + */ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { chmodSync, existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { resolveInboxPhones } from "@plannotator/shared/config"; +import { isPidAlive, readInboxRegistry, writeInboxRegistry } from "@plannotator/shared/inbox/registry"; +import { createInboxWorld, destroyInboxWorld, registry, stopInbox, stubBuiltHtml, waitFor, worldEnv, type InboxWorld } from "../../tests/helpers/inbox-world"; + +type Json = Record; + +describe("resolveInboxPhones", () => { + test("off by default; the env var wins over config.json; unrecognized env values count as unset", () => { + expect(resolveInboxPhones({}, {})).toBe(false); + expect(resolveInboxPhones({ inboxPhones: true }, {})).toBe(true); + expect(resolveInboxPhones({ inboxPhones: true }, { PLANNOTATOR_INBOX_PHONES: "0" })).toBe(false); + expect(resolveInboxPhones({ inboxPhones: false }, { PLANNOTATOR_INBOX_PHONES: "1" })).toBe(true); + expect(resolveInboxPhones({}, { PLANNOTATOR_INBOX_PHONES: "on" })).toBe(true); + expect(resolveInboxPhones({ inboxPhones: true }, { PLANNOTATOR_INBOX_PHONES: "maybe" })).toBe(true); + expect(resolveInboxPhones({}, { PLANNOTATOR_INBOX_PHONES: "" })).toBe(false); + }); +}); + +/** Every route the phone surface adds, as the window or a phone would call it (no Origin). */ +const PHONE_ROUTES: [method: string, path: string][] = [ + ["GET", "/api/inbox/device"], + ["GET", "/api/inbox/device/health"], + ["GET", "/api/inbox/device/threads"], + ["POST", "/api/inbox/device/pair"], + ["POST", "/api/inbox/device/revoke"], + ["POST", "/api/inbox/pairing"], + ["GET", "/api/inbox/devices"], + ["POST", "/api/inbox/devices/dev_x/revoke"], + ["GET", "/api/inbox/tailnet"], + ["POST", "/api/inbox/tailnet"], + ["GET", "/api/inbox/lan"], + ["POST", "/api/inbox/lan"], +]; + +describe("the phone switch, on the binary", () => { + let w: InboxWorld; + let stubs: string[] = []; + let relay: ReturnType; + const relayRequests: string[] = []; + let relaySockets = 0; + let marker = ""; + let token = ""; + + const base = () => `http://127.0.0.1:${registry(w).port}`; + const calls = () => (existsSync(marker) ? readFileSync(marker, "utf8") : ""); + + /** `plannotator inbox --background` with the switch as given, pointed at the fake relay. */ + const start = (phones: "0" | "1" | null) => { + const env: Record = { ...(process.env as Record), ...worldEnv(w), PLANNOTATOR_RELAY_URL: `http://127.0.0.1:${relay.port}` }; + if (phones === null) delete env.PLANNOTATOR_INBOX_PHONES; + else env.PLANNOTATOR_INBOX_PHONES = phones; + const run = Bun.spawnSync([join(w.bin, "plannotator"), "inbox", "--background"], { env, cwd: w.root }); + if (run.exitCode !== 0) throw new Error(`inbox --background failed: ${run.stderr.toString()}`); + return registry(w); + }; + const stop = async () => { + const { pid } = registry(w); + stopInbox(w); + await waitFor("the Inbox to exit", () => !isPidAlive(pid)); + }; + const call = (method: string, path: string, body: unknown = {}) => + fetch(`${base()}${path}`, { + method, + headers: method === "POST" ? { "Content-Type": "application/json" } : {}, + body: method === "POST" ? JSON.stringify(body) : undefined, + }); + const refused = (port: number) => + new Promise((resolve) => { + Bun.connect({ hostname: "127.0.0.1", port, socket: { open: (s) => (s.end(), resolve(false)), data() {}, error: () => resolve(true), connectError: () => resolve(true) } }).catch(() => resolve(true)); + }); + + beforeAll(() => { + stubs = stubBuiltHtml(); + w = createInboxWorld("plannotator-inbox-phones-gate-", "phones-gate", "phones-gate"); + marker = join(w.root, "calls.txt"); + // Stand-ins that record a call and fail: openssl makes the Wi-Fi certificate, tailscale publishes the tailnet path. + for (const name of ["openssl", "tailscale"]) { + writeFileSync(join(w.bin, name), `#!/bin/sh\necho "${name} $*" >> '${marker}'\nexit 1\n`); + chmodSync(join(w.bin, name), 0o755); + } + relay = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(req, server) { + const path = new URL(req.url).pathname; + relayRequests.push(`${req.method} ${path}`); + if (path.endsWith("/socket") && server.upgrade(req)) return undefined; + if (req.method === "POST" && path === "/v1/mailboxes") return Response.json({ mailbox_id: "mbx_gate" }, { status: 201 }); + return Response.json({ ok: true }); + }, + websocket: { open: () => void relaySockets++, message() {} }, + }); + }); + + afterAll(() => { + relay?.stop(true); + if (w) destroyInboxWorld(w); + for (const path of stubs) rmSync(path, { force: true }); + }); + + test("on: a phone pairs as before, and the paired Inbox holds the relay socket", async () => { + start("1"); + const health = (await (await call("GET", "/api/inbox/health")).json()) as Json; + expect(((await (await call("GET", "/api/inbox/settings")).json()) as Json).phones).toBe(true); + const offer = await call("POST", "/api/inbox/pairing", { serverSession: health.serverSession }); + expect(offer.status).toBe(201); + const { offer: open } = (await offer.json()) as Json; + const paired = await call("POST", "/api/inbox/device/pair", { code: open.code, name: "Gate iPhone", platform: "ios" }); + expect(paired.status).toBe(201); + const redeemed = (await paired.json()) as Json; + token = redeemed.token; + expect(redeemed.relay).toEqual({ url: `http://127.0.0.1:${relay.port}`, mailbox_id: "mbx_gate" }); + const asPhone = await fetch(`${base()}/api/inbox/device/health`, { headers: { Authorization: `Bearer ${token}` } }); + expect(asPhone.status).toBe(200); + await waitFor("the relay socket", () => relaySockets > 0); + await stop(); + }, 60_000); + + test("off (the default): the phone routes are the unknown-route 404, and nothing listens, connects, spawns or makes a certificate", async () => { + // What a phones run leaves, plus both reach switches on, as an iPhone developer's data dir would hold. + const previous = readInboxRegistry(w.dataDir)!; + const probe = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() }); + const lanPort = probe.port as number; + probe.stop(true); + writeInboxRegistry(w.dataDir, { ...previous, lan: { port: lanPort, on: true }, tailnet: { https_port: 8443, door_port: lanPort + 1 } }); + expect(existsSync(join(w.dataDir, "inbox", "relay.json"))).toBe(true); + relayRequests.length = 0; + relaySockets = 0; + rmSync(marker, { force: true }); + + const { pid, port } = start(null); + const unknown = await call("GET", "/api/inbox/no-such-route"); + expect(unknown.status).toBe(404); + const notFound = await unknown.json(); + for (const [method, path] of PHONE_ROUTES) { + const response = await call(method, path, { serverSession: "x", on: true, code: "000000", name: "x", platform: "ios" }); + expect({ route: `${method} ${path}`, status: response.status, body: await response.json() }).toEqual({ route: `${method} ${path}`, status: 404, body: notFound }); + } + // The paired phone's own token is just as unknown. + expect((await fetch(`${base()}/api/inbox/device/health`, { headers: { Authorization: `Bearer ${token}` } })).status).toBe(404); + expect(((await (await call("GET", "/api/inbox/settings")).json()) as Json).phones).toBe(false); + + // Past the relay's first reconnect delay (1 s): nothing reached it. + await Bun.sleep(2500); + expect(relayRequests).toEqual([]); + expect(relaySockets).toBe(0); + expect(calls()).toBe(""); + expect(existsSync(join(w.dataDir, "inbox", "tls"))).toBe(false); + expect(await refused(lanPort)).toBe(true); + expect(await refused(lanPort + 1)).toBe(true); + if (Bun.which("lsof")) { + const out = Bun.spawnSync(["lsof", "-nP", "-a", "-p", String(pid), "-i"]).stdout.toString(); + const sockets = out.split("\n").slice(1).filter(Boolean); + // Only the window's port: its listener and the test's own connections to it, no other local address. + expect(sockets.filter((line) => line.includes("(LISTEN)")).length).toBe(1); + for (const line of sockets) expect(line).toContain(`127.0.0.1:${port}`); + for (const line of sockets) expect(line).not.toContain(`:${relay.port}`); + } + await stop(); + }, 60_000); + + test("on again over the same state: the relay socket, openssl and tailscale come back", async () => { + start("1"); + await waitFor("the relay socket", () => relaySockets > 0); + await waitFor("openssl and tailscale", () => calls().includes("openssl") && calls().includes("tailscale")); + expect((await fetch(`${base()}/api/inbox/device/health`, { headers: { Authorization: `Bearer ${token}` } })).status).toBe(200); + await stop(); + }, 60_000); +}); diff --git a/packages/server/inbox.ts b/packages/server/inbox.ts index 8dff394c89..d8a19886e3 100644 --- a/packages/server/inbox.ts +++ b/packages/server/inbox.ts @@ -9,7 +9,9 @@ * paired phone reaches it through the device door only, over a path the * person switched on ("Reach from my tailnet": a door-only listener that * `tailscale serve` points at, inbox-devices.ts; "Reach from this Wi-Fi": a - * door-only TLS listener on every interface, inbox-lan.ts). Port: the + * door-only TLS listener on every interface, inbox-lan.ts). All of the phone + * surface exists only when PLANNOTATOR_INBOX_PHONES (or config.json + * `inboxPhones`) turns it on, until the iPhone app ships. Port: the * last one it had (from the registry) first, else random; never 19432. * * Security, on every request, in order: @@ -48,6 +50,7 @@ import { INBOX_TOOL_HOSTS, loadConfig, parseInboxToolEnv, + resolveInboxPhones, resolveInboxTool, saveConfig, configuredInboxTool, @@ -145,6 +148,14 @@ export interface InboxServerOptions { now?: () => Date; /** How "Reach from my tailnet" runs the `tailscale` CLI. Default: the CLI on PATH. */ tailscale?: TailscaleRunner; + /** + * Phones: pairing, the device door, the tailnet and Wi-Fi listeners, the + * relay and Settings' Phones block. Default: `resolveInboxPhones` + * (PLANNOTATOR_INBOX_PHONES, then config.json `inboxPhones`, else off). + * Off, none of it exists: its routes answer the unknown-route 404 and + * nothing listens, connects, spawns or makes a certificate. + */ + phones?: boolean; } export interface InboxServer { @@ -311,6 +322,8 @@ export async function startInboxServer(options: InboxServerOptions = {}): Promis const store = InboxStore.open(dataDir); const serverSession = createServerSessionNonce(); const token = createInboxToken(); + // Read once per start: the phone surface stays hidden until the iPhone app ships. + const phonesOn = options.phones ?? resolveInboxPhones(loadConfig()); const hostGuard = createRequestHostGuard({ localOnly: true }); const previous = readInboxRegistry(dataDir); let port = 0; @@ -457,6 +470,8 @@ export async function startInboxServer(options: InboxServerOptions = {}): Promis inbox_tool: inboxToolState(), notifications: notificationsState(), store: store.diskUsage(), + /** Whether this Inbox serves phones; the window shows the Phones block only then. */ + phones: phonesOn, }); /** Save the knob for the hosts named, keeping the others as they are. */ @@ -646,8 +661,9 @@ export async function startInboxServer(options: InboxServerOptions = {}): Promis let stopRequested = false; let server: ReturnType; let attachmentRoutes: ReturnType; - let phones: ReturnType; - let relay: ReturnType; + // Null while phones are off (the default): no door, no window routes, no listeners, no relay. + let phones: ReturnType | null = null; + let relay: ReturnType | null = null; const readBody = async (req: Request): Promise> => { try { @@ -674,7 +690,8 @@ export async function startInboxServer(options: InboxServerOptions = {}): Promis // Phones (packages/server/inbox-devices.ts): the device door. The tailnet // reaches the door only, through its own listener, never this port. - if (path === "/api/inbox/device" || path.startsWith(DOOR_PREFIX)) return phones.door(req, url); + // Off, the door's paths fall through to the unknown-route 404 below. + if (phones && (path === "/api/inbox/device" || path.startsWith(DOOR_PREFIX))) return phones.door(req, url); if (path === "/mcp") { if (origin !== null) { @@ -850,7 +867,7 @@ export async function startInboxServer(options: InboxServerOptions = {}): Promis if (attached) return attached; // Phones: pairing, the device list and revoke, the tailnet and Wi-Fi switches. - const phoneRoute = await phones.windowRoute(req, url); + const phoneRoute = phones ? await phones.windowRoute(req, url) : null; if (phoneRoute) return phoneRoute; if (path.startsWith("/api/")) return json({ error: "Not found", code: "not_found" }, 404); @@ -880,23 +897,26 @@ export async function startInboxServer(options: InboxServerOptions = {}): Promis ...(previous?.lan ? { lan: previous.lan } : {}), }; writeInboxRegistry(dataDir, registry); - phones = createInboxDevices({ - dataDir, - serverSession, - port: () => port, - registry: () => registry, - readBody, - dispatch: fetch, - now: options.now, - tailscale: options.tailscale, - relay: { paired: (device) => relay.paired(device), revoked: (device) => relay.revoked(device) }, - }); - // The relay (packages/server/inbox-relay.ts): the mailbox socket while a phone is paired, the pushes, and the - // carriage: store lines down as the event stream writes them, commands up through the door in-process. - relay = createInboxRelay({ dataDir, store, devices: phones.devices, payload: eventPayload, apply: phones.asDevice }); - relay.start(); - phones.startTailnet(); - phones.startLan(); + if (phonesOn) { + const devices = createInboxDevices({ + dataDir, + serverSession, + port: () => port, + registry: () => registry, + readBody, + dispatch: fetch, + now: options.now, + tailscale: options.tailscale, + relay: { paired: async (device) => (relay ? relay.paired(device) : null), revoked: (device) => relay?.revoked(device) }, + }); + phones = devices; + // The relay (packages/server/inbox-relay.ts): the mailbox socket while a phone is paired, the pushes, and the + // carriage: store lines down as the event stream writes them, commands up through the door in-process. + relay = createInboxRelay({ dataDir, store, devices: devices.devices, payload: eventPayload, apply: devices.asDevice }); + relay.start(); + devices.startTailnet(); + devices.startLan(); + } const binaryPath = options.binaryPath !== undefined ? options.binaryPath : version !== "dev" ? process.execPath : null; let tick: ReturnType | null = null; @@ -937,9 +957,9 @@ export async function startInboxServer(options: InboxServerOptions = {}): Promis server.stop(true); // The tailnet mapping and the door listener, the Wi-Fi listener and its // Bonjour record, before a restart starts the new run. - phones.stopTailnet(); - phones.stopLan(); - relay.stop(); + phones?.stopTailnet(); + phones?.stopLan(); + relay?.stop(); }; return { port, url: baseUrl, token, serverSession, portChanged, store, registry, stop }; diff --git a/packages/shared/config.ts b/packages/shared/config.ts index c61b134f6e..915d5ba88f 100644 --- a/packages/shared/config.ts +++ b/packages/shared/config.ts @@ -269,6 +269,14 @@ export interface PlannotatorConfig { dismissed?: boolean; allowedOrigin?: string | null; }; + /** + * The Plannotator Inbox's phone surface: pairing, the device door + * (`/api/inbox/device/*`), the tailnet and Wi-Fi listeners, the relay and + * Settings' Phones block. Hidden until the iPhone app ships, so `true` is + * for the app's own development and proofs. PLANNOTATOR_INBOX_PHONES wins + * over this key. Read once when the Inbox starts. Default: false. + */ + inboxPhones?: boolean; /** * Inject a Plannotator Flavored Markdown reminder into every EnterPlanMode * call so the agent is aware it can enrich plans with code-file links, @@ -1011,6 +1019,27 @@ export function resolveInboxTool( return parseInboxToolEnv(env) ?? configuredInboxTool(config, host) ?? INBOX_TOOL_DEFAULTS[host]; } +/** + * Resolve whether the Plannotator Inbox serves phones (pairing, the device + * door, the tailnet and Wi-Fi listeners, the relay, Settings' Phones block). + * + * Priority (highest wins): + * PLANNOTATOR_INBOX_PHONES env var → config.inboxPhones → default false + * + * Env `1` / `true` / `on` turn it on and `0` / `false` / `off` / `disabled` + * turn it off; an empty or unrecognized value counts as unset. Off until the + * iPhone app ships: the Inbox then does nothing phone-related at all. + */ +export function resolveInboxPhones( + config: PlannotatorConfig, + env: NodeJS.ProcessEnv = process.env, +): boolean { + const v = env.PLANNOTATOR_INBOX_PHONES?.trim().toLowerCase(); + if (v === "1" || v === "true" || v === "on") return true; + if (v === "0" || v === "false" || v === "off" || v === "disabled") return false; + return coerceConfigBoolean(config.inboxPhones, false); +} + /** * After a POST /api/config saveConfig: whether an `agentTool` change did NOT * reach config.json. saveConfig logs a write failure and returns normally, so diff --git a/scripts/dast/target.ts b/scripts/dast/target.ts index 1ee90f407e..3491774fbb 100644 --- a/scripts/dast/target.ts +++ b/scripts/dast/target.ts @@ -118,6 +118,8 @@ const scanTarget = Bun.serve({ const inbox = await startInboxServer({ version: "dast", binaryPath: null, + // The door's refusals are scanned too: phones on, as the iPhone app's proofs run them. + phones: true, // The real window, built by build:hook like the plan page above. htmlContent: readFileSync(resolve("apps/hook/dist/inbox.html"), "utf8"), }); diff --git a/tests/e2e/inbox-phones.spec.ts b/tests/e2e/inbox-phones.spec.ts index 5d43ec7598..68b42c926b 100644 --- a/tests/e2e/inbox-phones.spec.ts +++ b/tests/e2e/inbox-phones.spec.ts @@ -93,7 +93,7 @@ test.beforeAll(async ({ browser }: { browser: Browser }) => { mkdirSync(proofDir, { recursive: true }); const root = realpathSync(mkdtempSync(join(tmpdir(), 'plannotator-inbox-phones-e2e-'))); const dataDir = join(root, 'data'); - const env = { PATH: process.env.PATH ?? '', HOME: root, PLANNOTATOR_DATA_DIR: dataDir, PLANNOTATOR_BROWSER: 'none', PLANNOTATOR_RELAY_URL: 'http://127.0.0.1:9' }; + const env = { PATH: process.env.PATH ?? '', HOME: root, PLANNOTATOR_DATA_DIR: dataDir, PLANNOTATOR_BROWSER: 'none', PLANNOTATOR_RELAY_URL: 'http://127.0.0.1:9', PLANNOTATOR_INBOX_PHONES: '1' }; const started = spawnSync(binary, ['inbox', '--background'], { env, encoding: 'utf8', timeout: 60_000 }); expect(started.status, started.stderr).toBe(0); const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, colorScheme: 'light' }); @@ -186,7 +186,7 @@ test('a code closed by five wrong tries, and the tailnet switch\'s two error sta `#!/bin/sh\nif [ "$(cat '${join(root, 'mode')}')" = down ]; then echo 'Tailscale is stopped.' >&2; exit 1; fi\nif [ "$1" = serve ] && [ "$2" = status ]; then echo '{"TCP":{"8443":{"HTTPS":true}},"Web":{"x.ts.net:8443":{"Handlers":{"/":{"Proxy":"http://127.0.0.1:3000"}}}}}'; exit 0; fi\nexit 1\n`, ); chmodSync(join(bin, 'tailscale'), 0o755); - const env = { PATH: `${bin}:${process.env.PATH ?? ''}`, HOME: root, PLANNOTATOR_DATA_DIR: join(root, 'data'), PLANNOTATOR_BROWSER: 'none', PLANNOTATOR_RELAY_URL: 'http://127.0.0.1:9' }; + const env = { PATH: `${bin}:${process.env.PATH ?? ''}`, HOME: root, PLANNOTATOR_DATA_DIR: join(root, 'data'), PLANNOTATOR_BROWSER: 'none', PLANNOTATOR_RELAY_URL: 'http://127.0.0.1:9', PLANNOTATOR_INBOX_PHONES: '1' }; const started = spawnSync(binary, ['inbox', '--background'], { env, encoding: 'utf8', timeout: 60_000 }); expect(started.status, started.stderr).toBe(0); const other = await world.context.newPage(); @@ -276,7 +276,7 @@ test('Reach from this Wi-Fi where nothing can announce it (no Bonjour) and where writeFileSync(join(root, 'mode'), 'ok'); writeFileSync(join(bin, 'openssl'), `#!/bin/sh\nread mode < '${join(root, 'mode')}'\nif [ "$mode" = broken ]; then echo 'openssl: broken' >&2; exit 1; fi\nexec '${real!}' "$@"\n`); chmodSync(join(bin, 'openssl'), 0o755); - const env = { PATH: bin, HOME: root, PLANNOTATOR_DATA_DIR: join(root, 'data'), PLANNOTATOR_BROWSER: 'none', PLANNOTATOR_RELAY_URL: 'http://127.0.0.1:9' }; + const env = { PATH: bin, HOME: root, PLANNOTATOR_DATA_DIR: join(root, 'data'), PLANNOTATOR_BROWSER: 'none', PLANNOTATOR_RELAY_URL: 'http://127.0.0.1:9', PLANNOTATOR_INBOX_PHONES: '1' }; const started = spawnSync(binary, ['inbox', '--background'], { env, encoding: 'utf8', timeout: 60_000 }); expect(started.status, started.stderr).toBe(0); const other = await world.context.newPage(); diff --git a/tests/e2e/inbox.spec.ts b/tests/e2e/inbox.spec.ts index 056a257dec..50f73bf3cd 100644 --- a/tests/e2e/inbox.spec.ts +++ b/tests/e2e/inbox.spec.ts @@ -488,6 +488,9 @@ test('Settings: the agent tool knob applies to the next session, the compact pic await expect(piSwitch).toHaveAttribute('aria-checked', 'false'); await expect(page.getByRole('switch', { name: 'The Inbox tool in OpenCode' })).toHaveAttribute('aria-checked', 'false'); await expect(page.getByText('Applies to the next session. Sessions already running keep what they started with.')).toBeVisible(); + // Phones stay hidden until the iPhone app ships (PLANNOTATOR_INBOX_PHONES is unset here): no block, no mention. + await expect(page.locator('[data-settings-phones]')).toHaveCount(0); + await expect(page.getByText('Phones reach it only on a path you turn on below.')).toHaveCount(0); const binary = realpathSync(world.binary); const other = page.getByRole('tabpanel', { name: 'Other MCP client' }); diff --git a/tests/helpers/inbox-world.ts b/tests/helpers/inbox-world.ts index f33ff6fb0b..fc9486a085 100644 --- a/tests/helpers/inbox-world.ts +++ b/tests/helpers/inbox-world.ts @@ -25,6 +25,8 @@ export interface InboxWorld { path: string; /** Where the transcript goes, when INBOX_PROOF_DIR is set. */ proof: (line: string) => void; + /** The Inbox serves phones (PLANNOTATOR_INBOX_PHONES=1); off otherwise, as released. */ + phones: boolean; } export function inboxBinary(): string | undefined { @@ -40,7 +42,7 @@ export function stubBuiltHtml(): string[] { return made; } -export function createInboxWorld(prefix: string, name: string, proofSubdir: string): InboxWorld { +export function createInboxWorld(prefix: string, name: string, proofSubdir: string, options: { phones?: boolean } = {}): InboxWorld { const root = realpathSync(mkdtempSync(join(tmpdir(), prefix))); const home = join(root, "home"); const dataDir = join(home, ".plannotator"); @@ -68,6 +70,7 @@ export function createInboxWorld(prefix: string, name: string, proofSubdir: stri proof: (line) => { if (proofFile) appendFileSync(proofFile, `${line}\n`); }, + phones: options.phones === true, }; } @@ -81,6 +84,8 @@ export function worldEnv(w: InboxWorld): Record { PLANNOTATOR_BIN: "", // Never the hosted relay (tests/setup/feedback-archive-off.ts says why). PLANNOTATOR_RELAY_URL: process.env.PLANNOTATOR_RELAY_URL || "http://127.0.0.1:9", + // Phones are hidden until the iPhone app ships; only the phone proofs turn them on. + PLANNOTATOR_INBOX_PHONES: w.phones ? "1" : "0", TMPDIR: tmpdir(), }; }