test(browser-bridge): anchor the agent surface to the real op inventory (contains 1 DELIBERATE failure) - #277
test(browser-bridge): anchor the agent surface to the real op inventory (contains 1 DELIBERATE failure)#277ZacxDev wants to merge 2 commits into
Conversation
Closes the structural gap the 2026-08-02 surface audit called its most
important non-token finding (F9/V3, G1, G2).
The four-source agent parity test pinned browser.js's typed enum, the
agent-md capability table, the README contract and ALLOWED_OPS_DEFAULT to
EACH OTHER and to nothing upstream. Because all four omit `context`, `ping`
and `emulate` identically, the set was self-consistent, CI was green, and
the omission was invisible. Structurally the same defect as `context` being
dead on main (in protocol.js, absent from server.py's ALLOWED_OPS), one
layer further out - at the surface the agent actually touches.
tests/test_server.py::test_ping_op_set_mirrors_the_extension_protocol_js
already does this correctly for the wire layer: it PARSES protocol.js and
asserts set-equality. Same approach here, at two more layers.
browser_tool.test.mjs (+6 tests)
- the existing four-source parity test now names WHICH source is missing
WHICH op before the bare deepEqual (assertions added, none removed)
- UPSTREAM ANCHOR: every agent-facing op name must resolve through
OP_TO_SERVER to an op server.py actually allows
- AGENT SURFACE PARTITION: every wire op must be REACHABLE by the agent
or a DECLARED exclusion in REVIEWED_AGENT_EXCLUSIONS, each carrying a
written reason AND a `source` snippet asserted to exist verbatim in
browser_tool_impl.mjs - so an exclusion cannot be padded with an
invented rationale, and cannot outlive the comment it points at
- two HARNESS SELF-CHECK tests: plausible non-empty cardinality, and the
parser failing loudly on an unreadable/wrong-shaped source
tests/test_surface_parity.py (new, +12 tests, IS in the flake gate)
- CLI SUBCOMMANDS <-> op inventory. Not set-equality: 24 CLI names vs 18
wire ops + 1 server op. Every name is classified wire-op / server-op /
alias / client-only, the classification is asserted EXHAUSTIVE and a
partition, and it is anchored to the CLI's real `cmd_op <op>` call
sites so the table cannot claim a mapping the script does not make.
- SKILL.md ops table <-> SUBCOMMANDS, both directions, plus
server.py -> SKILL.md to close the triangle.
- parser negative controls: missing file and wrong-shaped file must
RAISE, never return an empty set (an empty set makes every parity
assertion pass vacuously).
ONE TEST FAILS ON PURPOSE - that failure IS the finding:
AGENT SURFACE PARTITION: UNDECLARED EXCLUSION(S): context, emulate, ping
Those three wire ops are unreachable by the autonomous agent (absent from
OP_TO_SERVER, so not even BROWSER_AGENT_ALLOWED_OPS re-enables them) and
carry no written rationale, unlike `open`/`close`/`tabs`/`activate`, which
all do. Whether each is a deliberate exclusion is an operator decision, not
one this test may make. Do NOT resolve it by tuning the test.
Counts (from the reporter lines, not exit codes):
node 454 pass / 0 fail -> 460 tests, 459 pass, 1 fail (the finding)
pytest 369 passed -> 381 passed, 0 failed
Mutation sweep: 25 mutations across delete / rename / add / alias-retarget /
unclassified-name / fabricated-rationale / harness-sabotage. All 25 went
red, each naming the specific op and source; verified red for its OWN
assertion, not an earlier one. Notably a fabricated exclusion rationale
does NOT silence the failure - it fails with "cited rationale is NOT
present in browser_tool_impl.mjs".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Hardens the CLI dispatch parser in test_surface_parity.py. It matched `\bcmd_op\s+([A-Za-z]+)` on any line whose first non-space character was not `#`, so any prose containing the phrase `cmd_op <word>` was harvested as a wire op. MEASURED on the merged tree of this PR + #278 (tip a0a0021): #278 added a Python docstring inside a `python3 -c` block reading "The machine-readable cause, from `cmd_op stderr` it already emits" — and the parser harvested a phantom op named `stderr`. Merged-tree pytest went 1 failed / 424 passed, with a diagnostic pointing at a wire op that does not exist. Invisible on either branch alone. Fixed here as a CLASS, not by special-casing the word. The rule now requires TWO independent conditions: 1. the occurrence survives `mask_shell_noncode()` — a small shell lexer that blanks comments, single-/double-quoted strings (including MULTI-LINE ones), backquoted spans and heredoc bodies, preserving byte offsets. `$( … )` is deliberately NOT masked even inside double quotes: that is genuine command position. 2. it sits in command position — start of statement, or after `;` `&&` `||` `|` `(` `$(` `then` `do` `else`. New fixture tests/fixtures/cmd_op_parse_rig.sh pins 8 dispatch shapes that MUST be harvested and 11 mention shapes that MUST NOT be. RED-FIRST (the whole point): against the rig, the previous parser harvested 7 phantoms — phantombacktick, phantomdocstring, phantomdq, phantomheredoc, phantommultilinedq, phantomquotedheredoc, phantomsq — while keeping all 8 real ones. Reverting the parser (mutation P1) reproduces exactly that. Over-tightening controls, because a permanently-green no-op is the obvious wrong fix: - the rig set must match EXACTLY (misses are named, not just leaks); - the real CLI must still yield all 19 ops == server.py's inventory; - `screenshot` is pinned by name — it is dispatched from `resp="$(cmd_op screenshot "$full")"`, a command substitution nested in double quotes. An earlier version of the masker blanked the `$(` and lost exactly that one op, 19 -> 18, while every other test stayed green. Caught by this control, not by review. - a new test pins that the CLI contains no LIVE backtick substitution, since backquoted spans are masked conservatively (113 backticks, all inside comments — MEASURED). Mutation sweep, 10 mutations, all red, each naming its specific phantom or missing op. Two SURVIVED the first pass and are the reason the rig grew two more cases: - P5 (drop command-position anchoring) was green — every phantom was already being caught by masking. Now caught by an UNQUOTED bareword mention (`echo usage: cmd_op phantombareword`). - P6 (drop single-quote masking) was green for the WRONG reason: the `"""` in the docstring case toggled double-quote state, so a different defence was catching it. Now caught by a single-quoted `python3 -c` block containing no double quotes. Also re-verified P7, the M14-style "new wire op with no CLI subcommand" case, still goes red naming `sniff`. Verified against the real #278 shape: injecting that exact docstring into a throwaway COPY of `browser` (the real one is owned by #278 and untouched) leaves the op set at 19 -> 19 and does not harvest `stderr`. Counts (from reporter lines, not exit codes; pytest 9.0.2 confirmed importable — a bare `python3 -m pytest` on this host exits 0 printing "No module named pytest"): pytest 381 -> 385 passed, 0 failed node 460 tests, 459 pass, 1 fail — unchanged, still the deliberate AGENT SURFACE PARTITION finding awaiting the operator's ruling on context/emulate/ping. Not pre-empted here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
aed6ad7 to
0f5577f
Compare
Parser hardened — a
|
| # | mutation | first pass | after rig fix |
|---|---|---|---|
| P5 | drop command-position anchoring | survived — masking alone already caught every phantom | ✔ red on phantombareword (unquoted echo usage: cmd_op …) |
| P6 | drop single-quote masking | survived — green for the WRONG reason: the """ in the docstring case toggled double-quote state, so a different defence was catching it |
✔ red on phantomsqblock (single-quoted python3 -c body with no double quotes) |
Other mutations: P1 revert-parser ✔, P2 blank $( again ✔ (red naming realsubshell and screenshot), P3 tighten to a no-op ✔ (HARNESS BROKEN, not vacuous green), P4 drop heredoc masking ✔, P7 M14 redux — new wire op sniff in server.py+protocol.js only ✔ still red naming sniff, P8 delete a real rig dispatch ✔, P0 identity control reproduces the baseline exactly.
Counts
Counted from reporter lines. pytest 9.0.2 confirmed importable first — noting for the lineage that a bare python3 -m pytest on this host exits 0 printing No module named pytest.
| suite | before | after |
|---|---|---|
| pytest | 381 passed, 0 failed | 385 passed, 0 failed |
| node | 460 tests / 459 pass / 1 fail | 460 / 459 / 1 — unchanged |
That one node failure is still the deliberate AGENT SURFACE PARTITION finding on context, emulate, ping. Not pre-empted — it stays failing until the operator rules. browser and server.py untouched.
…, wake causes 🔴-A A docstring inside a `python3 -c` block read "<dispatch-helper> stderr", and #277's surface-parity gate harvests wire ops with a regex that skips only lines whose first non-space char is `#`. A docstring line is not one, so it harvested a phantom wire op named `stderr` and reddened the MERGED tree (1 failed / 424 passed) while both branches were green alone. Reworded, and a local test now runs the same harvest so the trap is closed on this side too rather than depending on the other PR — either fix alone closes it, either alone can regress. 🟡-B The submitter abandoning a command does NOT free the extension: its serial loop keeps executing for up to EXEC(18) + RESULT(10) = 28s, LONGER than the 20s cmd_timeout. Popping the `inflight` entry at submitter-exit therefore opened a window where the instance looked IDLE while provably still busy, and the next ping fast-failed at 2s against a healthy extension — in that window a0a0021 was worse than both main (20s flat) and the interim constant (10s flat). Now the entry is dropped only when the command never left the outbox (it will never run); when the extension already took it the entry SURVIVES the submitter, is released when the late result arrives, and otherwise expires at INFLIGHT_STALE_S (EXEC + RESULT) so one wedged command cannot make ping slow forever. 🟡-C `unknown_op` was the one wake failure still losing its cause: its branch uses neither the "failed in the browser:" prefix nor an echoed body, so both classifier arms had nothing to read. It is also the most likely `--wake` failure here (a stale loaded extension) and the PERMANENT one the exit-3 contract promises callers can distinguish. Added the arm + a parametrize case. 🟢-E The headline "busy AND wedged" test was INSENSITIVE to the `- age` term it advertises: EXEC was monkeypatched to 0.2 so the no-age budget (2.2s) sat under its own 5.0s bound, and deleting `- age` left it green. Recalibrated (EXEC 3.0 / GRACE 0.5 / age ~4s) so correct=0.3s vs no-age=3.5s with the bound between them, and cmd_timeout lowered below _req's 10s urlopen timeout so the `return timeout` mutant now dies on THIS test's assertion instead of a transport error. 🟢-D `fast_timeout` is clamped into [.., cmd_timeout] before use — both are operator-settable and nothing validates the relation, so fast=60/cmd=20 escaped the "never above cmd_timeout" claim through the lower clamp (measured: 60.0). 🟢-F/G/H Comment above the ping-504 branch still described a "default 10s SHORT deadline"; the inflight-release test claimed three exits but had two blocks describing the same case (it now covers success / abandoned-while-queued / abandoned-while-running / late-result-release); and the "a busy profile is never reported as dead" claims are now conditional — the derived budget is still capped at cmd_timeout, so >cmd_timeout of legitimate serial work can still time a ping out. RESULT_BUDGET_S joins EXEC_OP_BUDGET_S with its own protocol.js drift guard. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…, orientation telemetry, screenshot Read hint (#278) * fix(browser-bridge): five CLI/behaviour fixes from the 2026-08-02 usage audit S1 --wake[=MS] on `nav` and `open` (F2). The flag existed on exactly html/text/js; 13 nav->wake / open->wake adjacent pairs in transcripts and one opencode session that is literally `nav wake eval` x5 were paying for the gap. The three copied parse blocks are consolidated into one `_wake_flag` helper (validation still at PARSE time). nav/open send NO `wake` field on the wire — the extension honours cmd.wake only on getHtml/text/eval — so `--wake` is a client-side compose (`_wake_after`): the nav/open op, then the existing `wake` op, annotated at result.data.wake exactly where a `--wake` read reports it. S2 `ping` gets its own 2s deadline (F7): 38.2% failure rate, every failure burning the full 20s CMD_TIMEOUT while 21 healthy pings averaged 3-4ms. New BROWSER_BRIDGE_PING_TIMEOUT / PING_TIMEOUT_DEFAULT; no other op's timeout moves. S3 unknown_instance / no_extension now separate "wrong label" from "right label, profile disconnected" (F6): 48 of 52 failures used the CORRECT label, with `eval` retried 37 times. The disconnected branch names when the profile went away, says FULLY RESTART Brave, and says DO NOT RETRY. S4 /whoami and /health emit metadata-only telemetry (F8) — 53 invocations were invisible to the only structured source. No domain (they are global ops), no key; /instances and /poll still emit nothing. S5 an explicit-path `screenshot` prints one `#`-prefixed hint that the .png must be Read (F4: 7 of 63 captures never read). Line 1 stays the bare path. S6 the CLI's "SKILL.md -> Concurrency" pointers named a heading that has never existed; a test now proves every SKILL.md heading the CLI names resolves. server.py changes need a `home-manager switch` + `systemctl --user restart browser-bridge` to go live; the CLI is a working-tree symlink and is live on pull. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(browser-bridge): --help prints the header block only, not every comment `--help` was `grep -E '^#( |$)' "$0"`, which matched EVERY column-0 comment in the file — so it shipped the whole implementation commentary as user-facing help: 25,440 bytes against a ~15 KB header, and it grew with every internal comment anyone added (the five audit fixes in the parent commit would alone have put 4,129 bytes of maintainer notes onto the help screen). Now: the contiguous `#` block after the shebang, stopping at the first line of code. Measured on this branch: 25,440 -> 15,532 bytes per `--help`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(browser-bridge): a 2s ping deadline false-negatives a BUSY healthy extension Adversarial audit of #278, confirmed by reproduction. The extension's poll loop is strictly serial, so `ping` skips the per-tab FIFO but still cannot be DEQUEUED until the running command finishes. Measured on a FakeExtension running a legitimate 6s getHtml, then a ping: ping_timeout=2.0 -> 504 timeout at 2.00s (the getHtml completed fine) ping_timeout=10.0 -> 200 pong at ~6.0s Real blast radius: two sessions on one profile; agent B runs `browser ping` — the skill's documented FIRST action — while agent A is mid-nav. B was told "is Brave focused / responsive?", whose documented remedy is a FULL Brave restart of the operator's live session. Root cause of the miss: the healthy 68ms measurement was against an IDLE extension, and both S2 tests used a wedged or idle one. NO test used a busy one. Fixes: * PING_TIMEOUT_DEFAULT 2.0 -> 10.0, justified as an INVARIANT rather than a number: it must exceed the longest a HEALTHY extension can be busy with a command the CLI can ASK for. ACTIVATE_WAIT_MAX_MS (8s) is that ceiling; 10s is the smallest value above it with slack for the result POST + poll turnaround. The residual (10s < EXEC_OP_BUDGET_MS 18s) is stated, not hidden, and the rejected "escalate when the instance is busy" alternative is recorded with the reason it does not work (pending>0 cannot tell BUSY from WEDGED). * The 504 message is now op-aware: a ping timeout says the extension may be BUSY, says to re-run once the other op finishes, names the env override, and explicitly refuses to recommend a Brave restart. * New regression test with a BUSY (not wedged, not idle) extension. Red at the previous tip at exactly 2.00s. Also hardened test_the_short_deadline_applies_to_ping_ONLY, which silently stopped discriminating when the default rose above its cmd_timeout: a lower bound alone now survives the `op_timeout = ping_timeout` mutant (10 >= 3.5). It is bounded on both sides and asserts its own precondition. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(browser-bridge): audit follow-ups A-G — half-states, lost evidence, docs A. A failing `--wake` no longer swallows the successful primary op. `_wake_after` used `|| exit 1`, which exited rc 1 with EMPTY stdout on all three measured failure modes (429, 504, op-level unknown_op) — so `T=$(browser nav --wake "$url")` yielded an empty T for a tab that really had navigated, and only the op-level branch even mentioned `wake`. Now: the primary JSON is always emitted, the failure is attached at result.data.wake.ok=false, stderr says the primary SUCCEEDED and which half failed, and the exit code is a distinct 3. B. `--frame X nav --wake <url>` is refused at PARSE time. `--frame` is spliced into every op by cmd_op; `nav`/`open` ignore it but the standalone `wake` is refused by the extension's assertWakeNotFramed — so the composed form navigated and THEN failed the wake. This half-state did not exist before `--wake` was added to nav/open (it was a hard parse error), i.e. the feature opened it. One shared predicate, both callers. VERIFIED LIVE, not inferred. C. Restored the drop evidence the S3 rewrite dropped: `last_unanswered_op` on every rendered instance, and the last-seen/last-op lines on the never-seen 404 branch (render_missing used to print them). server.py calls that field "the single fact that turns the next silent drop from inference into evidence". D. reference/errors.md: `404 unknown_instance` said "the --instance key matches no connected instance" — exactly the conflation S3 exists to break. Now documents both branches with their opposite actions, plus the same split for 503 and the `ping` exception to `504 timeout`. E. SKILL.md documents the flag where agents actually read it: `nav`/`open` rows and the `wake` row. 11,911 -> 12,010 B; 278 B under the 12,288 ceiling, 28 B above the 250 B floor. Agents read SKILL.md, not `browser --help`, so without this S1 changed agent behaviour approximately zero. F. The --help test now pins the LAST line and the full header line-for-line. `awk` exits at the first non-`#` line, so a blank line inserted near the end of the header truncated --help with landmarks + an upper length bound still green. G. `_wait_connected`/`_wait_count` use `body["count"]`, not `.get("count", 0)`: a missing key must raise, not read as "nothing connected" — which for want=False was an instantly vacuous pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(browser-bridge): derive the ping deadline from in-flight work, not a constant PRIMARY (audit 🟡-2, and it dissolves 🟡-1 and 🟢-3). The audit was right that a distinguishing signal exists on the Instance, and right that my ✗REJECTED note was about the wrong field. `inst.pending` cannot tell BUSY from WEDGED — but the AGE of the outstanding work can, because the extension self-bounds one command at EXEC_OP_BUDGET_MS = 18s. `inst.last_dispatch` cannot carry it, for two concrete reasons: * it is OVERWRITTEN by every new enqueue, so it names the NEWEST outstanding command, not the oldest — a fresh enqueue behind a wedged one makes the wedge look young; * its `at` is wall-clock time.time(), while all deadline math uses the injectable monotonic self._clock(); an NTP step would move it. Both are properties of that field, not of the idea. So `last_dispatch` stays DIAGNOSTIC and a new `Instance.inflight` (cid -> monotonic enqueue reading, every outstanding command) does the timing. Registry._effective_timeout_locked is the gate: * nothing in flight -> a ping that does not answer within the fast deadline IS the wedge. PING_TIMEOUT_DEFAULT is back to 2s and is now ONLY the idle floor. * work in flight -> allow it its own budget: N * EXEC_OP_BUDGET_S + WEDGE_GRACE_S - age(oldest). N scales it because the loop drains serially. * clamped to cmd_timeout above (so this can never be slower than the behaviour predating the fast ping) and to fast_timeout below (an overdue instance still gets its full fast deadline, not an instant fail). This dominates the tuned constant on both axes: wedge detection returns to ~2s (it was ~10s), and a busy profile is never reported dead — including the case a constant cannot express at all, busy AND wedged. 🟡-1 is therefore moot rather than fixed: the invariant no longer has to out-bid any ceiling. The old comment claimed 8s was "the caller-requestable ceiling" and was wrong twice over — `screenshot --fullpage` (CDP_OP_BUDGET_MS 15s) and `wake --wait 6000` composed with an 8s CDP attach both exceed 10s. Both are now named in the code as the reason the tuned approach was abandoned. EXEC_OP_BUDGET_S mirrors a constant across a language boundary, so test_exec_budget_matches_the_extension reads protocol.js and fails on drift. 🟡-3 The ping-timeout guidance — the entire mitigation for the disclosed residual — had zero test coverage; deleting the block left both suites green. Now pinned, with a negative control asserting a NON-ping timeout keeps the generic wording. The text is also rewritten for the new semantics (it claimed a "default 10s SHORT deadline"). 🟡-4 result.data.wake.error now carries the REAL cause (`rate_limited`, `timeout`, `wake_with_frame_unsupported`) instead of the constant "wake_failed_after_nav", so a script can tell transient from permanent. cmd_op stderr is captured, re-emitted VERBATIM, then classified. The test pins the error VALUE per failure mode. 🟢-1 result.data.wake is now shape-symmetric: `ok` is always present. Success used to write the raw payload, which has no `ok` key, so `if (data.wake.ok)` was falsy on every successful wake. Three shapes became two. 🟢-2 Exit code 3 is documented in the CLI header block (not SKILL.md, which has 28 B of slack). Also fixed: two background threads in the new tests raised into pytest as unhandled daemon-thread exceptions and were attributed to an unrelated later test (test_release_drops_ownership_without_dispatch). The suite now runs clean under -W error::pytest.PytestUnhandledThreadExceptionWarning. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(browser-bridge): merged-tree phantom op, abandoned-command window, wake causes 🔴-A A docstring inside a `python3 -c` block read "<dispatch-helper> stderr", and #277's surface-parity gate harvests wire ops with a regex that skips only lines whose first non-space char is `#`. A docstring line is not one, so it harvested a phantom wire op named `stderr` and reddened the MERGED tree (1 failed / 424 passed) while both branches were green alone. Reworded, and a local test now runs the same harvest so the trap is closed on this side too rather than depending on the other PR — either fix alone closes it, either alone can regress. 🟡-B The submitter abandoning a command does NOT free the extension: its serial loop keeps executing for up to EXEC(18) + RESULT(10) = 28s, LONGER than the 20s cmd_timeout. Popping the `inflight` entry at submitter-exit therefore opened a window where the instance looked IDLE while provably still busy, and the next ping fast-failed at 2s against a healthy extension — in that window a0a0021 was worse than both main (20s flat) and the interim constant (10s flat). Now the entry is dropped only when the command never left the outbox (it will never run); when the extension already took it the entry SURVIVES the submitter, is released when the late result arrives, and otherwise expires at INFLIGHT_STALE_S (EXEC + RESULT) so one wedged command cannot make ping slow forever. 🟡-C `unknown_op` was the one wake failure still losing its cause: its branch uses neither the "failed in the browser:" prefix nor an echoed body, so both classifier arms had nothing to read. It is also the most likely `--wake` failure here (a stale loaded extension) and the PERMANENT one the exit-3 contract promises callers can distinguish. Added the arm + a parametrize case. 🟢-E The headline "busy AND wedged" test was INSENSITIVE to the `- age` term it advertises: EXEC was monkeypatched to 0.2 so the no-age budget (2.2s) sat under its own 5.0s bound, and deleting `- age` left it green. Recalibrated (EXEC 3.0 / GRACE 0.5 / age ~4s) so correct=0.3s vs no-age=3.5s with the bound between them, and cmd_timeout lowered below _req's 10s urlopen timeout so the `return timeout` mutant now dies on THIS test's assertion instead of a transport error. 🟢-D `fast_timeout` is clamped into [.., cmd_timeout] before use — both are operator-settable and nothing validates the relation, so fast=60/cmd=20 escaped the "never above cmd_timeout" claim through the lower clamp (measured: 60.0). 🟢-F/G/H Comment above the ping-504 branch still described a "default 10s SHORT deadline"; the inflight-release test claimed three exits but had two blocks describing the same case (it now covers success / abandoned-while-queued / abandoned-while-running / late-result-release); and the "a busy profile is never reported as dead" claims are now conditional — the derived budget is still capped at cmd_timeout, so >cmd_timeout of legitimate serial work can still time a ping out. RESULT_BUDGET_S joins EXEC_OP_BUDGET_S with its own protocol.js drift guard. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(browser-bridge): never prune an inflight entry a submitter is still awaiting 🟡-1 (round-4 audit). INFLIGHT_STALE_S is measured from ENQUEUE, but a queued command's EXEC_OP_BUDGET_S does not start until the SERIAL extension dequeues it. So age alone cannot mean "a healthy extension has certainly answered" — and it fails precisely at N>=2, the case the `len(inst.inflight) * EXEC_OP_BUDGET_S` term exists to model. Measured with the injected clock, _effective_timeout_locked(inst, cmd_timeout, 2.0): N=3, cmd_timeout=60: age 27s -> 29.0s ; age 29s -> 2.0s (should be 27.0s) N=2, cmd_timeout=20: age 27s -> 11.0s ; age 29s -> 2.0s (should be 9.0s) Scenario: an operator follows the advice THIS BRANCH added at BROWSER_BRIDGE_CMD_TIMEOUT ("raise CMD_TIMEOUT if that is your workload"), sets 60s and submits 3 ops. At t=29s all three entries are pruned while their submitters are still blocked, the instance reads IDLE, and `ping` fast-fails at 2s with ~25s of legitimate work left. A regression versus a0a0021 in that window, and it falsified the remediation the same commit documented. Fix: prune requires BOTH age > INFLIGHT_STALE_S AND no live submitter (`cid not in inst.waiters`). The memory bound is not weakened, it is split by owner: a live-submitter entry is bounded by that submitter's own deadline (it unwinds through the finally, which pops it); an abandoned entry has no one to bound it, which is what INFLIGHT_STALE_S is for. `waiters` was verified to be exactly the live-submitter set by reading every mutation, not by assumption — and submit()'s finally now discards it too, so an unexpected raise inside the wait loop cannot strand an entry into permanent prune-exemption. A new test pins the property on both reachable exits. 🟢-2 The N=1 expiry test claimed "the staleness window bounds memory, not the verdict". True at N=1 ONLY — it holds there because a lone entry's budget is already negative at 20s, so pruning at 28s is verdict-neutral by construction. The docstring now names that scope and points at the N>=2 test. 🟢-3 The abandon-late-result branch popped `inflight` but left `last_dispatch` naming that cid, whose contract is "the command it never answered" and which is surfaced by health/whoami. After that path it DID answer. Cleared, on the same condition the normal path uses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
🔴 This PR contains ONE deliberately-failing test. The failure IS the finding.
Do not "fix" this by tuning the test, and do not close it by inventing a
rationale — the test asserts that the cited rationale exists verbatim in
browser_tool_impl.mjs, so a fabricated one fails with a different, equallyloud message (mutation M19 below proves it). Whether each of these three should
be reachable is an operator decision, not one a test may make. Two legitimate
resolutions:
OP_TO_SERVERand to all fouragent-facing sources; or
browser_tool_impl.mjsand add aREVIEWED_AGENT_EXCLUSIONSentry citing it.open,close,tabsandactivateare absent from the agent surface with acareful rationale paragraph each.
context,pingandemulateare absentwithout one. This PR makes CI able to tell those two states apart.
Why
Per the 2026-08-02 surface audit (
claudedocs/browser-bridge-surface-audit-2026-08-02.md,#274) — findings F9/V3, gaps G1/G2.
The four-source agent parity test at
browser_tool.test.mjs:846pinnedbrowser.js's typed enum, the agent-md capability table, the README contract andALLOWED_OPS_DEFAULTto each other and to nothing upstream. All four omitcontext,pingandemulateidentically, so the set is self-consistent, CI isgreen, and the omission is invisible. That is structurally the same defect as
contextbeing dead onmain(present inprotocol.js, absent fromserver.py'sALLOWED_OPS) — one layer further out, at the surface the agentactually touches.
test_server.py:3751already does this right for the wire layer: it parsesprotocol.jsand asserts set-equality againstserver.py. Same approach appliedhere at two more layers — an authoritative upstream source, parsed, not a
hand-maintained list.
What's in it
tests/browser_tool.test.mjs— +6 tests (T1)HARNESS SELF-CHECK: … plausible, non-empty setHARNESS SELF-CHECK: … FAILS LOUDLY on an unreadable source{}UPSTREAM ANCHOR: every agent-facing op name resolves to a REAL wire opOP_TO_SERVER→server.py ALLOWED_OPSUPSTREAM ANCHOR: every OP_TO_SERVER target is a wire op or a declared non-wire endpointDECLARED EXCLUSIONS: each carries a written rationale that EXISTS in the sourcereason≥40 chars and asourcesnippet found verbatim inbrowser_tool_impl.mjsAGENT SURFACE PARTITIONThe pre-existing four-source test keeps every assertion it had; added ahead of the
bare
deepEqualis a per-source diff so the message names which source ismissing which op rather than dumping two sorted arrays.
tests/test_surface_parity.py— new, +12 tests (T2, T3)Written as pytest on purpose:
⚠️ That also means the deliberate T1
flake.nix'schecks.pytests(the repo's realpre-merge gate) runs
scripts/run-tests.sh --set hermetic, which coversscripts/browser-bridge/testsfortest_*.pyonly. The.mjssuites aredocumented/manual (
README.md:1693) and are not in the flake gate — so T2/T3land where they will actually block a merge.
failure above is not enforced by the flake gate today; flagging that rather than
silently working around it.
T2 — CLI
SUBCOMMANDS↔ op inventory. Not a plain set-equality: 24 CLI namesvs 18 wire ops + 1 server op. The asymmetry is modelled explicitly — every name is
classified into exactly one of
CLI_WIRE_OPS/CLI_SERVER_OPS/CLI_ALIASES/CLI_CLIENT_ONLY(the last requires a written reason), the classification isasserted exhaustive and a partition, and it is anchored to the CLI's real
cmd_op <op>call sites so the table cannot claim a mapping the script does notmake. An unclassified new CLI name fails by name.
T3 — SKILL.md ops table ↔
SUBCOMMANDS, both directions, plus aserver.py → SKILL.mdtest closing the triangle. Every failure message names thespecific op and the direction of the mismatch.
Verification
Counted from reporter lines, never exit codes. Base ref
origin/main@ff0fe41.node --test tests/*.test.mjspytest tests/Mutation sweep — 25 mutations, all red, each verified red for its OWN assertion
Deliberately varied in construction, not just count: deletions, renames,
additions, alias retargeting, an unclassified name, a fabricated rationale, and
harness sabotage. Every mutation was applied to a pristine in-memory copy and
reverted; M0 is an identity negative control confirming the sweep reproduces
the exact baseline before any verdict is read.
wakefromALLOWED_OPS_DEFAULTonlywakefrombrowser.jsenum onlyactivaterationale comment from the sourceDECLARED EXCLUSIONS, cited rationale absentframesfromOP_TO_SERVER(silent surface shrink)UPSTREAM ANCHORcontextfromSUBCOMMANDSemulaterow from SKILL.md's tablescreenshotfromserver.py ALLOWED_OPSscreenshot→snapin agent-md onlysnaphas NO OP_TO_SERVER entry"htmlalias →getHTML(case typo)html→getHTML, NOT in ALLOWED_OPS"; py correctly unaffectedframes→iframescmd_op tabs→tablist)context→pagectxin SKILL.md's row onlycontexttobrowser.jsenum onlysniffto server.py + protocol.js onlybrowserCLI subcommand: sniff". This is the G1 defect.sniffteleportin SKILL.mdjsalias totextsniffinCLI_CLIENT_ONLYwith a stub reasoncontextexcluded citing a rationale that is NOT in the sourcetext(a reachable op) as an exclusionSUBCOMMANDSregex (parser yields nothing)HARNESS BROKEN, not a vacuous greenserver.py ALLOWED_OPSregexHARNESS SELF-CHECKfiresemulate/key)Right-reason check. For every mutation the full assertion text was captured
and inspected, confirming the test failed on its own assertion rather than an
earlier one throwing first. Two worth calling out:
AGENT SURFACE PARTITIONmessage fromcontext, emulate, pingtocontext, emulate, ping, sniff— the added op isnamed, so the test discriminates rather than merely staying red.
contextdisappear from the partition message (leavingemulate, ping) while raising a separate failure about the fabricatedcitation — i.e. the escape hatch is closed, and visibly so.
Harness validation
Both parser sets were pointed at a known-bad state before any green was
trusted: a path that does not exist, and a real file of the wrong shape. Both
raise
HARNESS BROKEN/ENOENTrather than returning an empty set — checked inas
test_parsers_fail_loudly_on_a_missing_sourceandtest_parsers_fail_loudly_on_a_source_of_the_wrong_shape, withtest_the_parsed_inventory_is_non_empty_and_plausibleas the positive control.M21/M22/M23 then sabotage the live parsers and confirm the guards fire.
Measured while writing the SKILL.md parser: splitting table rows on raw
|silently drops the
emulateandkeyrows (both carry\|inside a backtickspan), which would have made a documentation test fail for entirely the wrong
reason. The parser splits on unescaped pipes only; M23 pins that.
Not done / flagged
Files owned by sibling agents (
browser,server.py,SKILL.md,test_skill_size.py,reference/**) are untouched..mjssuites are outside the flake gate (above). Worth a follow-up.pytestat HEAD is fully green; the one red test is in the node suite.🤖 Generated with Claude Code