diff options
| author | amankumarsingh77 <amankumarsingh7702@gmail.com> | 2026-07-28 20:00:11 +0530 |
|---|---|---|
| committer | Aman Kumar <145339995+amankumarsingh77@users.noreply.github.com> | 2026-08-02 19:12:46 +0530 |
| commit | 5767a84e3daed965de30832ca4437b941284b06a (patch) | |
| tree | c73a2a26af445dc00a15d16955fe44f7cd117bf8 | |
| parent | 8ccff6c98b04666401b041002ca4aab997e1ff22 (diff) | |
docs: bug audit report
Thirty findings plus six investigated-not-bugs from the read-every-file sweep of
src/, with resolution status for the six the user approved for fixing and the
live-Chrome experiment F12 still needs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| -rw-r--r-- | docs/superpowers/reports/2026-07-26-bug-audit.md | 1482 |
1 files changed, 1482 insertions, 0 deletions
diff --git a/docs/superpowers/reports/2026-07-26-bug-audit.md b/docs/superpowers/reports/2026-07-26-bug-audit.md new file mode 100644 index 0000000..4ebdd66 --- /dev/null +++ b/docs/superpowers/reports/2026-07-26-bug-audit.md @@ -0,0 +1,1482 @@ +# Bug audit sweep — `src/` read-every-file pass + +Task 15, Steps 1–2. Branch `refactor/codebase-typesafety`, HEAD `4909192`. +No `src/` or `test/` file was modified by this pass. This document is the deliverable. + +**Coverage.** Every `.ts` file under `src/` was read in full (80 files, ~10.8 kLOC): +`cdp/` (17), `daemon/` (5), `domains/` (30 incl. `search/`, `readpage/`), `profile/` (7), +`schemas/` (2), `util/` (10), and the 7 root files. Also read: `scripts/check-boundaries.ts`, +`package.json`, and the test tree layout. Depth notes are in *Coverage and confidence* at the end. + +**Numbering.** F1…F36, ordered by severity. The summary table is at the bottom. + +--- + +## Resolution status + +The user ruled on 2026-07-28: fix six findings, leave the rest documented. + +| Finding | Status | Commit | +|---|---|---| +| — | test infrastructure (`makeFakeClient`) | `1d4ae38` test: fake browser client over a stub transport | +| F3 | **fixed** | `d10e729` fix(cdp): re-enable domains when re-attaching to a known tab | +| F4 | **fixed** | `7c6b3fd` fix(cdp): route background-tab events to the tab they belong to | +| F1 | **fixed** | `2ff5459` fix(tool): stop the socket gate rejecting every tool call on Windows | +| F6 | **fixed** | `fe90a9c` fix(domains): keep element refs stable across a mutation | +| F5 + F10 | **fixed** (one engine) | `16e7a30` fix(domains): one fill engine, so browser_fill stops lying about checkboxes | +| F2 | **fixed** | `77086bb` fix(domains): report a page-side throw instead of blaming the ref | +| F12 | **left unfixed by ruling** — needs one live-Chrome experiment | — | +| all others | documented only, unchanged | — | + +Every fix was driven by a test that first failed for the reason recorded here. +Test count went 206 → 251 passing (8 skipped, 0 failing); `npm run check` is green. + +### Observable behaviour changes shipped + +Three, all in the fill engine commit, and all deliberate: + +1. **`browser_fill` on a disabled input now errors instead of returning success.** + User-approved. The old success was the bug, and `browser_fill_form` already + refused it. +2. **`browser_fill` on a checkbox/radio now toggles `checked`** instead of writing + the submit value and reporting a no-op as success (F5). +3. **`browser_fill_form` on a contenteditable now uses `execCommand("insertText")`** + rather than assigning `textContent`, so it drives ProseMirror/Slack-style + editors the way `browser_fill` already did. This was not in the ruling's + enumerated list — it is the unavoidable consequence of the two tools sharing + one engine, and it is the better of the two behaviours, so it stayed. Flagging + it because it changes what `browser_fill_form` does to a rich-text editor. + +Minor, non-behavioural: both fill tools now return `tag` in `details`; the six +domain-enable round trips now also run on a re-attach (F3), and tab registration +moved ahead of them (F4). + +Deliberately *not* changed while fixing these: the object-handle leak (F29) still +exists on the fill/focus/select ref paths — routing them through `resolveAndCall` +would have fixed it for free, and I kept them on `runOnElement` precisely so this +pass did not quietly fix an out-of-scope finding. + +**Reproducibility.** `test/cdp/session-call-test.ts` already contains a stub `CdpTransport` +with a controllable `events()` iterable and a per-method canned-response map. That harness is +enough to reach most of the `cdp/` and `domains/` findings below without a live browser, which is +why so many are marked reproducible. There is no equivalent fake for `BrowserClient`; findings +that need one say so. + +--- + +## F1 — Every browser tool fails on Windows: `access()` on a named pipe + +> **FIXED** in `2ff5459`. Both call sites now share `daemonSocketMayExist(platform, socketPath)` +> in `src/daemon/spawn.ts`, following the precedent the same file already set. +> Tests: `test/util/daemon-socket-test.ts` pins the platform matrix — a `\\.\pipe\…` +> path on `win32` is never judged absent, while POSIX still consults the filesystem. +> **Still unverified on Windows hardware**: the tests pin the platform *branch*, not +> Chrome's behaviour on Windows. One run of any `browser_*` tool on a real Windows +> box is still the only thing that closes this out. +> The gate's own branch could not be tested deterministically — it calls +> `daemonSocketMayExist()` with no arguments, and this machine has a live +> `/tmp/pi-browser-daemon.sock`, so a gate test asserting "handler skipped" passes or +> fails depending on the developer's machine. I dropped that assertion rather than +> ship an environment-dependent test; the gate is covered only through the helper. + +**Location** `src/util/tool.ts:143-157` (guard), `src/daemon/protocol.ts:143-146` (the path). + +**What breaks.** `registerBrowserTool`'s `execute` gates every tool on +`await access(DAEMON_SOCKET_PATH)`. On Windows `DAEMON_SOCKET_PATH` is +`\\.\pipe\pi-browser-daemon` — a named pipe, which is not a filesystem entry, so +`GetFileAttributesW` (what libuv's `access` resolves to) cannot see it and `access` rejects. +The guard therefore fails for a perfectly healthy daemon and every `browser_*` call returns +`not_connected: "Browser harness not initialized. Run /browser-setup first"`. + +The codebase already knows this. `src/daemon/spawn.ts:44-51` skips the identical pre-check +for exactly this reason, with the comment *"Windows named pipes are not filesystem entries — +`access()` always fails there — so skip the pre-check and probe directly."* `tool.ts` is the +same mistake in the one place it is fatal. + +**Trigger.** Any `browser_*` tool call on `process.platform === "win32"`, with or without a +running daemon. `browser_setup` and `browser_http_get` still work (`ensureAlive: false`). + +**Impact** The harness is non-functional on Windows, and the error message misdirects the user +to a wizard that cannot fix it. Windows is clearly meant to be supported — the code carries +`.cmd` shim handling, `windowsVerbatimArguments`, PowerShell CIM process detection, and +`LOCALAPPDATA` profile paths. + +**Severity** crash (total loss of function on one platform). + +**Proposed fix.** Replace the `access` pre-check with the platform-correct liveness probe that +already exists: on POSIX keep `access`, on Windows skip straight to `client.ensureAlive()` +(whose transport `connect` already reports `ENOENT` as "Daemon not running"). Factoring the +POSIX-only branch out of `spawn.ts:45-51` into one shared helper would prevent a third copy. + +**Observable behaviour change.** Yes, on Windows only: tools start working. On POSIX, none. + +**Reproducible in a test?** Yes, but only after extraction. As written the check is inline in +`execute` and reads `process.platform` transitively through a module constant. Extract +`daemonEndpointReachable(): Promise<boolean>` (or pass the path in) and a test can assert that a +`\\.\pipe\…`-shaped path is not rejected on the strength of `access` alone. Asserting the real +Windows behaviour needs Windows. + +**Confidence.** High on the mechanism, **unverified on Windows hardware** — I could not run it +here. Corroborated by `spawn.ts`'s own comment, which the earlier work treated as a verified fact. + +--- + +## F2 — `runOnElement` ignores `exceptionDetails`: a page-side throw is reported as "No element matched" + +**Location** `src/domains/form.ts:30-62`; live at `:142` (fill), `:205` (focus), `:290` (select). + +> **FIXED** in `77086bb`. `runOnElement` now checks `exceptionDetails` and returns +> `cdp_error` carrying the exception text. Per the ruling, the error is +> distinguishable from both neighbours: `cdp_error` for a throw, `invalid_state` +> "No element matched" for a genuine miss, `invalid_state` "unknown or stale" for a +> bad ref — all three asserted in `test/domains/form-errors-test.ts`, which failed +> first with `invalid_state` for all three tools. The fix deliberately did **not** +> route the ref path through `resolveAndCall`, which would also have silently fixed +> the out-of-scope handle leak (F29). + +*Pre-confirmed item — I agree it is real. I do not rate it the single most severe item; see +"Ranking" below.* + +**What breaks.** The ref path calls `cdpCall(client, "Runtime.callFunctionOn", …)`. `cdpCall` +(`src/domains/cdp-call.ts:11-13`) only inspects transport success. A CDP command that *succeeds* +while the page function *throws* returns `{ result: {type:"undefined"}, exceptionDetails: {…} }`. +`runOnElement` returns `ok(r.data.result.value)` = `ok(undefined)`; the handler's +`isFillResult(undefined)` is false, so it takes the `res === undefined` branch and reports +`` `No element matched: ${target}` ``. + +**Trigger.** Any exception inside `FILL_FN` / `FOCUS_FN` / `SELECT_FN` on the ref path. Concretely: +`SELECT_FN` starts with `arg.wv` — if the argument ever arrives as `undefined` the very first +statement throws; `FILL_FN` calls `el.dispatchEvent`, which throws if a page overrode it or if a +CSP/permissions error surfaces; a cross-origin `contenteditable` focus can throw. Also every +future edit to those function bodies. + +**Impact** A **wrong diagnosis**. The agent is told the ref is bad and re-snapshots a ref that was +fine, then loops. The real error text is discarded. Compare `resolveAndCall` +(`form.ts:361-363`), which does check `exceptionDetails` and reports +`` `page function threw: …` `` — the two code paths in the same file disagree. + +**Severity** wrong result (misdiagnosis, agent-visible loop). + +**Proposed fix.** Check `r.data.exceptionDetails` in `runOnElement` before reading +`result.value` and return `{ kind: "cdp_error", message: \`page function threw: …\` }`. Better: +delete `runOnElement`'s ref branch and route it through `resolveAndCall`, which already checks +`exceptionDetails` *and* releases the object handle (F29). + +**Observable behaviour change.** Yes — a previously-mislabelled failure now returns +`cdp_error` with the page's own message. + +**Reproducible in a test?** Yes. Needs a fake `BrowserClient` whose `session().call` returns +`ok({ result: { type: "undefined" }, exceptionDetails: { text: "boom" } })`. No such fake exists +yet; the stub transport in `test/cdp/session-call-test.ts` plus a thin client shim gets there. + +--- + +## F3 — `switchTo` back to a previously-visited tab silently stops collecting console, network, and page events + +> **FIXED** in `d10e729`. `enableDomains` is now called on every attach, not only a +> tab's first visit. Tests: `test/cdp/session-switch-test.ts`, which failed first +> with **zero** `.enable` calls on the returned-to tab's new session. + +**Location** `src/cdp/session.ts:350-382`, specifically `368-377`. + +**What breaks.** `switchTo` calls `attachTo` (`Target.attachToTarget` with `flatten: true`), +which mints a **new** `sessionId` — the code knows this: `:369` says *"Each +Target.attachToTarget produces a new sessionId — update it."* CDP domain enablement is +**per-session**. But `enableDomains(attachedSessionId)` runs only in the `else` branch, i.e. only +the first time a tab is visited. On a revisit the new session is left with `Page`, `DOM`, +`Runtime`, `Network`, `Accessibility`, and `Log` all **disabled**. + +**Trigger.** `browser_switch_tab` to A → `browser_switch_tab` to B → `browser_switch_tab` back +to A. From that moment, on tab A: `Runtime.consoleAPICalled`, `Log.entryAdded`, and every +`Network.*` event stop arriving, so `browser_console` and `browser_network_requests` return +"_No matching … captured yet._" for a tab that is generating traffic. `Page.loadEventFired` / +`Page.frameNavigated` also stop, so `pageInfoDirty` is never set and `client.pageInfo()` serves +the 1-second-TTL cache without invalidation. `Page.javascriptDialogOpening` stops too, so a +dialog on the re-visited tab is never captured and `browser_page_info` cannot warn about it. + +**Impact** Silent, permanent-for-that-tab loss of all diagnostic data, plus a missed dialog +(which blocks every subsequent interaction with no explanation). The tool descriptions promise +the opposite: *"Buffer is reset on tab switch"* implies collection resumes after a switch. + +**Severity** wrong result (silent data loss) — with a plausible path to a hard hang via the +missed dialog. + +**Proposed fix.** Call `enableDomains(attachedSessionId)` unconditionally in `switchTo`, for the +`existing` branch as well. Re-enabling an already-enabled domain is a no-op in CDP, so hoisting +the call above the if/else is safe and simplest. + +**Observable behaviour change.** Yes — events resume; up to six extra CDP round-trips per +re-visit (the `TODO(perf)` at `:207` already notes these are sequential). + +**Reproducible in a test?** Yes, cleanly, with the existing stub transport: `switchTo("a")`, +`switchTo("b")`, `switchTo("a")`, then assert `calls` contains `Log.enable` for the third +`sessionId`. This is the single easiest finding in the report to pin down. + +**Confidence.** High on the code shape. The premise "a second `attachToTarget` mints a new +sessionId and domain state is per-session" is standard CDP behaviour and is asserted by the +code's own comment, but I did not confirm it against a live browser. + +--- + +## F4 — The event loop discards every event from non-active tabs, defeating the per-tab buffers + +> **FIXED** in `7c6b3fd`. Handled with the extra care the ruling asked for: before +> widening the filter I traced every `switch` arm against a background-tab event. +> Three arms needed to change together, and widening the filter alone would have +> introduced a new bug — +> - `Page.javascriptDialogOpening` used the *active* `targetId`, so a background +> tab's dialog would have been recorded against the foreground tab and +> `browser_page_info` would have pointed the agent at the wrong tab. Now +> `resolveTab(ev.sessionId)`. +> - `Page.frameNavigated` / `loadEventFired` had the same shape: the wrong tab's +> page-info cache invalidated and the right tab's left stale. Now `resolveTab`. +> - `Target.targetCreated` / `targetDestroyed` are browser-level with no sessionId, +> so they are unaffected either way; the `targetDestroyed` exemption was kept. +> - The `Network.*` / console arms already used `resolveTab` and simply start +> receiving events. +> +> One ordering hazard the widening exposed: with the filter keyed on +> `sessionIdToTargetId`, events arriving during the six domain-enable round trips +> would have been dropped as "unknown", so tab registration moved ahead of +> `enableDomains` in both `attachFirstPage` and `switchTo`. +> Tests: `test/cdp/session-events-test.ts` — four failed first (console, network, +> dialog and load events from a background tab all discarded); the fifth, asserting +> that a session this instance never attached is still ignored, passed before and +> after and is the guard that the filter did not become a free-for-all. +> No design decision turned out to be needed, so I did not stop. + +**Location** `src/cdp/session.ts:88-94`. + +```ts +if (ev.method !== "Target.targetDestroyed") { + if (ev.sessionId && ev.sessionId !== sessionId) continue; +} +``` + +**What breaks.** `sessionId` is the *active* tab's session. Every other attached tab's events are +dropped at the top of the loop. Yet the same loop then routes through +`resolveTab(ev.sessionId)` (`:151-178`), and `TabSession` carries a per-tab `networkBuffer` and +`consoleBuffer` explicitly so that *"switching tabs preserves the previous tab's data in its +TabSession instead of clearing it"* (`:13-16`). Those two designs contradict each other: the +per-tab buffers can only ever hold data captured while their tab was the active one. + +Tabs stay attached after a switch (`tabs` and `sessionIdToTargetId` are never pruned on switch), +and `browser_open_urls` / `browser_read_page` attach extra sessions of their own, so there is +real traffic being thrown away. + +**Trigger.** Switch to tab B while tab A is loading; switch back to A; call `browser_console` or +`browser_network_requests`. Everything A logged or fetched while B was active is absent, with no +`bufferOverflowed` flag or any other signal that data is missing. + +**Impact** wrong result. Silently incomplete diagnostics — the failure mode the console and +network tools exist to prevent. Note this **masks F3**: with the filter in place, the missing +domain-enable on a revisit is partly invisible, and fixing either one alone gives a partial fix. + +**Severity** wrong result (silent data loss). + +**Proposed fix.** Narrow the filter to what it is actually for. Keep dropping events for +sessions this `CdpSession` does not know (`!sessionIdToTargetId.has(ev.sessionId)`), and let +known sessions through to `resolveTab`. Then move the three handlers that use +`targetId ? tabs.get(targetId)` (`javascriptDialogOpening` at `:97`, `frameNavigated` / +`loadEventFired` at `:117`) onto `resolveTab(ev.sessionId)` too, or a background tab's dialog +will be recorded against the foreground tab. + +**Observable behaviour change.** Yes — background tabs accumulate console/network records, and +a background tab's dialog is attributed to the right tab. + +**Reproducible in a test?** Yes. The stub transport's `events()` is a generator; replace it with +a pushable queue, `switchTo("a")`, `switchTo("b")`, push a `Runtime.consoleAPICalled` carrying +tab A's sessionId, `switchTo("a")`, assert `drainConsoleBuffer` returns it. + +--- + +## F5 — `browser_fill` on a checkbox sets `value`, not `checked`, and reports success + +> **FIXED** in `16e7a30`, together with F10 — one engine in +> `src/domains/fill-engine.ts`, used by both tools. A checkbox now goes through the +> click/`checked` path instead of the `value` setter. +> Tests: `test/domains/fill-engine-test.ts` executes the real in-page engine source +> in Node against fake DOM elements — the "pure branch-decision seam" this report +> recommended. 9 of its 16 assertions failed first, including the headline one: +> after `browser_fill(checkbox, "true")`, `checked` was still `false`. + +**Location** `src/domains/form.ts:90-105` (`FILL_FN`). + +*Pre-confirmed item — verified, and I agree it is the worst row of the two-engines table.* + +**What breaks.** `FILL_FN` branches on `el instanceof HTMLInputElement` and, for any input, +fires `HTMLInputElement.prototype.value`'s setter. For `type="checkbox"` / `type="radio"` that +writes the element's *`value` attribute* — the string submitted when the box is ticked — and +leaves `checked` untouched. It then dispatches `input` + `change` (so a framework listener runs +and reads the *unchanged* checked state) and returns +`{ status: "ok", kind: "checkbox", value: el.value }`, which the handler renders as +`` Filled e7 = "true" ``. + +**Trigger.** `browser_fill({ ref: <a checkbox>, value: "true" })`. There is nothing in the tool +description or `promptGuidelines` steering the agent away from it — `browser_fill` is +described as the field-filling default, and `browser_snapshot` gives checkboxes refs like every +other interactive element. + +**Impact** A **silent no-op reported as success**, plus corruption of the checkbox's submit +value. The agent reads `Filled e7 = "true"`, believes the box is ticked, and moves on. The +appended `interactiveDiff` does compare `state`, so a careful reader might notice no +`checked` transition — but the primary result line says success. + +**Severity** wrong result — the most damaging failure mode in the report, because it is silent +and the agent has no reason to doubt it. + +**Proposed fix.** Add a checkbox/radio branch to `FILL_FN` that either delegates to the +`CHECK_FN` logic (`form.ts:379-394`, which clicks and falls back to `el.checked = want`) or +returns `{ status: "not_fillable", tag }` with a message pointing at `browser_set_checked`. +Failing loudly is strictly better than the current silence; delegating is better still, since +`browser_fill_form` already accepts a boolean for the same element (F10). + +**Observable behaviour change.** Yes, and deliberately: a call that reported success now either +works or errors. + +**Reproducible in a test?** The JS body is a string with no direct unit-test seam. Two options: +(a) reproduce in `test/manual/forms-test.ts` against live Chrome — proves it, doesn't guard it; +(b) restructure so the branch decision is a pure function of `{ tag, type, isContentEditable }` +and unit-test that. I recommend (b) — it is the only way this class of bug stays fixed, and it +covers F7 in the same seam. + +--- + +## F6 — Every mutation silently renumbers the ref map, so refs held by the agent can point at different elements + +> **FIXED** in `fe90a9c`. `interactiveDiff` now carries each ref over by +> `backendNodeId` (via a new `session.refMappings()`) and only mints a new `eN` for an +> element it has not seen; the counter starts above every existing number so a new +> element can never take a live ref. +> +> **Does this make refs genuinely stable, or narrow the window?** The ruling asked for +> an explicit answer. **Genuinely stable for any element that persists across the +> mutation** — which is the case the contract is about and the case that was silently +> wrong. Two honest residuals: +> - An element that disappears and later returns gets a **new** ref, because its old +> ref is dropped from the map when it vanishes. The agent sees it as new (`*[eN]`), +> which is truthful. +> - `browser_snapshot` still renumbers from scratch, by design: an explicit +> re-snapshot is the documented way to get a fresh ref set. +> +> So the F6 divergence between `browser_snapshot`'s `interestingOnly`/`maxNodes` and +> `interactiveDiff`'s hardcoded values no longer *renumbers* anything — a node absent +> from the diff's narrower tree simply keeps no ref until the next snapshot. +> Tests: `test/domains/ref-stability-test.ts`. Two of four failed first, with `e1` +> resolving to backendNodeId 999 (the newly inserted element) instead of 101. +> While writing them I found my own first fixture was wrong, not the product: real +> CDP AX nodes carry `parentId`, and without it `buildTree` treats every child as an +> additional root and collects it twice. Fixture corrected; no product bug there. + +**Location** `src/domains/ref-resolve.ts:75-94` (`interactiveDiff`), called from +`form.ts:160` (fill), `form.ts:307` (select), `click.ts:83` (click). +Compare `src/domains/snapshot.ts:332-344,371-376`. + +**What breaks.** After each mutation, `interactiveDiff` re-reads the AX tree, re-runs +`collectInteractiveTargets`, and assigns refs by **positional index** — `e1, e2, …` in tree +order — then calls `session.setRefMap(refMap, refSig)`, replacing the map wholesale. If the +mutation changed the interactive element set (an error message with a link appears, a +conditional field renders, a button unmounts), every ref at or after the insertion point now +resolves to a **different element**. Not stale — *wrong*. `resolveRefToObjectId` cannot detect +this: it looks up `e6`, gets a live `backendNodeId`, resolves it, and fills the wrong field. + +There is a second, independent divergence: `browser_snapshot` builds the tree with the +caller's `args.interestingOnly` / `args.maxNodes`, while `interactiveDiff` hardcodes +`{ interestingOnly: true, maxNodes: 1000 }`. A snapshot taken with `interestingOnly: false` or +`maxNodes: 5000` produces a *different numbering*, which the first mutation then replaces — +so refs shift even when the DOM did not change at all. + +**Trigger.** The documented happy path in the system prompt (`src/prompt.ts:64-72`): +snapshot once, then `browser_fill(e7)`, `browser_select_option(e9)`, `browser_click(e12)`. +Any validation message or conditional field appearing after the first fill shifts `e9` and +`e12`. Reproducible deterministically with `browser_snapshot({ interestingOnly: false })` +followed by any fill. + +**Impact** wrong result, silent, and it contradicts the contract the prompt and all seven tool +descriptions state: *"refs survive re-renders"*, *"refs are keyed to element identity"*. +What actually survives is the *backendNodeId behind a ref*, not the ref's meaning. + +**Severity** wrong result. High: it targets the wrong element while reporting success, and it +sits directly on the recommended workflow. + +**Proposed fix.** Make refs identity-keyed rather than positional. Keep a per-tab +`backendNodeId → ref` table and, when re-publishing in `interactiveDiff`, reuse the existing +ref for any backendNodeId already known, minting `eN` only for genuinely new nodes. That +preserves the diff (which already keys on `role|name`) and makes the "refs survive" claim true. +Minimum viable alternative: have `interactiveDiff` *not* replace the map — only add newly +appeared nodes — and use the same `buildTree` options the last snapshot used. + +**Observable behaviour change.** Yes — ref numbers stay stable across mutations, and the +`*[eN]` markers in the diff change to reflect stable numbering. + +**Reproducible in a test?** Yes, at the pure level and without a browser: +`buildTree(fixtureA)` → `collectInteractiveTargets` → refs; `buildTree(fixtureB)` where B is A +plus one interactive node near the top → assert the trailing refs still map to the same +`backendDOMNodeId`s. `test/deep-research/` already keeps AX-shaped JSON fixtures, so the +pattern exists. + +--- + +## F7 — `browser_fill` and `browser_select_option` write through a **disabled** control and report `status: "ok"` + +**Location** `src/domains/form.ts:90-122` (`FILL_FN`), `:254-268` (`SELECT_FN`). + +*Pre-confirmed item — verified.* + +**What breaks.** Neither function body checks `el.disabled`. `FILL_FN` fires the prototype +`value` setter, which works fine on a disabled input; `SELECT_FN` assigns `el.value` on a +disabled `<select>`. Both dispatch `input`/`change` and return `ok`. A real user cannot type +into either control, so the page's own state machine never expected the value, and the value is +not submitted. + +**Trigger.** `browser_fill` on a field the form disables until a precondition is met — a "Submit" +step that stays disabled until terms are accepted, a dependent select that unlocks after a +country is chosen. + +**Impact** wrong result. Same silent-success class as F5. `browser_fill_form` on the very same +element returns `{ ok: false, reason: "element is disabled" }` (`form-batch.ts:10`), so the two +tools give opposite answers about the same DOM. + +**Severity** wrong result. + +**Proposed fix.** Add `if (el.disabled) return { status: "not_fillable", tag }` — or a dedicated +`{ status: "disabled" }` so the message can say *why* — to both bodies, matching +`form-batch.ts:10` and `CHECK_FN` (`form.ts:385`), which both already do this. + +**Observable behaviour change.** Yes: a previously "successful" write now errors. That is the +point, and it is the behaviour `browser_fill_form` already has. + +**Reproducible in a test?** Same seam as F5 — pure branch-decision function, or live Chrome. + +--- + +## F8 — `browser_fill` reports `verified` without ever comparing it to the requested value + +**Location** `src/domains/form.ts:160-164`. + +*Pre-confirmed item — verified, and I rate it lower than the pre-confirmed list implies.* + +**What breaks.** The handler returns `details: { value: args.value, verified: res.value }` and +text `` Filled ${target} = ${JSON.stringify(res.value)} ``. `res.value` is `el.value` read back +after the write, but nothing compares it to `args.value`, and no mismatch is flagged. The tool +description promises *"Returns the field's value after writing, plus a compact diff"* — literally +true — while `promptGuidelines` says *"confirm both match what you intended"*, i.e. it delegates +verification to the agent's own reading of a string. + +**Trigger.** Any input that transforms its value: `<input type="number">` given `"abc"` (becomes +`""`), a masked phone input, `maxlength` truncation, an `<input type="date">` given a +non-ISO string, or a controlled component that rejects and reverts. + +**Impact** wrong result, but **mitigated**: the correct value *is* in the output text, so an +attentive agent can catch it. `browser_fill_form` computes an explicit `mismatch` flag +(`form-batch.ts:100-106`) and surfaces it in the summary line, which is what `browser_fill` +should do. + +**Severity** wrong result (mild — the data is present but unjudged). + +**Proposed fix.** Mirror `form-batch.ts`: compute `mismatch = res.value !== args.value`, put it +in `details`, and prefix the text (e.g. `` Filled … = "12" (requested "abc" — MISMATCH) ``). +Do not turn it into an error: legitimate normalisation (trimming, date reformatting) would +start failing. + +**Observable behaviour change.** Yes — an extra `mismatch` field and a warning in the text. + +**Reproducible in a test?** Yes, and this one is easy: the mismatch computation is pure over +`(args.value, res.value)`. Extract it (or share `form-batch`'s) and unit-test it. + +--- + +## F9 — `browser_type`'s focus guard is fail-open when the probe itself fails + +**Location** `src/domains/keyboard.ts:32-50`. + +*Pre-confirmed as a coverage hole. My independent call: it is also a live fail-open, of exactly +the shape the brief warns about.* + +```ts +const focused = await client.evaluateJs(safeJs`…`); +if (focused.success) { // ← probe failure skips the guard entirely + const f = isFocusProbe(focused.data) ? focused.data : { ok: false, tag: null }; + if (!f.ok) return err({ kind: "invalid_state", message: "No editable element is focused — typed text would be lost." }); +} +const r = await cdpCall(client, "Input.insertText", { text: args.text }); +``` + +The guard exists because typed text goes nowhere when nothing editable is focused. When the +probe cannot answer — the case where we know *least* about focus — the code proceeds to type +anyway and returns `` Typed: "…" ``. Note the inner fallback is correctly fail-closed +(`{ ok: false }` on a bad shape), which makes the outer fail-open look unintended rather than +chosen. + +**Trigger.** Anything that makes `client.evaluateJs` fail: a page-level CSP that blocks +evaluation, a detached/navigating frame, a transient timeout, or a JS dialog freezing the page +(the most likely one — a dialog is exactly when typed text is lost). + +**Impact** wrong result. Text silently discarded and reported as typed. Same class as F5, +narrower trigger. + +**Severity** wrong result. + +**Proposed fix.** Treat probe failure as "not focused": add an `else` returning +`invalid_state` with the probe's error message. If the team prefers not to block on a flaky +probe, keep typing but say so in the result text — never return an unqualified success. + +**Observable behaviour change.** Yes — `browser_type` starts failing in a case where it +previously claimed success. + +**Reproducible in a test?** Yes, but it needs a fake `BrowserClient` (the check runs through +`client.evaluateJs`). Return `err(cdpError("timeout", …))` from the fake and assert the handler +returns `invalid_state` rather than calling `Input.insertText`. A `makeFakeClient()` helper is +the missing piece; several other findings here (F2, F14) want the same helper. + +--- + +## F10 — Two fill engines coexist and disagree on the same element + +**Location** `src/domains/form.ts:90-122` (`browser_fill`) vs `src/domains/form-batch.ts:6-58` +(`browser_fill_form`). + +> **FIXED** in `16e7a30`, per the ruling: one engine, `browser_fill` delegates, and its +> documented `<select>` rejection survives as an explicit `rejectSelect` pre-check +> rather than as a happy accident of the old code falling through to "not fillable". +> Per-tool focus behaviour is a `focusFirst` parameter, so `browser_fill`'s documented +> "does not focus" promise still holds and `browser_fill_form` still focuses. +> `browser_fill` keeps appending `interactiveDiff` and republishing refs; +> `browser_fill_form` still does neither. The merged engine is the **union** of the two +> columns, so `browser_fill_form` gained the `execCommand` contenteditable path — the +> third behaviour change listed under Resolution status. + +*Pre-confirmed item — verified; the table in the brief matches the code exactly.* Both are +registered and agent-callable (`src/registry.ts:29-31`). + +| case | `browser_fill` | `browser_fill_form` | +|---|---|---| +| disabled input | writes, `ok` (F7) | `{ ok:false, reason:"element is disabled" }` | +| `<select>` | error, "use browser_select_option" | matches by `value`/`label`/`text`, fills | +| **checkbox** | prototype `value` setter, `ok` (F5) | flips `checked` via `click()` | +| contenteditable | `execCommand("insertText")` — drives ProseMirror/Slack | `el.textContent =` — does not | +| result | appends `interactiveDiff`, republishes refs | neither | +| verification | reports `verified`, no comparison (F8) | explicit `mismatch` flag | + +**Impact** The agent cannot reason about which tool to use, and the tool descriptions do not +warn it. `browser_fill_form` is documented as *"Preferred for forms"*, but it is the **weaker** +engine for contenteditable (the `textContent` assignment does not drive rich-text editors, +which is the one thing `FILL_FN`'s `execCommand` path exists for) and it returns no page diff. +Meanwhile `browser_fill` is the weaker engine for checkboxes, selects, and disabled controls. + +**Severity** wrong result (umbrella; F5, F7, F8 are its individually-testable instances). + +**Proposed fix.** One engine. Extract the per-element-type decision + write into a single JS +body used by both tools, with `browser_fill` as the single-field entry point (keeping the diff) +and `browser_fill_form` as the batch entry point (keeping `mismatch`). The union of the two +tables is strictly better than either column. This is a bigger change than the other fixes and +should be planned, not folded into a bug-fix commit. + +**Observable behaviour change.** Yes, substantially, in both tools. Needs the user's sign-off on +the merged semantics, not just on "is it a bug". + +**Reproducible in a test?** Once the decision logic is a pure function, yes — one table-driven +test per row. Today, no: both bodies are opaque strings and neither tool has any automated +coverage (F30). + +--- + +## F11 — Concurrent liveness probes can spawn a second daemon that unlinks the live one's socket + +**Location** `src/daemon/spawn.ts:41-101` (`isDaemonRunning`), `src/daemon/server.ts:83-89` +(`unlinkSync`), `:121-135` (duplicate-clientId rejection), +`src/daemon/protocol.ts:165` (`DAEMON_STALE_SOCKET_CLEANUP = true`). + +**What breaks.** Three facts compose into a bad outcome: + +1. `isDaemonRunning` registers with the **fixed** clientId `"_liveness_probe"` (`spawn.ts:70`). +2. `server.ts:126-134` rejects a registration whose clientId is already connected — it writes a + `shutdown` control and destroys the socket. +3. On rejection the probe's `close` handler fires `done(false)` (`spawn.ts:96-99`), so + `isDaemonRunning` reports **"not running"** for a daemon that is running and healthy. + `ensureDaemon` then spawns a second daemon, whose `start()` unconditionally + `unlinkSync(DAEMON_SOCKET_PATH)` (`server.ts:83-89`) — deleting the *live* daemon's socket + inode — and listens on a fresh one. + +The `close` handler's comment says *"daemon alive but rejected us (e.g. max clients); don't +unlink"* — so the author anticipated rejection, but the return value still says "not running", +which is what drives the spawn. + +**Trigger.** Two pi sessions calling `/browser-setup` (or `browser_setup`, or any first tool +call) within the ~2 s probe window. The `_liveness_probe` slot is also never freed on the +reject path, so a probe that races itself in one process can do it too. + +**Impact** Two daemons, two WebSockets to Chrome, two "Allow Remote Debugging" prompts — the +exact thing the daemon exists to eliminate. Sessions already connected keep working (their FD +survives the unlink), so the split is invisible until the user is prompted again. The orphaned +daemon lingers until its 30-minute idle timeout, if it ever reaches zero clients. + +**Severity** resource leak, with user-visible fallout (repeat Chrome prompts). + +**Proposed fix.** Two independent parts: +- Make the probe clientId unique: `` `_liveness_probe-${randomUUID()}` ``. Removes the collision + entirely and costs nothing. +- Distinguish "rejected by a live daemon" from "nothing listening". A received `shutdown` + control means the daemon is alive — `isDaemonRunning` should return `true`. And gate + `unlinkSync` on the socket not being live rather than doing it unconditionally. + +**Observable behaviour change.** No, in the normal single-session path. In the race, one daemon +instead of two. + +**Reproducible in a test?** Yes — `test/manual/ipc-server-test.ts` and +`live-daemon-test.ts` already drive the real server over a real socket. A test that registers +`_liveness_probe` and then calls `isDaemonRunning()` and asserts `true` is straightforward. +It lands in `test/manual/`, which the `npm test` glob excludes, so it would not run in CI +without the daemon-test infrastructure F30 asks for. + +--- + +## F12 — `openerId` is probably not a `Target.createTarget` parameter, so unpinned new tabs are placed by focus, not by ownership + +**Location** `src/cdp/commands.ts:90-99` (schema), `src/cdp/target-factory.ts:205-214` +(`openHarnessTab`, unpinned path). + +> **LEFT UNFIXED BY RULING — this is the highest-value thing a human with a browser can +> check.** Neither I nor the reviewer can run live Chrome. The experiment: call +> `Target.createTarget` with `openerId` set to an owned tab, then read the new target's +> `TargetInfo.openerId` and which window it joined. If Chrome ignores the parameter, +> then unpinned `browser_open_urls` / `browser_new_tab` tabs land in whichever window has +> **focus** — the user's window, not the harness's — and the session-boundary promise in +> `src/prompt.ts:98-105` is false. The fix would then be to pass `windowId: +> ownership.harnessWindowId()`, a documented parameter the harness already tracks for +> exactly this purpose and already has in its schema. + +*Pre-confirmed item, flagged for verification. My finding: it looks wrong, and I could not +confirm it either way from documentation.* + +**What breaks.** The command table declares `openerId: Type.Optional(Type.String())` as a +`Target.createTarget` parameter and `openHarnessTab` passes it to place a new tab "beside" +`openerTargetId`. Querying the CDP protocol definitions, `openerId` appears on **`TargetInfo`** +("Opener target Id") and I found no `openerId` in `CreateTargetRequest`. The documented +`Target.createTarget` parameters are `url`, `left`, `top`, `width`, `height`, `windowId`, +`browserContextId`, `enableBeginFrameControl`, `newWindow`, `background`, `forTab`. + +If it is not a parameter, Chrome ignores the unknown field and the new tab lands wherever a +bare `createTarget` lands — the focus-derived window. `target-factory.ts`'s own header says +*"`openerId` for siblings inside it"* while also stating (verified, for the pinned case) +that *"`openerId` does NOT inherit the opener's context"* — which is consistent with the field +being ignored rather than honoured. That the unpinned path appears to work in practice is +explained by the harness window usually being the focused one. + +**Trigger.** Unpinned profile, `browser_open_urls` or `browser_new_tab`, while a user window +has focus. The tabs would open in the user's window — still `ownership.add`-ed, so the harness +believes they are in its dedicated window. + +**Impact** wrong result, and it breaks the session-boundary promise in `prompt.ts:98-105` +(*"you operate only inside this session's own Chrome window"*). Also `windowId` — an actual, +documented parameter — is in the schema and available (`ownership.harnessWindowId()`) but is +never passed. + +**Severity** wrong result. **Unverified** — needs one live-Chrome experiment. + +**Proposed fix.** First verify: call `Target.createTarget` with `openerId` against live Chrome +and check whether the new target's `TargetInfo.openerId` is set and which window it joins. If +ignored, replace it with `windowId: ownership.harnessWindowId()`, which is exactly the +documented mechanism for "put this tab in that window" and which the harness already tracks +for this purpose. If it turns out to be honoured, delete the stale comment and note it. + +**Observable behaviour change.** Yes if changed — new tabs would reliably land in the harness +window rather than the focused one. + +**Reproducible in a test?** Not as a unit test — the question is about Chrome's behaviour. A +`test/manual/window-binding-test.ts` addition (that file already exists) is the right home. +The *schema* claim can be tested trivially once known: assert the table matches the protocol. + +--- + +## F13 — A `session_tree` event before client creation resets the namespace to `"default"` + +**Location** `src/index.ts:195-198`; `src/state.ts:51-68` (`restoreState`). + +*Pre-confirmed item, flagged "confirm whether the fallback makes these equivalent". It does +not, in one specific case.* + +**What breaks.** `session_tree` passes `client?.namespace`, which is `undefined` before the +client exists (the client is created in `session_start`). Inside `restoreState`: + +```ts +const fallback = defaultState(currentNamespace); // namespace = undefined ?? "default" +… +namespace: currentNamespace ?? data.namespace ?? fallback.namespace +``` + +With a persisted entry present, `data.namespace` wins and the two call sites *are* equivalent. +With **no** persisted entry, the loop falls through to `return fallback` — whose namespace is +`"default"`, not the `pi-<random>` value `index.ts:40` generated. `persistState` then writes +`"default"`, and the subsequent `session_start` reads it back and creates the client (and the +daemon transport) with `namespace: "default"`. + +**Trigger.** A `session_tree` event arriving before `session_start` in a session with no prior +`browser-harness-state` entry — a fresh session on a fresh branch. Whether pi actually orders +the events that way I could not determine from this repo; the ordering is the host's. + +**Impact** Every affected pi session shares the daemon clientId `"default"`, so the second one +to connect is rejected by `server.ts:126-134` ("clientId default is already connected") and +gets its socket destroyed. Multi-session browser control breaks, and the error surfaces as a +generic transport failure. If `--browser-namespace` was passed, `flagNs` is not consulted here +either, so an explicit user choice is discarded. + +**Severity** wrong result (conditional on host event ordering; **unverified** that the ordering +occurs). + +**Proposed fix.** Pass the namespace that is authoritative, not the one that happens to be +reachable: `restoreState(ctx, client?.namespace ?? state.namespace)`. `state.namespace` is +initialised from the flag or the generated value at `:40-42`, so it is never `undefined` — which +also makes the two call sites genuinely equivalent and removes the trap. + +**Observable behaviour change.** None in the ordering where `session_start` runs first. In the +other ordering, the namespace stops being clobbered. + +**Reproducible in a test?** Yes, and cheaply — `restoreState` takes only an `ExtensionContext` +whose `sessionManager.getBranch()` returns an array. A test asserting +`restoreState(ctxWithNoEntries, undefined).namespace` is not silently `"default"` when the +caller had a real namespace pins the contract. Reproducing the *event ordering* needs a pi +host fake, which does not exist here. + +--- + +## F14 — Both `AbortController` time budgets are dead code: the pre-`await` check can never fire + +**Location** `src/domains/snapshot.ts:346-366` (1 500 ms box budget) and +`src/domains/network.ts:249-266` (5 000 ms `BODY_BUDGET_MS`). + +*This is the "signal.aborted checked before an await, not re-checked after" item from the +brief's list — present in two places, in a slightly worse form than described.* + +**What breaks.** Both sites build the whole array of promises with `.map(async …)` and then +await them together. `.map` starts every async function **synchronously**, so the guard at the +top of each callback runs before the timer can possibly have fired: + +```ts +const timer = setTimeout(() => ac.abort(), BODY_BUDGET_MS); +const fills = drained.records.map(async (r) => { + if (ac.signal.aborted) return; // always false — same tick + const bodyRes = await session.call("Network.getResponseBody", …); // never re-checked + … +}); +await Promise.allSettled(fills); +``` + +`session.call` takes no `AbortSignal`, so aborting cannot cancel an in-flight request either. +Net effect in `network.ts`: the 5 s budget bounds nothing at all — all N `getResponseBody` +calls are issued, and the tool waits for every one (each up to the 15 s transport timeout). +In `snapshot.ts` the *first* check is likewise dead, so no `DOM.getBoxModel` is ever skipped; +only the **second** check (`:360`, after the `boxOf` await) does anything, and it only skips the +`liveValue` follow-up. + +**Trigger.** `browser_network_requests({ includeResponseBodies: true })` matching many records +on a slow page. `browser_snapshot` on a page with many interactive elements. + +**Impact** wrong result via latency: a tool documented as budgeted can block far past its +budget, and the caller sees a plain timeout with no partial data. Not a crash; nothing is +mis-stated in the output. + +**Severity** wrong result (latency/robustness). Low-to-medium. + +**Proposed fix.** Bound the whole batch instead of guarding inside each task: +`await Promise.race([Promise.allSettled(tasks), sleep(BUDGET_MS)])`, and record per-record +completion so partial results are reported honestly. If per-task cancellation is wanted, thread +a `timeoutMs` (already supported by `session.call`) derived from the remaining budget — +`src/util/time.ts` already exports a `deadline()` helper built for exactly this and currently +has no callers in `src/`. + +**Observable behaviour change.** Yes — the tool returns within its budget with partial bodies +instead of eventually returning all of them. + +**Reproducible in a test?** Yes for `network.ts`, with a fake client whose +`session().call("Network.getResponseBody")` resolves after a controllable delay: assert the +handler returns within ~`BODY_BUDGET_MS` for 50 records at 200 ms each. Needs the same +`makeFakeClient()` helper as F2/F9. + +--- + +## F15 — `asArrayOf` is all-or-nothing, so one malformed record blanks an entire TUI render + +**Location** `src/util/guards.ts:13-25`; consumers `src/domains/console.ts:139` and +`src/domains/network.ts:209`. + +*Pre-confirmed item — verified. Severity: cosmetic, not wrong-result, because it is +render-only.* + +**What breaks.** `asArrayOf` returns `undefined` if **any** element fails its item guard. In +`asConsoleDetails` / `asNetworkDetails` a single bad record makes the whole `records` array +`undefined`, which makes the details `undefined`, which makes `renderResult` return +`"console: no details"` / `"network: no details"` — hiding all the good records. + +**Trigger.** A record that fails the item guard. Both item guards require few fields +(`asConsoleRecord` needs a valid `level` and a string `text`; `asNetworkRecord` needs `method`, +`url`, `type`), and the producing buffers always populate them, so this is hard to hit from +inside the harness. It becomes reachable if a transcript is replayed after a schema change, or +if `details` is round-tripped through a lossy serializer. + +**Impact** cosmetic — the LLM's copy of the data comes from `ToolOk.text`, which is built +directly from `drained.records` and unaffected. Only the human's TUI panel goes blank. + +**Severity** cosmetic. + +**Proposed fix.** Add a lenient sibling — `asArrayOfLenient` / `compactMap`, which drops +unparseable elements and keeps the rest — and use it in the two render paths only. Leave +`asArrayOf` strict where a partial array would be wrong (`state.ts:39`, `ownedTargetIds`: +silently dropping a target id there would un-own a live tab, so strict is correct and should +stay). + +**Observable behaviour change.** Only in the failure case: a partial render instead of a blank one. + +**Reproducible in a test?** Yes, directly — `test/util/guards-test.ts` already exists and +`asArrayOf` is pure. + +--- + +## F16 — A strict `AxNode` schema means one bad node fails the whole `browser_snapshot` + +**Location** `src/cdp/commands.ts:55-76,239-242`; consumed by `snapshot.ts:313` and +`ref-resolve.ts:79`. + +*Pre-confirmed item — verified. My judgement: leave it, add a guard rail.* + +**What breaks.** `Accessibility.getFullAXTree`'s result is now `Type.Array(AxNode)`. A single +node failing validation makes `decodeResult` reject the entire response, so `browser_snapshot` +returns `invalid_response` and `interactiveDiff` silently returns `""`. Before the schema work +this was `Array(Unknown())` and could not fail. + +**Trigger.** A node where `nodeId` is absent or not a string — the only required field. CDP +always sends `nodeId` as a string, and everything else is optional with +`additionalProperties: true`, so I could not construct a realistic trigger. A future Chrome +change to the AX payload could. + +**Impact** If it ever fires, the harness's **default** page-inspection tool stops working +entirely, and the `interactiveDiff` failure is invisible (returns `""`, callers append nothing). +The blast radius is large; the probability is low. + +**Severity** cosmetic today (unreachable), crash-class if it ever fires. + +**Proposed fix.** The low-cost mitigation is per-node leniency at the consumer, not a looser +schema: keep `Type.Array(AxNode)`, but if the decode fails, fall back to filtering nodes that +individually validate and proceed with those, noting the count in `details`. Alternatively add +a `depth` cap. I would **not** revert to `Array(Unknown())` — that discards the type safety +Task 13 bought for a risk that has no demonstrated trigger. + +**Observable behaviour change.** Only in the failure case. + +**Reproducible in a test?** Yes — `test/cdp/commands-test.ts` exists; `decodeResult` is pure. +Feed a nodes array with one `{ nodeId: 42 }` and assert the whole decode fails today. + +--- + +## F17 — Every `Input.*` command's parameters are unvalidated + +**Location** `src/cdp/commands.ts:228-231`; worst consumer `src/domains/drag.ts:39-59`. + +*Pre-confirmed item — verified.* + +**What breaks.** `Input.dispatchMouseEvent`, `Input.dispatchKeyEvent`, and +`Input.dispatchDragEvent` are all `cmd(Type.Object({}, { additionalProperties: true }), Empty)`. +Any object satisfies them, so `ParamsOf<M>` is effectively `Record<string, unknown>` and a typo +(`deltaY` → `delatY`, `clickCount` → `clickcount`) typechecks, passes the boundary scanner, and +reaches Chrome, which ignores the unknown field. `drag.ts`'s `InputCall` tuple types its second +element as `Record<string, unknown>` for the same reason, and `type InputCall` is declared inside +the handler body rather than at module scope. + +**Trigger.** A typo or a wrong-typed value in any of the ~10 `Input.*` call sites +(`click.ts:42-51`, `scroll.ts:48-55`, `keyboard.ts:90-108`, `drag.ts:41-58`). Nothing catches it; +the action silently does the wrong thing or nothing. + +**Impact** No live bug found — I checked every call site against the CDP parameter names and +they are correct. This is a missing guard rail, not a defect. + +**Severity** cosmetic (latent). + +**Proposed fix.** Write out the real parameter sets: `type`, `x`, `y`, `button`, `buttons`, +`clickCount`, `deltaX`, `deltaY`, `modifiers`, `pointerType` for `dispatchMouseEvent`; `type`, +`key`, `code`, `text`, `unmodifiedText`, `windowsVirtualKeyCode`, `nativeVirtualKeyCode`, +`modifiers` for `dispatchKeyEvent`; `type`, `x`, `y`, `data`, `modifiers` for +`dispatchDragEvent`. Keep `additionalProperties: true` so a future CDP field is not a hard +error. Then `drag.ts`'s tuple can be typed properly and `InputCall` hoisted to module scope. + +**Observable behaviour change.** None — same wire payloads. + +**Reproducible in a test?** Not as a runtime test (the current code is correct). It is a +compile-time guarantee: a `// @ts-expect-error`-style negative test, or simply the typecheck. + +--- + +## F18 — `browser_snapshot` silently omits `screenshotPath` when the screenshot fails + +**Location** `src/domains/snapshot.ts:383-390`, with `:399` and the renderer at `:419`. + +*Pre-confirmed item — verified.* + +**What breaks.** + +```ts +if (args.includeScreenshot) { + const shot = await session.call("Page.captureScreenshot", …); + if (shot.success) { shotPath = …; writeFileSync(…); } // no else +} +``` + +A failed capture leaves `shotPath` undefined, `details.screenshotPath` is omitted, and the +renderer prints `"screenshot omitted"` — the identical wording it uses when the caller never +asked for one. The caller who explicitly passed `includeScreenshot: true` is told nothing. + +**Trigger.** `browser_snapshot({ includeScreenshot: true })` where `Page.captureScreenshot` +fails — a detached target, a page mid-navigation, or a GPU/compositor hiccup. + +**Impact** cosmetic: the AX tree, the refs, and everything else the tool exists for are correct. +Only the screenshot's absence is unexplained. + +**Severity** cosmetic. + +**Proposed fix.** Add `details.screenshotError = shot.error.message` on the failure path and +render `"screenshot failed: …"` instead of `"screenshot omitted"`. Do not fail the tool — the +snapshot itself succeeded. + +**Observable behaviour change.** Yes — a new `details` field and a distinct message. + +**Reproducible in a test?** Yes with a fake client (`Page.captureScreenshot` → `err`). Same +missing helper as F2/F9/F14. + +--- + +## F19 — A bad `outputPath` throws out of the handler instead of returning `io_error` + +**Location** `src/domains/files.ts:212-221` (`browser_print_to_pdf`); +also `src/domains/snapshot.ts:388` (`writeFileSync`) and +`src/domains/screenshot.ts:40` (`writeFile`). + +**What breaks.** `printToPdfTool` does `await writeFile(path, …)` with `path = args.outputPath ?? pdfPath(…)` +— caller-supplied, unvalidated, and outside any try/catch. An `ENOENT`/`EACCES`/`EISDIR` +rejection escapes the handler as a thrown exception instead of the `Result<ToolOk, ToolErr>` +every tool is contracted to return. `registerBrowserTool`'s `execute` has no try/catch around +`def.handler` (`util/tool.ts:178-196`) — only a `finally` that releases the mutex — so the +rejection propagates into pi's tool runner. The mutex *is* released, so there is no deadlock. + +The other two sites write to `screenshotPath()`/`pdfPath()` paths under `tmpdir()`, so they are +only reachable on a full or read-only tmpdir — but they are the same unguarded shape. + +**Trigger.** `browser_print_to_pdf({ outputPath: "/nonexistent/dir/out.pdf" })`, or a path the +process cannot write, or a directory path. + +**Impact** The agent gets a raw host-level error instead of a typed `io_error` it can act on. +Every sibling in `files.ts` (`downloadTool`, `verifyReadable`) validates its path carefully and +returns `io_error` — this one path does not. + +**Severity** crash (of the tool call), no data loss. + +**Proposed fix.** Wrap the three writes and map to `{ kind: "io_error", message }`. Separately, +add a try/catch around `def.handler` in `util/tool.ts:178` that maps an unexpected throw to +`{ kind: "internal" }` — a defence-in-depth fix that covers all 39 tools at once and is the +better first move. + +**Observable behaviour change.** Yes — a typed tool error instead of a thrown exception. + +**Reproducible in a test?** Yes for the `util/tool.ts` guard: register a tool whose handler +throws and assert `execute` resolves to an `isError` result. For `printToPdf` specifically it +needs the fake client. + +--- + +## F20 — Aborting `browser_wait_for` throws instead of returning a typed error + +**Location** `src/domains/page.ts:132` (`await sleep(50, signal)`); `src/util/time.ts:1-16`. + +**What breaks.** `sleep` **rejects** when the signal aborts. `waitTool` wraps its `sleep` in +try/catch (`page.ts:52-57`); `waitForTool` calls it bare inside the poll loop. The loop's own +`if (signal?.aborted)` check runs at the top of each iteration, so aborting *during* the 50 ms +sleep rejects out of the handler. Same unguarded-throw path as F19. + +Note `waitForLoadTool` (`:87`) uses a raw `new Promise(setTimeout)` rather than `sleep`, so it +is not affected — the three wait tools use three different sleep strategies. + +**Trigger.** Cancelling `browser_wait_for` (Esc / agent cancellation) within a 50 ms window. + +**Impact** A thrown exception instead of the `{ kind: "internal", message: "aborted" }` the +same function returns when the check wins the race. Inconsistent, not damaging. + +**Severity** cosmetic. + +**Proposed fix.** Wrap the loop body, or make the three wait tools share one abortable-sleep +helper that returns a `Result` rather than throwing. The blanket handler try/catch from F19 +also covers it. + +**Observable behaviour change.** Yes — a typed error on cancellation. + +**Reproducible in a test?** Yes — `sleep` is pure and needs no client; the handler needs a fake +client for `evaluateJs`. The `sleep`-rejects half is testable today. + +--- + +## F21 — `openIsolatedTab` can adopt and then close the harness window's only tab + +**Location** `src/domains/isolated-tab.ts:35-63` and `:112-114` (`closeIsolatedTab`); +`src/cdp/target-factory.ts:144-182`. + +**What breaks.** When `ensureHarnessWindow` reports `freshlyCreated: true`, +`openIsolatedTab` uses the **seed tab of the newly created harness window** as its isolated tab. +`ensureHarnessWindow` has, by then, recorded that same target as `harnessWindow` and added it to +ownership. `closeIsolatedTab` closes it in the `finally`, so the window that was just +established is destroyed and the window binding is dropped (`ownership.remove` clears +`harnessWindow` when the ids match, `ownership.ts:54-57`). + +**Trigger.** `browser_web_search` or `browser_read_page({ url })` at a moment when no owned tab +is live. `ensureAlive` normally runs first and seeds a window, so the window usually already +exists and `freshlyCreated` is false — the exposure is the narrow window where the user closes +the harness tabs between `ensureAlive` and the isolated-tab call. Also reachable when the +concurrency coordinator hands `freshlyCreated: true` to the creating caller while a second +concurrent `browser_read_page` is in flight. + +**Impact** With a pinned profile, the next tool call re-runs `seedPinnedProfileWindow`, which +**launches a browser window via the command line** — visible, slow (up to 15 s of sentinel +polling), and disruptive. If the session was attached to that seed tab, the next call also gets +`session_not_found` and re-attaches. It contradicts the tool's documented promise: +*"Runs in its own isolated tab and never disturbs the user's current tab."* + +**Severity** resource leak / lifecycle churn (no wrong data). + +**Proposed fix.** Never let an isolated tab *be* the harness window's seed tab: after +`ensureHarnessWindow` returns `freshlyCreated: true`, still call `openHarnessTab` to get a +sibling, and leave the seed alone. That is a two-line change and makes the seed tab's role +(a durable window anchor) consistent across all callers. + +**Observable behaviour change.** One extra tab open+close per isolated read in the fresh-window +case; the harness window survives. + +**Reproducible in a test?** Only with a fake client that can report "no owned tabs" and record +`Target.closeTarget` calls. Fiddly but doable; the race variant is not deterministically +reproducible. **Partly unverified** — I traced it in the code but did not observe it. + +--- + +## F22 — The daemon never releases session ownership on `Target.detachFromTarget` + +**Location** `src/daemon/bridge.ts:386-391`; caller `src/client.ts:375-383`. + +**What breaks.** The bridge does `if (isDetach && req.sessionId) router.release(req.sessionId)`. +But `client.detach()` sends `Target.detachFromTarget` with the session id in **`params`**, at +browser level (`{ sessionId: null }` transport option), so `req.sessionId` on the envelope is +`undefined` and the release never runs. `EventRouter.owners` keeps the mapping for a session +that no longer exists. + +**Trigger.** `session_shutdown` (which calls `client.detach()`), every session, every time. + +**Impact** A slow leak in a long-lived daemon: `owners` grows by one entry per detached session +and is only pruned by an `Inspector.detached` event (`bridge.ts:199-201`) or `removeClient` on +disconnect. Since the transport deliberately survives session shutdown +(`index.ts:184-187`), `removeClient` does not fire. Events for a dead session would also still +be routed to the stale owner — harmless, since Chrome stops sending them. + +**Severity** resource leak (small, bounded by daemon lifetime and the 30-minute idle exit). + +**Proposed fix.** Read the id from either place: +`const sid = req.sessionId ?? asString(req.params?.["sessionId"])`. `asString` is already +imported in `bridge.ts`. + +**Observable behaviour change.** None observable; the map shrinks. + +**Reproducible in a test?** Yes — `handleRequest` is a plain function taking a `WireRequest` +and a `send` callback; `getSessionOwner` is exported for the assertion. It lands in +`test/manual/bridge-test.ts`, outside the CI glob (F30). + +--- + +## F23 — `handleRequest`'s promise is dropped with no `.catch` + +**Location** `src/daemon/index.ts:24-32`. + +**What breaks.** `cdpBridge.handleRequest(msg, client.id, send)` returns a `Promise<void>` that +is neither awaited nor `.catch`-ed, inside a `readline` `"line"` handler. Any rejection is an +unhandled rejection, which on Node ≥ 15 terminates the process by default — taking the daemon, +and every connected pi session's browser control, with it. + +**Trigger.** No reachable throw found. `handleRequest`'s only `await` is a `setTimeout` sleep; +`JSON.stringify(payload)` and `ws.send` are inside a try/catch; `mux.allocate` cannot throw. +A future edit that adds an unguarded `await` in that function makes it reachable. + +**Impact** None today. This is the same latent shape as the `bridge.ts:156` `JSON.parse` bug +that Task 4 fixed — an unguarded failure path inside a socket handler with nothing above it — +and it sits in the same file's blast radius. + +**Severity** cosmetic today, crash-class if a throw is ever introduced. + +**Proposed fix.** `void cdpBridge.handleRequest(msg, client.id, send).catch((e) => { … send an +error response … });` — or make it explicit with `process.on("unhandledRejection")` logging in +the daemon entry point. Cheap insurance for a process whose whole job is to stay up. + +**Reproducible in a test?** Yes at the seam — inject a bridge whose `handleRequest` rejects and +assert the process/handler survives. Needs the daemon test infrastructure from F30. + +--- + +## F24 — `interactiveDiff` keys the diff on `role|name`, so duplicate-named elements collapse + +**Location** `src/domains/ref-resolve.ts:96-126`. + +**What breaks.** The diff builds `prevByKey` / `curByKey` maps keyed on `` `${role}|${name}` ``. +Any two interactive elements sharing a role and an accessible name overwrite each other, so only +the last one is compared. The comment explains the choice (*"Compare by stable identity +(role|name) rather than ref position, since refs renumber"*) — which is itself the symptom of F6. + +**Trigger.** Very common: a table with a "Delete" button per row; several unlabelled textboxes +(all `textbox|`); repeated "Add to cart" buttons. + +**Impact** The "Page changes" block under-reports. A value change on row 3's field can be +attributed to row 7's, or dropped. Since the whole point of the diff is *"read it to confirm the +change landed"* (`prompt.ts:30-31`), an agent trusting it can be misled — but the primary result +line (`Filled e7 = "…"`) is still correct, so nothing depends solely on the diff. + +**Severity** cosmetic (misleading advisory output). + +**Proposed fix.** Falls out of F6's fix for free. With identity-stable refs, key the diff on +`backendNodeId` and the collapse disappears. Not worth fixing independently. + +**Reproducible in a test?** Yes, pure, from two AX fixtures with duplicate `role|name` pairs. + +--- + +## F25 — `limit: 0` would make both buffers return everything (investigated — not a live bug) + +**Location** `src/cdp/console-buffer.ts:227-228`, `src/cdp/network-buffer.ts:174-175`. + +```ts +const limit = Math.min(filter.limit ?? 50, 500); +const limited = matched.slice(-limit); +``` + +With `limit === 0`, `-0` is `0` and `matched.slice(0)` returns the **entire** array — the exact +opposite of "return nothing". + +**Why it is not a live bug.** Both tool schemas set `minimum: 1` on `limit` +(`console.ts:35`, `network.ts:80`), so a validated `0` cannot arrive. `drainConsoleBuffer` / +`drainNetworkBuffer` have no other callers in `src/`. + +**Note for the record.** This is a **guard made dead by a schema constraint** — the category the +brief flags. Here the guard is fine and the schema is right; the fragility is that the buffer's +own contract does not hold without the schema. If the buffers are ever reused (a script via +`browser_run_script` gets `daemon` = the full `BrowserClient` and can reach +`client.session().drainConsoleBuffer({ limit: 0 })` **today**), it fires. + +**Severity** cosmetic (latent; reachable only via `browser_run_script`). + +**Proposed fix.** One line in each: `if (limit <= 0) return { records: [], total, bufferOverflowed }` +— or `matched.slice(Math.max(0, matched.length - limit))`. + +**Reproducible in a test?** Yes, trivially — both buffers are pure and dependency-free, and +this is a 5-line test. Worth doing purely because it is so cheap. + +--- + +## F26 — `"Provide either \`ref\` or \`selector\`."` appears three times; at most two can share a helper + +**Location** `src/domains/form.ts:46-48`, `src/domains/keyboard.ts:167-169`, +`src/domains/files.ts:127-129`. + +*Pre-confirmed item — verified, including the caveat.* `form.ts` and `keyboard.ts` both resolve +a ref to an **objectId** via `resolveRefToObjectId` and both fall back to +`document.querySelector`, so those two can share. `files.ts` resolves to a **backendNodeId** +(`resolveRefToBackendId`, for `DOM.setFileInputFiles`) and its selector path is a genuinely +different three-stage fallback (`DOM.querySelector` → CDP set → JS `DataTransfer`). Unifying all +three would force the file-upload shape into a mould that does not fit. + +Also note `src/domains/target.ts:34` carries the sibling message +`"Provide either \`ref\` or both \`x\` and \`y\`."` for the coordinate-based tools — already +correctly factored into one place, which is the pattern to copy. + +**Severity** cosmetic. + +**Proposed fix.** Extract the shared guard + message as a constant or a small +`requireRefOrSelector(args)` helper used by `form.ts` and `keyboard.ts`. Leave `files.ts` +referencing the same message constant but keeping its own control flow. + +**Observable behaviour change.** None. + +**Reproducible in a test?** Not a bug; no test needed. A test asserting all three produce the +identical message would lock the wording. + +--- + +## F27 — The boundary scanner has no rule for `: any` + +**Location** `scripts/check-boundaries.ts:13-19` (`RULES`). + +*Pre-confirmed item — verified, including the false-positive hazard.* The five rules cover +`as any`, `@ts-ignore`/`expect-error`/`nocheck`, non-null assertions, `as {`, and +`as unknown as`. Nothing covers a bare annotation: `const x: any = …`, `(p: any) => …`, +`Promise<any>`. + +`src/` has no `: any` today, so nothing is broken — but the whole point of the scanner is that +the property cannot silently regress, and this one can. + +**The false-positive hazard is real.** A naive `/:\s*any\b/` matches the long prose +`description` strings throughout `src/domains/` — e.g. `snapshot.ts:296` +(*"or (b) browser_snapshot and browser_execute_js cannot answer your question"* is safe, but +`js.ts:48` contains *"is this element visible / enabled / checked / focused"* and +`network.ts:74`'s resource-type list and several `promptGuidelines` lines contain `any` after a +colon in ordinary English). Anchoring on a terminator is required. + +**Severity** cosmetic (process guard rail). + +**Proposed fix.** Add `{ rule: "any-annotation", re: /:\s*any\s*(?:[=;,)\]>|&]|$)/ }` — a colon, +`any`, then a type-position terminator. Verify against the current tree: the rule must report +zero violations on `src/` as it stands. `test/util/check-boundaries-test.ts` already exists and +`checkSource` takes injected files, so both the positive and negative cases are directly +testable. + +**Observable behaviour change.** None to runtime; `npm run boundaries` gains a rule. + +--- + +## F28 — `sharp-shim.ts`'s indirection has invisible intent and invites a breaking "simplification" + +**Location** `src/util/sharp-shim.ts:23,34`. + +*Pre-confirmed item — verified. Functionally correct; a maintenance trap.* + +`const SHARP_SPECIFIER: string = "sharp";` followed by `await import(SHARP_SPECIFIER)` exists so +TypeScript cannot statically resolve the specifier and therefore does not require `sharp`'s +types — which is what removed the `@ts-ignore`. Nothing in the file says so. The explicit +`: string` annotation (redundant on a string literal) is the entire mechanism: it widens the +literal type so the import is dynamic to the compiler. + +A reader applying an "inline the constant" cleanup — plausibly Task 17, which strips comments — +restores a statically-resolvable `await import("sharp")` and breaks the build in any workspace +where the optional dependency is absent. + +**Severity** cosmetic (maintainability). + +**Proposed fix.** This is the one place in the file that needs a comment to survive Task 17. +Alternatively make the intent structural and self-evident: +`const specifier = ["sh", "arp"].join("")` is worse; a named export +`const OPTIONAL_SHARP_SPECIFIER` plus one line of explanation is the honest answer. Flag it as +comment-exempt for Task 17 either way. + +**Reproducible in a test?** Partly. A test asserting `loadSharp()` returns `{ kind: "missing" }` +rather than throwing when `sharp` is absent guards the runtime behaviour, but nothing can +prevent the compile-time regression except the comment. + +--- + +## F29 — Ref-based fill/focus/select leak a CDP object handle on every call + +**Location** `src/domains/form.ts:30-45` (`runOnElement`, ref branch); +also `src/domains/keyboard.ts:152-165` (`browser_dispatch_key`, ref branch) and +`src/domains/snapshot.ts:263-281` (`liveValue`). + +*Pre-confirmed item — verified, and it is in **three** places, not one.* + +**What breaks.** `resolveRefToObjectId` produces a `Runtime.RemoteObject` handle via +`DOM.resolveNode`. Chrome retains that object (it cannot be GC'd) until +`Runtime.releaseObject` is called or the execution context is destroyed. `runOnElement` never +releases it. `resolveAndCall` in the same file **does** (`form.ts:369-371`, in a `finally`), +so the correct pattern already exists two hundred lines away, and `Runtime.releaseObject` is +already in the typed command table (`commands.ts:193`). + +`liveValue` in `snapshot.ts` is the worst site by volume: it resolves one handle **per +interactive form control per snapshot** and releases none. + +**Trigger.** Every `browser_fill`, `browser_focus`, `browser_select_option`, and +`browser_dispatch_key` on the ref path, plus every `browser_snapshot` (once per +`VALUE_ROLES` control). + +**Impact** Renderer memory growth in the user's real browser tab, proportional to how long the +session runs. Released on navigation (context destroyed), so it is bounded per page. A +long-running SPA session doing hundreds of snapshots and fills is the bad case. + +**Severity** resource leak. + +**Proposed fix.** Wrap all three in `try { … } finally { await session.call("Runtime.releaseObject", { objectId }) }`, +exactly as `resolveAndCall` does. For `runOnElement` specifically, F2's suggested fix +(route the ref branch through `resolveAndCall`) fixes the leak and the missing +`exceptionDetails` check in one change. + +**Observable behaviour change.** One extra CDP round-trip per ref operation; no user-visible +difference. + +**Reproducible in a test?** Yes with a fake client that records calls: assert +`Runtime.releaseObject` appears after `Runtime.callFunctionOn`. Same missing `makeFakeClient()` +helper as F2. + +--- + +## F30 — Coverage holes where the type system cannot help + +*Pre-confirmed item — verified against `package.json` and the test tree.* + +The `npm test` glob is `test/{profile,deep-research,domains,cdp,util}/**/*-test.ts`. Confirmed +gaps: + +- **`test/manual/` is excluded entirely** — 10 files, including `bridge-test.ts`, + `ipc-server-test.ts`, `daemon-transport-test.ts`, `socket-guard-test.ts`, + `window-binding-test.ts`, `forms-test.ts`. All the daemon and forms coverage that exists is + outside CI. +- **`test/daemon/` does not exist.** `daemon/bridge.ts:156` (the `isCdpRawMessage` guard that + stops a `null` CDP frame from killing the daemon) has no test and no type protection — + `onChromeMessage` is a closure reachable only through a live WebSocket. Fix direction: extract + `onChromeMessage`'s body as a pure `handleChromeFrame(raw, deps)` and test it directly. That + also makes F22 and F23 testable. +- **`domains/` has three test files** (`box`, `registry`, `target`). `form.ts` (425 lines), + `form-batch.ts`, `keyboard.ts`, `snapshot.ts` (477 lines), and `ref-resolve.ts` have **zero** + automated coverage — i.e. F2, F5, F6, F7, F8, F9, F24, F29 all live in untested files. + `fillFormTool` and `setCheckedTool` are newly agent-callable with no coverage at all. +- **`test/manual/forms-test.ts` does not drive `browser_fill_form` at a `<select>`**, so it + would not have caught the select-by-label class of bug (`dcfe4f3`) even if it were run. +- **The missing enabler is a `makeFakeClient()` test helper.** Eight findings above need one and + none can be reached without it. `test/cdp/session-call-test.ts`'s stub transport is the + precedent; a `BrowserClient` shim over it is the single highest-leverage piece of test + infrastructure this codebase is missing. + +**Severity** cosmetic (process), but it is the reason most findings in this report are latent +rather than caught. + +--- + +## Investigated — not bugs + +Recorded because the reasoning is worth as much as a finding. + +**F31 — `spawn.ts` resolving the daemon entry as `index.ts`.** The comment at `spawn.ts:107-112` +says the entry is `dist/daemon/index.js` when running compiled, but the code only ever builds +`join(moduleDir, "index.ts")`. Checked `package.json`: `files` ships `src/` (no `dist/`) and +`pi.extensions` is `./src/index.ts`. The package runs from TypeScript source via `tsx` in every +distribution path, so the code is right and only the comment is stale. + +**F32 — `picker.ts:61` mapping a selected label back to a profile by `options.indexOf`.** Looked +for an off-by-one or a collision: `rows[i]` is built from `labels[i]` which is built from +`profiles[i]`, and `CLEAR_SELECTION_LABEL` is appended last and handled before the `indexOf`. +`disambiguate` guarantees distinct labels, and the pinned row's `"✓ "` prefix keeps it distinct +from an unpinned duplicate. Correct. + +**F33 — `restoreState`'s fallback making the two call sites equivalent.** They *are* equivalent +whenever a persisted entry exists. The non-equivalence is narrow and specific — see F13, which +is the real finding this investigation produced. + +**F34 — `util/mutex.ts` and `util/result.ts`.** Read closely for a lost-release or a chain that +can reject. The mutex's queue is only ever advanced by `resolve`, `registerBrowserTool` releases +in a `finally`, and no handler holds the lock across a `return` without it. Correct. + +**F35 — `console-buffer.clear()` resetting `nextSeq` to 1.** This would invalidate every +outstanding `nextCursor` (a stale cursor would suppress new records whose seq restarted below +it). Grepped: `clear()` has **no caller** in `src/`. Dead code, not a bug. Worth deleting or +making seq-monotonic if it is ever wired up. + +**F36 — `bind.ts:111` `if (seed.browserContextId) client.setProfileContextId(...)`.** If a +sentinel page arrived without a `browserContextId`, `profileContextId()` stays undefined, +`canReuse` in `ensureHarnessWindowUncoordinated` stays `false` forever, and **every** +`ensureHarnessWindow` call would command-line-launch a new profile window. That would be severe. +But `TargetInfo.browserContextId` is populated for targets in a non-default browser context — +which a pinned non-default profile's window is by construction — and the launched window is +always non-default relative to the connected browser's default context. Classified **not a bug, +low residual risk**: if the pinned profile happens to *be* the browser's default context, the +field may be omitted and the degenerate loop becomes reachable. Unverified; a one-line +`else` returning a clear error would remove the doubt cheaply. + +--- + +## Ranking: is `exceptionDetails` the most severe item? + +**No — I put it fourth.** F2 is real and should be fixed, but its failure is *loud*: the agent +gets an error, the wrong error. It re-snapshots and retries. Nothing downstream is corrupted. + +Above it I place three findings whose failure is **silent**: + +1. **F1** — the harness does not work at all on Windows. Nothing else matters on that platform. +2. **F5** — `browser_fill` on a checkbox reports success for a no-op. The agent proceeds on a + false belief, submits a form with the box unticked, and reports the task done. No error is + ever surfaced. This is the single worst *behaviour* in the codebase. +3. **F6** — refs renumber under the agent's feet, so a mutation can hit the wrong element while + reporting success — on the exact workflow the system prompt recommends. + +F3 and F4 are close behind for the same reason (silent data loss), and they are the two easiest +findings in the report to reproduce and fix. + +The ordering principle: **silence beats severity**. A wrong error message costs a retry; a false +success costs correctness with no signal. + +--- + +## Coverage and confidence + +**Read thoroughly, line by line** — `cdp/` (all 17), `daemon/` (all 5), `util/` (all 10), +`schemas/` (both), the 7 root files, and in `domains/`: `form.ts`, `form-batch.ts`, +`keyboard.ts`, `ref-resolve.ts`, `snapshot.ts`, `cdp-call.ts`, `click.ts`, `navigate.ts`, +`tabs.ts`, `page.ts`, `files.ts`, `js.ts`, `network.ts`, `console.ts`, `drag.ts`, +`isolated-tab.ts`, `screenshot.ts`, `target.ts`, `box.ts`. + +**Read, lower scrutiny** — `profile/` (7 files: read in full, but the platform-specific process +detection in `browser-process.ts` (437 lines of `ps`/PowerShell/registry parsing) was assessed +for shape rather than probed per-platform; it also has the best test coverage in the repo, +`test/profile/browser-process-test.ts`). Same for `domains/search/` and `domains/readpage/` — +read in full, and their extraction/scoring logic is already pure and fixture-tested, so I looked +for integration bugs (F21) rather than re-auditing the parsers. + +**Skimmed** — the long `description` / `promptGuidelines` string blocks in every tool +definition, except where a claim contradicted the code (F5, F6, F12, F21 all turn on such a +contradiction, so the ones that mattered were read). + +**Not covered** — `test/` beyond its layout and `session-call-test.ts`'s stub; `skills/`; +`docs/`. All out of scope for a `src/` sweep. + +**Unverified, called out in place** — F1 (needs Windows), F12 (needs live Chrome), F13 (needs +the host's event ordering), F21 (traced, not observed), F36 (needs a live pinned profile in the +default browser context). F3's premise (per-session domain enablement) is standard CDP behaviour +asserted by the code's own comment but not confirmed against a browser here. + +--- + +## Summary table + +| # | Finding | Location | Severity | Pre-confirmed? | Reproducible in a test? | Fix changes behaviour? | +|---|---|---|---|---|---|---| +| **F1 ✔ fixed** | `access()` on a Windows named pipe fails ⇒ **every** browser tool returns "not initialized" | `util/tool.ts:145` | crash (platform-wide) | no | after extracting the check; real Windows behaviour needs Windows | yes (Windows only) | +| **F5 ✔ fixed** | `browser_fill` on a checkbox sets `value` not `checked`, returns `ok` | `domains/form.ts:90-105` | wrong result (silent no-op) | yes — agree, worst behaviour | needs a pure branch-decision seam, or live Chrome | yes | +| **F6 ✔ fixed** | Every mutation renumbers the ref map ⇒ held refs can target the wrong element | `domains/ref-resolve.ts:85-94` | wrong result (silent) | no | yes, pure, from AX fixtures | yes | +| **F2 ✔ fixed** | `runOnElement` ignores `exceptionDetails` ⇒ a page throw reports "No element matched" | `domains/form.ts:37-44` (3 sites) | wrong result (misdiagnosis) | yes — agree; I rank it 4th, not 1st | yes, needs a `makeFakeClient()` helper | yes | +| **F3 ✔ fixed** | `switchTo` back to a visited tab skips `enableDomains` ⇒ console/network/page events stop | `cdp/session.ts:368-377` | wrong result (silent data loss) | no | **yes — easiest in the report**, existing stub transport | yes | +| **F4 ✔ fixed** | Event loop drops all non-active-tab events, defeating the per-tab buffers | `cdp/session.ts:93` | wrong result (silent data loss) | no | yes, stub transport + pushable event queue | yes | +| F7 | Fill/select write through a **disabled** control and report `ok` | `domains/form.ts:90,254` | wrong result (silent) | yes — agree | same seam as F5 | yes | +| F9 | `browser_type` focus guard is **fail-open** when the probe itself fails | `domains/keyboard.ts:40` | wrong result (silent) | yes (as a coverage hole) — I also call it a live fail-open | yes, needs the fake client | yes | +| **F10 ✔ fixed** | Two fill engines diverge on disabled / select / **checkbox** / contenteditable / diff | `form.ts` vs `form-batch.ts` | wrong result (umbrella) | yes — table verified exactly | per-row, once the decision logic is pure | yes, substantially — needs design sign-off | +| F8 | `verified` is reported but never compared to the requested value | `domains/form.ts:161-164` | wrong result (mild — data present, unjudged) | yes — agree it's real, rank it lower | yes, the comparison is pure | yes | +| F11 | Concurrent liveness probes ⇒ second daemon unlinks the live socket, two Chrome prompts | `daemon/spawn.ts:70` + `server.ts:83` | resource leak (user-visible) | no | yes, but lands in excluded `test/manual/` | no (normal path) | +| **F12 ⚠ needs live Chrome** | `openerId` is likely not a `createTarget` param ⇒ unpinned tabs placed by focus | `cdp/commands.ts:96`, `target-factory.ts:210` | wrong result — **unverified** | yes (flagged to verify) — looks wrong; could not confirm | no; needs one live-Chrome experiment | yes if changed | +| F13 | `session_tree` before client creation resets namespace to `"default"` | `index.ts:196` | wrong result — **unverified ordering** | yes — not equivalent, in one narrow case | `restoreState` yes; the event ordering no | no (normal ordering) | +| F19 | Bad `outputPath` throws out of the handler instead of `io_error` | `domains/files.ts:219` (+2) | crash (tool call) | no | yes, via a handler-level try/catch test | yes | +| F14 | Both abort budgets are dead — `.map` starts every task before the timer can fire | `snapshot.ts:351`, `network.ts:253` | wrong result (latency) | yes (as the abort-path item) | yes for `network.ts`, needs the fake client | yes | +| F29 | Ref fill/focus/select/dispatch_key + `liveValue` leak CDP object handles (3 sites) | `form.ts:37`, `keyboard.ts:155`, `snapshot.ts:273` | resource leak | yes — agree; it is 3 sites, not 1 | yes, assert `releaseObject` is called | no | +| F21 | `openIsolatedTab` can adopt then close the harness window's only tab | `domains/isolated-tab.ts:41-47` | resource leak / churn | no | partly — race variant not deterministic | yes (one extra tab open/close) | +| F22 | Daemon misses `detachFromTarget` session release (id is in `params`, not the envelope) | `daemon/bridge.ts:389` | resource leak (small) | no | yes, but in excluded `test/manual/` | no | +| F15 | `asArrayOf` all-or-nothing blanks a whole TUI render | `util/guards.ts:13` | cosmetic (render only) | yes — agree it's real; **render-only, not wrong result** | yes, pure, test file exists | only in the failure case | +| F18 | `screenshotPath` silently omitted when the screenshot fails | `domains/snapshot.ts:384-390` | cosmetic | yes — agree | yes, needs the fake client | yes (new `details` field) | +| F16 | Strict `AxNode` array ⇒ one bad node fails all of `browser_snapshot` | `cdp/commands.ts:241` | cosmetic today; crash-class if it fires | yes — no realistic trigger found; **mitigate, don't revert** | yes, `decodeResult` is pure | only in the failure case | +| F17 | All `Input.*` params unvalidated; `drag.ts` `InputCall` untyped + declared in a handler | `cdp/commands.ts:228-231` | cosmetic (latent) | yes — verified; **no live bug**, every call site is correct | compile-time only | no | +| F23 | `handleRequest` promise dropped with no `.catch` in a socket handler | `daemon/index.ts:31` | cosmetic today; crash-class if a throw appears | no | yes, with daemon test infra | no | +| F24 | Diff keyed on `role\|name` collapses duplicate-named elements | `domains/ref-resolve.ts:98-105` | cosmetic (misleading advisory) | no | yes, pure — but fixed for free by F6 | no (subsumed) | +| F20 | Aborting `browser_wait_for` throws instead of returning a typed error | `domains/page.ts:132` | cosmetic | no | yes (`sleep` half is testable today) | yes | +| F25 | `limit: 0` ⇒ `slice(-0)` returns everything | `console-buffer.ts:228`, `network-buffer.ts:175` | cosmetic (latent) | no | **yes, 5-line pure test** — reachable via `browser_run_script` | no | +| F26 | `"Provide either ref or selector"` ×3; only 2 can share a helper | `form.ts:47`, `keyboard.ts:168`, `files.ts:128` | cosmetic | yes — agree, including the caveat | n/a | no | +| F27 | Boundary scanner has no `: any` rule; a naive regex hits prose `description`s | `scripts/check-boundaries.ts:13-19` | cosmetic (guard rail) | yes — agree, anchoring needed | yes, `checkSource` takes injected files | no | +| F28 | `sharp-shim.ts` indirection's intent is invisible; a "simplification" breaks the build | `util/sharp-shim.ts:23,34` | cosmetic (maintainability) | yes — agree; **flag comment-exempt for Task 17** | runtime half only | no | +| F30 | Coverage holes: `test/manual/` excluded, no `test/daemon/`, `form`/`snapshot`/`keyboard` untested, no `makeFakeClient()` | `package.json` test glob | cosmetic (process) | yes — verified and expanded | n/a | no | +| F31–F36 | **Investigated, not bugs**: stale `dist/` comment in `spawn.ts`; `picker.ts` index mapping; `restoreState` equivalence (⇒ F13); `mutex`/`result`; `console-buffer.clear()` is uncalled dead code; `bind.ts` missing-`browserContextId` degenerate loop (low residual risk, unverified) | various | — | — | — | — | + +**Fixed in this pass (2026-07-28 ruling):** F1, F2, F3, F4, F5+F10, F6 — six findings in +six commits, each preceded by a test that failed for the reason recorded above. Test suite +206 → 251 passing, 8 skipped, 0 failing; `npm run check` green. See *Resolution status* at +the top for commits and the three shipped behaviour changes. **F12 remains open and needs +one live-Chrome experiment.** Everything else is documented only, by ruling. + +**Counts.** 30 findings + 6 investigated-not-bugs. +By severity: **crash 2** (F1, F19) plus 2 crash-class-if-triggered (F16, F23) · +**wrong result 11** (F2, F3, F4, F5, F6, F7, F8, F9, F10, F12, F13, F14 — 12 including F14's +latency class) · **resource leak 4** (F11, F21, F22, F29) · **cosmetic 11**. +By verification: 25 verified by reading · **5 unverified** (F1 needs Windows, F12 needs live +Chrome, F13 needs the host's event ordering, F21 traced-not-observed, F36 needs a live pinned +default-context profile). +Pre-confirmed items: 12 reviewed, **12 agreed real**, 0 rejected — but three re-rated +(F8 down, F15 down to cosmetic/render-only, F17 confirmed as latent-only with no live bug), +one broadened (F29 is 3 sites, not 1), and one, F12, remains **unverified and probably wrong +as written**. |
