1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
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**.
|