Skip to content

Inbox: Phones stay hidden until the iPhone app ships (PLANNOTATOR_INBOX_PHONES) - #1812

Merged
backnotprop merged 1 commit into
mainfrom
fix/inbox-phones-hidden
Oct 9, 2026
Merged

backnotprop merged 1 commit into
mainfrom
fix/inbox-phones-hidden

Conversation

@backnotprop

Copy link
Copy Markdown
Owner

Main carries the server side of the unreleased iPhone app: pairing, the device door, the tailnet and Wi-Fi listeners, the relay connection, and Settings' Phones block. The app and relay.plannotator.ai are not live. This PR puts all of it behind one switch, off by default, so 0.28.9 shows and serves none of it.

Audit: what main does today with a fresh data dir and no pairing

I read the code, then ran a real Inbox (plannotator inbox --no-open, CLI from source) under a temp PLANNOTATOR_DATA_DIR and checked it with lsof -p <pid> -i.

On its own, it does nothing phone-related.

  • The only socket is the window's 127.0.0.1:<port> listener.
  • It makes no outbound connections and starts no child processes.
  • The data dir holds only inbox/inbox.json: no tls/, no relay.json, no devices.jsonl.
  • relay.start() subscribes to the store, but connect() and pushFor() return early with no mailbox.
  • startTailnet / startLan return early unless inbox.json holds tailnet / lan.on.
  • There are no phone timers. The relay only sets a reconnect timer after a socket closes.

But every phone route is mounted and answers. The curl results with no pairing:

Route Answer
GET /api/inbox/device, GET /api/inbox/device/health, POST /api/inbox/device/revoke (no token) 401 device_token_missing
GET /api/inbox/device/threads (bogus token) 401 device_token_invalid
POST /api/inbox/device/pair (no open offer) 410 offer_expired
any door route with an Origin 403 origin_not_allowed
GET /api/inbox/devices 200 {"devices":[]}
POST /api/inbox/devices/dev_x/revoke 404 device_not_found
GET /api/inbox/tailnet / GET /api/inbox/lan 200, both off
POST /api/inbox/pairing 201, opens a live offer: QR link, secret, six digits, and the computer's name (it runs scutil)

So one local POST can switch it all on. Any caller that passes the window's same-origin/no-Origin guard can:

  • make an offer and redeem its six digits at /api/inbox/device/pair, which returns a bearer token, creates the relay mailbox (an outbound POST to https://relay.plannotator.ai/v1/mailboxes) and then holds a WebSocket to it;
  • turn on the Wi-Fi listener with POST /api/inbox/lan, which runs openssl to make a certificate, binds a TLS listener on 0.0.0.0 and starts dns-sd / avahi-publish;
  • turn on the tailnet with POST /api/inbox/tailnet, which runs tailscale serve.

The switch

  • Settings: PLANNOTATOR_INBOX_PHONES=1 (also true / on; 0 / false / off / disabled turn it off), or config.json { "inboxPhones": true }. The env var wins. It is resolved by resolveInboxPhones in packages/shared/config.ts, read once when the Inbox starts. startInboxServer({ phones }) overrides it.

  • Off (the default):

    • createInboxDevices and createInboxRelay are never constructed.
    • Every door, pairing, device and reach-switch route falls through to the same 404 {"error":"Not found","code":"not_found"} as an unknown route.
    • Nothing listens, connects, spawns openssl / tailscale / Bonjour, or makes a certificate. This holds even when inbox.json, relay.json and devices.jsonl hold state from an earlier run with phones on. That state is kept for the next run with phones on.
  • The window: GET /api/inbox/settings carries phones. Settings draws <PhonesBlock /> and the "Phones reach it…" sentence only when it is true.

  • On: behaviour is exactly as it is today.

  • Proofs that turn it on:

    • apps/ios/scripts/proof.ts and workspaces-proof.ts (their env, which is the only env the Inbox gets in ios.yml)
    • apps/relay/test/relay.test.ts
    • tests/e2e/inbox-phones.spec.ts
    • inbox-devices.test.ts and inbox-lan.test.ts, through createInboxWorld(..., { phones: true }) and phones: true
    • the DAST target, so the door's refusals are still scanned

    tests/helpers/inbox-world.ts now sets PLANNOTATOR_INBOX_PHONES=0 for every other world.

  • Docs: an env table row and a line in the "Phones" section of AGENTS.md (CLAUDE.md is a symlink to it). Nothing in the public marketing docs.

Proof

packages/server/inbox-phones-gate.test.ts runs plannotator inbox --background against a fake relay on loopback, with openssl and tailscale stand-ins on PATH that record every call:

  1. On: a phone pairs with the six digits, the device token reads /api/inbox/device/health, and the Inbox holds the relay socket.
  2. Off, over that state, with lan.on and tailnet also seeded in inbox.json:
    • all 12 phone routes (and the paired token) answer the same 404 as an unknown route;
    • settings.phones is false;
    • after 2.5 s the relay has received zero requests;
    • openssl and tailscale were never called and inbox/tls does not exist;
    • the seeded LAN and door ports refuse connections;
    • lsof shows only the window's port.
  3. On again: the relay socket, openssl and tailscale all come back, which shows that step 2's silence comes from the switch.

The test passes with the CLI from source and with the compiled binary. With the gate forced on, the off case fails, so the test catches a regression. tests/e2e/inbox.spec.ts's Settings test now also asserts that the Phones block is absent.

Tests

  • Inbox server, store, config and CLI tests (13 files): 213 pass.
  • bun run typecheck passes.
  • bun run test:e2e:inbox against the compiled binary: 73 pass and 1 skips. The skip is the manual tailnet test, which needs a Mac signed in to Tailscale.
  • Full bun test: 6854 pass and 1 fails, agent-terminal.test.ts "node sidecar rebuilds spawn options" (JSON Parse error: Unexpected EOF under load). This PR does not touch that code, and the file passes 5/5 on its own.

Not covered by the gate

  • uninstall --purge still takes down a tailnet mapping that a crashed phones-on Inbox left. That is cleanup, and it only runs when inbox.json has tailnet.
  • With phones off, a tailscale serve mapping left by a kill -9 of a phones-on run stays until the next run with phones on, or until uninstall --purge. Only people who turned phones on can hit this.
  • The iPhone app and the relay Worker (apps/ios, apps/relay) are unchanged. They are not part of the release artifacts.

…OX_PHONES)

The phone surface (pairing, the device door, the tailnet and Wi-Fi
listeners, the relay, Settings' Phones block) now exists only when
PLANNOTATOR_INBOX_PHONES=1 or config.json inboxPhones is true. Off by
default: its routes answer the unknown-route 404, nothing listens,
connects, spawns or makes a certificate, and the window hides the block
(GET /api/inbox/settings carries phones). The iPhone, relay, DAST and
phone e2e proofs turn it on.

Co-Authored-By: Claude <noreply@anthropic.com>
@backnotprop
backnotprop merged commit b0f60ec into main Oct 9, 2026
38 checks passed
@backnotprop
backnotprop deleted the fix/inbox-phones-hidden branch October 9, 2026 18:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant