Skip to content

Inbox mobile: pairing, the device token door and the tailnet path (P1) - #1776

Merged
backnotprop merged 2 commits into
mainfrom
feat/mobile-p1-pairing
Oct 8, 2026
Merged

backnotprop merged 2 commits into
mainfrom
feat/mobile-p1-pairing

Conversation

@backnotprop

@backnotprop backnotprop commented Oct 8, 2026 •

Copy link
Copy Markdown
Owner

The story

A phone can now pair with the local Inbox and read and answer everything the window does, through one door. Settings has a Phones block: "Pair a phone" shows a QR code and six digits, the paired phones are listed with Remove, and "Reach from my tailnet" publishes the Inbox on the tailnet at port 8443. Every phone request goes through /api/inbox/device/*: no Origin, the phone's own bearer token, an explicit allowlist mapped onto the window's own handlers, and answers kept per idempotency key.

Mobile plan step P1. Contract: adr/implementation/inbox-mobile.md sections 1, 2 and 6 (PR 1775). Reviewer: Opus 5.5 (standing ruling; the contract names Fable as an option the owner did not take).

Blocker from plannotator-ops's review, fixed on this branch (second commit)

The hole. tailscale serve passes the client's own Host header through (r.Out.Host = r.In.Host). The first commit kept tailnet requests on the door by checking that header (servedHostRefusal). A tailnet peer sending Host: localhost passed the loopback allowlist and reached the window's routes: the pairing offer with its secret, /mcp, every thread, settings writes, project delete and restart. The contract's assumption, "through tailscale serve the Host is the MagicDNS name", was wrong.

The fix. It is the shape section 3 already plans for the LAN listener. While "Reach from my tailnet" is on, the Inbox runs a second loopback listener that serves only /api/inbox/device/*. Every other path answers 404 device_route_not_found there, whatever the Host. tailscale serve points at that listener, never at the window's port. The separation comes from the socket. The Host-based refusal is removed, and request-host-guard.ts is back to main.

The listener and the 8443 mapping close when the switch goes off and at every clean stop: quit, the stop route, and a restart to update, which takes them down before it starts the new binary, so the two never race. This folds in review item 1. inbox.json keeps tailnet: { https_port: 8443, door_port }, and the listener takes its last port again when it is free.

The contract edit. Section 2 "Where the door answers" now states the two-listener shape and removes the Host assumption, with the reason. Section 1's tailnet publication says what tailscale serve points at and when it is taken down. forbidden_host is gone from the error table.

Proofs of the fix.

  • packages/server/inbox-devices.test.ts, in process, through the tailscale runner seam (a scripted CLI holding the serve state, so no live tailscale call in CI):
    • serve's target is the door listener, not the window's port;
    • on that listener, spoofed Hosts (localhost, 127.0.0.1, the window's own 127.0.0.1:<port>, the tailnet name) for /, a window route, POST /api/inbox/pairing, /mcp, settings GET and POST, restart, project delete, the bridge, control, devices and tailnet are each 404 device_route_not_found, while the door works there;
    • the window's own port with Host: localhost and no Origin behaves as main does (threads 200, pairing 201);
    • switch off closes the listener and removes the mapping;
    • a clean stop removes the mapping and keeps the switch;
    • a restart removes the mapping before the new run starts;
    • a foreign 8443 mapping is never taken down.
  • On this Mac's real tailnet, compiled binary: research/operational/mobile-proofs/P1-2026-10-08/tailnet-spoof.txt.
    • 8443 maps to the door listener's port, not the window's.
    • A spoofed-Host POST /api/inbox/pairing through the tailnet name with Host: localhost, 127.0.0.1 or the window's 127.0.0.1:<port> answers 404 device_route_not_found. So do /api/inbox/threads, settings, /, /mcp and restart.
    • The door still pairs (201) and answers health (200).
    • Off removes 8443. A SIGTERM stop removes it too.
    • The 443 mapping already on this Mac was untouched throughout.
  • inbox-phones.spec.ts's tailnet test (PLANNOTATOR_E2E_TAILNET=1) now expects 404 for the window, /mcp and / through the tailnet name: 2 of 2 passed on the binary.

What changed

  • packages/shared/inbox/devices.ts (new): inbox/devices.jsonl on the store's line rules (append only, full snapshot per line, last line wins, torn last line skipped, fields only added). Each record has the dev_ id, name, platform, the token's SHA-256 (never the token), created, last seen, revoked and carriage. It also keeps inbox/device-secrets/<dev id> (0600 in a 0700 folder, deleted on revoke) and the JsonLines helper the door's command log uses. Last seen is kept in memory and written when its UTC day changes.

  • packages/server/inbox-devices.ts (new):

    • Pairing (POST /api/inbox/pairing, a window route with the window's guards) and the redemption (POST /api/inbox/device/pair).
    • The door: the ALLOWLIST table, the token check, the Origin refusal, the idempotency_key rule and inbox/device-commands.jsonl (replayed with Idempotent-Replayed: true; another route is 409 idempotency_key_reused; a 5xx is not kept).
    • The window's list and revoke. Revoke also ends the phone's open event streams.
    • The tailnet switch (GET/POST /api/inbox/tailnet).
    • The door-only listener the tailnet mapping points at (second commit).
  • packages/server/inbox.ts: the door and the served-name check at the top of fetch, the window routes at the end of the route table (one block each, so S1's rebase stays one block). inbox.json keeps tailnet across starts. The server's now option is the offer's clock, used by tests.

  • packages/server/tailscale-serve.ts, packages/shared/tailscale.ts: buildServeArgs(port, httpsPort) (the two-port form) and serveStatusProxy (where an existing mapping points). enableTailscaleServe gains three options:

    • httpsPort.
    • ownTargets: a mapping that points at this run's or the last run's loopback port is the Inbox's own and is re-pointed. Any other mapping is never overwritten.
    • persist: the mapping is not torn down on exit, so a restart to update never races its own teardown.

    A typed TailscaleServeError lets the window word the failure. --tailscale sessions behave as before.

  • packages/shared/inbox/registry.ts: tailnet?: { https_port }. packages/core/inbox-types.ts: the dev id prefix.

  • packages/inbox/components/Phones.tsx, packages/inbox/qr.ts (new), Settings.tsx, api.ts, icons.tsx (a phone icon), inbox.css, plus uqr as a dependency of packages/inbox (already in the lockfile for packages/server; one lockfile line).

  • AGENTS.md: "Local only" rewritten as "Local only by default; each wider path is a switch". A new "Phones" section names the door. The route table, Security and Proofs sections are updated.

  • .github/workflows/dast.yml, scripts/dast/target.ts, scripts/dast/zap-hooks.py: the door's refusals join the probe and the ZAP seed paths (401 without a token, 403 with an Origin).

  • .github/workflows/inbox-e2e.yml: the door proof runs on the compiled binary after the Playwright run. Paths gain tailscale-serve.ts, request-host-guard.ts and shared/tailscale.ts.

Contract edits (made first, in this PR, with the reason)

  1. The tailnet switch's window route. The draft named the switch but gave it no route. Added GET/POST /api/inbox/tailnet with 409 tailnet_port_taken and 409 tailnet_unavailable. Also written down: how the Inbox tells its own 8443 mapping from someone else's, and why the mapping persists past exit.
  2. "Where the door answers". Superseded by the blocker fix above. The tailnet reaches a door-only listener, so no Host header is read for this, and names the person added through PLANNOTATOR_ALLOWED_HOSTS (the DAST target, a person's own proxy) keep the window on its own port, as on main.
  3. Small additions. 404 device_not_found on the window's revoke. Revoke also ends the phone's open event streams. Every device answer includes carriage: the 7.2 and 7.25 examples omit it, while 7.36 and 7.37 show it.

Record sections this makes real

  • Contract section 1 (offer, QR link, the tailnet publication), section 2 (the device record, redemption, list and revoke, the door, the allowlist, what it never answers, the error codes) and section 6's door side (device-commands.jsonl). Exchanges 7.1 to 7.25, 7.36 and 7.37 are replayed.
  • PLAN.md step P1. The iPhone record's 1.2 points at this panel: "In the Inbox on your computer, open Settings, then Pair a phone". The panel uses the render's own words: "Pair a phone", "Scan with Plannotator on your iPhone, or enter the code.", and the digits as "482 913".

Not in the design record (OWNER-ITEMS item 23)

The desktop Phones block is not drawn anywhere. It is built in the window's existing Settings style, with no new component kinds:

  • the same ib-sblock and ib-srow rows and the same ib-sw switch;
  • the existing ib-btn buttons;
  • a Remove that asks twice like Delete thread;
  • one bordered panel for the QR, the digits, the countdown, New code and Done.

The QR is black on white in both themes so every camera reads it. PNGs for the owner's look, light and dark, are in research/operational/mobile-proofs/P1-2026-10-08/ (empty, pair, pair with the tailnet on, paired, Remove confirm, paired over the tailnet). The owner's look does not block the merge (rapid mode); what he dislikes becomes a follow-up.

Proofs and how to run them

  • The door on the compiled binary. packages/server/inbox-devices.test.ts, 17 tests. Run: PLANNOTATOR_INBOX_TEST_BINARY=$PWD/plannotator PLANNOTATOR_DATA_DIR=$(mktemp -d) bun test packages/server/inbox-devices.test.ts. Without the variable it runs the CLI from source, which is what bun test packages runs. It covers:

    • Every exchange 7.1 to 7.25, 7.36 and 7.37 against plannotator inbox --background, each answer's keys compared to the contract's shapes.
    • Every door route refusing no token, a wrong token and an Origin. A revoked token gets device_revoked.
    • What the door never answers (settings, restart, mcp, bridge, control, raw bytes, project delete, decision retire and replace, pairing, devices: all 404 device_route_not_found). The bridge and control answer a device token with 401 unauthorized.
    • An offer redeems once and a second redemption is 410. A new offer closes the old one. The sixth try after five wrong codes is 410.
    • A paired phone reads threads, picks (replayed by its key, a stale revision 409) and sends. The agent's wait_for_reply, through inbox mcp and the MCP SDK stdio client, gets the answer.
    • Seen, events, attachments, view, an HTML asset's bytes, an annotation saved and removed, the guide and a tick, the decision switch, decisions, live sessions and New message (409 session_not_live, then 200 once the session polls).
    • A key replays after the Inbox restarts.
    • Remove revokes, ends the phone's open stream, and the next call is 401.
    • In process: an offer expires 10 minutes after it was made (the server's clock option). The tailnet's door-only listener is covered in the blocker section above.
  • The Settings screen. tests/e2e/inbox-phones.spec.ts in the paths-gated inbox-e2e.yml job: headless Chromium with --use-mock-keychain --password-store=basic against the compiled binary. It proves:

    • the QR on screen is the offer's own link (the drawn path equals qrModules(link));
    • the digits match the offer's code and the countdown runs;
    • a phone (the script) that redeems the QR secret appears in the list and the panel closes;
    • Remove, asked twice, revokes, and the token's next call is 401 device_revoked.

    Run: PLANNOTATOR_E2E_BINARY=$PWD/plannotator bunx playwright test -c tests/e2e/playwright.config.ts inbox-phones.spec.ts.

  • The tailnet, a run on this Mac (CI has no Tailscale). This Mac is on a tailnet (Tailscale 1.102.2). Two runs on the compiled binary:

    • PLANNOTATOR_E2E_TAILNET=1 runs the second spec test. It turns the switch on, pairs at https://<MagicDNS name>:8443/api/inbox/device/pair by the six digits, reads health there, gets 404 for the window, /mcp and /, and turns the switch off. 2 of 2 passed.
    • A curl transcript of the first commit in research/operational/mobile-proofs/P1-2026-10-08/tailnet-run.txt, tokens and the tailnet's domain redacted. It predates the fix: its 403s came from the Host check. tailnet-spoof.txt is the current run.
    • The Mac's own serve mapping on 443 was there before and after every run, untouched. 8443 was free before and removed after.
  • Unit tests for the serve shape. packages/shared/tailscale.test.ts (the two-port buildServeArgs, serveStatusProxy on the status JSON this Mac prints) and packages/server/tailscale-serve.test.ts (re-points its own mapping and keeps it past exit; never overwrites a foreign 8443).

Gates run

Second commit: bun run typecheck green. Gitleaks on origin/main..HEAD: no leaks. Semgrep over the changed files: 0 findings. packages/server/inbox*, packages/shared/inbox, apps/hook/server/inbox-cli.test.ts, the tailscale and request-host tests: all pass. The door test passes on the compiled binary (17 of 17). The figures below are from the first commit.

  • bun run typecheck: green.
  • bun test packages apps/hook tests scripts under a temp data dir: 5839 pass, 1 fail. The failure is apps/hook/server/review-directory.test.ts "direct CLI honors PLANNOTATOR_CWD", a 15 s timeout under full-suite load. Run alone it passes 7 of 7; this PR does not touch it.
  • tests/entry-assets.test.ts and tests/favicon-surfaces.test.ts, inside the run above: green.
  • DOM-gated files: none added or changed.
  • bun run check:release-version: green (v0.28.8). packages/inbox stays private; only its dependency list changed.
  • bun test scripts/dast/report.test.ts: green.
  • Gitleaks on origin/main..HEAD with .gitleaks.toml: no leaks.
  • Semgrep 1.172.0 with .semgrep.yml over the changed files: no findings in this PR's code. The two request-to-network findings in scripts/dast/target.ts are on main's unchanged proxy lines; the same scan of main's file reports them.
  • packages/ui and packages/core smoke: core changed only by one id prefix in a type union and its regex (no new export that ui imports), so smoke:package was not run.

Limits, each named once

  • A pairing offer lives 10 minutes (PAIRING_OFFER_MS). It must expire, and 10 minutes is long enough to fetch a phone.
  • Its six-digit code closes after 5 wrong tries (PAIRING_CODE_TRIES). Six digits are only safe to type with a small number of guesses.

There are no other limits: no device count, no rate, no size bound.

What it leaves out

  • The LAN listener and the Wi-Fi switch (P2), the relay and carriage changes (R1, R2: relay answers null and nothing sets carriage yet), the app (M1).
  • A window signal when an offer closes from five wrong codes. The panel still shows the code until New code or expiry, and the phone shows the refusal. Left out as not needed for v1; it is one poll field if the owner's look asks for it.
  • DAST with a paired token. ZAP sees only the door's refusals, because the baseline scan has no way to hold a bearer.
  • Help text, the skill and the marketing reference: no command, MCP tool or knob changed. The Phones block is window UI.

Deviations from the record or the contract

  • The three contract edits above.
  • uqr added to packages/inbox's dependencies: the window draws the QR. The contract names uqr as a dependency of packages/server, but the window bundle cannot import it from there.
  • The tailnet row's off-state words ("Through Tailscale, without opening this computer to the internet") and the Phones intro line are new copy. No render draws them.

Settings gains Phones: Pair a phone (a QR code of the plannotator://pair
link and six digits, one offer at a time, 10 minutes, 5 wrong codes), the
paired phones with Remove, and Reach from my tailnet (tailscale serve on
8443, kept in inbox.json, re-pointed at each start, never over a mapping
the Inbox did not make). A phone reaches the Inbox only through
/api/inbox/device/*: no Origin, its own bearer token, an explicit
allowlist mapped onto the window's handlers, and answers kept per
idempotency key. Under the tailnet name only the door answers.

Contract: adr/implementation/inbox-mobile.md sections 1, 2 and 6, with
the tailnet switch's window route and the served-name rule written in.
…ndow's port

tailscale serve passes the client's Host header through, so the Host check
that held tailnet requests on the door could be spoofed with Host: localhost
and reached the window's routes (the pairing offer, /mcp, settings, restart).
While Reach from my tailnet is on, the Inbox now runs a second loopback
listener that serves only /api/inbox/device/*, and tailscale serve points at
it; the Host-based refusal is gone. The listener and the 8443 mapping go when
the switch goes off and at every clean stop (quit, the stop route, a restart
before the new run starts). Contract section 2 says so, with the reason.
@backnotprop
backnotprop merged commit c7bb505 into main Oct 8, 2026
31 checks passed
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