Skip to content

The /live token rode in URLs, the page was blind while the planner thought, and a finished run could not be watched - #89

Merged
Shashankss1205 merged 2 commits into
mainfrom
fix/live-view-improvements
Aug 4, 2026
Merged

The /live token rode in URLs, the page was blind while the planner thought, and a finished run could not be watched#89
Shashankss1205 merged 2 commits into
mainfrom
fix/live-view-improvements

Conversation

@Shashankss1205

Copy link
Copy Markdown
Collaborator

Three defects in grapharc/server/live.py, fixed in one PR because they share the file.

Fixes #41
Fixes #47
Fixes #48


#41 — the token rode in URLs

?token= is now accepted on /live/api/stream and nowhere else: that route has
no alternative, because a browser EventSource cannot set a header. Every other
/live route refuses a query-string token with a 401 whose reason says where
to put it instead. A browser gets a sign-in page rather than a bare 401 and
trades the token for a cookie — a SHA-256 digest of the token rather than the
token, HttpOnly, SameSite=Strict, scoped to /live, and always ASCII, so a
non-ASCII secret works where a latin-1 Bearer header cannot carry it. The
index no longer writes the token into the links it renders. The residual
exposure (the SSE request line) is named in the cookbook beside --live-token,
with what to scrub.

Verified against a real grapharc serve --live-root … --live-token …:

probe before after
GET /live?token=T 200, and every rendered link carries &token=T 401 — "the token may not travel in the query string on this route…"
GET /live/api/runs?token=T 200 401, same reason as JSON detail
GET /live/view?trace=…&token=T 200 401 + sign-in form
GET /live/api/stream?trace=…&token=T 200 200 (unchanged — the exemption)
Authorization: Bearer T on /live/api/runs 200 200
POST /live/auth (right token) 404 — no such route 303 → the requested page, Set-Cookie: grapharc_live_token=e1974d93… (a digest)
POST /live/auth (wrong token) 404 401
cookie on /live, /live/api/runs, /live/view 401 200
token in the index HTML present absent

#74's confinement is unchanged, checked on the same server: ../outside.jsonl,
%2e%2e%2ffinished.jsonl, /etc/passwd, t%00.jsonl, a symlinked
link_out.jsonl and finished.txt are all 404 on /live/view and
/live/api/stream, the index still lists only the two real traces, and a
hostile token (non-ASCII, 9 000 chars, empty) is still a 401 rather than a 500.

#47 — the page was blind while the planner thought

The snapshot carries a planning block folded from the plan, admission and
round events already on disk (no new trace events), and the page renders it as
a panel. An open planning round now also reads as active: a planner
mid-inference writes nothing for 30–45 seconds, so "quiet" was reading as
"idle" exactly when the operator most needs to tell thinking from wedged.

A run refused on all three rounds, on the real server:

Before"planning": null, and the page's only content is the Mermaid box
no graph ran: no proposal was admitted and built. Nothing about the rounds,
the refusals, or the 2 079 tokens spent.

After — same diagram, plus:

planning
 round     status     nodes   why                            tokens
 round 1   rejected   4       policy/edge_denied, policy     693
 round 2   rejected   4       policy/edge_denied, policy     693
 round 3   rejected   4       policy/edge_denied, policy     693
 stopped: admission_refused — 3 rounds, none admitted

And a run caught mid-round (a plan + admission on disk, no round event
yet) — before: "planning": null, status idle; after: an amber in-flight row
round 1 · admitted · 5 nodes · 2 081 tokens, status planning, graph reading
planning… instead of the "no graph ran" placeholder. (The panel rendering is
shown by driving the shipped page script against the real snapshot payload.)

A run that never planned has no planning field and renders exactly as before.

#48 — a finished trace rendered as a done deal

?replay=1 walks the recorded events in timestamp order and emits the snapshots
the run would have sent; &speed=N divides the wall clock; the whole replay is
capped at 40 s so a long incident trace stays watchable. Frames are built by the
same snapshot code the live stream uses, pointed at a prefix of the file, and
consult no clock — so a trace replayed twice renders identically.

A finished three-node run (12 s recorded), on the real server:

Beforereplay=1 ignored: one snapshot, class n0,n1,n2 done. All green,
instantly; no amber frame exists for the run at any point.

Afterreplay=1&speed=8, 7 snapshots in 1.5 s:

frame 0: class n0,n1,n2 pending
frame 1: class n0 running      class n1,n2 pending
frame 2: class n0 done         class n1,n2 pending
frame 3: class n0 done         class n1 running     class n2 pending
frame 4: class n0,n1 done      class n2 pending
frame 5: class n0,n1 done      class n2 running
frame 6: class n0,n1,n2 done

With no parameter: 1 snapshot, class n0,n1,n2 done, byte-identical to before
(the done frame payload is unchanged too; only a replay's done frame carries
the extra "replay": true).


Tests

tests/test_server_live.py grows 16 tests: query-token refusal per route, the
sign-in/cookie round trip, open-redirect refusal on next, no token in index
links, the planning fold (refused, in-flight, mid-inference activity, absent for
a plain run, carried over SSE), and replay (schedule scaling and cap, the
amber→green sweep, determinism, the speed multiplier, off-by-default, and an
unstarted trace).

Checked against the pre-fix live.py: the test module does not even import
(replay_schedule is absent), and with that one import stubbed out 14 of the
16 new tests fail and a 15th hangs
— replaying an unstarted trace falls
through to the live stream, which waits forever. The 16th ("a replay renders
identically twice") passes vacuously before the fix, because both "replays" are
the same single all-green snapshot. Two existing auth tests were updated for the
intended behaviour change; one of them (the non-ASCII token) fails pre-fix
because it now goes through sign-in.

1860 passed for the full suite; ruff check . clean.

🤖 Generated with Claude Code

Shashankss1205 and others added 2 commits August 5, 2026 00:27
…ought, and a finished run could not be watched

Three defects in the same file, fixed together because they share it.

The token was accepted in the query string on every `/live` route, and a URL
outlives the request: the uvicorn request line, the nginx access log, browser
history, the referrer of anything the page opens. The index wrote the token
into every link it rendered, so clicking a trace filed the secret a second
time. `?token=` now reaches `/live/api/stream` alone — a browser `EventSource`
cannot set a header, so that route has no alternative — and every other route
refuses it with a 401 that says where to put the token instead. A browser gets
a sign-in page rather than a bare 401 and trades the token for a cookie: a
digest of it, `HttpOnly`, `SameSite=Strict`, scoped to `/live`, and ASCII, so a
non-ASCII secret works where a latin-1 header could not carry it. The residual
exposure — the SSE request line — is named in the cookbook beside
`--live-token`, with what to scrub.

The page rendered nothing for the whole planning phase, because it keys the
graph off the `topology` event that only lands once a round is admitted and
materialised — while `plan`, `admission` and `round` events were already on
disk, thousands of tokens in. A run refused on every round has no topology at
all and showed nothing from start to "finished". The snapshot now carries a
`planning` block folded from those same events — no new trace events — and the
page renders it: per round the proposal size, admission status, failed checks
and rejection codes, planner tokens, and the loop's stop reason when it stopped
without a graph. An open planning round also reads as active, since a planner
mid-inference writes nothing for a minute at a time.

A finished trace rendered instantly all-green, which makes the amber `running`
styling unreachable for every run that is already over. `?replay=1` walks the
recorded events in timestamp order and emits the snapshots the run would have
sent, `&speed=N` divides the wall clock, and the whole replay is capped at 40
seconds so a long incident trace stays watchable. Frames come from the same
snapshot code a live stream uses, pointed at a prefix of the file, and consult
no clock — a trace replayed twice renders identically. With no parameter,
nothing about the stream changed.

The reader's confinement is untouched: `../`, `%2e%2e%2f`, absolute paths, NUL
bytes and symlinked traces are the same 404s, and a hostile token is still a
401 rather than a crash.

Fixes #41
Fixes #47
Fixes #48

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Shashankss1205
Shashankss1205 merged commit d951e5e into main Aug 4, 2026
6 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

1 participant