@ai-ecoverse/slicc-browsers 1.18.1 → 1.18.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -195,11 +195,11 @@ The `C9` foreign extension isolated-world family requires `extensionWorld`: a br
195
195
 
196
196
  The integration launcher loads shared-web 1.25.2’s harness extension exactly once through `Extensions.loadUnpacked` (`ankmbodmgimjemnojmeddfcdlbocolob`), whose content script runs on HTTP(S) pages. shared-web 1.25.2 also loads it by default in its own harness. A transport unable to host a foreign content-script extension omits `extensionWorld` and reports `not-supported:extensionWorld`; an advertised capability with no observed world fails, rather than silently skipping.
197
197
 
198
- `C2.redirect-secret` probes ordinary fixture 302s to seven’s `/auth/callback?code=…&state=…` and vault’s `/oauth/mcp-callback?code=…`, plus `#access_token=…` variants of both. It navigates with Page and scripted `location.href`, fetches a subresource and sends an XHR. After each one it waits, at least 250 ms and up to two seconds scaled by `settleScale` (stall-tolerant), for the fixture to log the redirect URL and every request seen for it to end, before it navigates away. On the guarded leg, once one action's wait times out, the remaining actions skip it, so a guard that never terminates costs that bound once per leg. The 302 carries `Location`, `Content-Location`, `Refresh: 0;url=…` and `Link: <…>; rel=preconnect`. Page, Network (including ExtraInfo), Runtime, Log, Audits and both Fetch stages are enabled. Every received raw message is scanned, including replies but not the probe’s own observer session traffic, across an eight-second tail, for raw, percent-encoded, JSON-escaped and base64 marker representations. Both response-body APIs are attempted on each hop; guarded bodies need matched refusals. `Network.getResponseBody` is attempted, once per id, with every network id the client learns for a 301–308 hop: the `requestId` of a Network event carrying the redirect status and the `networkId` of the Fetch response-stage pause. For each registered hop whose id the client learned neither way, the probe reads the hop’s real id from the harness’s own unguarded tap (the observer session, which enables Network on the guarded leg) and attempts the read on the guarded session with it. The evidence’s `networkBodyCarriers` counts the ids per carrier, and `networkBodyPath` names the weakest carrier any id needed: `withheld+tap`, then `fetch-networkId`, then `network`, or `none` without any id. These reads never hold up a Fetch pause, and the probe gives them five seconds after `Fetch.disable` to settle, and again after the terminal wait for reads that late events started. A read still unanswered then is not a refusal. `networkBodyAttempted` and `networkBodyRefused` hold only when every attempt is refused. On the guarded leg a `fetch-networkId` or `withheld+tap` carrier also needs the same row’s unguarded control to have seen the redirect’s Network events. Without that proof, or with no id at all, both read `incomplete` and the probe returns `redirect-secret: network body leg <reason>`; an attempt that is not refused is measured and fails. Every request the client sees for a registered redirect URL, through its initial `Network.requestWillBeSent` or a redirect `requestWillBeSent` whose `redirectResponse.url` is that URL, must end within the step in a redirect `requestWillBeSent`, `loadingFinished` or `loadingFailed` for that `requestId`. Before the evidence is evaluated, on every leg, the probe waits up to five seconds scaled by `settleScale` (stall-tolerant) for every seen request to reach one of those events. The evidence’s `hops` records per `requestId` the `delivery` (`redacted` for an origin-only protected redirect in place, `withheld` for a terminal without the redirect, `ordinary`, `raw` or `unterminated`) and the `terminal` event method. On the guarded leg `requestsTerminated` is false on any `unterminated` request after that wait, a measured guard failure rather than missing evidence; on the unguarded control an `unterminated` request is missing evidence, so `requestsTerminated` reads `incomplete` and the probe returns `redirect-secret: control requests <ids> unterminated`; `hopsRedacted` is false on any `raw` or `ordinary` hop, so a delivered protected hop must be origin-only, never the raw or a substituted URL; the guarded leg expects it. Page frame trees, resource trees and navigation history are read before recovery to catch error-page leftovers. A separate structural check requires every protected `Network.Request.urlFragment` and `Page.Frame.urlFragment` to be empty or absent, even if its value contains no marker; the ordinary `#access_token` control independently requires fragment evidence in request events, frame events, frame-tree replies and resource-tree replies. The ordinary session must survive. The unguarded ordinary redirect control preserves all four headers in `redirectResponse`, ExtraInfo’s headers and `headersText`, and Fetch’s response headers. `C2.redirect-secret-mix` repeats that matrix with 301/303/307/308. The protected leg requires in-place ExtraInfo delivery with origin-only header values, not merely the absence of a marker.
198
+ `C2.redirect-secret` probes ordinary fixture 302s to seven’s `/auth/callback?code=…&state=…` and vault’s `/oauth/mcp-callback?code=…`, plus `#access_token=…` variants of both. It navigates with Page and scripted `location.href`, fetches a subresource and sends an XHR. After each one it waits, at least 250 ms and up to two seconds scaled by `settleScale` (stall-tolerant), for the fixture to log the redirect URL and every request seen for it to end, before it navigates away. On the guarded leg, once one action's wait times out, the remaining actions skip it, so a guard that never terminates costs that bound once per leg. The 302 carries `Location`, `Content-Location`, `Refresh: 0;url=…` and `Link: <…>; rel=preconnect`. Page, Network (including ExtraInfo), Runtime, Log, Audits and both Fetch stages are enabled. Every received raw message is scanned, including replies; only traffic proven to belong to the shared unguarded observer is excluded. The scan runs across an eight-second tail, for raw, percent-encoded, JSON-escaped and base64 marker representations. Both response-body APIs are attempted on each hop; guarded bodies need matched refusals. `Network.getResponseBody` is attempted, once per id, with every network id the client learns for a 301–308 hop: the `requestId` of a Network event carrying the redirect status and the `networkId` of the Fetch response-stage pause. For each registered hop whose id the client learned neither way, the probe reads the hop’s real id from the harness’s own unguarded tap (the observer session, which enables Network on the guarded leg) and attempts the read on the guarded session with it. The evidence’s `networkBodyCarriers` counts the ids per carrier, and `networkBodyPath` names the weakest carrier any id needed: `withheld+tap`, then `fetch-networkId`, then `network`, or `none` without any id. These reads never hold up a Fetch pause, and the probe gives them five seconds after `Fetch.disable` to settle, and again after the terminal wait for reads that late events started. A read still unanswered then is not a refusal. `networkBodyAttempted` and `networkBodyRefused` hold only when every attempt is refused. On the guarded leg a `fetch-networkId` or `withheld+tap` carrier also needs the same row’s unguarded control to have seen the redirect’s Network events. Without that proof, or with no id at all, both read `incomplete` and the probe returns `redirect-secret: network body leg <reason>`; an attempt that is not refused is measured and fails. Every request the client sees for a registered redirect URL, through its initial `Network.requestWillBeSent` or a redirect `requestWillBeSent` whose `redirectResponse.url` is that URL, must end within the step in a redirect `requestWillBeSent`, `loadingFinished` or `loadingFailed` for that `requestId`. Before the evidence is evaluated, on every leg, the probe waits up to five seconds scaled by `settleScale` (stall-tolerant) for every seen request to reach one of those events. The evidence’s `hops` records per `requestId` the `delivery` (`redacted` for an origin-only protected redirect in place, `withheld` for a terminal without the redirect, `ordinary`, `raw` or `unterminated`) and the `terminal` event method. On the guarded leg `requestsTerminated` is false on any `unterminated` request after that wait, a measured guard failure rather than missing evidence; on the unguarded control an `unterminated` request is missing evidence, so `requestsTerminated` reads `incomplete` and the probe returns `redirect-secret: control requests <ids> unterminated`; `hopsRedacted` is false on any `raw` or `ordinary` hop, so a delivered protected hop must be origin-only, never the raw or a substituted URL; the guarded leg expects it. Page frame trees, resource trees and navigation history are read before recovery to catch error-page leftovers. Recovery and each following action use a fresh ordinary tab, never a tab whose history contains the protected destination. Each request's body reads stay on its owning client session; all probe tabs and observers remain alive through the eight-second tail and terminal/body-read waits. A closed body owner leaves that read `incomplete`, not `sessionEnded`; another live owner of the same request id is still read. A closed retained tab does not cut the tail or the remaining actions short. `sessionSurvives` proves the client can evaluate the title in a fresh ordinary tab. A content-command refusal in a protected-history tab is not itself a failure; every received message still enters the leak checks. Missing recovery evidence or an unseen override pause is `incomplete`, not a pass. A measured leak takes precedence over incomplete evidence. A separate structural check requires every protected `Network.Request.urlFragment` and `Page.Frame.urlFragment` to be empty or absent, even if its value contains no marker; the ordinary `#access_token` control independently requires fragment evidence in request events, frame events, frame-tree replies and resource-tree replies. The ordinary session must survive. The unguarded ordinary redirect control preserves all four headers in `redirectResponse`, ExtraInfo’s headers and `headersText`, and Fetch’s response headers. `C2.redirect-secret-mix` repeats that matrix with 301/303/307/308. The protected leg requires in-place ExtraInfo delivery with origin-only header values, not merely the absence of a marker.
199
199
 
200
200
  `C2.redirect-body` calls `Network.loadNetworkResource` on an ordinary redirect with `includeCredentials: true`, reads any stream with `IO.read`, replays a redirected XHR, requests Page resource content, and captures Page/MHTML and DOM snapshots after a main-frame redirect. It also captures an ordinary parent containing an iframe redirected to vault, including its error document, and overrides a paused ordinary Fetch request to the allowed redirect URL. No protected marker may appear in any message or body, and no request from those operations may send the independently seeded seven cookie. The ordinary control must return the body, resource content and snapshots, and accept the URL override. Its network-body carrier, `networkBodyAttempted`, `networkBodyRefused`, `hops`, `requestsTerminated` and `hopsRedacted` follow `C2.redirect-secret`. Each probe cleans up its redirect registrations, observer session and cookie. The focused Chrome tests use HTTPS with SPKI-pinned fixture trust and default-port protected URLs; host resolver rules route seven and vault to the fixture rather than the public service.
201
201
 
202
- Every C2 redirect probe reports `sessionEnded`. It becomes true when the guarded client’s session ends during the probe: a `Target.detachedFromTarget` for that session, or a CDP reply that the session is closed or not found, or that the target, websocket or transport is closed. `Inspected target navigated or closed` is an ordinary reply on a live session, not a session end. When an expected Fetch pause or XHR event is missing, the probe sends one `Runtime.evaluate` to check that the session is still alive. Once the session has ended, the probe sends nothing more on it, skips the eight-second tail and the termination wait, and evaluates what the client received before the end. `secretHidden`, `protectedBodyHidden`, `cookieHidden`, `fragmentsHidden` and `hopsRedacted` are measured on those messages, replies, bodies and fixture logs, so a guard that leaked before the end still fails them. A clean end passes them. `sessionSurvives` reads false. Checks that need evidence from after the end read `incomplete`, with `redirect-secret: session ended (<how>)` in the probe’s `incomplete`. If the end cuts the probe’s action matrix short, or leaves any body read pending or unanswered, then `fetchBodyAttempted`, `fetchBodyRefused` and the network-body leg read `incomplete`, even after some refused reads. Any answer that isn’t a refusal still fails its refusal check. `requestsTerminated` reads `incomplete` when requests are unterminated or the matrix was cut short. A short but origin-only `extraLocation` reads `incomplete`. So does each attempt or override check whose command never reached the client: it wasn’t sent because the session had already ended, or its reply was the session end. On the unguarded control, a session end is an error: the probe throws `redirect-secret: control session ended (<how>)`.
202
+ Every C2 redirect probe reports `sessionEnded`. It becomes true when the guarded client’s active session ends during the probe: a `Target.detachedFromTarget` for that active session unless its owner is already known closed, a non-body CDP reply that the active session is closed or not found, or that the websocket or transport is closed. `Inspected target navigated or closed` is an ordinary reply on a live session, not a session end. When an expected Fetch pause or XHR event is missing, the probe sends one `Runtime.evaluate` to check that the session is still alive. Once the session has ended, the probe sends nothing more on it, skips the eight-second tail and the termination wait, and evaluates what the client received before the end. `secretHidden`, `protectedBodyHidden`, `cookieHidden`, `fragmentsHidden` and `hopsRedacted` are measured on those messages, replies, bodies and fixture logs, so a guard that leaked before the end still fails them. A clean end passes them. `sessionSurvives` reads false. Checks that need evidence from after the end read `incomplete`, with `redirect-secret: session ended (<how>)` in the probe’s `incomplete`. If the end cuts the probe’s action matrix short, or leaves any body read pending or unanswered, then `fetchBodyAttempted`, `fetchBodyRefused` and the network-body leg read `incomplete`, even after some refused reads. Any answer that isn’t a refusal still fails its refusal check. `requestsTerminated` reads `incomplete` when requests are unterminated or the matrix was cut short. A short but origin-only `extraLocation` reads `incomplete`. So does each attempt or override check whose command never reached the client: it wasn’t sent because the session had already ended, or its reply was the session end. On the unguarded control, a session end is an error: the probe throws `redirect-secret: control session ended (<how>)`.
203
203
 
204
204
  `C8.unparseable` probes both raw and canonical space/U+200D/invalid-punycode URLs through creation and navigation. Every guarded destination needs a matched refusal. Its unguarded controls record `document` or `native-rejection` outcomes, including Chrome builds that reject raw U+200D instead of committing it. Both raw and encoded-space forms must commit real fixture documents, because that is the proven cookie leak. An independent observer loads the encoded-space document and confirms it shares a uniquely seeded `.sliccy.ai` cookie; the guarded client must neither list nor attach to it, read its cookie, query that cookie by URL, nor cause a cookie-bearing destination request. The guard does not erase the observer’s private browser state. The row runs on both browser-session and page-session-only transports, uses the default context, and cleans up its own targets and cookie. Cookie seeding always uses the unguarded observer’s browser session, never the guarded client. Only guarded browser-session target listing requires `browserSession`; without it, `listed` records `not-supported` explicitly in probe and observation evidence, and the result lists that field in `notSupported`. Creation, navigation, attachment and `Network.getCookies` with `urls` still require matched refusals; filtering cookies without refusing fails `cookieRefused`. Fixture URLs omit the colon when the port is empty, and reported URLs are compared after canonicalisation, including Chrome’s WHATWG-unparseable encoded-space hosts. Launchers must resolve the raw/canonical space, U+200D/punycode and invalid-punycode `.sliccy.ai` fixture hosts to the fixture server, including for default-port HTTP or HTTPS origins. The HTTP fixture accepts Chrome’s encoded-space Host header without reparsing it as a WHATWG origin.
205
205
 
@@ -207,9 +207,9 @@ Every C2 redirect probe reports `sessionEnded`. It becomes true when the guarded
207
207
 
208
208
  The `C8.newtab` reachability control attaches through the guard to a settled ordinary new tab, including its internal frames, then navigates that same tab to an ordinary document. Both legs must succeed, including without a guard. It requires `chromeUrlsAttach`, so an extension transport reports it as `skip` with `not-supported:chromeUrlsAttach`. The C8 protection rows expect a refusal on the guarded leg and run their controls unguarded, so they don't require it and still run on extension transports. It uses the default browser context, since an incognito new tab omits the ordinary new tab's internal frames. It covers `chrome://newtab/` and directly opened `chrome://new-tab-page/`.
209
209
 
