Skip to content

test(browser-bridge): anchor the agent surface to the real op inventory (contains 1 DELIBERATE failure) - #277

Open
ZacxDev wants to merge 2 commits into
mainfrom
test/browser-bridge-surface-parity
Open

test(browser-bridge): anchor the agent surface to the real op inventory (contains 1 DELIBERATE failure)#277
ZacxDev wants to merge 2 commits into
mainfrom
test/browser-bridge-surface-parity

Conversation

@ZacxDev

@ZacxDev ZacxDev commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

🔴 This PR contains ONE deliberately-failing test. The failure IS the finding.

✖ AGENT SURFACE PARTITION: every wire op is REACHABLE or a DECLARED exclusion
  UNDECLARED EXCLUSION(S): context, emulate, ping — these wire ops are unreachable
  by the autonomous agent (absent from OP_TO_SERVER, so not even
  BROWSER_AGENT_ALLOWED_OPS can re-enable them) and carry NO written rationale,
  unlike every other exclusion in that file.

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, equally
loud 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:

  • (a) the op should be reachable → add it to OP_TO_SERVER and to all four
    agent-facing sources; or
  • (b) the exclusion is deliberate → write the rationale comment in
    browser_tool_impl.mjs and add a REVIEWED_AGENT_EXCLUSIONS entry citing it.

open, close, tabs and activate are absent from the agent surface with a
careful rationale paragraph each. context, ping and emulate are absent
without 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:846 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. All four omit
context, ping and emulate identically, so the set is self-consistent, CI is
green, and the omission is invisible. That is structurally the same defect as
context being dead on main (present in protocol.js, absent from
server.py's ALLOWED_OPS) — one layer further out, at the surface the agent
actually touches.

test_server.py:3751 already does this right for the wire layer: it parses
protocol.js and asserts set-equality against server.py. Same approach applied
here 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)

test what it pins
HARNESS SELF-CHECK: … plausible, non-empty set the inventory parses to ≥18 ops and contains known literals
HARNESS SELF-CHECK: … FAILS LOUDLY on an unreadable source the parser raises rather than returning {}
UPSTREAM ANCHOR: every agent-facing op name resolves to a REAL wire op each of the four sources → OP_TO_SERVERserver.py ALLOWED_OPS
UPSTREAM ANCHOR: every OP_TO_SERVER target is a wire op or a declared non-wire endpoint catches a retargeted alias / typo'd mapping
DECLARED EXCLUSIONS: each carries a written rationale that EXISTS in the source reason ≥40 chars and a source snippet found verbatim in browser_tool_impl.mjs
AGENT SURFACE PARTITION the failing one. reachable ∪ declared-excluded == the full wire inventory, exactly

The pre-existing four-source test keeps every assertion it had; added ahead of the
bare deepEqual is a per-source diff so the message names which source is
missing which op rather than dumping two sorted arrays.

tests/test_surface_parity.py — new, +12 tests (T2, T3)

Written as pytest on purpose: flake.nix's checks.pytests (the repo's real
pre-merge gate) runs scripts/run-tests.sh --set hermetic, which covers
scripts/browser-bridge/tests for test_*.py only. The .mjs suites are
documented/manual (README.md:1693) and are not in the flake gate — so T2/T3
land where they will actually block a merge. ⚠️ That also means the deliberate T1
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 names
vs 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 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. An unclassified new CLI name fails by name.

T3 — SKILL.md ops table ↔ SUBCOMMANDS, both directions, plus a
server.py → SKILL.md test closing the triangle. Every failure message names the
specific op and the direction of the mismatch.

Verification

Counted from reporter lines, never exit codes. Base ref origin/main @ ff0fe41.

suite at base ref at HEAD
node --test tests/*.test.mjs tests 454 / pass 454 / fail 0 tests 460 / pass 459 / fail 1 (the finding)
pytest tests/ 369 passed, 0 failed 381 passed, 0 failed

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.

# mutation result
M0 identity — no change (negative control) node 70/1 baseline, py 12/0 — sweep is trustworthy
M1 drop wake from ALLOWED_OPS_DEFAULT only ✔ red, 4-source parity + partition
M2 drop wake from browser.js enum only ✔ red, names browser.js
M3 delete the activate rationale comment from the source ✔ red — DECLARED EXCLUSIONS, cited rationale absent
M4 drop frames from OP_TO_SERVER (silent surface shrink) ✔ red, UPSTREAM ANCHOR
M5 remove context from SUBCOMMANDS ✔ red — "STALE classification: context"
M6 remove the emulate row from SKILL.md's table ✔ red — undocumented subcommand
M7 remove screenshot from server.py ALLOWED_OPS ✔ red (10 tests incl. the harness spot-pin)
M8 rename screenshotsnap in agent-md only ✔ red — "agent op snap has NO OP_TO_SERVER entry"
M9 retarget the html alias → getHTML (case typo) ✔ red — "htmlgetHTML, NOT in ALLOWED_OPS"; py correctly unaffected
M10 rename CLI framesiframes ✔ red, 3 tests
M11 change what the CLI dispatches (cmd_op tabstablist) ✔ red — "dispatches op(s) no classification entry claims: tablist"
M12 rename contextpagectx in SKILL.md's row only ✔ red, both directions
M13 add context to browser.js enum only ✔ red — one source ahead of the others
M14 add a new wire op sniff to server.py + protocol.js only ✔ red both suites — "wire op(s) with NO browser CLI subcommand: sniff". This is the G1 defect.
M15 add an unclassified CLI name sniff ✔ red — "UNCLASSIFIED CLI subcommand(s): sniff"
M16 document a phantom command teleport in SKILL.md ✔ red — "documents command(s) the CLI does not have"
M17 retarget the js alias to text ✔ red — "assert 'text' == 'eval'"
M18 silence M15 by parking sniff in CLI_CLIENT_ONLY with a stub reason ✔ red — the required-reason check refuses the stub
M19 declare context excluded citing a rationale that is NOT in the source ✔ red — "cited rationale is NOT present in browser_tool_impl.mjs". This is the anti-tuning property.
M20 declare text (a reachable op) as an exclusion ✔ red — "declared EXCLUDED but OP_TO_SERVER makes it reachable"
M21 break the SUBCOMMANDS regex (parser yields nothing) ✔ red — HARNESS BROKEN, not a vacuous green
M22 break the server.py ALLOWED_OPS regex ✔ red — HARNESS SELF-CHECK fires
M23 make the SKILL.md parser split on raw pipes (silently loses rows) ✔ red (loses emulate/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:

  • M14 changed the already-failing AGENT SURFACE PARTITION message from
    context, emulate, ping to context, emulate, ping, sniff — the added op is
    named, so the test discriminates rather than merely staying red.
  • M19 made context disappear from the partition message (leaving
    emulate, ping) while raising a separate failure about the fabricated
    citation — 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 / ENOENT rather than returning an empty set — checked in
as test_parsers_fail_loudly_on_a_missing_source and
test_parsers_fail_loudly_on_a_source_of_the_wrong_shape, with
test_the_parsed_inventory_is_non_empty_and_plausible as 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 emulate and key rows (both carry \| inside a backtick
span), 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

  • No existing assertion was weakened or deleted; no production file was touched.
    Files owned by sibling agents (browser, server.py, SKILL.md,
    test_skill_size.py, reference/**) are untouched.
  • The .mjs suites are outside the flake gate (above). Worth a follow-up.
  • pytest at HEAD is fully green; the one red test is in the node suite.

🤖 Generated with Claude Code

ZacxDev and others added 2 commits August 2, 2026 02:37
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>
@ZacxDev
ZacxDev force-pushed the test/browser-bridge-surface-parity branch from aed6ad7 to 0f5577f Compare August 2, 2026 07:45
@ZacxDev

ZacxDev commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Parser hardened — a cmd_op MENTION is no longer a dispatch (0f5577f)

Thanks for the cross-PR catch. Fixed as a class, not by special-casing stderr. Also rebased onto current main (556aa1b, includes #266/#272/#274/#275/#279) — rebase, not merge; clean, no conflicts.

The rule now

Two independent conditions, both required:

  1. the occurrence survives mask_shell_noncode() — a small shell lexer that blanks comments, single-/double-quoted strings (including multi-line), 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

Against the rig, the previous parser harvested 7 phantoms while keeping all 8 real dispatches:

phantombacktick, phantomdocstring, phantomdq, phantomheredoc,
phantommultilinedq, phantomquotedheredoc, phantomsq

Mutation P1 (revert to the old parser) reproduces exactly that list, so the demonstration is checked in and repeatable rather than a one-off claim.

Against the real #278 shape — that exact docstring injected into a throwaway copy of browser (the real file is yours and is untouched): stderr not harvested, op set 19 → 19.

Over-tightening controls

A permanently-green no-op is the obvious wrong fix, so:

  • the rig set must match exactly — misses are named, not just leaks;
  • the real CLI must still yield all 19 ops, asserted == 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 my masker blanked the $( and lost exactly that one op, 19 → 18, while every other test in the file stayed green. This control caught it, not review.
  • new test pins that the CLI has 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/missing op

Two survived the first pass, and are why the rig grew two more cases:

# 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.

ZacxDev added a commit that referenced this pull request Aug 2, 2026
…, 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>
ZacxDev added a commit that referenced this pull request Aug 2, 2026
…, 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant