Repository navigation
Inbox mobile: pairing, the device token door and the tailnet path (P1) - #1776
Merged
Merged
Conversation
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.
1 task
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.mdsections 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 servepasses 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 sendingHost: localhostpassed 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, "throughtailscale servethe 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 answers404 device_route_not_foundthere, whatever the Host.tailscale servepoints at that listener, never at the window's port. The separation comes from the socket. The Host-based refusal is removed, andrequest-host-guard.tsis 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.jsonkeepstailnet: { 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 servepoints at and when it is taken down.forbidden_hostis gone from the error table.Proofs of the fix.
packages/server/inbox-devices.test.ts, in process, through thetailscalerunner seam (a scripted CLI holding the serve state, so no live tailscale call in CI):localhost,127.0.0.1, the window's own127.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 each404 device_route_not_found, while the door works there;Host: localhostand no Origin behaves as main does (threads 200, pairing 201);research/operational/mobile-proofs/P1-2026-10-08/tailnet-spoof.txt.POST /api/inbox/pairingthrough the tailnet name withHost: localhost,127.0.0.1or the window's127.0.0.1:<port>answers 404device_route_not_found. So do/api/inbox/threads, settings,/,/mcpand restart.inbox-phones.spec.ts's tailnet test (PLANNOTATOR_E2E_TAILNET=1) now expects 404 for the window,/mcpand/through the tailnet name: 2 of 2 passed on the binary.What changed
packages/shared/inbox/devices.ts(new):inbox/devices.jsonlon the store's line rules (append only, full snapshot per line, last line wins, torn last line skipped, fields only added). Each record has thedev_id, name, platform, the token's SHA-256 (never the token), created, last seen, revoked andcarriage. It also keepsinbox/device-secrets/<dev id>(0600 in a 0700 folder, deleted on revoke) and theJsonLineshelper 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):POST /api/inbox/pairing, a window route with the window's guards) and the redemption (POST /api/inbox/device/pair).ALLOWLISTtable, the token check, the Origin refusal, theidempotency_keyrule andinbox/device-commands.jsonl(replayed withIdempotent-Replayed: true; another route is409 idempotency_key_reused; a 5xx is not kept).GET/POST /api/inbox/tailnet).packages/server/inbox.ts: the door and the served-name check at the top offetch, the window routes at the end of the route table (one block each, so S1's rebase stays one block).inbox.jsonkeepstailnetacross starts. The server'snowoption is the offer's clock, used by tests.packages/server/tailscale-serve.ts,packages/shared/tailscale.ts:buildServeArgs(port, httpsPort)(the two-port form) andserveStatusProxy(where an existing mapping points).enableTailscaleServegains 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
TailscaleServeErrorlets the window word the failure.--tailscalesessions behave as before.packages/shared/inbox/registry.ts:tailnet?: { https_port }.packages/core/inbox-types.ts: thedevid prefix.packages/inbox/components/Phones.tsx,packages/inbox/qr.ts(new),Settings.tsx,api.ts,icons.tsx(a phone icon),inbox.css, plusuqras a dependency ofpackages/inbox(already in the lockfile forpackages/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 gaintailscale-serve.ts,request-host-guard.tsandshared/tailscale.ts.Contract edits (made first, in this PR, with the reason)
GET/POST /api/inbox/tailnetwith409 tailnet_port_takenand409 tailnet_unavailable. Also written down: how the Inbox tells its own 8443 mapping from someone else's, and why the mapping persists past exit.PLANNOTATOR_ALLOWED_HOSTS(the DAST target, a person's own proxy) keep the window on its own port, as on main.404 device_not_foundon the window's revoke. Revoke also ends the phone's open event streams. Everydeviceanswer includescarriage: the 7.2 and 7.25 examples omit it, while 7.36 and 7.37 show it.Record sections this makes real
device-commands.jsonl). Exchanges 7.1 to 7.25, 7.36 and 7.37 are replayed.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:
ib-sblockandib-srowrows and the sameib-swswitch;ib-btnbuttons;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 whatbun test packagesruns. It covers:plannotator inbox --background, each answer's keys compared to the contract's shapes.device_revoked.404 device_route_not_found). The bridge and control answer a device token with401 unauthorized.wait_for_reply, throughinbox mcpand the MCP SDK stdio client, gets the answer.409 session_not_live, then 200 once the session polls).The Settings screen.
tests/e2e/inbox-phones.spec.tsin the paths-gatedinbox-e2e.ymljob: headless Chromium with--use-mock-keychain --password-store=basicagainst the compiled binary. It proves:qrModules(link));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=1runs the second spec test. It turns the switch on, pairs athttps://<MagicDNS name>:8443/api/inbox/device/pairby the six digits, reads health there, gets404for the window,/mcpand/, and turns the switch off. 2 of 2 passed.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.txtis the current run.Unit tests for the serve shape.
packages/shared/tailscale.test.ts(the two-portbuildServeArgs,serveStatusProxyon the status JSON this Mac prints) andpackages/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 typecheckgreen. Gitleaks onorigin/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 scriptsunder a temp data dir: 5839 pass, 1 fail. The failure isapps/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.tsandtests/favicon-surfaces.test.ts, inside the run above: green.bun run check:release-version: green (v0.28.8).packages/inboxstays private; only its dependency list changed.bun test scripts/dast/report.test.ts: green.origin/main..HEADwith.gitleaks.toml: no leaks..semgrep.ymlover the changed files: no findings in this PR's code. The tworequest-to-networkfindings inscripts/dast/target.tsare on main's unchanged proxy lines; the same scan of main's file reports them.packages/uiandpackages/coresmoke: core changed only by one id prefix in a type union and its regex (no new export that ui imports), sosmoke:packagewas not run.Limits, each named once
PAIRING_OFFER_MS). It must expire, and 10 minutes is long enough to fetch a phone.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
carriagechanges (R1, R2:relayanswers null and nothing setscarriageyet), the app (M1).Deviations from the record or the contract
uqradded topackages/inbox's dependencies: the window draws the QR. The contract namesuqras a dependency ofpackages/server, but the window bundle cannot import it from there.