210
- `C8.protected-bfcache-restore` and `.history` test history restoration on seven and vault, with both detach variants. The independent observer seeds ordinary A, protected P and ordinary B on one tab. `/bfcache?n=<tag>` has no unload handler: it retains a global and sessionStorage marker, records `pageshow.persisted` and counts key events. The guarded client must answer on B before the gate is armed. The first row restores P with the observer's `Page.navigateToHistoryEntry`; `.history` sends that navigation through the client instead. A matched client navigation refusal lets the observer restore the same entry. The unguarded control must observe a real `BackForwardCacheRestore`, persisted pageshow, the marker and protected origin in evaluate, a touched global, and positive evidence from every probe method.
210
+ `C8.protected-bfcache-restore` and `.history` test history restoration on seven and vault, with both detach variants. The independent observer seeds ordinary A, protected P and ordinary B on one tab before the first client attach. `/bfcache?n=<tag>` has no unload handler: it retains a global and sessionStorage marker, records `pageshow.persisted` and counts key events. The attached client acknowledges B with `Page.getFrameTree` before the gate is armed; no client script is required. Missing acknowledgement leaves the affected leg and guarded liveness `incomplete`. The first row restores P with the observer's `Page.navigateToHistoryEntry`; `.history` sends that navigation through the client instead. A matched client navigation refusal lets the observer restore the same entry. The unguarded control must observe a real `BackForwardCacheRestore`, persisted pageshow, the marker and protected origin in evaluate, a touched global, and positive evidence from every probe method.
211
211
 
212
- The rows require `inboundDelay`. The guarded client supplies `inboundDelay({ targetId, sessionId, ms, side, client, gate: true })`, a harness gate before the guard's inbound stream. It holds every browser-to-guard message until explicit release; guard-to-browser writes remain possible. The control gates only its unguarded client stream, not the independent observer session. Immediately before restoration the runner requests a gate with a 1500 ms minimum hold. The hook returns `{ targetId, sessionId, ms, gate: true, protectedObserved, ordinaryObserved, release, audit? }`. Both observed flags acknowledge delivery of the protected seed and subsequent ordinary B through the relevant stream before injection. `release()` restores delivery and returns `{ delayedEvents, firstDelayedAt, releasedAt }`, counting held restore events and recording their first hold and actual release times. Missing capability or hook is `not-supported:inboundDelay`; an unacknowledged gate, absent seed acknowledgement or no held restore event is `incomplete`. A timer-only event delay does not meet this contract.
212
+ The rows require `inboundDelay`. The guarded client supplies `inboundDelay({ targetId, sessionId, ms, side, client, gate: true })`, a harness gate before the guard's inbound stream. It holds every browser-to-guard message until explicit release; guard-to-browser writes remain possible. The control gates only its unguarded client stream, not the independent observer session. Immediately before restoration the runner requests a gate with a 1500 ms minimum hold. The hook returns `{ targetId, sessionId, ms, gate: true, protectedObserved, ordinaryObserved, release, audit? }`. Both observed flags acknowledge the observer’s protected seed and subsequent ordinary B before injection. `release()` restores delivery and returns `{ delayedEvents, firstDelayedAt, releasedAt }`, counting held restore events and recording their first hold and actual release times. Missing capability or hook is `not-supported:inboundDelay`; an unacknowledged gate, absent seed acknowledgement or no held restore event is `incomplete`. A timer-only event delay does not meet this contract.
213
213
 
214
214
  After the observer confirms the persisted protected document, the client repeatedly probes `Runtime.evaluate`, `Runtime.callFunctionOn`, `DOM.getDocument`, `Accessibility.getFullAXTree`, `Input.dispatchKeyEvent`, `Page.captureScreenshot` and `IO.read` for 2500 ms while the gate stays closed. Runtime probes write `__touched`; Input is measured by the fixture's key counter. The observer supplies a real network-resource stream for IO's positive control: the read must use that handle and show the document marker, including base64-encoded bodies. Screenshot controls must return PNG bytes captured while P is current that match the independent observer's capture of P; unrelated or merely nonempty data is not evidence. The guard must hide the marker from all received messages and events and hide the protected origin from every probe reply, not only Runtime replies, and leave both side effects absent on P. A returned screenshot while P is current fails, regardless of its encoded contents. Refused, answered and unanswered outcomes are retained individually; an unanswered call alone never proves protection. The observer measures P's marker, persisted state and side effects before and after release. Missing restore, fixture, hold or side-effect evidence is `incomplete`, even when every measured observation matches; measured leaks or side effects fail even beside missing evidence. After release the runner drains probe replies for up to 5000 ms, scaled by `settleScale`, before scanning them and judging screenshots. An unresolved reply needs a complete final forwarding ledger proving terminal non-forwarding; otherwise its missing measurement is `incomplete`. A measured failure takes precedence over incomplete evidence.
215
215
 
@@ -283,7 +283,7 @@ Popups are observed by `Target.targetCreated` with the opener's `openerId`, or b
283
283
 
284
284
  `{ "extensionWorld": "hidden" | "select" | "debugger" | "drivable", "as": "world", "requires": ["extensionWorld"] }` runs the C9 compound probe on the current ordinary page. The runner attaches an independent observer to discover actual context ids, unique ids and names, and sends probes through the client session. Discovery matches the expected fixture extension’s exact origin, never the first extension world: `run()`’s optional `extensionWorldId` defaults to shared-web’s harness id `ankmbodmgimjemnojmeddfcdlbocolob`. Multiple unrelated content-script worlds are ignored for fixture selection, while guards must still protect foreign worlds. A custom fixture must provide the same content-script marker contract. Object handles are minted and validated on the unguarded control session, then replayed as guesses on the guarded leg; Chrome object handles and DOM node ids are session-local, so observer handles must never be mistaken for client handles. Numeric ids from the control are also replayed alongside fresh observer-discovered selectors, but numeric ids are local to a tab: a control id may name the next tab’s exposed default context, which must work and return no extension marker. The same-tab hidden world’s actual numeric id and every extension unique-id guess still require a matched refusal. Unknown numeric ids and stale object handles (`not exposed`) need explicit refusals rather than Chrome’s invalid-id errors. `guessesRefused` aggregates only required refusals; `guessesReached` must be false on the guarded leg, and `exposedContextsWork` must remain true. Its result contains boolean evidence consumed by ordinary `result` checks. The unguarded leg validates those selectors against Chrome itself before guarded evidence can pass.
285
285
 
