Repository navigation
feat(box): let the phone app read conversations, follow events and get notified - #207
Conversation
…t notified A box with phone access now also serves the phone API's version 2 on its tailnet listener: an agent's recent conversation, copied field by field so nothing new reaches a phone by accident; the events a phone may hear, rebuilt without paths, logins or secrets; stopping an agent through the send gate; and registering for notifications through the push relay, which a laptop chooses and a phone cannot. A new code or access turned off forgets every phone and ends their streams. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
11 issues found across 11 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="docs/reference/events.mdx">
<violation number="1" location="docs/reference/events.mdx:148">
P3: The `phone.push.removed` row omits one trigger: turning phone access off also forgets every phone and emits this event (`internal/box/phone.go`, the `!c.Enabled` branch). Add it to the list of triggers.</violation>
</file>
<file name="docs/concepts/security.mdx">
<violation number="1" location="docs/concepts/security.mdx:23">
P3: This row under-describes what the push relay receives. The notice also carries the project (location) name and the run name, as the phone guide states. Name them so the security table matches the payload.</violation>
</file>
<file name="docs/changelog.mdx">
<violation number="1" location="docs/changelog.mdx:17">
P2: This says push is used 'instead of ntfy', but the code sends both when each is configured, and the guide says push works without ntfy. Change the wording to 'alongside ntfy'.</violation>
</file>
<file name="internal/box/phonev2_test.go">
<violation number="1" location="internal/box/phonev2_test.go:467">
P3: Check the errors from `Locations.Add` and `CreateWorktree` with `t.Fatal`. Otherwise a setup failure shows up as "ntfy heard nothing" after 5 seconds.</violation>
<violation number="2" location="internal/box/phonev2_test.go:468">
P3: These tests publish after a fixed 100ms sleep, assuming `Run` has subscribed by then. Under `-race` on a loaded CI runner, the event can be published before the subscription, and the test then fails with a timeout. Wait on an observable readiness signal instead of sleeping.</violation>
</file>
<file name="docs/reference/phone-api.mdx">
<violation number="1" location="docs/reference/phone-api.mdx:17">
P3: The tenth bad token still returns 401; only subsequent requests get 429 while ten failures remain in the window. Say the lockout starts after ten failures.</violation>
</file>
<file name="docs/guides/phone.mdx">
<violation number="1" location="docs/guides/phone.mdx:55">
P2: This promises no file contents or diffs reach the phone, but `phoneTranscript` forwards `text` items verbatim. Clarify that separate tool/file records are excluded while agent-written text may still contain them.</violation>
<violation number="2" location="docs/guides/phone.mdx:141">
P2: `sendRelay` uses `outboundPolicy`, whose `network.json` allowlist can permit private destinations. Qualify this as public-by-default and mention the allowlist override.</violation>
</file>
<file name="internal/box/phonev2.go">
<violation number="1" location="internal/box/phonev2.go:83">
P3: A malformed or overflowing `since` silently becomes zero and reloads the initial transcript page. Check the parse error and return `400 Bad Request`, as the event endpoint does.</violation>
<violation number="2" location="internal/box/phonev2.go:495">
P2: A registration authorized just before code rotation can add its phone after rotation clears `Push`; this update checks only `Enabled`, not whether the request token is still current. Revalidate the bearer token inside the update before persisting the registration.</violation>
<violation number="3" location="internal/box/phonev2.go:513">
P3: Evicting the oldest phone silently removes its registration, so event subscribers never learn that it stopped receiving notifications. Capture the evicted device and publish `phone.push.removed` as the other removal paths do.</violation>
</file>
Reply with feedback, questions, or to request a fix.
View guided diff | Turn on auto-fix | Re-trigger cubic
| box now also lets a paired phone read an agent's recent conversation | ||
| (never tool output or files), follow which agents and runs change as it | ||
| happens, stop an agent, and ask to be notified through Shipyard's push | ||
| relay instead of ntfy. Notifications still never carry what an agent |
There was a problem hiding this comment.
P2: This says push is used 'instead of ntfy', but the code sends both when each is configured, and the guide says push works without ntfy. Change the wording to 'alongside ntfy'.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/changelog.mdx, line 17:
<comment>This says push is used 'instead of ntfy', but the code sends both when each is configured, and the guide says push works without ntfy. Change the wording to 'alongside ntfy'.</comment>
<file context>
@@ -10,6 +10,13 @@ upgrades with `berth upgrade BOX`, or by running the
+ box now also lets a paired phone read an agent's recent conversation
+ (never tool output or files), follow which agents and runs change as it
+ happens, stop an agent, and ask to be notified through Shipyard's push
+ relay instead of ntfy. Notifications still never carry what an agent
+ wrote, a new code forgets every phone, and `before:phone.push` hooks can
+ refuse a phone. See [the phone API](/reference/phone-api).
</file context>
| relay instead of ntfy. Notifications still never carry what an agent | |
| relay, alongside ntfy. Notifications still never carry what an agent |
| [outbound requests](/guides/automations#outbound-requests)); the check is | ||
| made on the address actually dialled, redirects included. | ||
| - **The push relay** is chosen by a paired laptop, never by a phone, and the | ||
| box reaches it on public addresses only. It gets the same notice ntfy |
There was a problem hiding this comment.
P2: sendRelay uses outboundPolicy, whose network.json allowlist can permit private destinations. Qualify this as public-by-default and mention the allowlist override.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/guides/phone.mdx, line 141:
<comment>`sendRelay` uses `outboundPolicy`, whose `network.json` allowlist can permit private destinations. Qualify this as public-by-default and mention the allowlist override.</comment>
<file context>
@@ -90,6 +137,9 @@ does not serve them.
[outbound requests](/guides/automations#outbound-requests)); the check is
made on the address actually dialled, redirects included.
+- **The push relay** is chosen by a paired laptop, never by a phone, and the
+ box reaches it on public addresses only. It gets the same notice ntfy
+ does, plus the key that names the phone, and keeps neither.
</file context>
| box reaches it on public addresses only. It gets the same notice ntfy | |
| box reaches it on public addresses by default; an owner allowlist can permit private destinations. It gets the same notice ntfy |
| questions and their answers. Never tool output, thinking, file contents, | ||
| diffs or older pages; |
There was a problem hiding this comment.
P2: This promises no file contents or diffs reach the phone, but phoneTranscript forwards text items verbatim. Clarify that separate tool/file records are excluded while agent-written text may still contain them.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/guides/phone.mdx, line 55:
<comment>This promises no file contents or diffs reach the phone, but `phoneTranscript` forwards `text` items verbatim. Clarify that separate tool/file records are excluded while agent-written text may still contain them.</comment>
<file context>
@@ -47,11 +47,45 @@ The phone can:
+
+- an agent's recent conversation, as its chat shows it: prompts, replies,
+ one-line tool calls, the files it edited with their line counts, its
+ questions and their answers. Never tool output, thinking, file contents,
+ diffs or older pages;
+- what happens as it happens: which session or run changed, by name, so the
</file context>
| questions and their answers. Never tool output, thinking, file contents, | |
| diffs or older pages; | |
| questions and their answers; it exposes no separate tool-output or file records, | |
| but agent-written text can still include them. Older pages are not available; |
| return err | ||
| } | ||
| _, err := p.update(func(c *PhoneConfig) error { | ||
| if !c.Enabled { |
There was a problem hiding this comment.
P2: A registration authorized just before code rotation can add its phone after rotation clears Push; this update checks only Enabled, not whether the request token is still current. Revalidate the bearer token inside the update before persisting the registration.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At internal/box/phonev2.go, line 495:
<comment>A registration authorized just before code rotation can add its phone after rotation clears `Push`; this update checks only `Enabled`, not whether the request token is still current. Revalidate the bearer token inside the update before persisting the registration.</comment>
<file context>
@@ -0,0 +1,639 @@
+ return err
+ }
+ _, err := p.update(func(c *PhoneConfig) error {
+ if !c.Enabled {
+ return errors.New("phone access is off")
+ }
</file context>
| | `helper.removed` | `plugin`, `purged`, `attached`: one was stopped and removed, with its data when `purged`; `attached` counts the running services that still point at it. Not announced when nothing of the helper was on the box | | ||
| | `phone.changed` | `enabled` | | ||
| | `phone.push.added` | `name`, `platform`: a phone app asked to be notified (`origin: "phone"`) | | ||
| | `phone.push.removed` | `name`, `platform`: a phone app is no longer notified: removed from the phone or a laptop, forgotten with a new code, or `reason: "gone"` when the push relay said it no longer takes notifications | |
There was a problem hiding this comment.
P3: The phone.push.removed row omits one trigger: turning phone access off also forgets every phone and emits this event (internal/box/phone.go, the !c.Enabled branch). Add it to the list of triggers.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/reference/events.mdx, line 148:
<comment>The `phone.push.removed` row omits one trigger: turning phone access off also forgets every phone and emits this event (`internal/box/phone.go`, the `!c.Enabled` branch). Add it to the list of triggers.</comment>
<file context>
@@ -144,6 +144,8 @@ events.
| `helper.removed` | `plugin`, `purged`, `attached`: one was stopped and removed, with its data when `purged`; `attached` counts the running services that still point at it. Not announced when nothing of the helper was on the box |
| `phone.changed` | `enabled` |
+| `phone.push.added` | `name`, `platform`: a phone app asked to be notified (`origin: "phone"`) |
+| `phone.push.removed` | `name`, `platform`: a phone app is no longer notified: removed from the phone or a laptop, forgotten with a new code, or `reason: "gone"` when the push relay said it no longer takes notifications |
| `turncheck.changed` | `enabled`, `provider`, `url`, `model`, `key` (a reference, or empty), `key_pasted`, `key_removed`; never a key's value |
| `box.upgraded` | `build` |
</file context>
| | `phone.push.removed` | `name`, `platform`: a phone app is no longer notified: removed from the phone or a laptop, forgotten with a new code, or `reason: "gone"` when the push relay said it no longer takes notifications | | |
| | `phone.push.removed` | `name`, `platform`: a phone app is no longer notified: removed from the phone or a laptop, forgotten with a new code or when phone access is turned off, or `reason: "gone"` when the push relay said it no longer takes notifications | |
| }}) | ||
| repo := gitRepo(t) | ||
| b.Locations.Add(ctx, "acme", repo) | ||
| wt, _ := b.Locations.CreateWorktree(ctx, "acme", "checkout", "", "") |
There was a problem hiding this comment.
P3: Check the errors from Locations.Add and CreateWorktree with t.Fatal. Otherwise a setup failure shows up as "ntfy heard nothing" after 5 seconds.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At internal/box/phonev2_test.go, line 467:
<comment>Check the errors from `Locations.Add` and `CreateWorktree` with `t.Fatal`. Otherwise a setup failure shows up as "ntfy heard nothing" after 5 seconds.</comment>
<file context>
@@ -0,0 +1,591 @@
+ }})
+ repo := gitRepo(t)
+ b.Locations.Add(ctx, "acme", repo)
+ wt, _ := b.Locations.CreateWorktree(ctx, "acme", "checkout", "", "")
+ go p.Run(ctx, b)
+ time.Sleep(100 * time.Millisecond)
</file context>
| wt, _ := b.Locations.CreateWorktree(ctx, "acme", "checkout", "", "") | |
| wt, err := b.Locations.CreateWorktree(ctx, "acme", "checkout", "", "") | |
| if err != nil { | |
| t.Fatal(err) | |
| } |
| repo := gitRepo(t) | ||
| b.Locations.Add(ctx, "acme", repo) | ||
| wt, _ := b.Locations.CreateWorktree(ctx, "acme", "checkout", "", "") | ||
| go p.Run(ctx, b) |
There was a problem hiding this comment.
P3: These tests publish after a fixed 100ms sleep, assuming Run has subscribed by then. Under -race on a loaded CI runner, the event can be published before the subscription, and the test then fails with a timeout. Wait on an observable readiness signal instead of sleeping.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At internal/box/phonev2_test.go, line 468:
<comment>These tests publish after a fixed 100ms sleep, assuming `Run` has subscribed by then. Under `-race` on a loaded CI runner, the event can be published before the subscription, and the test then fails with a timeout. Wait on an observable readiness signal instead of sleeping.</comment>
<file context>
@@ -0,0 +1,591 @@
+ repo := gitRepo(t)
+ b.Locations.Add(ctx, "acme", repo)
+ wt, _ := b.Locations.CreateWorktree(ctx, "acme", "checkout", "", "")
+ go p.Run(ctx, b)
+ time.Sleep(100 * time.Millisecond)
+ b.Events.Publish(events.Event{Type: "agent.waiting", Data: map[string]any{"path": wt.Path, "agent": "claude", "text": "the agent's own words"}})
</file context>
| name, or its own short name, with the port. | ||
| - **Token.** Every request carries `Authorization: Bearer <token>`, the box's | ||
| phone token from the pairing link. Ten wrong tokens in a minute from one | ||
| address answer 429 until the minute is up. |
There was a problem hiding this comment.
P3: The tenth bad token still returns 401; only subsequent requests get 429 while ten failures remain in the window. Say the lockout starts after ten failures.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/reference/phone-api.mdx, line 17:
<comment>The tenth bad token still returns 401; only subsequent requests get 429 while ten failures remain in the window. Say the lockout starts after ten failures.</comment>
<file context>
@@ -0,0 +1,127 @@
+ name, or its own short name, with the port.
+- **Token.** Every request carries `Authorization: Bearer <token>`, the box's
+ phone token from the pairing link. Ten wrong tokens in a minute from one
+ address answer 429 until the minute is up.
+- **CORS.** Pages served from tailnet addresses and `*.ts.net` may read it
+ (another box's web phone app); the token is always a header, never a
</file context>
| writeError(w, statusFor(err), err.Error()) | ||
| return | ||
| } | ||
| since, _ := strconv.Atoi(r.URL.Query().Get("since")) |
There was a problem hiding this comment.
P3: A malformed or overflowing since silently becomes zero and reloads the initial transcript page. Check the parse error and return 400 Bad Request, as the event endpoint does.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At internal/box/phonev2.go, line 83:
<comment>A malformed or overflowing `since` silently becomes zero and reloads the initial transcript page. Check the parse error and return `400 Bad Request`, as the event endpoint does.</comment>
<file context>
@@ -0,0 +1,639 @@
+ writeError(w, statusFor(err), err.Error())
+ return
+ }
+ since, _ := strconv.Atoi(r.URL.Query().Get("since"))
+ res, err := b.driverFor(sess).Conversation(r.Context(), sess, all, since, r.URL.Query().Get("gen"))
+ if err != nil {
</file context>
| oldest = i | ||
| } | ||
| } | ||
| c.Push = slices.Delete(c.Push, oldest, oldest+1) |
There was a problem hiding this comment.
P3: Evicting the oldest phone silently removes its registration, so event subscribers never learn that it stopped receiving notifications. Capture the evicted device and publish phone.push.removed as the other removal paths do.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At internal/box/phonev2.go, line 513:
<comment>Evicting the oldest phone silently removes its registration, so event subscribers never learn that it stopped receiving notifications. Capture the evicted device and publish `phone.push.removed` as the other removal paths do.</comment>
<file context>
@@ -0,0 +1,639 @@
+ oldest = i
+ }
+ }
+ c.Push = slices.Delete(c.Push, oldest, oldest+1)
+ }
+ c.Push = append(c.Push, d)
</file context>
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 indocs/reference/phone-api.mdx)GET /phone/v1/infonow saysversion: 2andcapabilities.GET …/sessions/{name}/transcript?since=&gen=: the recent conversation, copied field by field into the phone's own structs, so a field added totranscript.Itemnever 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 andtranscript.changedby 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 gatebefore:phone.push, eventsphone.push.added/removed. At most 10 phones.POST …/sessions/{name}/interrupt: Stop through the driver, gated assession.sendwithfrom: "phone"(refactored out ofinterruptSession).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 throughoutboundPolicy()(public addresses only).GET /v1/phonelists phones without their keys; rotating the code or turning access off forgets them all. Capabilityphone.pushfor 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,-racecaught a cursor closed while its reader was inNext; the phone stream closes it in the reader goroutine.streamEventsinapi.gohas 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