feat(box): let the phone app read conversations, follow events and get notified

Merged
PU
6d ago
+1624-61

Second of the phone app's PRs (after #206; independent of it). The box side of the iPhone app: version 2 of the phone API on the tailnet listener (:1379), behind the existing token, Host check and lockout.

New phone routes (internal/box/phonev2.go, documented in docs/reference/phone-api.mdx)

  • GET /phone/v1/info now says version: 2 and capabilities.
  • GET …/sessions/{name}/transcript?since=&gen=: the recent conversation, copied field by field into the phone's own structs, so a field added to transcript.Item never reaches a phone by accident. No tool ids, uuids, signals, artifacts, crew details or record path; ! output cut to 2 KB; no ?before= or tool details.
  • GET /phone/v1/events?since=: NDJSON like /v1/events, but an allowlist that rebuilds each event: agent/session/run state and transcript.changed by name, never paths, logins, secrets or other events. Replays at most 500; at most 16 streams per box; a new code or access turned off ends them (the listener closes).
  • POST /phone/v1/push, DELETE /phone/v1/push/{id}: a phone app registers the key the push relay gave it. A phone can never set the relay (a laptop can, PUT /v1/phone {relay}, https only). New gate before:phone.push, events phone.push.added/removed. At most 10 phones.
  • POST …/sessions/{name}/interrupt: Stop through the driver, gated as session.send with from: "phone" (refactored out of interruptSession).

Notifications: notify() is split into a notice plus ntfy and relay deliveries, run side by side. The relay gets the same fields ntfy does (title, "loc/wt on box", session or run), never agent text. 410 forgets the phone, 5xx retries once, the result is noted per phone. Outbound goes through outboundPolicy() (public addresses only). GET /v1/phone lists phones without their keys; rotating the code or turning access off forgets them all. Capability phone.push for laptops.

Tests (phonev2_test.go): the projection leaks nothing; the stream carries only allowed types and fields; replay cap; stream cap; rotate ends streams; register, forget and rotate-clears; a phone can't pick the relay; registration gate; relay and ntfy both notified, no content; 410 forgets; 5xx retried and noted; loopback refused; interrupt gated. While writing these, -race caught a cursor closed while its reader was in Next; the phone stream closes it in the reader goroutine. streamEvents in api.go has the same pattern and is untouched here.

Docs: phone guide (what's new, the relay, threat model), phone API reference, events, security, changelog.

Checks: go vet ./..., go test -race ./..., docs-site build.

šŸ¤– Generated with Claude Code