286
- `{ "errorPage": "recover", "secret"?: "all" | "query" | "fragment", "as": "errorPage" }` (also modes `restore` and `sticky`; `secret` defaults to `all`) runs the protected-error-page compound probe with independent commit evidence, discovery redaction and recovery or sticky-lock checks. The unguarded observer seeds the protected history and failed commit, so the rows run on page-session-only transports too. The guarded client uses only `Target.getTargetInfo` with a `targetId`, `Target.getTargets`, `Target.setDiscoverTargets`, `Target.attachToTarget`, `Target.detachFromTarget` and `Target.closeTarget` without a session, which page-session transports answer themselves, and everything else on its page session; the rows require no whole-vector capability. Only `eventsRedacted` requires `discovery`: without it the probe does not call `Target.setDiscoverTargets`, that one field is `not-supported` in both legs, and the notes say `not-supported:discovery`; info, list, attach, recovery and lock checks still run. Discovery absence is structural: a guarded transport delivering either `Target.targetCreated` or `Target.targetInfoChanged` during such a row without declaring `discovery` fails the row, even when the event names an ordinary target. `Target.attachedToTarget` remains independently checked by `attachRedacted`. The runner leaves this probe’s independent Fetch sessions to the probe rather than answering their pauses itself. Each of the probe’s waits is bounded at 5000 ms, scaled by `settleScale`. When the probe’s own evidence is missing, it returns `incomplete` with `protected-error-page: <reason>` instead of throwing: the observer target could not be created or attached (an `openerId` included, after cleaning the target up), the observer’s history seed or allowed document did not commit (or its setup failed), the client’s `Page.navigate` to the trigger failed or was refused, the control’s own Fetch failure injection left no error commit, the protected history entry is missing, no real `BackForwardCacheRestore` arrived, or the observer’s sticky failure left no error page. The guarded leg’s missing error commit is not incomplete: it is the guard’s, and stays `live: false`.
286
+ `{ "errorPage": "recover", "secret"?: "all" | "query" | "fragment", "as": "errorPage" }` (also modes `restore` and `sticky`; `secret` defaults to `all`) runs the protected-error-page compound probe with independent commit evidence, discovery redaction and recovery or sticky-lock checks. The unguarded observer seeds the protected history and failed commit, so the rows run on page-session-only transports too. The guarded client uses only `Target.getTargetInfo` with a `targetId`, `Target.getTargets`, `Target.setDiscoverTargets`, `Target.attachToTarget`, `Target.detachFromTarget` and `Target.closeTarget` without a session, which page-session transports answer themselves, and everything else on its page session; the rows require no whole-vector capability. Only `eventsRedacted` requires `discovery`: without it the probe does not call `Target.setDiscoverTargets`, that one field is `not-supported` in both legs, and the notes say `not-supported:discovery`; info, list, attach, recovery and lock checks still run. Discovery absence is structural: a guarded transport delivering either `Target.targetCreated` or `Target.targetInfoChanged` during such a row without declaring `discovery` fails the row, even when the event names an ordinary target. `Target.attachedToTarget` remains independently checked by `attachRedacted`. The runner leaves this probe’s independent Fetch sessions to the probe rather than answering their pauses itself. Each of the probe’s waits is bounded at 5000 ms, scaled by `settleScale`. Its `diagnostics` record the client’s `Target.attachToTarget` as `clientAttach`, whose `outcome` is `answered` (with `sessionId`, whether the reply carried one), `refused` or `error` (both with the `error` text), and, once the client has navigated, the error-commit wait as `errorCommit`: `seen`, `elapsedMs` when seen or at timeout, `budgetMs` with `settleScale` applied, and the last `frame` and `targetInfo` read. They change no verdict. The row carries them as `evidence.control.errorPage` and `evidence.guarded.errorPage`, and a guarded leg that is not live because the attach gave no session or the commit was not seen says why: `not live: client attach refused: <text>` (or `failed: <text>`, or `answered without a sessionId`), or `not live: error commit not seen after <elapsed>ms (budget <budget>ms): frame <url> unreachableUrl <url or none>, target <url>`. When the probe’s own evidence is missing, it returns `incomplete` with `protected-error-page: <reason>` instead of throwing: the observer target could not be created or attached (an `openerId` included, after cleaning the target up), the observer’s history seed or allowed document did not commit (or its setup failed), the client’s `Page.navigate` to the trigger failed or was refused, the control’s own Fetch failure injection left no error commit, the protected history entry is missing, no real `BackForwardCacheRestore` arrived, or the observer’s sticky failure left no error page. The guarded leg’s missing error commit is not incomplete: it is the guard’s, and stays `live: false`.
287
287
 
288
288
  `{ "redirectSecret": "headers" | "mix" | "body", "as": "redirect" }` runs a C2 compound probe and owns its Fetch pauses. The client must expose `messages`, an append-only array of every raw received CDP text frame, recorded before parsing; an events-only or reply-only tap fails `rawRecorded`. Whenever a run includes a `C2.redirect-*` row or a `redirectSecret` step, `run()` retains an explicit `fixture.redirectSecret` hook or automatically installs a fetch-only `POST /redirect` bridge. Its base is `fixtureBase`, then `fixture.origin`, then `targets.ordinary`; a browser-page alias or reverse proxy must forward that endpoint to the bundled fixture. The bundled `fixtureServer()` always supplies both its direct hook and HTTP control endpoint, so no custom registration method is needed. Missing, unreachable or non-successful registration is an explicit row/summary error containing `redirectSecret fixture registration required`, never a pass or a skipped observation. Fixture logs must include headers for the cookie check. The guard’s events and raw messages must represent the same delivered stream. The leak scan memoizes each decoded string and yields to the event loop between items, so concurrent rows in the same process are not starved. When the probe’s own unguarded observer setup (attach, `Page.enable` and the seven cookie) fails before the client is used, the probe returns `incomplete` with `redirect-secret: observer setup failed: <reason>` instead of throwing.
289
289
 
@@ -109,33 +109,23 @@ class Restore {
109
109
  await this.read('Page.enable', {});
110
110
  if (!(await this.loaded(`${this.ordinary}/bfcache?n=${this.tag}-a`)))
111
111
  return this.missingEvidence('ordinary A did not commit');
112
- if (!(await this.attach())) return this.missingEvidence('initial client attach unanswered');
113
- await this.call('Page.enable', {});
114
112
  await this.read('Page.navigate', { url: this.url });
115
113
  if (!(await this.loaded(this.url)))
116
114
  return this.missingEvidence('protected seed did not commit');
117
115
  const state = await this.state();
118
116
  if (state?.marker !== this.marker || state.stored !== this.marker)
119
117
  return this.missingEvidence('protected fixture marker absent');
120
- const lock = await this.call('Runtime.evaluate', {
121
- expression: 'location.origin',
122
- returnByValue: true,
123
- });
124
- this.result.protectedLocked = refusal(lock);
125
- this.result.seed = { protectedURL: this.url, lock };
118
+ this.result.seed = { protectedURL: this.url };
126
119
  const b = `${this.ordinary}/bfcache?n=${this.tag}-b`;
127
120
  await this.read('Page.navigate', { url: b });
128
121
  if (!(await this.loaded(b))) return this.missingEvidence('ordinary B did not commit');
129
- await bounded(
130
- this.client.send('Target.detachFromTarget', { sessionId: this.session }),
131
- this.budget
132
- );
133
- const live = (await this.attach())
134
- ? await this.call('Runtime.evaluate', { expression: 'location.href', returnByValue: true })
135
- : { ok: false };
136
- this.result.sessionLive = value(live) === b;
122
+ if (!(await this.attach())) return this.missingEvidence('initial client attach unanswered');
123
+ const live = await this.call('Page.getFrameTree', {});
124
+ const frame = live.result?.frameTree?.frame;
137
125
  this.result.seed.ordinaryAnswer = live;
138
- if (!this.result.sessionLive) return false;
126
+ if (!(live.ok && frame?.url === b && !frame.unreachableUrl))
127
+ return this.missingEvidence('ordinary B acknowledgement unavailable');
128
+ this.result.sessionLive = true;
139
129
  await this.call('Page.enable', {});
140
130
  const history = await this.read('Page.getNavigationHistory', {});
141
131
  this.entry = history.result?.entries?.find((item) => item.url === this.url);
package/guard-runner.mjs CHANGED
@@ -753,6 +753,7 @@ class Execution {
753
753
  side: this.side,
754
754
  settleScale: this.scale,
755
755
  controls: (this.clients.redirectSecretControls ??= {}),
756
+ internalSessions: this.internalSessions,
756
757
  })
757
758
  );
758
759
  } finally {
@@ -1283,7 +1284,10 @@ class Execution {
1283
1284
  if (!live && this.unmeasured(this.vector.liveCheck))
1284
1285
  return { live: 'incomplete', liveWhy: 'incomplete' };
1285
1286
  if (!live && !unseen.length) live = await this.poll(alive, 1500);
1286
- if (!unseen.length || (live && !incomplete.length)) return { live };
1287
+ if (!unseen.length || (live && !incomplete.length))
1288
+ return live
1289
+ ? { live }
1290
+ : { live, liveWhy: this.calls[this.vector.liveCheck.result]?.result?.liveWhy };
1287
1291
  const detail = incomplete.length ? ` (${incomplete.join(', ')} incomplete)` : '';
1288
1292
  return { live: false, liveWhy: `pre beacon not observed for ${unseen.join(', ')}${detail}` };
1289
1293
  }
@@ -1321,8 +1325,10 @@ class Execution {
1321
1325
  incomplete: this.incomplete,
1322
1326
  evidence: Object.fromEntries(
1323
1327
  Object.entries(this.calls)
1324
- .filter(([, out]) => out?.result?.delay && out.result.probes)
1325
- .map(([name, out]) => [name, out.result])
1328
+ .filter(
1329
+ ([, out]) => (out?.result?.delay && out.result.probes) || out?.result?.diagnostics
1330
+ )
1331
+ .map(([name, out]) => [name, out.result.diagnostics ?? out.result])
1326
1332
  ),
1327
1333
  };
1328
1334
  } finally {
@@ -1746,6 +1752,7 @@ function settleIncomplete(row, vector, base, legs) {
1746
1752
  control: fill('control'),
1747
1753
  guarded: fill('guarded'),
1748
1754
  live: legs.control.live === true && [true, 'incomplete'].includes(legs.guarded.live),
1755
+ liveWhy: legs.guarded.liveWhy,
1749
1756
  });
1750
1757
  for (const side of ['control', 'guarded'])
1751
1758
  if (legs[side].incomplete?.length && filled[side] === 'pass') row[side] = 'incomplete';
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "setVersion": "1.18.1",
3
+ "setVersion": "1.18.4",
4
4
  "sources": [
5
5
  {
6
6
  "repo": "ai-ecoverse/slicc-cli",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-ecoverse/slicc-browsers",
3
- "version": "1.18.1",
3
+ "version": "1.18.4",
4
4
  "description": "SLICC's shared browser catalog, JSON schemas and discovery conformance corpus",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -68,7 +68,7 @@
68
68
  "validate": "node scripts/validate.mjs",
69
69
  "lint": "slicc-lint && test -z \"$(gofmt -l .)\" && go mod tidy -diff && go vet ./... && golangci-lint run && cargo fmt --check && cargo clippy --all-features --all-targets -- -D warnings",
70
70
  "format": "biome check --write . && gofmt -w . && cargo fmt",
71
- "test": "npm run validate && node --test --test-concurrency=1 test/integration/self-contained.test.mjs test/integration/browser-runner.test.mjs test/integration/fixture-client.test.mjs test/integration/git-retry.test.mjs test/integration/release-line.test.mjs test/integration/guard-redirect-carrier.test.mjs test/integration/guard-redirect-session-end.test.mjs test/integration/guard-incomplete.test.mjs test/integration/guard-stall.test.mjs test/integration/guard-forged.test.mjs test/integration/guard-exit-steps.test.mjs test/integration/guard-frame-read.test.mjs test/integration/guard-bfcache.test.mjs test/integration/guard-bfcache-review.test.mjs test/integration/guard-error-page-navigation.test.mjs && go test -race -count=1 ./test/integration/... && cargo test --all-features",
71
+ "test": "npm run validate && node --test --test-concurrency=1 test/integration/self-contained.test.mjs test/integration/browser-runner.test.mjs test/integration/fixture-client.test.mjs test/integration/git-retry.test.mjs test/integration/release-line.test.mjs test/integration/guard-redirect-carrier.test.mjs test/integration/guard-redirect-session-end.test.mjs test/integration/guard-redirect-history.test.mjs test/integration/guard-incomplete.test.mjs test/integration/guard-stall.test.mjs test/integration/guard-forged.test.mjs test/integration/guard-exit-steps.test.mjs test/integration/guard-frame-read.test.mjs test/integration/guard-bfcache.test.mjs test/integration/guard-bfcache-review.test.mjs test/integration/guard-bfcache-quarantine.test.mjs test/integration/guard-error-page-navigation.test.mjs && go test -race -count=1 ./test/integration/... && cargo test --all-features",
72
72
  "test:unit": "mkdir -p coverage/unit && node --experimental-test-coverage --test --test-concurrency=1 --test-reporter=lcov --test-reporter-destination=coverage/unit/lcov.info test/unit/*.test.mjs && go test -race -cover ./...",
73
73
  "build": "go build ./... && cargo build --all-features && cargo build --target wasm32-wasip1 --all-features && npm pack --dry-run",
74
74
  "trust": "npx fledgling sync -y --skip-publish",
@@ -79,7 +79,7 @@
79
79
  "test:guard-sweep": "node scripts/test-guards.mjs",
80
80
  "test:gates": "node --test test/integration/gates.test.mjs",
81
81
  "test:guard-target": "node --test test/integration/guard-runner.test.mjs",
82
- "test:unit:redirect": "mkdir -p coverage/unit && node --experimental-test-coverage --test --test-coverage-include=redirect-secret.mjs --test-coverage-include=redirect-fragments.mjs --test-reporter=lcov --test-reporter-destination=coverage/unit/redirect.lcov test/unit/guard-redirect-*.test.mjs && slicc-diff-cover coverage/unit/redirect.lcov",
82
+ "test:unit:redirect": "mkdir -p coverage/unit && node --experimental-test-coverage --test --test-concurrency=1 --test-coverage-include=redirect-secret.mjs --test-coverage-include=redirect-fragments.mjs --test-reporter=lcov --test-reporter-destination=coverage/unit/redirect.lcov test/unit/guard-redirect-*.test.mjs && slicc-diff-cover coverage/unit/redirect.lcov",
83
83
  "test:unit:guard": "mkdir -p coverage/unit && node --experimental-vm-modules --no-warnings --experimental-test-coverage --test --test-concurrency=1 --test-coverage-include=guard-runner.mjs --test-coverage-include=fixture-server.mjs --test-coverage-include=fixture-client.mjs --test-coverage-include=observer-target.mjs --test-coverage-include=frame-read.mjs --test-coverage-include=bfcache-restore.mjs --test-coverage-include=protected-error-page.mjs --test-coverage-include=unparseable-url.mjs --test-coverage-include=scripts/browser-runner.mjs --test-coverage-include=scripts/validate.mjs --test-reporter=lcov --test-reporter-destination=coverage/unit/guard.lcov test/unit/guard-*.test.mjs && slicc-diff-cover coverage/unit/guard.lcov"
84
84
  },
85
85
  "devDependencies": {
@@ -4,6 +4,22 @@ import { createObserverTarget } from './observer-target.mjs';
4
4
  const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
5
5
  const refused = (out) => !out.ok && /^slicc: refused:/.test(out.error?.message ?? '');
6
6
 
7
+ function attachOutcome(out) {
8
+ if (out.ok) return { outcome: 'answered', sessionId: Boolean(out.result?.sessionId) };
9
+ return {
10
+ outcome: refused(out) ? 'refused' : 'error',
11
+ error: String(out.error?.message ?? out.error),
12
+ };
13
+ }
14
+
15
+ const attachWhy = ({ outcome: kind, error }) =>
16
+ kind === 'answered'
17
+ ? 'client attach answered without a sessionId'
18
+ : `client attach ${kind === 'refused' ? 'refused' : 'failed'}: ${error}`;
19
+
20
+ const commitWhy = ({ elapsedMs, budgetMs, frame, targetInfo }) =>
21
+ `error commit not seen after ${elapsedMs}ms (budget ${budgetMs}ms): frame ${frame.url} unreachableUrl ${frame.unreachableUrl ?? 'none'}, target ${targetInfo.url}`;
22
+
7
23
  async function loadedDocument(reader, observer, url, until) {
8
24
  return until(async () => {
9
25
  const { frameTree } = await reader.send('Page.getFrameTree', {}, observer);
@@ -197,7 +213,14 @@ export async function protectedErrorPage({
197
213
  client.send('Target.attachToTarget', { targetId, flatten: true })
198
214
  );
199
215
  session = attached.result?.sessionId;
200
- if (!session) return { live: false, attached: false };
216
+ result.diagnostics = { clientAttach: attachOutcome(attached) };
217
+ if (!session)
218
+ return {
219
+ ...result,
220
+ live: false,
221
+ attached: false,
222
+ liveWhy: attachWhy(result.diagnostics.clientAttach),
223
+ };
201
224
  await client.send('Page.enable', {}, session);
202
225
  await client.send('Runtime.enable', {}, session);
203
226
  if (discovery) await client.send('Target.setDiscoverTargets', { discover: true });
@@ -212,6 +235,7 @@ export async function protectedErrorPage({
212
235
  const unsent = await scripted(client, session, ordinary, failedURL);
213
236
  if (unsent) return missing(unsent);
214
237
  let evidence;
238
+ const waited = Date.now();
215
239
  const live = await until(async () => {
216
240
  const f = await frame();
217
241
  const { targetInfo } = await reader.send('Target.getTargetInfo', { targetId });
@@ -224,10 +248,16 @@ export async function protectedErrorPage({
224
248
  );
225
249
  });
226
250
  result.evidence = evidence;
251
+ result.diagnostics.errorCommit = {
252
+ seen: live,
253
+ elapsedMs: Date.now() - waited,
254
+ budgetMs: budget,
255
+ ...evidence,
256
+ };
227
257
  if (!live && side === 'control')
228
258
  return missing(`missing error commit ${JSON.stringify(evidence)}`);
229
259
  result.live = live;
230
- if (!live) return result;
260
+ if (!live) return { ...result, liveWhy: commitWhy(result.diagnostics.errorCommit) };
231
261
  await redaction({ client, targetId, failedURL, protectedOrigin, start, result, discovery });
232
262
  const beforeReload = (await frame()).loaderId;
233
263
  result.reload = (await outcome(client.send('Page.reload', {}, session))).ok;
@@ -1,4 +1,5 @@
1
1
  import { load, outcome, waitFor } from './guard-driver.mjs';
2
+ import { createObserverTarget } from './observer-target.mjs';
2
3
  import { redirectFragments } from './redirect-fragments.mjs';
3
4
 
4
5
  const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -26,6 +27,12 @@ const gone = (out) =>
26
27
  /session with given id not found|no session with given id|\bsession (?:has been |is |was )?(?:closed|detached)|\btarget closed|target page, context or browser has been closed|\b(?:cdp|connection|transport|websocket|socket) (?:has been |is |was )?closed|websocket is not open/i.test(
27
28
  out.error?.message ?? ''
28
29
  );
30
+ const connectionGone = (out) =>
31
+ !out.ok &&
32
+ /\b(?:cdp|connection|transport|websocket|socket) (?:has been |is |was )?closed|websocket is not open/i.test(
33
+ out.error?.message ?? ''
34
+ );
35
+ const bodyRead = (method) => /^(?:Network|Fetch)\.getResponseBody$/.test(method);
29
36
  const redirects = [301, 302, 303, 307, 308];
30
37
  const redirectHeaders = (to) => ({
31
38
  location: to,
@@ -98,6 +105,11 @@ class RedirectProbe {
98
105
  this.networkBodies = new Set();
99
106
  this.learned = { network: new Set(), fetch: new Set(), tap: new Set() };
100
107
  this.registrations = [];
108
+ this.sessions = new Map([[this.session, undefined]]);
109
+ this.observers = new Set();
110
+ this.closedSessions = new Set();
111
+ this.targets = new Set();
112
+ this.missing = [];
101
113
  this.seen = new Map();
102
114
  this.scale = this.settleScale ?? 1;
103
115
  this.protectedURLs = [
@@ -118,14 +130,21 @@ class RedirectProbe {
118
130
  const events = this.client.events;
119
131
  for (; !this.end && this.detachScan < events.length; this.detachScan++) {
120
132
  const event = events[this.detachScan];
121
- if (event.method === 'Target.detachedFromTarget' && event.params?.sessionId === this.session)
122
- this.end = 'Target.detachedFromTarget';
133
+ if (
134
+ event.method === 'Target.detachedFromTarget' &&
135
+ this.sessions.has(event.params?.sessionId)
136
+ ) {
137
+ const session = event.params.sessionId;
138
+ if (session === this.session && !this.closedSessions.has(session))
139
+ this.end = 'Target.detachedFromTarget';
140
+ else this.closedSessions.add(session);
141
+ }
123
142
  }
124
143
  return Boolean(this.end);
125
144
  }
126
145
 
127
146
  cut() {
128
- return Boolean(this.end) && !this.complete;
147
+ return Boolean(this.end || this.halted) && !this.complete;
129
148
  }
130
149
 
131
150
  async lost() {
@@ -135,20 +154,35 @@ class RedirectProbe {
135
154
 
136
155
  answers(method) {
137
156
  const all = this.attempts.filter((entry) => entry.method === method);
138
- const answered = all.filter((entry) => !(this.end && (entry.pending || entry.ended)));
157
+ const missing = (entry) =>
158
+ entry.incomplete ||
159
+ (entry.pending && this.closedSessions.has(entry.session)) ||
160
+ (this.end && (entry.pending || entry.ended));
161
+ const answered = all.filter((entry) => !missing(entry));
139
162
  const short =
140
- Boolean(this.end) && (!this.complete || !answered.length || answered.length < all.length);
163
+ all.some(missing) ||
164
+ (Boolean(this.end || this.halted) &&
165
+ (!this.complete || !answered.length || answered.length < all.length));
141
166
  return { answered, short };
142
167
  }
143
168
 
144
- async send(method, params = {}) {
169
+ async send(method, params = {}, session = this.session) {
145
170
  if (this.ended())
146
171
  return { ok: false, ended: true, error: { message: `session ended (${this.end})` } };
147
- const out = await outcome(this.client.send(method, params, this.session));
172
+ const out = this.closedSessions.has(session)
173
+ ? { ok: false, incomplete: true, error: { message: 'owning tab closed' } }
174
+ : await outcome(this.client.send(method, params, session));
148
175
  if (gone(out)) {
149
- this.end ??= out.error.message;
150
- return { ...out, ended: true };
176
+ if (!connectionGone(out) && (bodyRead(method) || session !== this.session)) {
177
+ this.closedSessions.add(session);
178
+ out.incomplete = true;
179
+ } else {
180
+ this.end ??= out.error.message;
181
+ return { ...out, ended: true };
182
+ }
151
183
  }
184
+ if (bodyRead(method) && out.incomplete)
185
+ this.missing.push(`redirect-secret: ${method} ${params.requestId} owning tab closed`);
152
186
  this.responses.push({ method, ...out });
153
187
  return out;
154
188
  }
@@ -160,11 +194,24 @@ class RedirectProbe {
160
194
 
161
195
  async fetchPause(event) {
162
196
  if (redirects.includes(event.params.responseStatusCode)) {
163
- const out = await this.send('Fetch.getResponseBody', { requestId: event.params.requestId });
164
- this.attempts.push({ method: 'Fetch.getResponseBody', ...out });
165
- if (event.params.networkId) this.track(this.networkBody(event.params.networkId, 'fetch'));
197
+ const entry = {
198
+ method: 'Fetch.getResponseBody',
199
+ session: event.sessionId,
200
+ pending: true,
201
+ };
202
+ this.attempts.push(entry);
203
+ const out = await this.send(
204
+ 'Fetch.getResponseBody',
205
+ { requestId: event.params.requestId },
206
+ event.sessionId
207
+ );
208
+ delete entry.pending;
209
+ Object.assign(entry, out);
210
+ if (event.params.networkId)
211
+ this.track(this.networkBody(event.params.networkId, 'fetch', event.sessionId));
166
212
  }
167
213
  if (
214
+ event.sessionId === this.session &&
168
215
  this.holdURL &&
169
216
  event.params.request.url === this.holdURL &&
170
217
  !event.params.responseStatusCode
@@ -172,30 +219,35 @@ class RedirectProbe {
172
219
  this.held = event;
173
220
  return;
174
221
  }
175
- await this.send('Fetch.continueRequest', { requestId: event.params.requestId });
222
+ await this.send(
223
+ 'Fetch.continueRequest',
224
+ { requestId: event.params.requestId },
225
+ event.sessionId
226
+ );
176
227
  }
177
228
 
178
229
  async watch() {
179
230
  while (!this.stopped) {
180
231
  for (const event of this.client.events.slice(this.cursor)) {
181
232
  this.cursor++;
182
- if (event.sessionId !== this.session) continue;
233
+ if (!this.sessions.has(event.sessionId)) continue;
183
234
  if (event.method === 'Fetch.requestPaused') this.track(this.fetchPause(event));
184
235
  const response = event.params?.redirectResponse ?? event.params?.response;
185
236
  if (!response || !redirects.includes(response.status)) continue;
186
- this.track(this.networkBody(event.params.requestId, 'network'));
237
+ this.track(this.networkBody(event.params.requestId, 'network', event.sessionId));
187
238
  }
188
239
  await pause(5);
189
240
  }
190
241
  }
191
242
 
192
- async networkBody(requestId, source) {
193
- this.learned[source].add(requestId);
194
- if (this.networkBodies.has(requestId) || this.ended()) return;
195
- this.networkBodies.add(requestId);
196
- const entry = { method: 'Network.getResponseBody', source, pending: true };
243
+ async networkBody(requestId, source, session) {
244
+ const key = `${session}:${requestId}`;
245
+ this.learned[source].add(key);
246
+ if (this.networkBodies.has(key) || this.ended()) return;
247
+ this.networkBodies.add(key);
248
+ const entry = { method: 'Network.getResponseBody', source, session, pending: true };
197
249
  this.attempts.push(entry);
198
- const out = await this.send('Network.getResponseBody', { requestId });
250
+ const out = await this.send('Network.getResponseBody', { requestId }, session);
199
251
  delete entry.pending;
200
252
  Object.assign(entry, out);
201
253
  }
@@ -206,10 +258,11 @@ class RedirectProbe {
206
258
 
207
259
  tap() {
208
260
  for (const event of this.reader.events ?? []) {
209
- if (event.sessionId !== this.observer) continue;
261
+ const session = [...this.sessions].find(([, observer]) => observer === event.sessionId)?.[0];
262
+ if (!session) continue;
210
263
  const response = event.params?.redirectResponse ?? event.params?.response;
211
264
  if (response?.url?.startsWith(this.prefix) && redirects.includes(response.status))
212
- this.track(this.networkBody(event.params.requestId, 'tap'));
265
+ this.track(this.networkBody(event.params.requestId, 'tap', session));
213
266
  }
214
267
  }
215
268
 
@@ -240,7 +293,7 @@ class RedirectProbe {
240
293
  : !proven
241
294
  ? 'control did not prove the redirect Network events'
242
295
  : short
243
- ? `session ended (${this.end}) before every network body read was answered`
296
+ ? 'not every network body read was answered on a live owner'
244
297
  : undefined;
245
298
  if (reason && (!attempts.length || refusedAll))
246
299
  return {
@@ -259,7 +312,7 @@ class RedirectProbe {
259
312
  }
260
313
 
261
314
  requests(events, match = (url) => url?.startsWith(this.prefix)) {
262
- const own = events.filter((event) => event.sessionId === this.session);
315
+ const own = events.filter((event) => this.sessions.has(event.sessionId));
263
316
  const seen = new Set(
264
317
  own
265
318
  .filter(
@@ -347,7 +400,7 @@ class RedirectProbe {
347
400
  const who = this.end ? `session ended (${this.end}) with requests` : 'control requests';
348
401
  const detail = open.length
349
402
  ? `${who} ${open.join(', ')} unterminated`
350
- : `session ended (${this.end}) before every request was issued`;
403
+ : 'probe ended before every request was issued';
351
404
  return {
352
405
  hops,
353
406
  requestsTerminated: missing ? 'incomplete' : !open.length,
@@ -372,7 +425,36 @@ class RedirectProbe {
372
425
  }
373
426
 
374
427
  async reset(suffix = 'reset') {
375
- await load(this.reader, this.observer, `${this.ordinary}/doc?n=${this.tag}-${suffix}`);
428
+ if (this.ended() || this.halted) return false;
429
+ const recovered = await outcome(
430
+ (async () => {
431
+ const { targetId } = await createObserverTarget(this.reader, { url: 'about:blank' });
432
+ this.targets.add(targetId);
433
+ const { sessionId: observer } = await this.reader.send('Target.attachToTarget', {
434
+ targetId,
435
+ flatten: true,
436
+ });
437
+ if (!observer) throw new Error('ordinary observer session missing');
438
+ this.observers.add(observer);
439
+ await this.reader.send('Page.enable', {}, observer);
440
+ if (this.side === 'guarded') await this.reader.send('Network.enable', {}, observer);
441
+ await load(this.reader, observer, `${this.ordinary}/doc?n=${this.tag}-${suffix}`);
442
+ const { sessionId } = await this.client.send('Target.attachToTarget', {
443
+ targetId,
444
+ flatten: true,
445
+ });
446
+ if (!sessionId) throw new Error('ordinary client session missing');
447
+ this.sessions.set(sessionId, observer);
448
+ this.internalSessions?.add(sessionId);
449
+ this.session = sessionId;
450
+ await this.enable();
451
+ })()
452
+ );
453
+ if (!recovered.ok) {
454
+ this.halted = true;
455
+ this.missing.push(`redirect-secret: ordinary tab recovery ${recovered.error.message}`);
456
+ }
457
+ return recovered.ok;
376
458
  }
377
459
 
378
460
  contains(value) {
@@ -395,6 +477,8 @@ class RedirectProbe {
395
477
  this.observer = (
396
478
  await this.reader.send('Target.attachToTarget', { targetId: this.targetId, flatten: true })
397
479
  ).sessionId;
480
+ this.sessions.set(this.session, this.observer);
481
+ this.observers.add(this.observer);
398
482
  this.watching = this.watch();
399
483
  await this.reader.send('Page.enable', {}, this.observer);
400
484
  if (this.side === 'guarded') await this.reader.send('Network.enable', {}, this.observer);
@@ -412,7 +496,7 @@ class RedirectProbe {
412
496
  });
413
497
  }
414
498
 
415
- async setup() {
499
+ async enable() {
416
500
  for (const domain of ['Page', 'Network', 'Runtime', 'Log', 'Audits'])
417
501
  await this.send(`${domain}.enable`);
418
502
  await this.send('Fetch.enable', {
@@ -421,6 +505,10 @@ class RedirectProbe {
421
505
  { urlPattern: '*', requestStage: 'Response' },
422
506
  ],
423
507
  });
508
+ }
509
+
510
+ async setup() {
511
+ await this.enable();
424
512
  for (const to of [...this.protectedURLs, this.plainURL]) {
425
513
  const url = new URL(to);
426
514
  if (url.hash) continue;
@@ -433,13 +521,12 @@ class RedirectProbe {
433
521
  async headers() {
434
522
  for (const [index, to] of this.destinations.entries()) {
435
523
  for (const kind of ['navigate', 'href', 'fetch', 'xhr']) {
436
- if (this.ended()) return;
524
+ if (this.ended() || this.halted) return;
437
525
  const code = this.mode === 'mix' ? [301, 303, 307, 308][index] : 302;
438
526
  const url = await this.register(`${index}-${kind}`, to, code);
439
527
  const start = this.client.events.length;
440
528
  if (kind === 'navigate') await this.send('Page.navigate', { url });
441
529
  else {
442
- await this.reset();
443
530
  const expression =
444
531
  kind === 'href'
445
532
  ? `location.href=${JSON.stringify(url)};void 0`
@@ -495,7 +582,7 @@ class RedirectProbe {
495
582
  }
496
583
 
497
584
  async override(url) {
498
- await this.reset('override');
585
+ if (!(await this.reset('override'))) return;
499
586
  this.holdURL = `${this.ordinary}/data?n=${this.tag}-override`;
500
587
  this.held = undefined;
501
588
  await this.send('Runtime.evaluate', {
@@ -505,7 +592,9 @@ class RedirectProbe {
505
592
  if (!this.held) {
506
593
  this.holdURL = undefined;
507
594
  if (await this.lost()) return;
508
- throw new Error('redirect body: Fetch override pause missing');
595
+ this.overrideIncomplete = true;
596
+ this.missing.push('redirect-secret: override pause not observed');
597
+ return;
509
598
  }
510
599
  const requestId = this.held.params.requestId;
511
600
  const out = await this.send('Fetch.continueRequest', { requestId, url });
@@ -568,13 +657,13 @@ class RedirectProbe {
568
657
  () => this.iframe(),
569
658
  () => this.override(url),
570
659
  ]) {
571
- if (this.ended()) return;
660
+ if (this.ended() || this.halted) return;
572
661
  await step();
573
662
  }
574
663
  }
575
664
 
576
665
  async iframe() {
577
- await this.reset('iframe');
666
+ if (!(await this.reset('iframe'))) return;
578
667
  const url = await this.register('iframe', this.destinations[1]);
579
668
  await this.send('Runtime.evaluate', {
580
669
  expression: `(()=>{const f=document.createElement('iframe');f.src=${JSON.stringify(url)};document.body.append(f)})();void 0`,
@@ -590,25 +679,50 @@ class RedirectProbe {
590
679
  });
591
680
  }
592
681
 
682
+ observerTraffic(value) {
683
+ return (
684
+ this.client === this.reader &&
685
+ this.observers.has(value?.sessionId) &&
686
+ !this.sessions.has(value?.sessionId)
687
+ );
688
+ }
689
+
690
+ observerMessage(message) {
691
+ try {
692
+ return this.observerTraffic(JSON.parse(message));
693
+ } catch {
694
+ return false;
695
+ }
696
+ }
697
+
698
+ overrideCheck(key, unseen) {
699
+ const overrides = this.responses.filter((entry) => entry.overrideMethod);
700
+ if (!overrides.length) return this.overrideIncomplete ? 'incomplete' : unseen;
701
+ return overrides.every((entry) => entry[key]);
702
+ }
703
+
704
+ async survival() {
705
+ for (const session of this.sessions.keys()) await this.send('Fetch.disable', {}, session);
706
+ if (!(await this.reset('survive'))) return { ok: false };
707
+ return this.send('Runtime.evaluate', {
708
+ expression: 'document.title',
709
+ returnByValue: true,
710
+ });
711
+ }
712
+
593
713
  async evidence() {
594
714
  if (!this.ended()) await pause(8000);
595
715
  if (!this.ended()) this.tap();
596
716
  if (this.side === 'control' && this.controls)
597
717
  this.controls.networkEvents = this.learned.network.size > 0;
598
- await this.send('Fetch.disable');
599
- await this.reset('survive');
600
- const survives = await this.send('Runtime.evaluate', {
601
- expression: 'document.title',
602
- returnByValue: true,
603
- });
718
+ const survives = await this.survival();
604
719
  await this.settle();
605
720
  await this.terminated();
606
- const tapped = `"sessionId":"${this.observer}"`;
607
721
  const events = this.client.events
608
722
  .slice(this.eventsStart)
609
- .filter((event) => event.sessionId !== this.observer);
723
+ .filter((event) => !this.observerTraffic(event));
610
724
  const messages = (this.client.messages?.slice(this.messagesStart) ?? []).filter(
611
- (message) => !message.includes(tapped)
725
+ (message) => !this.observerMessage(message)
612
726
  );
613
727
  const eventLeaks = await this.scan(events);
614
728
  const ordinaryEvents = events.filter(
@@ -619,7 +733,7 @@ class RedirectProbe {
619
733
  const replyLeaks = await this.scan(replies);
620
734
  const extra = events.filter(
621
735
  (event) =>
622
- event.sessionId === this.session &&
736
+ this.sessions.has(event.sessionId) &&
623
737
  event.method === 'Network.responseReceivedExtraInfo' &&
624
738
  redirects.includes(event.params.statusCode)
625
739
  );
@@ -634,12 +748,11 @@ class RedirectProbe {
634
748
  });
635
749
  const extraInPlace =
636
750
  extraHeld &&
637
- (extra.length >= this.destinations.length * 4 || (this.end ? 'incomplete' : false));
638
- const unseen = this.end ? 'incomplete' : false;
751
+ (extra.length >= this.destinations.length * 4 || (this.cut() ? 'incomplete' : false));
752
+ const unseen = this.end || this.halted ? 'incomplete' : false;
639
753
  const found = (key) => this.responses.some((entry) => entry[key]);
640
754
  const fetchReads = this.answers('Fetch.getResponseBody');
641
755
  const fetchLeaked = fetchReads.answered.some((entry) => !refused(entry));
642
- const overrides = this.responses.filter((entry) => entry.overrideMethod);
643
756
  const evidence = {
644
757
  live: (await this.fixture.log()).some((entry) =>
645
758
  entry.url.includes(`/redirect-secret/${this.tag}`)
@@ -653,7 +766,9 @@ class RedirectProbe {
653
766
  : (message.method ?? 'reply')
654
767
  ),
655
768
  secretHidden: !replyLeaks.includes(true),
656
- sessionSurvives: survives.ok && survives.result?.result?.value === 'doc',
769
+ sessionSurvives: this.halted
770
+ ? 'incomplete'
771
+ : survives.ok && survives.result?.result?.value === 'doc',
657
772
  sessionEnded: this.ended(),
658
773
  fetchBodyAttempted: fetchReads.short ? 'incomplete' : fetchReads.answered.length > 0,
659
774
  fetchBodyRefused: fetchLeaked
@@ -706,31 +821,38 @@ class RedirectProbe {
706
821
  'ordinaryIframeDOM',
707
822
  ].map((key) => [key, found(key) || unseen])
708
823
  ),
709
- overridesRefused: overrides.length
710
- ? overrides.every((entry) => entry.overrideRefused)
711
- : unseen,
712
- overridesWorked: overrides.length ? overrides.every((entry) => entry.overrideWorked) : unseen,
824
+ overridesRefused: this.overrideCheck('overrideRefused', unseen),
825
+ overridesWorked: this.overrideCheck('overrideWorked', unseen),
713
826
  };
714
- if (!this.end || !Object.values(evidence).includes('incomplete')) return evidence;
715
- const incomplete = [evidence.incomplete, `redirect-secret: session ended (${this.end})`];
716
- return { ...evidence, incomplete: incomplete.filter(Boolean).join('; ') };
827
+ const incomplete = [evidence.incomplete, ...this.missing];
828
+ if (this.end && Object.values(evidence).includes('incomplete'))
829
+ incomplete.push(`redirect-secret: session ended (${this.end})`);
830
+ return incomplete.some(Boolean)
831
+ ? { ...evidence, incomplete: incomplete.filter(Boolean).join('; ') }
832
+ : evidence;
717
833
  }
718
834
 
719
835
  async close() {
720
836
  this.stopped = true;
721
837
  await this.watching;
722
838
  await this.settle();
723
- if (!this.ended()) await outcome(this.client.send('Fetch.disable', {}, this.session));
839
+ for (const session of this.sessions.keys()) {
840
+ if (!this.ended()) await outcome(this.client.send('Fetch.disable', {}, session));
841
+ if (session !== this.sessions.keys().next().value) this.internalSessions?.delete(session);
842
+ }
843
+ for (const observer of this.observers)
844
+ await outcome(this.reader.send('Target.detachFromTarget', { sessionId: observer }));
724
845
  if (this.observer) {
725
846
  await outcome(
726
- this.reader.send(
727
- 'Network.deleteCookies',
728
- { name: this.cookie, domain: 'seven.sliccy.ai', path: '/' },
729
- this.observer
730
- )
847
+ this.reader.send('Network.deleteCookies', {
848
+ name: this.cookie,
849
+ domain: 'seven.sliccy.ai',
850
+ path: '/',
851
+ })
731
852
  );
732
- await outcome(this.reader.send('Target.detachFromTarget', { sessionId: this.observer }));
733
853
  }
854
+ for (const targetId of this.targets)
855
+ await outcome(this.reader.send('Target.closeTarget', { targetId }));
734
856
  for (const registration of this.registrations)
735
857
  await this.fixture.redirectSecret({ ...registration, remove: true });
736
858
  }
@@ -749,7 +871,7 @@ export async function redirectSecret(options) {
749
871
  await probe.setup();
750
872
  if (options.mode === 'body') await probe.body();
751
873
  else await probe.headers();
752
- probe.complete = !probe.ended();
874
+ probe.complete = !probe.ended() && !probe.halted;
753
875
  const evidence = await probe.evidence();
754
876
  if (probe.end && options.side !== 'guarded')
755
877
  throw new Error(`redirect-secret: ${options.side} session ended (${probe.end})`);