@ai-ecoverse/slicc-browsers 1.18.2 → 1.18.5

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
- `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.
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 iframe MHTML and DOM reads are polled until both show the marker, for up to 5000 ms scaled by `settleScale`, with the stall-tolerant `waitFor` bound. A channel that never shows it reads `incomplete`, while a measured channel stays true; the control is incomplete, never a pass or a failure solely for missing iframe evidence. Guarded leak checks remain measured on every received reply. 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
 
@@ -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 {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "setVersion": "1.18.2",
3
+ "setVersion": "1.18.5",
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.2",
3
+ "version": "1.18.5",
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-redirect-iframe.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": {
@@ -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,25 +657,80 @@ 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`,
581
670
  });
582
- await pause(500);
583
- await this.send('Page.getFrameTree');
584
- const snapshot = await this.send('Page.captureSnapshot', { format: 'mhtml' });
585
- const dom = await this.send('DOMSnapshot.captureSnapshot', { computedStyles: [] });
671
+ const control = this.side === 'control';
672
+ const ms = Math.round(5000 * this.scale);
673
+ let snapshot;
674
+ let dom;
675
+ let ordinaryIframeSnapshot = false;
676
+ let ordinaryIframeDOM = false;
677
+ const read = async () => {
678
+ await this.send('Page.getFrameTree');
679
+ snapshot = await this.send('Page.captureSnapshot', { format: 'mhtml' });
680
+ dom = await this.send('DOMSnapshot.captureSnapshot', { computedStyles: [] });
681
+ ordinaryIframeSnapshot ||= snapshot.ok && this.contains(snapshot);
682
+ ordinaryIframeDOM ||= dom.ok && this.contains(dom);
683
+ return this.ended() || (ordinaryIframeSnapshot && ordinaryIframeDOM);
684
+ };
685
+ if (control) {
686
+ if (!(await waitFor(read, ms, 50))) {
687
+ const missing = [
688
+ !ordinaryIframeSnapshot && 'ordinaryIframeSnapshot',
689
+ !ordinaryIframeDOM && 'ordinaryIframeDOM',
690
+ ].filter(Boolean);
691
+ this.missing.push(
692
+ `redirect-secret: control iframe ${missing.join(', ')} not observed within ${ms}ms`
693
+ );
694
+ }
695
+ } else {
696
+ await pause(500);
697
+ await read();
698
+ }
586
699
  this.responses.push({
587
700
  iframeSnapshotAttempted: !snapshot.ended && !dom.ended,
588
- ordinaryIframeSnapshot: this.contains(snapshot),
589
- ordinaryIframeDOM: this.contains(dom),
701
+ ordinaryIframeSnapshot: ordinaryIframeSnapshot || (control ? 'incomplete' : false),
702
+ ordinaryIframeDOM: ordinaryIframeDOM || (control ? 'incomplete' : false),
703
+ });
704
+ }
705
+
706
+ observerTraffic(value) {
707
+ return (
708
+ this.client === this.reader &&
709
+ this.observers.has(value?.sessionId) &&
710
+ !this.sessions.has(value?.sessionId)
711
+ );
712
+ }
713
+
714
+ observerMessage(message) {
715
+ try {
716
+ return this.observerTraffic(JSON.parse(message));
717
+ } catch {
718
+ return false;
719
+ }
720
+ }
721
+
722
+ overrideCheck(key, unseen) {
723
+ const overrides = this.responses.filter((entry) => entry.overrideMethod);
724
+ if (!overrides.length) return this.overrideIncomplete ? 'incomplete' : unseen;
725
+ return overrides.every((entry) => entry[key]);
726
+ }
727
+
728
+ async survival() {
729
+ for (const session of this.sessions.keys()) await this.send('Fetch.disable', {}, session);
730
+ if (!(await this.reset('survive'))) return { ok: false };
731
+ return this.send('Runtime.evaluate', {
732
+ expression: 'document.title',
733
+ returnByValue: true,
590
734
  });
591
735
  }
592
736
 
@@ -595,20 +739,14 @@ class RedirectProbe {
595
739
  if (!this.ended()) this.tap();
596
740
  if (this.side === 'control' && this.controls)
597
741
  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
- });
742
+ const survives = await this.survival();
604
743
  await this.settle();
605
744
  await this.terminated();
606
- const tapped = `"sessionId":"${this.observer}"`;
607
745
  const events = this.client.events
608
746
  .slice(this.eventsStart)
609
- .filter((event) => event.sessionId !== this.observer);
747
+ .filter((event) => !this.observerTraffic(event));
610
748
  const messages = (this.client.messages?.slice(this.messagesStart) ?? []).filter(
611
- (message) => !message.includes(tapped)
749
+ (message) => !this.observerMessage(message)
612
750
  );
613
751
  const eventLeaks = await this.scan(events);
614
752
  const ordinaryEvents = events.filter(
@@ -619,7 +757,7 @@ class RedirectProbe {
619
757
  const replyLeaks = await this.scan(replies);
620
758
  const extra = events.filter(
621
759
  (event) =>
622
- event.sessionId === this.session &&
760
+ this.sessions.has(event.sessionId) &&
623
761
  event.method === 'Network.responseReceivedExtraInfo' &&
624
762
  redirects.includes(event.params.statusCode)
625
763
  );
@@ -634,12 +772,14 @@ class RedirectProbe {
634
772
  });
635
773
  const extraInPlace =
636
774
  extraHeld &&
637
- (extra.length >= this.destinations.length * 4 || (this.end ? 'incomplete' : false));
638
- const unseen = this.end ? 'incomplete' : false;
639
- const found = (key) => this.responses.some((entry) => entry[key]);
775
+ (extra.length >= this.destinations.length * 4 || (this.cut() ? 'incomplete' : false));
776
+ const unseen = this.end || this.halted ? 'incomplete' : false;
777
+ const found = (key) => {
778
+ const values = this.responses.map((entry) => entry[key]);
779
+ return values.includes(true) || (values.includes('incomplete') ? 'incomplete' : false);
780
+ };
640
781
  const fetchReads = this.answers('Fetch.getResponseBody');
641
782
  const fetchLeaked = fetchReads.answered.some((entry) => !refused(entry));
642
- const overrides = this.responses.filter((entry) => entry.overrideMethod);
643
783
  const evidence = {
644
784
  live: (await this.fixture.log()).some((entry) =>
645
785
  entry.url.includes(`/redirect-secret/${this.tag}`)
@@ -653,7 +793,9 @@ class RedirectProbe {
653
793
  : (message.method ?? 'reply')
654
794
  ),
655
795
  secretHidden: !replyLeaks.includes(true),
656
- sessionSurvives: survives.ok && survives.result?.result?.value === 'doc',
796
+ sessionSurvives: this.halted
797
+ ? 'incomplete'
798
+ : survives.ok && survives.result?.result?.value === 'doc',
657
799
  sessionEnded: this.ended(),
658
800
  fetchBodyAttempted: fetchReads.short ? 'incomplete' : fetchReads.answered.length > 0,
659
801
  fetchBodyRefused: fetchLeaked
@@ -706,31 +848,38 @@ class RedirectProbe {
706
848
  'ordinaryIframeDOM',
707
849
  ].map((key) => [key, found(key) || unseen])
708
850
  ),
709
- overridesRefused: overrides.length
710
- ? overrides.every((entry) => entry.overrideRefused)
711
- : unseen,
712
- overridesWorked: overrides.length ? overrides.every((entry) => entry.overrideWorked) : unseen,
851
+ overridesRefused: this.overrideCheck('overrideRefused', unseen),
852
+ overridesWorked: this.overrideCheck('overrideWorked', unseen),
713
853
  };
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('; ') };
854
+ const incomplete = [evidence.incomplete, ...this.missing];
855
+ if (this.end && Object.values(evidence).includes('incomplete'))
856
+ incomplete.push(`redirect-secret: session ended (${this.end})`);
857
+ return incomplete.some(Boolean)
858
+ ? { ...evidence, incomplete: incomplete.filter(Boolean).join('; ') }
859
+ : evidence;
717
860
  }
718
861
 
719
862
  async close() {
720
863
  this.stopped = true;
721
864
  await this.watching;
722
865
  await this.settle();
723
- if (!this.ended()) await outcome(this.client.send('Fetch.disable', {}, this.session));
866
+ for (const session of this.sessions.keys()) {
867
+ if (!this.ended()) await outcome(this.client.send('Fetch.disable', {}, session));
868
+ if (session !== this.sessions.keys().next().value) this.internalSessions?.delete(session);
869
+ }
870
+ for (const observer of this.observers)
871
+ await outcome(this.reader.send('Target.detachFromTarget', { sessionId: observer }));
724
872
  if (this.observer) {
725
873
  await outcome(
726
- this.reader.send(
727
- 'Network.deleteCookies',
728
- { name: this.cookie, domain: 'seven.sliccy.ai', path: '/' },
729
- this.observer
730
- )
874
+ this.reader.send('Network.deleteCookies', {
875
+ name: this.cookie,
876
+ domain: 'seven.sliccy.ai',
877
+ path: '/',
878
+ })
731
879
  );
732
- await outcome(this.reader.send('Target.detachFromTarget', { sessionId: this.observer }));
733
880
  }
881
+ for (const targetId of this.targets)
882
+ await outcome(this.reader.send('Target.closeTarget', { targetId }));
734
883
  for (const registration of this.registrations)
735
884
  await this.fixture.redirectSecret({ ...registration, remove: true });
736
885
  }
@@ -749,7 +898,7 @@ export async function redirectSecret(options) {
749
898
  await probe.setup();
750
899
  if (options.mode === 'body') await probe.body();
751
900
  else await probe.headers();
752
- probe.complete = !probe.ended();
901
+ probe.complete = !probe.ended() && !probe.halted;
753
902
  const evidence = await probe.evidence();
754
903
  if (probe.end && options.side !== 'guarded')
755
904
  throw new Error(`redirect-secret: ${options.side} session ended (${probe.end})`);