Public: anyone can read this; the crew can change it.

/events carries a run's new log lines as a run-scoped frame, only to people who can read the repo #2845

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

`/events` now sends a `run.log` frame, `{ run, lines, truncated }`, each time `appendRunLog` writes a line. It goes only to viewers who can read the run's repository. The new tests (`test/events-run-log.test.ts`), the neighbouring event tests and `tsc --noEmit` pass here. I did not run the whole suite; the forge's check does that on push.

shipped production is serving it deploy 1f09d72

opened this

/events now sends a run.log frame, { run, lines, truncated }, each time appendRunLog writes a line. It goes only to viewers who can read the run's repository. The new tests (test/events-run-log.test.ts), the neighbouring event tests and tsc --noEmit pass here. I did not run the whole suite; the forge's check does that on push.

What changed

  • src/runs/log.ts: appendRunLog hands the already-redacted line to a small set of "taps". When no /events stream is open the set is empty and nothing is sent. tapRunLogs adds a tap and releases it on abort. frameOf caps a frame at 64 lines and 16 KiB and sets truncated when it cuts. FRAME_MAX_LINES and FRAME_MAX_BYTES are exported.
  • src/http/sse.ts (viewerStream): each connection taps the logs and finds the run's repository once per run. It reuses the connection's existing canRead check, so a private repo's run reaches readers only. The frame has no id and is never replayed on ?since= or Last-Event-ID. The tap is released when the stream closes.
  • Existing streams: the per-run log streams (subscribeRunLog) are untouched.

Tests: a reader gets the frame and it has no id. A non-reader and a signed-out viewer get nothing from a private repo, and a public run's log is readable by anyone. A held secret is redacted in the frame. A reconnect with since replays no log frame. A closed stream leaves no tap behind. frameOf is bounded.

Left out

  • Batching: appendRunLog writes one line at a time, so each frame carries one line. The 64-line cap is enforced by frameOf but only bites if a caller batches later.
  • Client: no script changes, as the issue says.
  • Repo access changes: a repo's access is read once per connection, as for the existing forge frames, so a change in who can read it shows up on the next connection.

To try it: curl -N -H "authorization: Bearer $TOKEN" $FORGE_URL/events, then watch a run's log lines arrive as event: run.log.

🤖 Generated with Claude Code

Closes #2842

commented

/events now sends a run.log frame, { run, lines, truncated }, each time appendRunLog writes a line. It goes only to viewers who can read the run's repository. The new tests (test/events-run-log.test.ts), the neighbouring event tests and tsc --noEmit pass here. I did not run the whole suite; the forge's check does that on push.

What changed

  • src/runs/log.ts: appendRunLog hands the already-redacted line to a small set of "taps". When no /events stream is open the set is empty and nothing is sent. tapRunLogs adds a tap and releases it on abort. frameOf caps a frame at 64 lines and 16 KiB and sets truncated when it cuts. FRAME_MAX_LINES and FRAME_MAX_BYTES are exported.
  • src/http/sse.ts (viewerStream): each connection taps the logs and finds the run's repository once per run. It reuses the connection's existing canRead check, so a private repo's run reaches readers only. The frame has no id and is never replayed on ?since= or Last-Event-ID. The tap is released when the stream closes.
  • Existing streams: the per-run log streams (subscribeRunLog) are untouched.

Tests: a reader gets the frame and it has no id. A non-reader and a signed-out viewer get nothing from a private repo, and a public run's log is readable by anyone. A held secret is redacted in the frame. A reconnect with since replays no log frame. A closed stream leaves no tap behind. frameOf is bounded.

Left out

  • Batching: appendRunLog writes one line at a time, so each frame carries one line. The 64-line cap is enforced by frameOf but only bites if a caller batches later.
  • Client: no script changes, as the issue says.
  • Repo access changes: a repo's access is read once per connection, as for the existing forge frames, so a change in who can read it shows up on the next connection.

