mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
0cdb6798924c3d408d3269ef302fd102b2fd3390
53 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
1d7db73305 |
fix(models): review feedback — separator vocabulary, constraints stub, import style
The scopes sanitize now shares the registry guard's separator vocabulary: tab/newline/CR read as spaces, and every other C0 byte — including the U+001C–U+001F block str.split() would silently promote to separators — strips like the control it is, so a control byte inside a token can never split it into two valid-looking scopes (pinned alongside the registry's refusal). The livepass auth-constraints stub serves the new app_identity_auth_modes field so the pass exercises the served-data path for the model list's auth badge, and the session-module import in the mint tests drops to the string-path monkeypatch spelling (single-style imports). |
||
|
|
8605c9783d |
feat(models): rfc8693_obo auth mode, per-alias exchange scopes, identity-keyed mint cache
Adds the dedicated `rfc8693_obo` model auth mode (#955): model definitions gain an `obo_scopes` column (migration 069), the mint threads the scopes to the token-exchange leg (RFC 8693), and every dynamic mode pins its grant leg — a mode is a dialect commitment, not a hint the deployment profile resolves. Exchange-capable IdPs refuse an audience whose scope was not requested; this closes the structurally unmintable model-OBO path on token-exchange deployments. The model mint-cache is identity-keyed on the owning definition's alias (`__model_obo__:<alias>` per user, `__model_app__:<alias>` under the shared app principal), matching the MCP discipline where rows key on the unique server name. The bearer's shape lives in the row's audience/scopes columns and the freshness gate compares it on every read, so a re-aimed alias refuses its old row and overwrites the same key in place. Admin lifecycle (rename, re-aim, scope change, delete) purges a definition's own rows through one shared helper — sound because one definition owns each key; a sibling's rows are untouchable by construction. Cooldown and backoff additionally key on the dispatch shape, so an operator's config repair is an instant clean slate. Cause records, cooldowns, locks and memoization are per-alias end to end, and the session heartbeat reads refusal causes under the same keys. Console: default-deny write gating for dynamic rows (value-diff over the full column ladder, admin.mcp escalation, a never-blockable pure-disable carve-out), a two-tier validator (audience allow-list on every write; deployment-posture checks when the pair is chosen), one shared scopes parser whose omit-unchanged arm keeps over-cap DB-direct residue rows disarmable without ungating real changes, and served constraints (dynamic/scopes/app-identity mode lists, mode-to-profile pairing) so the shelf tracks the registry by data. The admin shelf gains the mode option, a scopes input with residue affordances, pairing-aware option greying, and a derived auth badge. Registry load refuses control characters in alias, audience, and scopes — including the C0 separator block that str.split() would silently collapse — and the C0/DEL class has one exported spelling shared by every surface. Profile-mismatch visibility warns at reload and boot with the mode-correct cause, gated on OIDC being enabled. Breaking: a stored `entra_obo` alias on a deployment whose `[oidc] obo_grant_profile` is `rfc8693` (or the inverse pairing) no longer mints via the profile-driven overload — the mint refuses before any IdP traffic with cause `grant_profile_mismatch`, and the `model.auth_fail_closed` policy governs static fallback. Such rows never minted usefully on scope-gating IdPs; the shelf now surfaces the pairing and the per-turn heartbeat names the refusal cause. Live-verified end to end: scoped token exchange mints, the warm cache serves with zero IdP calls, and the mode/profile mismatch refuses with zero IdP traffic (scripts/obo-e2e/keycloak_e2e.sh); the refresh-redemption profile's E1-E7 hold via scripts/obo-e2e/entra_e2e.py. Closes #955. |
||
|
|
1e34e19d48 |
refactor: single-style module imports and narrowed JSON body typing
Consolidates the repeated function-local model_registry imports onto one from-style module import per test file (the module object stays available for monkeypatching), converts the e2e script's mcp_oauth import to match, and reads the request body as Any before the isinstance narrow so the declared dict type is earned rather than asserted. Addresses the automated review feedback on the pull request; the two code-scanning flags are dismissed as false positives separately (the missing-key refusal log names config knobs and carries no secret value; the URL assertion is a test expectation, not a sanitizer). |
||
|
|
33ace975d2 |
feat(models): default-deny governance and admin UI for per-alias backend auth
Follow-up to the per-alias Entra OBO/app-identity backend auth: the console write path now applies default-deny field classification, the admin shelf gains full backend-auth support, and the session/registry rebind machinery is hardened for config changes landing under live sessions. Console write gate: - Default-deny classification: any non-neutral change to a row that is or becomes dynamic requires admin.mcp plus validation; the provably auth-neutral columns are enumerated (MODEL_AUTH_NEUTRAL_FIELDS) and a live-schema classification test forces every future column to be classified. The derivation is a pure function (_derive_auth_gate) with unit-pinned exclusivity invariants. - Two-tier validation mirroring the MCP oauth_obo validator: the row tier (audience allow-list) runs on every gated write; the posture tier (OIDC configured, token store present) runs on pair changes and on enable-arming. - Pure-disable carve-out: disabling a dynamic row is de-escalation and is never blocked — admin.models suffices and validation is skipped, including for rows with corrupt or skewed stored values. - Capabilities are compared canonically (key order, integral floats), the audience compare normalizes both sides, and staging an audience on a static row is refused on both write twins. - Calibrate writes the capabilities column under an enforced confinement invariant with a compare-and-swap persist. Admin shelf: - Backend-auth section with a per-open constraints fetch (GET /model-definitions/auth-constraints: audience allow-list, grant profile, dynamic modes), datalist audience suggestions, server-defined modes preserved on round-trip, and permission-aware visibility built on cache-skew-safe helpers shared through auth.js. - Refused live-registry swaps surface as an amber registry_warning on the write, delete, reload, and calibrate responses; audit rows carry auth_gated / auth_disarmed markers visible in the audit view. Registry and sessions: - The encryption-key requirement for dynamic auth is enforced inside ModelRegistry.reload() itself — nodes refuse with 503 and the console records coord_registry_error — and reload bumps the generation before the map swap so a racing reader can never pair a stale generation with new maps. - resolve()/resolve_binding() return the generation from inside the registry lock; sessions rebind per send on generation change with atomic client/provider/config commits, fallback-first handling of removed or unconstructable aliases, and judge/limiter resets only when the binding actually changed. - Mint refusals record per-user causes surfaced in the per-turn heartbeat logs; misconfiguration warnings are deduplicated with bounded state. Verification: 10417 tests (99 added on this branch), a 71-scenario browser harness over the real admin shelf, and a live rfc8693 token-exchange e2e run (MCP legs verified end to end; the model-leg scope gap is tracked as #955 under a narrow known-gap signature). Closes #950. |
||
|
|
729a02a833 |
feat(ui): copy-to-clipboard for messages and rendered blocks
Three idle-only affordances on every chat surface: a persistent copy button in each assistant bubble's actions bar, a pointer-only floating button over the hovered markdown block (fence, mermaid diagram, table), and Enter on a focused block for keyboard users, with the outcome flashed on the block itself. Copy resolves to SOURCE, not rendered text. The renderer stashes each table's raw markdown in data-md-source at render time — span sentinels restored in reverse mask order, footnote-definition bodies restored to raw before their recursive render — and whole-message copy reads the streaming pipeline's per-frame stash. The clipboard transport falls back to the legacy execCommand path for plain-HTTP LAN nodes, cloning and restoring the user's selection and focus. Outcomes surface button-local only: flash + title + one live-region announcement through the shared makeAnnouncer factory (also adopted by the interactive voice/tool announcers, whose lazily created regions swallowed their first announcement). Busy refusals answer with their own message. Coordinator retry and admin token-copy keep zero-module-dependency degrade paths. |
||
|
|
6f194d338f |
fix(console): restore back-to-console from a proxied node view
The console proxies a node's web UI at /node/{id}/ and injects a shim into
the page it fetches upstream. The only way back was a node-picker menu the
shim built into #ui-header — an element the L-shell renovation (
|
||
|
|
4007fab855 |
fix(#900): close two vacuity holes the round-2 scenarios left open
Round-3 review, unprimed. Two of its majors were the new scenarios asserting things they did not prove — the false-detector class this campaign keeps returning to. E8 never checked that the held /history was still OUTSTANDING when the redial completed. The disconnect/send/wait_turn/redial sequence is unbounded (wait_turn alone allows 45s), so on a slow box the payload resolves while evtSource is still null, the PRESENCE term declines it, and the run stamps dupes1-healed1 without ever evaluating the generation term. It now fails loudly with the counter values instead. E7 gained the same positive proof its siblings already carried: sse_opens == 0 only means "nothing connected in 8s", which is not the same as "the held load settled and its .finally chose not to reconnect". E5's stated control was simply wrong, in three places. A hide nulls evtSource, and connectSSE early-returns while hidden, so the scenario cannot produce the non-null-but-not-OPEN source that readyState === OPEN exists for — it exercises the presence term only. The earlier control removed both terms at once, which is what disguised it. The readyState half is covered by reasoning plus coord parity, and its correctness twin IS covered through the render-time gate by E6/E8; that scope is now written down rather than overclaimed. Coord's G5 has the same shape. The retry floor becomes a shared export beside its jitter: four sites must move together (both clients' arms, both non-occurrence windows) and it was the only one of them with no single source of truth. Interactive's use of the expression had no pin at all — reverting it to a bare 2000 would have broken cross-client parity with the suite green. Coord's re-anchor still raised ValueError rather than failing on a named assertion, and its first replacement used a fixed window that truncated mid-expression. |
||
|
|
7ed5d90a98 |
test(e2e): E8 observes the double render; honest non-vacuity for E6 (#900 r2)
Until now nothing in this harness could see the artefact the campaign prevents. Every scenario counts .msg.user rows, and user rows never travel on the SSE stream — a /send emits none, only /history replay paints them — so a duplicated assistant bubble was invisible to all of them. E8 counts a sentinel's occurrences in the transcript text instead, which is structure-agnostic across duplicate bubbles and tool blocks. The window it drives is the one readyState cannot see: the retry fires with the transport OPEN, its /history is held, and inside that await the transport drops and re-establishes. readyState reads OPEN afterwards exactly as before. The redial is a real disconnect+connect rather than a visibility change on purpose — a hide leaves evtSource null, which the presence term already decides, so a hide-based control would pass for the wrong reason. Control: stripping the generation term stamps dupes2. E8's expectation needed correcting once: unlike E6, the stream is live at flush time here, so the declined render's queued settle fires the transport-free backstop and the pane converges in one settle. That is correct behaviour, so the heal is asserted as a convergence leg — a "no duplicates" verdict must not be earnable by rendering nothing. E6 gains the non-vacuity it was missing: history_requests counts on ARRIVAL, before the hold and before any status is chosen, so "the gate declined a good payload" and "there was no good payload" stamped identical observables. history_ok — incremented only when the production route answers 200 — closes that, including the production-side-failure hole an injected-fail budget cannot see. Both hidden-window detectors widened for the additive jitter: sized on the 2000 floor alone they would have closed before a top-of-range firing and reported hidden0 for the wrong reason. delay_history(0) comments corrected — it cannot release an in-flight hold. |
||
|
|
539b91d30b |
test(e2e): E7 destroy-invalidation — first browser coverage of the factory teardown (#900)
Every other interactive scenario mounts the Pane class directly, so the factory closure that owns destroy() had no browser coverage at all — and that is exactly where #900's largest hole lived. E7 mounts through createInteractivePane (scenario-scoped: the factory owns its own connect/recover-beat lifecycle, so switching the others would change what they test), holds the first /history at the fault layer, destroys the controller mid-flight, and lets the load resolve into the void. Detector is a fault-layer non-occurrence: events_requests still 0, pane detached, _visHandler null behind it. Without the bump it stamps sse1-vis0 — the .finally reopens an EventSource on the detached pane and re-registers the document-level visibilitychange listener destroy just removed, which is the leak, now observed rather than traced. |
||
|
|
0a31709aed |
test(e2e): script E6's heal turn explicitly
An exhausted script queue still settles — into the error arm, which the idle-edge backstop also consumes — so the heal leg would have passed for a reason the scenario does not name. Queue the fourth turn like E4/E5 and assert its sentinel. |
||
|
|
c0261c907c |
test(e2e): E6 await-window-gate — the render-time half the fire guard can't see (#900)
E5's retry never fetches, so it cannot exercise the render-time check. E6 reaches it the only way available: the retry fires on a live stream, its /history is held at the fault layer, and a close-on-hide drops the transport while the payload is in flight. The detector is the stale-but-real PRE-rewind transcript surviving a RESOLVED fetch — three user rows with the latch still set. The latch leg is what makes it honest: replayHistory is the latch's only clear site, so a held latch proves no render ran rather than inferring it from row counts alone. |
||
|
|
8effe656bb |
test(e2e): E5 hidden-retry — the fire guard's non-occurrence detector (#900)
The interactive mirror of coord's G5. A close-on-hide inside the retry's 2s arm window is the reachable way to make it fire against a down transport, and the detector is a NON-occurrence counted at the fault layer: history_requests must be unchanged across the hidden window. Remove the OPEN term and the hidden fetch lands, stamping hidden1. The scenario also pins the two rulings the guard leans on: the latch must still be SET after the skip (a skipped retry heals nothing), and the show edge alone must not heal it — a replay_ok reconnect carries no synthetic state_change, so the repair rides a plain send's organic settle into the transport-free backstop, which is why exactly one new SSE open spans show + heal. |
||
|
|
d8d026394f |
fix(#894): cold flights key on None; typed generation access; abort-Set producer pins
Review round 10 (1 minor bug; 2 major + 2 small quality — the majors both pins-that-cannot-fail). - The flight key's cold fallback was the literal 0, which collides with a live session's generation 0: an eviction/close landing inside a held flight's window let a post-truncation request rejoin a generation-0 pre-truncation flight. Cold/detached workstreams now key on None (rewinds need a live session, so two cold flights are always mutually safe; a rehydrated session restarting at 0 can never share the manager slot with its evicted predecessor — documented at-site). The read is TYPED (live_session.session._history_generation) so mypy carries the shape a getattr chain hid — and the typed access immediately surfaced an unfaithful SimpleNamespace mock in the reasoning-rehydration tests (no .session attr), now made faithful. - Abort-Set producer pins: histCtrls.add exactly once and BEFORE the await, delete exactly once and in the finally — without them the destroy() consumer sweep was satisfiable by an always-empty Set. - _make_session gains ws_id; the generation producer pin uses it. - _coord_stick_latch: G2/G5's inline single-failure prologues RULED deliberate at-site (their baselines/phase timings interleave into the prologue; a per-divergence flag would obscure the choreography). - Stray trailing whitespace stripped. 250 pins green; G2/G5/G7 re-run READY. |
||
|
|
60f6dc07a2 |
fix(#894): drop the unreachable epoch guard; abort-Set; bump-after-delete; producer pins
Review round 9 (4 minor bug, 4 quality, 1 perf nit; security zero). - The r8 clearUiEpoch guard was UNREACHABLE (r9 bug find): clear_ui always dispatches immediately after bumping, so a stale-epoch dispatch is also a stale-seq dispatch and the currency gate discards it before it can paint or clear — the client half of the joined- flight fix was already carried by seq, and the server generation key is the sole load-bearing layer. Machinery removed (decl, bump, capture, conditional clear, section-9 pins); the latch-clear comment now states the two-layer accounting. - destroy()'s abort handle becomes a Set: a newest-wins single slot, nulled by the newer dispatch's finally, left an OLDER overlapping fetch unabortable — the destroyed closure pinned for the bound's remainder. Pinned. - _history_generation now bumps AFTER delete_messages_after: flights rebuild from storage, so old-generation-reads-post-delete is the harmless spuriously-fresh direction while new-generation-reads- pre-delete would be wrongly joinable; the count/floor error paths correctly leave it unbumped. Two-arm producer pin in test_rewind_retry (persisted-rows bump on rewind AND retry; in-memory-only error path must NOT bump) — the flight test's mock can no longer mask a deleted bump. - The harness load_calls increment takes a lock (to_thread workers genuinely overlap under delay_load; a lost update false-fails G7). - G7's viewer B is now a background authenticated GET (a raw request enters load_messages identically; the second browser bought no proof); stale two-tuple key comments and the coalescing matrix line updated; the _send_in_page enumeration dropped for prose. 250 pins green; G1/G6/G7 re-run READY. |
||
|
|
b85f792925 |
test(e2e): G7 joined-flight detector at the flight layer; fix the generation read path it caught (#894 r8)
G7: two browsers on one ws; delay_load parks B's pre-rewind /history flight open INSIDE load_messages — the flight layer. (A first cut held via delay_history, which sleeps in the FAULT layer before the route: flights never overlapped there and the 'negative control' passed vacuously — a false detector, caught and rebuilt. The knob also sleeps AFTER the load so a parked flight holds the rows it actually read: its transaction point.) A rewinds mid-hold; the miss proof is load_calls growing TWO (a joined request never enters load_messages — the e2e twin of the unit test's proof) plus A rendering the post-rewind single row. The rebuilt detector immediately caught a real bug in the server fix: mgr.get returns the Workstream WRAPPER, and the route's direct getattr for _history_generation silently defaulted to 0 forever — joining stayed enabled while the unit test's mock (attr on the wrong object) masked the shape. The route now reads ws.session, and the mock pins the nested shape so a wrong-object read can never pass again. Negative control (flight key reverted to (ws_id, limit)): stamps FAILED-loads1-rows3 — A joins the pre-rewind flight and paints three stale rows as fresh truth. Fixed: READY-posts1-loads2-rows1. |
||
|
|
30b6ff7f7b |
fix(#894): rewind-freshness epoch closes the #884 joined-flight window; destroy aborts the bounded fetch
Review round 8 (2 major + 2 minor bug, 2 major + 3 small quality; security/perf zero at five consecutive rounds). - clearUiEpoch (r8 major): the #884 /history single-flight can hand a joiner a payload whose load_messages ran BEFORE the rewind committed (the flight key is (ws_id, limit); joining is invisible to the client, and the client seq stamp cannot see server-side staleness) — reachable single-user (rewind clicked during a truncated-resync fetch joins that flight) and multi-viewer (any concurrent pane's /history). The joined payload rendered as 'success' and CLEARED the latch: the original over-rewind window, resurrected through the server seam. Fix: the epoch bumps at clear_ui arrival, every dispatch captures it pre-await, and only a dispatch that post-dates the latest clear_ui may CLEAR the latch — a pre-rewind payload may still paint (stale-but-real posture, gate holds), the surviving latch arms the retry, and the retry's fresh dispatch starts a new flight with post-rewind truth. Producer/consumer/placement pinned. - destroy() aborts the in-flight bounded fetch (activeHistCtrl): the r7 15s bound alone pinned a destroyed pane's closure until it fired — the same dead-not-inert ruling destroy applies to staleRetryTimer. Pinned. - stop(hard=True) no longer sets force_exit: it skipped the ASGI lifespan teardown and leaked the #885 daemon threads + sse_executor. The 2s graceful-shutdown timeout already force-closes open SSE, and the lifespan runs on both paths (docstring corrected; G6 re-verified — the orphan still manifests). - G6 pacing sized above the scenario's worst-case deadline sum (~200s vs ~95s) so the in-process bash cannot resolve the orphan mid-scenario and degrade the detector to a false READY. - Quality: the r6 reachability comments rewritten to the r7 truth (orphan REAL via hard crash; graceful-close-only synthesis); the bound's WIRING pinned (signal reaches getJSON; getJSON forwards init); _strip_comments deduped (4 inline copies); seq comment re-paired with its asserts; retry_fire window tail-anchored. Full harness (16/16 scenarios) + full suite (9724) green on the prior commit; 136 pins green here. |
||
|
|
412161aa4a |
fix(#894): live-set retirement policy — transport death is not retirement; bound the refetch await; G6 hard-kill detector
Review round 7 (2 major + 1 minor bug, 4 minor quality; security/perf zero). Both majors traced the r6 stratum: - The closeStreamTransport drain of liveToolCalls rested on a false re-announcement premise (verified: replay_ok yields only events past the cursor; the coord fresh/truncated replay yields connected/status/ pending-cards/verdicts, never tool_pending/tool_info). An emptied set fails OPEN — a mid-batch redial plus a slow seedless refetch wiped the live batch. Retirement policy re-derived at the decl: an id leaves on its RESULT, at the SETTLE edge, or with pane death; transport death is NOT a retirement event; a stale id fails CLOSED (skip, latch survives, settle heals). Site-anchored pins: the one drain inside the idle/error block, the delete inside tool_result, the adds inside tool_pending/tool_info, and closeStreamTransport's comment-stripped code may not touch the set. - G6's kill was not a kill: RecoveryServer.stop() gracefully closed workstreams, and session.cancel()'s bash path persisted 'Cancelled by user' BEFORE the reboot — the r6 'recovery synthesizes' ruling was observing the cancel path. stop(hard=True) (skip the close sweep + uvicorn force_exit: a crash does not drain SSE) leaves the orphan genuinely unresulted — REACHABILITY FLIPS: the poisoned-pane state is real, the live-set hardening is reachably load-bearing, and G6 is now its behavioral detector: hard kill -> reload paints the orphan (asserted PRESENT) -> the seedless rewind renders THROUGH the residue (rewind-for-retry truth: the user message stays), negative-controlled against the DOM-probe encoding (stamps orphan1, hist2). Discovered and tracked separately: a hard-crashed reborn node answers stale-high cursors with a silent fresh stream (no replay_truncated — the honest truncation signal rides gracefully-persisted state). - refetchHistory's await is now bounded (AbortController + 15s, the coordSend shape): an accepted-never-answered /history pinned refetchesInFlight and permanently disabled both heals. Pinned. Quality: seq-producer position pinned earlier; the stale section-7 comment corrected; retry/backstop guard windows comment-stripped (vacuous-by-comment-mention foreclosed); the shared G3/G4 double-fail prologue extracted into _coord_stick_latch. G3/G4/G6 re-run READY. |
||
|
|
cc776bfb2d |
fix(#894): event-driven live-tool-call set replaces the DOM liveness probe; G6 synthesis tripwire
Review round 6 (1 bug find + 5 quality; security/perf zero). The bug
finder out-traced r6-perf's dismissal: refetchHistory's own replay path
paints orphan batches (committed tool_calls, no persisted result) with
the same .conv-batch--running class the live path uses, and nothing
ever strips a dead orphan's class — so the r5 DOM-probed gate term
would let one orphan paint poison every seedless heal for the life of
the page (rewind/edit permanently dead; the seeded escape renders
through but REPAINTS the residue).
Reachability ruling (verified empirically): post-kill /history shows
the server synthesizes results for interrupted tool calls at recovery
('Cancelled by user. Outcome UNKNOWN'), so no persisted orphan exists
today and the poisoned state is unreachable — the client-side trace
was right, the server-side producer absent. Hardened regardless:
- liveToolCalls: an event-driven Set — fed ONLY by live tool_pending/
tool_info announces, retired by tool_result, drained at settle edges
and closeStreamTransport, and NEVER touched by any render (pinned:
refetchHistory's comment-stripped body may reference it exactly
once — the gate read). Liveness is read from the channel that
creates the hazard, never from DOM a render can forge.
- G6 coord-orphan-rewind: pins the SERVER invariant the client's
safety rests on — after a mid-bash node kill + reboot the batch must
render RESULTED (no --running residue) and the seedless rewind flow
must work end to end. Honestly scoped in its docstring: with
synthesis present a DOM-probe gate also passes, so the client
discipline is carried by the static pin set.
Quality batch: the seq stamp's producer position pinned (captured
before the await — the twin of the counter-bracket pin); two stale
G5 synthetic-idle comments corrected to the replay_ok-precise shape;
contract-test docstring item 7 restated to the enforced
universal-vs-seedless split; char-count pin windows replaced with
function-boundary slices (both test files); section 6 reuses _fn_slice.
Full coord family C + G1-G6 READY; 136 pins green.
|
||
|
|
fac2393967 |
fix(#894): re-derive the render gate on DOM-live signals; drop the busy conflation
Review round 5 step-back (fix-era critical): the r4 gate's busy term conflated 'a turn is executing' with 'this DOM holds live turn state'. _editAndResend flips busy BEFORE its POST and /rewind emits only clear_ui (no state_change), so the busy term skipped the truncation render the rewind exists to produce and appended the resent bubble onto the PRE-rewind transcript; /retry's regenerated turn likewise raced its own clear_ui refetch. The seam was re-derived once against the caller x state matrix; the gate reads DOM-live signals only, split by scope: - UNIVERSAL: dispatch seq (refetchSeq — overlapping fetches resolve last-DISPATCH-wins; an older snapshot landing late can neither double-render nor clear the latch over newer truth) and the content refs (skipping always beats stranding a ref; seeded callers null theirs before fetching, so it never blocks them). - SEEDLESS-ONLY (keyed on the seedCursor arg): the .conv-batch--running DOM marker for the tool phase (NOT activeBatch — that is the pending-APPROVAL tracker, set only for opts.pending batches; ruled at-site), coordSend's busySource === 'optimistic' flavor (the one busy that marks un-committed DOM), and stream-OPENness (CONNECTING keeps the handle with a frozen cursor and a pending replay; handle-existence was not liveness — also applied to the retry's fire guard). Seedless-only because the SEEDED resync renders over these deliberately: after a node dies mid-batch the --running class is dead residue no result will ever strip, and the resync's render IS the recovery — a universal term wedged the coord-restart scenario outright (family-run find; the r5 finders missed the seeded-path interaction). Backstop comment corrected (r5): fresh/truncated SSE replays DO carry a synthetic state_change (replay_ok does not) — a latched pane pays one refetch per reconnect, bounded by reconnect jitter/backoff and #884's server single-flight; heal-caused triggers remain structurally impossible. Caller fire-time ref guards demoted to the efficiency layer at-site. Contract test re-pins the gate: term presence in comment-stripped CODE, universal-vs-seedless placement, wipe between failure guard and latch-clear, no plain busy. The edit-resend commit gap under a second actor's clear_ui is accepted at-site (re-appears at settle heal). G4's honesty note names the tool-phase branch it behaviorally detects; G5 wording replay_ok-precise. Full coord family (C + G1-G5) green; 136 pins green; 5 static mutants + the G4 behavioral control caught. |
||
|
|
54631d3111 |
fix(#894): render-time gate at the refetch chokepoint; liveness-gate the retry; G4/G5 scenarios
Review round 4 (1 major + 3 minor bug, 1 major + 3 minor quality; bug-4≡q-2). Two correctness findings landed in one seam — the refetch-vs-live-state chokepoint — so the seam was redesigned once against its matrix (caller x stream-state-at-render x refs-at-render) instead of patched per-finding: - RENDER-TIME gate inside refetchHistory, post-await, pre-wipe: the await is a real window (queued sends drain at exactly the idle edges the backstop rides; another operator on a shared coordinator can send any time; hide/suspend can land mid-fetch), and only the chokepoint can see across it. Skip the wipe when a live turn exists (content refs — a wipe strands the bubble and loses the rest of the turn invisibly) or when a seedless render lost its idle/live-stream precondition (busy covers the tool phase the ref check can't see; a dead stream means rendering past the frozen cursor and double-rendering on the show-edge replay). Both requirements key on the seedCursor ARG — seeded callers own their reconnect flows and legitimately rebuild mid-turn. Skips leave the latch set; heals converge at the next organic settle. - The retry's fire guard gains evtSource (close-on-hide keeps the timer armed by design; a hidden firing must not fetch). The backstop needs no term — it runs inside SSE dispatch. - Pins: producer ORDER (inc < await < finally < dec), ref-guard pairs on both heal arms, the else-if exclusivity structure, the render-gate order and terms, the evtSource guard tail. Seven mutants, all caught. - Harness: __esOpens gate in G1-G3 (a pre-connect rewind drops its clear_ui into a channel nobody joined and false-fails the scenario); G4 coord-heal-midturn (a turn started under a held backstop fetch survives its resolution; hist==2 is the discriminating bit — noted honestly in the docstring); G5 coord-hidden-retry (hidden0 non-occurrence + organic-settle heal after show, per the accepted liveness-lag ruling — a quiet reconnect delivers no state_change edge). Negative controls: gate-stripped stamps hist1; guard-less stamps hidden1. Docstring gains the G-family catalog. |
||
|
|
85214f433f |
test(#894): pin the yield guards; narrow the clear_ui clearTimeout pin
Review round 1 (0 correctness/security/perf; 1 minor + 1 nit) + the suite's collateral: - The latch-contract test now pins !refetchesInFlight on BOTH heal paths (backstop arm + retry fire guard) — the yield guard is load-bearing (same-snapshot double-render stomp without it) and was previously deletable with every test green. Mutation-verified: the backstop pin fails against a guard-stripped coordinator.js. - test_app_js.py's clear_ui pin narrowed from all-clearTimeout to clearTimeout(truncatedResyncTimer): the invariant it protects is that clear_ui carries no path-local cancel of the TRUNCATED repair intent; #894's staleRetryTimer re-arm cancel is the staleness latch's own machinery, deliberately armed there. - _send_in_page's caller enumeration gains G3. |
||
|
|
f58fcd1b0a |
test(e2e): coordinator rewind-window scenario trio with storm assertion (#894)
G1/G2/G3 mirror interactive's E2/E3/E4 for the coordinator pane, adapted to its structure: the pane object exposes no messagesEl and no latch/quiesce fields (closure-private state), so every probe reads the public #coord-messages container and the runners drive the verdicts off the fault layer's authoritative counters — the in-flight edge is the history_requests bump (counted on arrival, before the delay hold), the closed phase is proven by the gated click's POST non-occurrence, and the latch-cleared proof is the reopen POST rather than a field read. - G1 coord-rewind-window: the busy||historyStale gate under a held-open clear_ui refetch (delay_history); posts stays 1. - G2 coord-rewind-failed-window: the failed-refetch aftermath — the latch survives the failed exit, the bounded 2s retry heals (its fetch held to defer the clear site), the healed render reopens the gate. - G3 coord-stale-backstop: double failure (fail_history(2)) exhausts clear_ui refetch + retry; a plain send's organic idle edge fires the TRANSPORT-FREE backstop. Storm assertion: events_requests delta is 0 across the whole heal; history delta exactly 1. Negative-control validated: pre-latch coordinator.js stamps COORDREWINDWIN-posts2-rows0 and COORDREWINDFAIL-closed2-heal0; a transport-touching backstop variant (loadHistoryThenReconnect) stamps COORDSTALEBACKSTOP-...-sse1 — each detector has observed its bug. The coord recovery page gains a scenario dispatch; the auto-send now runs only for coord-restart (the rewind scenarios seed server-side), verified against the existing coord-restart scenario. |
||
|
|
5386d598ef |
test(e2e): native-transport roster scenario (F2) + strict absence assertions (#881)
Scenario F splits into F1 (manual ?last_event_id= transport) and F2, the native-header sibling — the only behavioral coverage of two pure-browser semantics no Tier-1 harness can express: the auto-reconnect header echo, and id-less frames inheriting the connection's persisted lastEventId (the mechanism behind app.js's node_snapshot-branch clear). F2's phase C is the round-3 fix's discriminator: after the native heal, a forced manual reconnect must go CURSORLESS with no second truncated round (pre-fix: cursor1-trunc2), guarded by an idFrames precondition against the aggregate tick. Two harness seams earned by F2's first failures, both documented at site: a failed EventSource reconnect attempt is TERMINAL per WHATWG, so the restart must never expose a refused window — a SO_REUSEPORT placeholder binds before the old node stops and hands its backlog to the successor's uvicorn (make_listen_socket + RecoveryServer sock injection); and an SSE stream still open at stop() parked uvicorn's graceful drain indefinitely — timeout_graceful_shutdown=2 bounds it with the #885 lifespan teardown intact. Absence assertions tightened (round-4 review): ghost-gone now requires absence from BOTH the model and the rail via _roster_absent_ws — the negated AND-membership helper De Morganed into either-surface and could false-pass a rail-render regression. |
||
|
|
ea706bf4c0 |
test(e2e): roster-restart scenario proves the global boot-epoch heal (#881)
Scenario F drives the REAL node dashboard (/ + app.js) through a node restart on the global stream — no custom page; transport instrumentation is injected via CDP addScriptToEvaluateOnNewDocument, scoped to /events/global URLs so per-ws streams can't pollute the counters. Phase A is the negative control: live roster, live cursor, zero replay_truncated. Phase B: hide, force the CLOSED state (a closed EventSource never auto-retries, making the show edge's manual reconnect the only reconnect), restart the node re-opening only one of two workstreams, show. Asserted: cursor presented via ?last_event_id= and replay_truncated observed at the transport, the not-reopened workstream's ghost evicted from the roster model and rail (the dashboard table's membership refreshes on interaction by design — documented at _roster_has_ws), and the reborn node's global_events_requests counter proves the reconnect hit the real endpoint. The native header transport differs only in carriage and is pinned by the Tier-1 boot-epoch tests. |
||
|
|
af918c321c |
docs(interactive): correct #890 gate refs + rule the heal's fire-and-forget
Addresses the Copilot review of #895 (docs/comments only, no behavior change): - recovery_e2e.py / _sse_recovery_server.py: the mutating affordance gate is `busy || _historyStale`, not the superseded `busy || _replayQueue` quiesce gate the r3 latch replaced — corrected both docstrings (E2 now matches E3). - interactive.js cross-ws supersession: the branch drops the pending edit and releases busy but does NOT clear `_historyStale` (its sole clear site is replayHistory) — reworded so it no longer implies the latch is released. - interactive.js idle-edge backstop + bounded retry: documented that the fire-and-forget `_refetchHistory` (no `.catch`) is deliberate — no composer state to un-strand there, unlike the primary clear_ui caller, so a render throw stays loud (peer of the load path's `.finally`). |
||
|
|
74eedff1a8 |
test(e2e): fault-injection knobs + five recovery scenarios for the /history failure paths
RecoveryServer grows an in-process fault layer (pure-ASGI wrapper; the production app is untouched): fail_history(count) serves minimal 500s for the next N GET /history requests, delay_history(ms) holds responses to widen or hold open a refetch window, and per-route request counters (history_requests, rewind_requests) let scenarios assert backend state rather than scripted absence. Five scenarios on that layer, all stamping RECOVERY-READY/FAILED titles like their siblings: - fail-refetch: hide mid-turn -> restart -> failed first resync -> the stale transcript survives (no wipe, no empty-state) while the truncation record stays armed -> the connect-chokepoint retry heals (history_requests proves the re-fetch). The #890 acceptance contract, browser-observed end to end. - stale-ref-reload: mid-segment transport death -> turn completes during the outage -> failed unarmed same-ws reload -> the next turn renders in a FRESH bubble and the stale bubble's text is unchanged (regression test for the resumability-gated ref reset). - rewind-window: a second rewind clicked during a held clear_ui refetch window never reaches the server (rewind_requests == 1) and the transcript reflects one rewind (regression test for the busy-or-latch affordance gate, in-window arm). - rewind-failed-window: the failed-fetch AFTERMATH sibling — the refetch 500s, the staleness latch keeps the gate closed over the stale rows (rewind_requests stuck at 1, proven latch-not-quiesce via a settle-poll), the bounded turn-free retry heals (3 -> 1 user rows), and only then does the gate reopen (rewind_requests == 2). Negative-control validated: with the interactive.js fixes reverted, stale-ref-reload stamps fresh0-unchanged0 (the concatenation bug), rewind-window stamps posts2-rows0 (the in-window over-rewind), and rewind-failed-window stamps closed2-rows0 (the failed-exit over-rewind) — every detector observes its bug, then stamps READY again with the fixes restored. |
||
|
|
14c246a569 |
fix(coord): port the truncated-recovery design from interactive (#882)
replay_truncated is now a dead-stream signal, mirroring the converged interactive.js machinery: - loadHistoryThenReconnect: tear the transport down first, drop the live cursor, refetch /history with cursor adoption, reconnect in .finally. The old in-place refetch discarded the /history cursor while /history trims the trailing in-flight turn whenever it returns one — a mid-run truncation wiped the executing turn with no redelivery and later tool results orphaned into top-level bubbles. Both consumption sites (immediate branch and idle-edge deferred consumer) route through it. Dropping the cursor before the fetch is load-bearing, not just parity: a post-restart heal on an idle ws gets no /history cursor, and re-presenting the frozen pre-restart cursor against the reseeded empty ring draws replay_truncated forever — an envelope→resync loop that parks the pane in degraded cooldown cycles (caught by the new browser-level scenario, invisible to source-pattern tests). - truncatedFromCursor: the truncation-time cursor, recorded keep-oldest at the envelope and cleared only by a successful full render; the connect chokepoint presents it over the live cursor so every manual reconnect re-draws the envelope and the repair survives any teardown interleaving (hide/show, degraded cooldown, CLOSED retry, failed fetch). - churn ladder: truncated resyncs feed the same rolling window as overflow closes via the extracted recordChurnAndMaybeTrip(); a trip skips the resync (the degraded wake re-arms via the chokepoint). - herd jitter: resyncs start behind a 0..TRUNCATED_RESYNC_JITTER_MS spread; one pending resync at a time; the fire path nulls its handle before loading; closeStreamTransport owns cancellation. - sidebar refresh: while a truncation gap is on record the gap machinery owns recovery outright — the envelope refreshes once per NEW gap, one heal-time refresh covers the retry window, and onopen's no-cursor / long-gap arm stands down — so a failed-resync retry loop cannot stampede /children + /tasks un-jittered once per reconnect through either path. - a failed /history refetch no longer blanks the pane (wipe + tracking resets sit below the !hist guard); a successful full render supersedes ALL pending repair intent in one place (gap record, deferred latch, pending resync timer) so a heal can never strand a phantom resync. Behavioral coverage: scripts/recovery_e2e.py gains --scenario coord-restart — the REAL coordinator pane (chrome, cookie auth, EventSource, connect chokepoint, resync, churn limiter) mounted against the interactive recovery node (/coord-static + /coord-recovery), driven through hide → node restart → show over CDP, asserting the envelope is drawn, the hidden-window turns heal, the stream re-opens, and the pane converges. Revised the two tests that pinned the in-place shape, added the coordinator mirror of interactive's fresh-connect/churn-limit pins (keep-oldest record, chokepoint consult, clear-on-render, shared churn step, trip-skip, jitter scheduler, cancellation site, cursor drop, per-gap sidebar dedup). |
||
|
|
7a43d37f8b |
fix(sse): capture the reconnect cursor from the MessageEvent, not the EventSource
All three clients read lastEventId off the EventSource object, but per WHATWG the property lives on the MessageEvent — EventSource exposes only url/withCredentials/readyState. The object-form reads were dead conditionals in every real browser: the cursor never tracked live traffic, every MANUAL reconnect (close-on-hide show edge, degraded- ladder retry, recover beat) opened cursorless as a fresh connect, and a fresh connect does not refetch history — so turns committed while a tab was hidden silently never painted. This is the cleanest mechanism behind the 'turn disappeared, never healed' field reports, and it gated the branch's recovery fixes: without a presented cursor, the empty-ring truncated honesty could never fire for hidden-tab restarts and the truncation record captured null. Native auto-reconnects were unaffected (the browser sends its internal Last-Event-ID header), which is why the bug stayed invisible: transient blips healed, deliberate closes lost. Capture e.lastEventId in each onmessage instead, guarded != null and !== "" — no-id frames carry the empty string and "0" is a valid id (the error-surface snap_seq can be 0 on a brand-new workstream). The coordinator's counter-reset detector, which compared against the same dead property and so never fired, now works as documented. Found by the recovery harness's first real-browser run: source-pattern tests pin a wrong-object property read as happily as a right one, so a tripwire test now forbids the object form by name across all three clients, and Tier-2 scenario B is upgraded to hide MID-turn and require the browser-observed replay_truncated envelope plus the healed gap (RECOVERY-READY-RESTART-rows1-trunc1 demonstrated; was trunc0). |
||
|
|
43561c9b08 |
test(sse): end-to-end recovery harness (server-contract + browser livepass)
Tier 1 (tests/test_sse_recovery_e2e.py, opt-in e2e_recovery marker): six scenarios against a real interactive server with a scripted provider and ephemeral DBs — storm batching without loss, slow-consumer overflow with lossless ring replay, mid-run truncation with cursor-adoption rebuild, restart truncated-honesty (exact lost_count; no-loss variant replay_ok), failed-resync retry via the truncation record, and sub-agent storm attribution. BrowserlikeSSEClient (tests/_sse_recovery_helpers.py) implements the browser cursor contract; RecoveryServer (tests/_sse_recovery_server.py) boots the real app per test. Tier 2 (scripts/recovery_e2e.py): the livepass idiom against a REAL node — boots the real InteractivePane over real EventSource/authFetch, with a dependency-free CDP runner driving the storm and hide-restart-show scenarios; document.title stamps verdicts so a broken state cannot pass silently. Events are produced by the real session engine through the provider boundary — no synthetic frames; teardown leaves no leaked threads; the default suite keeps these deselected. |
||
|
|
3a28dc2f16 |
fix(providers): review round 5 — same-id fragment merge, post-finish blip tolerance, chat finish shim
Correctness: - ToolCallSlotter's reannounce split is gated to ID-LESS deltas: id equality proves the same call, so compat servers that repeat the id+name header on every argument fragment merge back into one call with valid JSON (round 4's ungated heuristic split them into duplicate half-JSON calls — execution-confirmed by the review). The residual id-less repeat-name-per-fragment shape is documented as inherently ambiguous; ids are the only disambiguator. - drain_stream keeps a completed result when the transport blips AFTER the finish reason (trailing usage chunk / citation footer window): the generation is in hand, so forfeit the trailing metadata instead of discarding a fully-delivered verdict or re-paying a compaction. - The chat iterator shims finish_reason="stop" when a stream ends CLEANLY after delivering content or tool calls — the deleted non-streaming `or "stop"` default for lax finish-reason-less servers, now safe to restore because abrupt deaths surface as httpx.TransportError (round 4) rather than clean exhaustion. This supersedes the round-3 keep-the-gate ruling: the httpx catch changed the calculus, and the Anthropic/Responses lanes already got their marker-based shims. Empty/reasoning-only streams still fail the complete-or-error gate. Two streaming tests gained the shim chunk. Dispositions held: o1-era stream-rejecting models (third re-report) stay a release-note remediation per the earlier ruling. Cleanup: the two Responses terminal branches collapse into one path (status derived from the event type when the payload is missing — also fixes the end-of-stream debug log reporting finish_reason=None for completed lax streams); the annotations walk is one shared helper (the two copies had already diverged on None-content guarding); _raise_responses_failure is annotated NoReturn; scripts/livepass.py drops the phantom supports_streaming key; test_model_registry's capture helpers ride scripted_chat_client; _openai_stream_chunk points at its fake_chat_stream shape-twin for future consolidation. |
||
|
|
32c76499fa |
fix(mcp): harden obo mint path and admin lifecycle after review
Mint engine: guard the credential-rotation persist so a storage blip cannot escape the classified-result contract mid-mint (and cannot brick the user's other obo servers on strict-rotation IdPs); stop borrowing the login flow's httpx client across event loops — mints use a transient per-request client (obo_http_client remains as a test seam); retry OIDC discovery at runtime (cooldown-gated, single-flight) so a node that booted during an IdP outage can mint again without a restart; key the under-lock force-refresh reuse gate on created, which delete+create makes the mint time (obo rows never set last_refreshed, so the copied oauth_user gate never fired and serialized waiters each re-redeemed). Cross-node consent badges: the cleared-pairs set becomes a TTL map with bounded growth, so a badge written by another node after this node's last clear self-heals within one TTL window instead of surviving until a restart. Admin lifecycle: purge the mint cache when oauth_scopes changes on an obo row (an rfc8693 privilege reduction now applies immediately, like audience changes); normalize no-op scope/audience re-sends out of updates — the admin form re-submits pre-filled fields on every save, which both re-triggered purges and made entra-profile rows with legacy scopes un-editable; make flip-into-obo scope handling grant-profile aware (entra clears the carry-over, rfc8693 honors the request); clear obo-era audience/scopes when flipping back to oauth_user (the IdP-side app identifier is not a resource indicator); mirror the same column policy in the create handler. Revocation honesty: hide obo mint-cache rows from the user connections list and refuse the per-server disconnect with 409 — deleting the row returned 204, audited token_revoked, and then session-start priming silently re-minted from the surviving captured credential. Console form: keep the audience-from-URL autofill off for sign-in passthrough (the audience there is an IdP application identifier, and the prefilled URL passed every validation layer then failed every mint); clear the autofill artifact when switching modes; omit unchanged scopes from submissions. Dispatchers: route tool/resource/prompt through one shared lookup-error mapping and an auth-model-aware 401-exhausted detail (obo users are no longer pointed at a consent flow that does not exist). The consent-url audit count drops 13 → 7: the three per-dispatcher mapping copies collapsed into _pool_lookup_error. Priming: skip all obo servers for users with no captured credential via one existence SELECT (previously three reads per server per session). Also: USER_SCOPED_AUTH_TYPES now lives in storage._protocol so the backend SQL predicates share the application layer's set; docs describe the actual purge-on-transition behavior (the orphan-and-reactivate claims were wrong); the entra e2e setup script no longer aborts silently under set -e with suppressed stderr. |
||
|
|
44e9d46e40 |
fix(mcp): address pre-push review — obo scope/audience/priming defects
Frontend↔backend interaction bugs the backend-only rounds couldn't see: - flip oauth_user->oauth_obo: the admin form re-submits the pre-filled oauth_user scopes, so the flip-clear (gated on 'oauth_scopes' not in body) was skipped -> rfc8693 mints broke permanently. Clear now compares to the existing value, robust to the re-send. - entra edit-lockout: update validated the MERGED scopes, so a pre-existing scoped obo row under the entra profile became un-editable (every PUT 400'd). Reject only when the request actually SETS scopes. - flush-cache button never rendered: consented_users_count is now populated for oauth_obo rows too, not just oauth_user. Mint engine + priming: - audience guard: a cached token minted for a since-narrowed audience is no longer served (extracted _is_fresh_obo_cache_row, used pre/post-lock, checks refresh-less + audience-match + fresh). _persist_obo_cache_row now delete+creates so the row's audience column tracks the mint (a plain update kept the stale audience -> re-mint loop). - obo session priming passes revoke_ambiguous_escalation=False (new param threaded through get_obo_...), so an IdP wobble during a bulk prime can't escalate-revoke obo cache rows cluster-wide. Cross-node + lifecycle: - pending-consent success-clear now clears once-per-failure-cycle via a _pending_consent_cleared set (was gated on 'we wrote it' -> never fired cross-node/after-restart -> stale badge). Still no per-call SQL. - identity-unlink cache purge: per-server try/except so one failure doesn't leave other servers' bearers un-purged. - entra ignored-scopes: warn once per audience (was per-mint flood -> downgraded to debug -> no signal on a profile switch). - entra_setup.sh writes single-quoted .env values (secret may contain $). +6 regression tests. 1892 mcp/oidc/console tests green; mypy clean. Refs #551. |
||
|
|
3e88c54751 |
test(mcp): check in oauth_obo e2e harnesses under scripts/obo-e2e
Manual (non-CI) harnesses that exercise the real oauth_obo mint path against a live IdP, kept for future validation of the feature: - entra_e2e.py: real Entra tenant, one interactive sign-in, drives get_obo_access_token_classified -> _obo_mint_entra (E1-E7) - keycloak_e2e.py + .sh: ephemeral Keycloak, fully headless, drives the rfc8693 leg (refresh grant -> token exchange) - entra_spike.py: raw-OAuth wire probe (pre-implementation reference) - entra_setup.sh: creates the Entra spike app registrations - .env.example template; real creds stay in a gitignored .env Both legs pass E1-E7 (mint + aud, cache hit, single-credential->multi- audience, rotation write-back, force_refresh, unconsented->credential survives, flush->re-mint). Not wired into CI. Refs #551. |
||
|
|
41e7d5b7d7 |
feat(livepass): add long-session perf harness (--perf)
New /perf/livepass.html mounts the real InteractivePane at production scroll geometry and drives production-shaped SSE events through handleEvent/replayHistory in real time (no virtual-time budget, no forced reduced-motion — both corrupt the measurement), reporting: replayHistory wall time at N messages, per-turn live-storm cost on top of that transcript, tool_output_chunk throughput, busy/idle churn, heap + node + agent-card counts across repeated replay cycles (the detached-DOM leak probe), and longtask counts. The --perf runner builds, serves, and launches headless Chrome with --js-flags=--expose-gc and --enable-precise-memory-info so heap numbers are real floors; the page POSTs its JSON report to /perf/report. Reports carry a per-attempt run token the runner validates, so a straggler POST from a killed prior attempt cannot be misattributed to the next size, and the wait loop polls the Chrome process so a sandbox startup failure bails to the --no-sandbox fallback in seconds instead of burning the full timeout. |
||
|
|
8dd356b7e6 |
fix(task-agent): keep sub-tool steps nested + preserve denial reasons
Address the Copilot review on #732 plus a task-agent sub-tool nesting race surfaced alongside it. Nesting (web UI): - A sub-tool step whose task_agent row hasn't painted yet (the 4-wide tool pool's ordering window) buffers and nests when the row lands, instead of escaping to a top-level row that looks main-harness-issued. - A row that never paints (id-correlation mismatch / aborted agent) escapes its buffered steps back to a visible top-level paint after a grace window, so steps are never buffered invisibly or leaked. - The nested card survives the parent row's pending->resolved rebuild; a call_id reused across turns builds a fresh card rather than stealing the prior agent's steps. - tool_info routes through the same nesting path (no duplicate top-level row); a namespaced sub-tool result no longer grafts onto an unrelated top-level row. Denial reasons (backend): - Preserve the specific denial reason a gate already stamped (operator feedback, or the matched policy pattern; web and CLI contracts) instead of clobbering it with a flat "Denied by user" -- in both the sub-agent and the main tool loop. Verified with the livepass task_agent harness (race + orphan-escape scenarios, headless) and unit tests. |
||
|
|
77cb76c006 |
feat(task-agent): recall sub-trajectory + per-agent read isolation
Final chunk of the task_agent modernization: rebuild a finished task
agent's card from /history (reload / reopen while the workstream is in
memory) and isolate each sub-agent's file-read tracking.
Recall: _project_agent_steps projects a sub-agent's trajectory into step
items (FIFO-per-call_id pairing via _iter_agent_tool_results, shared with
_cancel_ledger; output/arguments/count capped); _stash_agent_trajectory
keeps them on the UI in an LRU-bounded store; make_history_handler
attaches them as agent_steps to each task_agent tool_call, and
replayHistory/_replayAgentCard rebuild the collapsed card. In-memory only
(durable persistence deferred); a cold/evicted entry renders the flat
parent row ("not retained"), never a fabricated 0-step card.
Read isolation: _read_files (the blind-overwrite guard's memory) is now
per-sub-agent via the _active_read_files contextvar -- _exec_task copies
the parent's set on spawn and merges the agent's reads back on
completion, so a sibling in the 4-wide pool can't suppress another
agent's guard.
Also: _exec_task now self-reports the task_agent tool_result on every
path (the parent loop only reports error/denied results centrally) --
without it the live card never completed and a failed task recorded
is_error=False in the canonical trajectory. is_error flows from
_tool_error_flags to the recalled step; on_info suppression is per-thread
so a parallel sibling tool's progress isn't dropped.
|
||
|
|
ca7958329a |
feat(task-agent): nest sub-tool steps in an expandable card
Route a task agent's sub-tool events (tool_pending / approve_request, tagged with parent_call_id) into a collapsible card under the task_agent row, replacing the blue on_info turn-legs. - conversation.js / interactive.js: buildAgentCardBody + _routeAgentItems / _ensureAgentCard nest steps by parent_call_id. Collapsed by default (a task agent can run 100+ steps and the parent fans out many in parallel); the label carries the live count + state. Auto-expand when a nested approval is pending so the blocking prompt can't hide behind the toggle. - session.py / session_ui_base.py: on_agent_step paints auto-tool step rows; namespace child call_ids by parent so the 4-wide task pool can't collide on local sequential ids (call_0); suppress sub-agent on_info on the web pane (no call_id to nest by — the card carries steps + result). - cli.py: on_agent_step prints a dim step leg (no card on the CLI, which keeps its on_info). - livepass.py: task-agent card harness driving the real InteractivePane. |
||
|
|
9ad447ca33 |
fix(attachments): design-review polish for preview chips/pills
Two-reviewer + sanity pass over the attachment previews: - composer audio chip is icon+name+size only; the native <audio> player renders on the sent message, not the staging chip (too heavy at chip scale) - cap sent-message pills (+ in-pill audio/snippet) so they no longer overflow the bubble at narrow widths; player and snippet drop to their own row - clamp the chip filename in shared chat.css so long names ellipsize instead of wrapping (console main + coordinator previously left it unclamped) - merge the duplicated .composer-chip rule; drop unused kind-modifier classes and inert vertical-align / inline-block declarations - fix undefined var(--bg-base) -> var(--bg-surface) thumbnail backing - label the <audio> control (aria-label) and drop the decorative snippet from the a11y tree scripts/livepass.py: add an attachments harness that drives the real createAttachmentController + Pane.addUserMessage so these surfaces render headlessly for review. |
||
|
|
44c11efb53 |
feat(ui): split-view follow-ups — per-pane ✕, child-opens-beside, close-on-ws_closed
Four refinements from first live use: - Per-pane ✕ chip, top-right of every visible pane. Split mode: hide that cell (closeCell — the tab stays, the sibling absorbs the space). Single-pane: close the pane outright (withheld from the unclosable Dashboard). The click decides at click time; the label tracks the mode. Manager-injected into the pane section — content untouched. - Coordinator child links open BESIDE the coordinator (openPaneBeside: split right of the focused cell, seeded with the child pane) instead of replacing it — the parent stays on screen. Degrades to the plain focused-cell swap when the split is denied (cap / narrow viewport). splitFocused() gained an optional explicit-fill parameter for this. - Tier-1 ws_closed now CLOSES the open interactive pane (tab gone, a split cell collapses) — the coordinator-closes-its-child flow, matching the standalone's pane-auto-close. The dead-banner lane stays for streams that die without a ws_closed (node crash/network), where the session may still be revivable. - Paint bug: the focused-cell ring was an inset box-shadow on the section, which paints in the element's own background layer — UNDER opaque children touching the edges, so the status bar / composer strip occluded it. The ring now rides a click-transparent ::after overlay above pane content; the 2px top bar sits above the ring line. The livepass shell surface's demo panes grew a .ws-status-bar footer so the occlusion bug class stays visible to future passes. |
||
|
|
f8f7152d63 |
feat(ui): split view returns to the L-shell — PaneManager layout tree
Revives the split-pane feature retired with ui/static (step 6), rebuilt on PaneManager: an optional binary layout tree (null = the one-pane-per- tab behaviour, unchanged) renders visible panes as %-inset cells — no reparenting, so live stream DOM, scroll state and media survive layout changes. Tabs stay global: the active tab is the focused cell, a backgrounded tab swaps into it, clicking inside a visible pane focuses its cell, .shown marks visible-unfocused tabs. Separators resize by pointer-capture drag and arrow keys (role=separator + aria-value*); the tree persists in the working-set blob and rehydrate prunes leaves whose pane did not restore. Limits: 6 cells, 200x150 cell minimums, denials toast the manager's reason. Affordance: Split right / Split down / Unsplit buttons in the tab-bar tail replace the redundant [+] (the permanent Dashboard tab is the launcher) — deliberately no contextmenu override this time. The dead TS_APP.focusLauncher seam goes with it. Measured chrome: the focused cell wears a 2px accent top bar (no thin tinted ring clears 3:1 in both themes) plus a 55%-mix inset ring; separators rest at --ink-4 with solid-accent hover/drag/focus; .shown tabs carry an accent underline; the tail cluster is fenced and lifted to --ink-3. scripts/livepass.py grows a third surface: shell/livepass.html boots the real shell.js + pane.js and drives ?split=right|down|three|none (+ &theme=light), stamping SPLIT-READY-<cells> / SPLIT-FAILED-<reason>. |
||
|
|
b3c3acc5d1 |
fix(scripts): livepass dialog-tier riders + loud open-failure + dock-displacement probe
Designer-review round on the scroll fix found the harness's dialog-tier gate silently green: confirm-dialog (and install/coord-delete) markup lives OUTSIDE #admin-layout, so the fragment extraction never embedded it — ?open=confirm threw at showConfirmModal and screenshot a normal, dialog-less page. build() now injects every hatch dialog the fragment does not already contain, and a driven ?open= that ends with no open dialog stamps OPEN-FAILED-<state> into the title instead of passing. Also upstreams the review's probe states: &focuslast=1 focuses the last shelf-body control (the displaced-dock regression class — only .sh-body may scroll; head/foot must stay pinned) and &scrolled=bottom shows the 24px scroll tail. |
||
|
|
6bb47cad2d |
fix(console): manage-pane scroll regressions — interior scroller, clip the hatch-host, anchor hidden inputs
The L-shell height-pins the admin chain and .hatch-host clipped it, so no box below the pane could scroll: tabs taller than the pane were cut dead, and the overflow:hidden host doubled as a hidden scroll container that focus-into-view silently scrolled — visually-hidden toggle/cap/radio inputs escape the .sh-body scroller (abspos under an unpositioned label), overhang the shelf, and a Tab keypress shoved the docked hatch off its head with no scrollbar to recover by. - .admin-content becomes the manage pane's interior scroller (the #main precedent); switchAdminTab resets it on real tab changes only - .hatch-host: overflow hidden -> clip — paint clipping without a scroll container, so focus can never displace the dock - position:relative anchors on the three hidden-input labels (toggle-switch, .sh-body .cap, segmented-option); .settings-toggle already carried one - livepass: the console harness wraps the fragment in the REAL L-shell chain (its bespoke height pin is exactly how this bug class stayed invisible to the screenshot gates) and gains a ?tall=1/&scrolled=1 scroll state |
||
|
|
b991dc2e83 |
fix: CI lint pin + copilot-thread hardening
The wiring-lint test used percent-formatted regex patterns — UP031 under the ruff 0.15.6 the CI pre-commit pins (the older venv binary let it through; checked repo-wide against the exact pin now). f-strings with doubled quantifier braces, plus one over-long fixture line in the livepass generator split. Copilot threads, both validated rather than blindly applied: - closeShelf's scrim-ownership scan now skips detached entries. The thread's throw scenario doesn't occur on the real removal path (a pane close detaches an ANCESTOR, so _hostOf still resolves inside the detached subtree) — but a detached shelf is genuinely not a scrim owner, so the guard is correct beyond being defensive. - toast.js drops the popover attribute via removeAttribute instead of the null assignment. The claim that null leaves popover="null" is refuted — the IDL is nullable and null removes the attribute (verified empirically in headless Chrome) — but removeAttribute reads correct without requiring that spec knowledge. |
||
|
|
9102f858a4 |
chore(scripts): commit the livepass harness generator
The livepass harness — the headless-render rig that verified every converted modal surface and click-drives submits (the dead-Save bug class) — lived as ad hoc files in /tmp and got wiped once already. The durable piece is the GENERATOR: the markup is extracted fresh from the index files at build time (a committed snapshot would drift) and the stylesheets/scripts are symlinked so edits are live on refresh. scripts/livepass.py builds both harnesses into /tmp/livepass/ (ui: all six dialog-tier surfaces incl. the real cards.js batch controller drive; console: the admin-pane fragment hosting the shelves, with schedule/model/policy/confirm/token fixtures and the model-save click drive that flips document.title to PUT-OK-<n>). --serve included; the chrome screenshot incantation and the ?open= registry are in the module docstring. Governance fixtures (roles/HR/OGP/memory/skill) are documented seams for when those surfaces need driving. |
||
|
|
80d201a67b |
chore(ui): L-shell step 6 (5e.2f) — retire dead split-pane CSS from ui/static
Removes the structurally-dead CSS the L-shell superseded — 794 lines: the tab bar (.ws-tab*, #tab-bar, #split-btn, .ws-tab-dropdown-*), the binary split-pane machinery (.split-*, #split-root, .pane-ctx-*), the old approval/verdict card (.ts-approval-*, .verdict-*, the judge spinner), the fixed .dashboard-overlay, and the retired appbar/settings-overlay bits — plus their [data-theme=light] overrides. The standalone now styles its conversation from the shared sheets (chat/conversation/interactive.css); the dashboard table + saved list were always shared (base/cards.css). Method: a conservative token-diff — a rule is dropped only when EVERY selector's class/id token is absent (word-boundary, comments stripped) from the standalone runtime (index.html + every JS it loads, incl. the vendored hljs/katex/mermaid so their runtime-built classes aren't mistaken for dead). Mixed/any-live rules are kept verbatim (no reformatting), so ~50 dead-but-harmless rules that share a generic token like `.active` survive — safe over-keep. The markdown / syntax / math / diagram theme lives ONLY in this style.css (the shared sheets don't carry it), so the hljs/katex/mermaid families are protected from removal. Also fixes four dead tab-DOM pokes in app.js (editWorkstreamTitle / confirmDeleteWorkstream read the title from the workstreams roster now, not the retired .ws-tab .tab-name; the cancel handlers drop the gone .tab-chevron focus restore). Verified: braces balanced (370/370), the headless harness still builds clean (errs:[]), git diff confirms zero live dashboard/render rules removed, and the css_specificity_audit (manifest synced to the standalone's new sheet set) shows the SAME 10 pre-existing findings before/after — zero new cascade flips (removing a rule for a non-existent selector can't change any live element's cascade). 121 JS guards green, ruff/mypy clean. |
||
|
|
215f7506ba |
feat(rerank): wire endpoint-backed reranking into BM25 retrieval surfaces
Reuse the shipped Cohere/Jina rerank client as an optional post-process on the BM25 surfaces (tool search, skill search, memory composition) via one seam: BM25Index gains an injected reranker + a two-stage search (BM25 recall top-50 -> rerank -> top-k). No new storage. Gated on a configured endpoint plus tools.rerank_bm25 (default on, matching rerank_web_search). tools.rerank_bm25_threshold (default 0.0 = off) is a relevance FLOOR for proactive memory surfacing: BM25 always returns something, so without a floor every-turn memory injection spends tokens on the top-k of whatever lexically matched; the reranker score is what makes a meaningful "inject nothing" gate possible. Two reranker modes (BM25Index rerank_filters): - REORDER (reactive tool/skill search): the reranker must never drop results -> fall back to BM25 order on empty, backfill omitted pool items, so a misbehaving endpoint can't silently lose tools. - FILTER (memory, rerank_filters = threshold > 0): a clean empty/short result is honoured (inject nothing) -- a deliberate divergence from web_search._rerank_results. Parse/endpoint failure is a discrete branch from the floor: an empty result for non-empty input means an unparseable response (a conforming reranker scores every doc), so the closure raises RerankError and BM25Index falls back to BM25 order in BOTH modes -- the floor only acts on valid scores. Also: cap the rerank client timeout at 15s (the per-turn memory path can't afford tools.timeout's 120s default); move the Reranker alias to rerank.py (shared, no import cycle); document the endpoint egress in the rerank_bm25 help, the admin Reranker-role description, and docs/tools.md; add scripts/bench_bm25_rerank.py (manual, needs a live endpoint) to measure precision@k/MRR lift and recommend a threshold default. Negative-tested: reorder fallback-on-empty and omitted-item backfill, filter-mode honor-empty, singleton-still-floored, the parse-fail RerankError raise, the >= floor boundary, and pool-position-to-doc-index mapping -- each guard reverted to confirm its test fails, then restored. |
||
|
|
a8eec0d740 |
fix(vendor): widen update-vendored-js sweep to catch shared_static/ + .py
The shared_static exclude in scripts/update-vendored-js.sh was meant to skip self-references inside vendored libraries, but it also hid shared_static/renderer.js — which loads the vendored libs and pinned mermaid-11.14.0 across every renovate bump since #426. Tests under tests/test_web_helpers.py were similarly invisible because the include list omitted *.py. Replace the broad shared_static exclude with the specific old-versioned vendor directory (about to be rm -rf'd next anyway), and add *.py to the include list. Bump renderer.js to mermaid-11.15.0 to repair the live 404, and refresh the test fixtures to current vendor versions so they stop drifting. |
||
|
|
4b5edce8c5 |
fix(css): two cascade-flip bugs found by specificity audit (#433)
* fix(css): two cascade-flip bugs found by specificity audit PR #431 stripped [data-design="v1"] from ~400 rules, dropping each by a specificity tier; two cascade flips (#header outranking .appbar, #header h1 outranking .appbar-title) were caught visually during that PR's review and fixed by renaming id="header" → id="ui-header" on the per-node UI page. This is the audit follow-up; it found two more: - textarea.skill-content-area (was .skill-content-area) — bumped to (0,1,1) so the rule ties with `.admin-modal textarea` (0,1,1) and wins on source order. Without the bump, min-height: 220px was clobbered to 40px by the modal default and the spec-content textarea rendered short. The three !important markers (font-family/size/line-height) are now redundant against the modal's font: inherit shorthand and are dropped. - h3.skill-spec-heading — removed `font-size: inherit;`. The author wrote it to "reset UA defaults" but it locked font-size to the parent's (~14-16px) at (0,1,1), silently overriding `.skill-spec-heading`'s 10px at (0,1,0). The bare class already beats UA `h3` on specificity (class > tag), so no font-size reset was needed; the `margin-block: 0` line stays because the bare class's `margin: 14px 0 6px` shorthand may not reset the UA's logical margin-block-start/end on every engine. Adds scripts/css_specificity_audit.py — the audit tool. It parses every CSS file referenced from the project's three HTML entry points, computes selector specificity (incl. :not/:is/:has math, attribute selectors, and !important), and flags every place an unscoped legacy rule could outrank a bare-class designed primitive. Honours per-page stylesheet manifests, state-pseudo subset gating (a `:hover` rule overriding a resting-state base rule is intentional, not a flip), and shorthand→longhand expansion for font/padding/margin/border/background. Triage of remaining findings (26 id-tier in default mode, 74 total at --all-tiers) confirmed all are intentional designer overrides — id-scoped buttons, BEM modifier classes, contextual ancestor selectors, last-child margin reset, [hidden] toggle. * fix(css-audit): correct two cascade-resolution bugs flagged by Copilot 1. _parse_declarations dict insertion order didn't update on overwrite, so a sequence like `font-size: 13px; font: inherit; font-size: 12px;` would iterate as (font-size=12px, font=inherit) and the shorthand expansion then clobbered font-size back to `inherit` — wrong. Delete-then-insert on overwrite so the last occurrence lands at the dict's tail and the shorthand expansion sees the real source order. 2. The cascade-winner tie-break used `rule.line_no` only, ignoring the stylesheet load order. A rule at line 1000 of `base.css` looked "later" than a rule at line 50 of `style.css`, even though the page loads `base.css` BEFORE `style.css`. Sort by `(file_index, line_no)` keyed off the element's per-page stylesheet manifest instead. |
||
|
|
7968f1b361 |
feat: auto-invalidate JWT and static assets on version upgrade (#307)
* feat: auto-invalidate JWT and static assets on version upgrade
Add a `ver` claim (major.minor) to user-facing JWTs so tokens from
previous versions are rejected after upgrade, triggering re-login.
Service tokens are excluded for rolling-deployment safety. Tokens
without a `ver` claim (pre-upgrade) are accepted for backward compat.
Inject `?v={__version__}` query strings into static asset URLs at
startup so browsers fetch fresh JS/CSS after any release. Vendored
libraries (KaTeX, Highlight.js, etc.) are skipped since they already
carry version numbers in directory paths. HTML responses now include
`Cache-Control: no-cache` to ensure browsers always revalidate.
Frontend detects upgrade-specific 401s and shows a contextual subtitle
("The server was updated — please sign in again"), then performs a full
page reload after re-auth to load the new versioned assets.
* refactor: address PR review — public API name, single decode, idempotent regex
Rename _version_slot() → jwt_version_slot() to make the cross-module
import explicit rather than relying on a private name.
Move version gating from validate_jwt() into check_request() via a new
AuthResult.token_version field. This eliminates the double JWT decode
that occurred on version-mismatch detection — the token is now decoded
once and the version compared afterward.
Guard version_html() regex against double-apply by excluding URLs that
already contain a query string ([^"?]+ instead of [^"]+).
* feat: structured version_mismatch code, ETag, cross-tab auth sync
Add structured "code": "version_mismatch" field to the 401 response
so the frontend detects upgrade-triggered re-auth without string
matching on the error message.
Add ETag headers to HTML index responses (server, console, and proxied
node UI). Combined with Cache-Control: no-cache, browsers send
conditional GETs and receive 304 between upgrades, saving bandwidth.
Add BroadcastChannel-based cross-tab auth sync so logging in on one
tab dismisses the login modal on all other tabs (and vice-versa for
logout).
Add a reminder to the vendored JS update script about the
version_html() regex lookahead.
* fix: remove unused import in test_web_helpers
|
||
|
|
db0baefeb2 |
feat: render rich media embeds for MCP tool results (#292)
* feat: render rich media embeds for MCP tool results Detect structured media JSON (stream_url, results, sessions) in MCP tool output and render interactive cards instead of plain text. Web UI: media cards with thumbnail, title, metadata, and click-to-play video/audio. HLS via lazy-loaded hls.js with direct-stream preference. Collapsed raw JSON (API keys redacted) for inspection. Discord: rich embeds with proxied thumbnail images (fetched by the bot since Discord CDN cannot reach private media servers). Search results as numbered lists, session state as "Now Playing" cards. Stream URLs never exposed in embeds — web_url used for safe clickable links. CI: vendor hls.js 1.6.15 with renovate tracking and update script. * fix: address PR #292 review — SSRF guards, streaming fetch, tests - URL validation: reject non-http(s) schemes and userinfo in thumbnail URLs. Private IPs intentionally allowed (media servers are on LAN). - Streaming fetch: use http.stream() with aiter_bytes() and a running byte count to enforce the 2MB cap without buffering the full response. Validate content-type is image/* before downloading. - Resilience: wrap try_build_media_embed in try/except in bot.py so a media embed failure falls through to the code-block path. - LICENSE: download hls.js LICENSE from npm on update instead of only copying from old dir. - Tests: add 19 new tests — try_parse_media (8 cases), _is_safe_image_url (7 cases), embed builders (4 cases including stream_url exclusion and string season/episode safety). * chore: add LICENSE file for vendored hls.js * fix: remove ANSI escape codes from tool preview fields Preview text (tool args, URLs, queries) was wrapped in DIM/RESET ANSI codes at the source in session.py, which leaked into SSE events and rendered as raw escape sequences in Discord and the web UI. Move ANSI styling to the CLI consumer (cli.py) where it belongs. Also escape markdown in Discord tool name titles to prevent __ from being interpreted as underline formatting. * fix: drop [MCP: server] prefix from tool descriptions The prefix made MCP tools look second-class compared to builtins, causing models to hesitate using them. The server name is already encoded in the tool name (mcp__server__tool). * feat: pretty-print JSON tool output, player error state, broader key redaction - JSON tool results are detected and pretty-printed with 2-space indent instead of rendering as a wall of text - API key redaction extended to cover api_key, apiKey, api-key, and token query params across all tool output (not just media embeds) - Video/audio player shows styled error message when stream fails to load instead of leaving a broken player element - Both appendToolOutput and replayHistory use shared renderToolOutput() * fix: designer review — player error retry, contrast, tool-cmd cap - Player error: role="alert" for screen readers, retry button that reuses existing play handler, includes media title in error message - Light theme: darken --red from #dc2626 to #b91c1c (5.7:1 contrast on --code-bg, was 4.3:1 failing WCAG AA at 12px) - Pretty-print collapsed raw JSON in media embeds (was missed earlier) - Cap .tool-cmd at 120px to prevent tools with many args from making approval blocks disproportionately tall in history replay - Dedicated .media-player-error class instead of reusing .tool-output * fix: Discord tool info name matching regression, suppress deprecation warning The escape_markdown call on tool names was stored for matching against ToolResultEvent.name, but event.name is raw/unescaped. The escaped name never matched, so the "Running → Done" transition silently failed and previews disappeared from the status embed. Fix: store raw name for matching, use escaped name only for display. Also suppress discord.py's re.sub count deprecation warning (Python 3.13+ issue, fixed upstream). * fix: update MCP tool description tests to match prefix removal * fix: address PR #292 review round 2 - Retry button: handle missing span children in click handler so retry buttons from player error state don't throw - Footer count: use len(lines) instead of min(len(results), 10) to reflect actual rendered count after char budget truncation - Null display: use "null" instead of "None" in JS tool arg preview - Broader redaction: also redact JSON "api_key": "..." patterns - SSRF hardening: block loopback and link-local IPs plus cloud metadata hostnames in thumbnail fetch (private LAN IPs still allowed) |