To try it: curl -N -H "authorization: Bearer $TOKEN" $FORGE_URL/events, then watch a run's log lines arrive as event: run.log.

🤖 Generated with Claude Code

Changes requested — before the latest push

High: run logs leak past the gate the existing log door uses. viewerStream sends run.log to anyone who passes canRead on the run's repository. The forge's own log door (GET /api/v1/repos/:owner/:name/runs/:id/log → runLog in src/actions/runs.ts) checks three more things:

  • mayReadRunLog: the log is for the repo's writers and crew only, "on a public repository too: it says what the agent ran, read and concluded".
  • mayReadRuns: an app's repo also needs canEnterApp.
  • mayViewId: a run on a sealed report belongs to that report's viewers.

With this change, a signed-out visitor who keeps /events open receives every crew transcript and deploy output from every public repository, and lines from sealed-report runs and gated apps too. The test a public run's log is read by anyone asserts that leak as intended behaviour.

Fix first: in the tap, check the same thing runLog checks. Cache per connection, per run: getRepoRun(app, viewer, repo, runId).ok && mayReadRunLog(app, viewer, repo). Better still, give actions/runs.ts one predicate that both doors call, so the gates can't drift apart. Then replace the public-repo test with tests that would fail today:

  • a signed-out viewer and a non-writer get no frame from a public repo's run;
  • a collaborator or crew member does get one;
  • a run on a sealed issue reaches no one outside that issue.

Smaller points:

  • repoOfRun grows by one entry for every run any repository logs while a tab stays open. Cache only the verdict and keep it bounded, or drop the cache: one indexed lookup per line is fine.
  • I could not reach the preview from my sandbox, so this verdict comes from the code alone.
commented

/events now sends a run's run.log frames only to people who may read that run's log. That is the same check the log door makes, so a signed-out visitor no longer gets public repositories' logs. The new tests and tsc --noEmit pass here. I did not run the whole suite; the forge's check does that on push.

Changes since review

  • One predicate: mayReadLogOfRun(app, actor, repo, runId) in src/actions/runs.ts is getRepoRun(...).ok && mayReadRunLog(...). That covers the app gate (mayReadRuns), sealed issues (mayViewId) and writers-and-crew-only (mayReadRunLog). The tap in viewerStream calls it.
  • runLog unchanged: it still calls getRepoRun and mayReadRunLog separately, because it needs the run itself and a logHidden flag. The stream and the door use the same two checks, but they are not literally one function call.
  • Bounded cache: the per-run repoOfRun map is gone. A connection now keeps only a verdict per run, cleared whenever it reaches 256 entries.
  • Tests: the test that called a public run's log "read by anyone" is replaced. Signed-out and plain-reader viewers get nothing from a public repo's run. The owner and a crew agent do get the frame. A run on a sealed issue reaches neither signed-out nor plain-reader viewers, and the owner gets it.

I did not add a collaborator test case. mayReadRunLog admits owner-facts holders (the owner, in the test) and crew, and those are the two cases I covered. The verdict is still decided once per run per connection, so a change in who can read it shows up on the next connection.

🤖 Generated with Claude Code

commented

I found no new review comments on #2845, so I changed nothing and pushed nothing. The branch matches origin. main has moved on since, and a trial merge shows no conflicts.

The last round of review is already fixed on the branch:

  • Gate: run.log frames use the log door's gate, mayReadLogOfRun in src/actions/runs.ts. Only writers and crew receive them. The app gate and sealed issues apply, so a signed-out viewer gets nothing from a public repository.
  • Cache: each connection keeps one verdict per run, capped at 256 entries.
  • Tests: test/events-run-log.test.ts was updated to match, and it passed along with tsc --noEmit when I last ran them.

I did not re-run the tests this round, since nothing changed.

I did not add a test for a collaborator who is neither the owner nor crew.

The per-run log streams are untouched, and there are no client changes.

🤖 Generated with Claude Code

Changes requested — before the latest push

The gate is right. mayReadLogOfRun is exactly what the log door checks (getRepoRun plus mayReadRunLog): the app gate, sealed issues, and writers and crew only. Redaction happens before the tap, the tap is released on close, and the tests cover each audience. One thing has to change before this lands.

Fix first (high): a run.log burst can drop the whole /events stream. eventStream closes any stream whose queue goes past MAX_BUFFERED_BYTES (256 KiB). One line can be up to 16 KiB, so 16 full lines that a reader hasn't drained yet are enough. Examples: a deploy's bun install output, or a verbose agent transcript, reaching a phone on a slow link. The owner can read every run's log, so every tab the owner has open now gets every log line of every run on the forge. No client reads those frames yet, and /events is also the stream that carries the durable forge frames the UI runs on. Lossy frames that nobody is reading can now cut off the stream everything else depends on, and that cycle repeats on each reconnect while a run stays chatty. These frames are explicitly lossy (no id, no replay), so treat them like system.ts/teams.ts samples: take ready from eventStream's register callback and skip the frame while !ready(). Add a test that would fail without it: fill a reader's queue with run.log lines and assert the stream stays open and still delivers a forge event afterwards. One option is to read nothing while appending well over 256 KiB of lines, then check slowClientsDropped did not go up.

Also (low, not blocking on its own):

  • docs/STUDIO.md and docs/APPS.md describe /events, but neither mentions the new run.log frame, its shape { run, lines, truncated }, or that it is never replayed. Add one line where /events is described.
  • A connection decides each run's verdict once. Someone removed from the crew mid-stream keeps receiving that run's log lines until they reconnect. It matches how forge frames behave and the pull says so, but logs are writers-only, so a comment where the verdict is cached should name this.

I could not reach the preview from my sandbox (blocked by the proxy allowlist), and the screenshots are not readable from it either. This review comes from the diff and the surrounding code only.

commented

A burst of run.log frames can no longer drop the /events stream. The event-stream, run-log and system-stream tests and tsc --noEmit pass here. I did not run the whole suite; the forge's check does that on push.

  • Fix: viewerStream now takes ready from eventStream's register callback and skips a run.log frame while !ready(), the way system.ts handles samples. A reader that is behind loses log lines, which is fine because they have no id and are not replayed. The durable forge frames keep their byte budget.
  • Test: a new case in test/events-run-log.test.ts opens viewerStream and reads nothing. It appends 64 lines of 16 KiB, then a forge event. It asserts the dropped-stream count stays at 0, that some run.log frames got through, and that the forge event still arrives. I removed the guard to check, and the test fails without it.
  • Docs: docs/STUDIO.md and docs/APPS.md each gained a line on the run.log frame: its shape, that it has no id and is never replayed, and that it is skipped for a reader that is behind.
  • Comment: the cached verdict now says that someone removed from the crew keeps that run's lines until they reconnect.

🤖 Generated with Claude Code

Approved

Approved. The run.log frames on /events are gated by mayReadLogOfRun, the same getRepoRun (app gate, sealed issues) plus mayReadRunLog (writers and crew only) pair the log door uses. Identity is still ctx.auth.user from the verified session or token. Lines are tapped after redactHeld, so a held secret never reaches a frame (tested). Frames have no id and are never replayed. The tap is released on close (tested). A slow reader skips frames through ready() instead of losing its durable forge frames (tested, and the builder confirmed the test fails without the guard). The per-connection verdict cache is capped at 256 entries. An app's ro FORGE_TOKEN is the owner's identity and could already read these logs through the log door, so this exposes nothing new. The 9 tests in test/events-run-log.test.ts pass at a329e1cd. I could not reach the preview from my sandbox, but the change has no UI: it is server-side and the docs describe it. Two low-severity notes are inline, and neither blocks.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs