@ai-ecoverse/slicc-browsers 1.17.0 → 1.18.1

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
@@ -207,7 +207,15 @@ 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-error-page` touches an allowed tab, scripts a protected navigation, and requires the guard to fail its Document request. The independent observer verifies the committed error document and the exact unreachable/reported URL pair. The session stays attached; reload, navigation to `about:blank` and close succeed, while `Target.getTargetInfo`, `Target.getTargets`, the `Target.attachedToTarget` of a fresh attach and the `targetCreated`/`targetInfoChanged` events redact URL and title to the origin (`infoRedacted`, `listRedacted`, `attachRedacted`, `eventsRedacted`), and none of them shows the full failed URL (`fullURL`). The failed URL carries a secret path, `?query` and `#fragment`; the `.query` and `.fragment` variants of this row and of `C8.protected-error-page-sticky` fail `/doc?query-secret=…` and `/doc#fragment-secret-…` instead, so no path, query or fragment secret may survive alone either. The unguarded control uses independent Fetch failure injection and keeps raw discovery values: its `fullURL` must be `true`. `C8.protected-document-restore` seeds a real protected document before attachment, navigates away, then restores it from BFCache in the same tab after the error/recovery sequence; evaluate, attach and close must be refused. A real `BackForwardCacheRestore` commit is required, so a second failed network request cannot masquerade as this control. `C8.protected-error-page-sticky` additionally fails a request after that real protected restore and requires the already-protected tab to remain locked. They run on browser-session and page-session-only transports alike, have unguarded controls and clean up their own targets. The regression tests include URL-only locking, unconditional error-page unlocking, and a plain client with only request-failure injection.
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.
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.
213
+
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
+
216
+ `forwardAudit` gates only `commandsUnforwarded`. When advertised, `audit({ probes, final })` must return `{ complete: true, probes: [{ id, forwarded }] }` with a browser-side forwarding decision for every probe id; any probe sent while the gate is armed that was forwarded fails, even when its frame state was unknown. The gate-window audit runs before release. The final audit runs after reply settlement and certifies terminal forwarding decisions, including commands queued at release; its ledger must remain available after `release()`. An observed forward fails even beside a partial ledger. Guard-generated internal calls and the history trigger are not probe calls. A missing or partial ledger is `incomplete`. Without the capability the audit is `not-supported`, and the independent side-effect, leak and screenshot checks remain required. Each row's `evidence.control.restore` and `evidence.guarded.restore` retains gate acknowledgement/release, the restore timestamp, every probe's send time and outcome, forwarding evidence when available, and the independent side-effect reads. Cleanup releases the gate and closes only that row's targets, streams and sessions.
217
+
218
+ `C8.protected-error-page` touches an allowed tab, scripts a protected navigation, and requires the guard to fail its Document request. That navigation stays script-initiated, because a protected `Page.navigate` destination must be refused: the client sends `Page.navigate` to the ordinary `/page?k=deferred&defer=0` trigger, whose own script navigates to the protected URL, passed fully percent-encoded so it never appears in the trigger's URL. No step navigates with client or observer script, so a guard that refuses client `Runtime.evaluate` in a tab whose history holds a protected entry still gets a verdict; a failed or refused client `Page.navigate` leaves the row `incomplete`. The independent observer verifies the committed error document and the exact unreachable/reported URL pair. The session stays attached; reload, navigation to `about:blank` and close succeed (close after the same trigger fails the URL again), while `Target.getTargetInfo`, `Target.getTargets`, the `Target.attachedToTarget` of a fresh attach and the `targetCreated`/`targetInfoChanged` events redact URL and title to the origin (`infoRedacted`, `listRedacted`, `attachRedacted`, `eventsRedacted`), and none of them shows the full failed URL (`fullURL`). The failed URL carries a secret path, `?query` and `#fragment`; the `.query` and `.fragment` variants of this row and of `C8.protected-error-page-sticky` fail `/doc?query-secret=…` and `/doc#fragment-secret-…` instead, so no path, query or fragment secret may survive alone either. The unguarded control uses independent Fetch failure injection on the protected origin and keeps raw discovery values: its `fullURL` must be `true`. `C8.protected-document-restore` seeds a real protected document before attachment, navigates away, then the observer restores it from BFCache with `Page.navigateToHistoryEntry` in the same tab after the error/recovery sequence; evaluate, attach and close must be refused. A real `BackForwardCacheRestore` commit is required, so a second failed network request cannot masquerade as this control. `C8.protected-error-page-sticky` additionally fails a request after that real protected restore and requires the already-protected tab to remain locked. They run on browser-session and page-session-only transports alike, have unguarded controls and clean up their own targets. The regression tests include URL-only locking, unconditional error-page unlocking, a plain client with only request-failure injection, and a guard that refuses client `Runtime.evaluate` in a tab whose history holds a protected entry.
211
219
 
212
220
  `C8.network-error` and `C8.iframe-error` are ordinary-error reachability controls: request the configured `network-error` URL (default `http://nonexistent.invalid/`) directly or in an ordinary iframe, observe a failed Document request, reattach to the settled tab, evaluate and snapshot its document, then navigate the same tab to an ordinary document. Both control and guarded legs must pass. The failing URL is unchanged on the control, rather than replaced with a healthy origin, and the rows require `browserSession` but not `chromeUrlsAttach`. With Chrome for Testing 156.0.8078.4, scheme-wide `chrome-error` protection made the direct-error row fail both legs because reattachment was refused; removing it passed both rows, with readable DOM and successful recovery. The old emulator still allowed the cross-process iframe parent because the iframe TargetInfo kept the failing HTTP URL, but that layout-specific behavior is not grounds for protecting the error scheme. Plain-client controls and reachability legs also passed both rows.
213
221
 
@@ -265,7 +273,7 @@ Popups are observed by `Target.targetCreated` with the opener's `openerId`, or b
265
273
  - `/listener.js`: one line registering a window `load` listener, served with `SourceMap: /listener.js.map`;
266
274
  - `/data?n=`: `real-<n>`, with CORS;
267
275
  - `/script?n=`: a script setting `globalThis.vectorScript` to `real-<n>`;
268
- - `/page?k=&to=`: a trigger page whose `go()` sends the page to `to` (`k=href`, `replace`, `popup` or `form`), and `/canary/<tag>`: a document titled `canary` with the marker;
276
+ - `/page?k=&to=`: a trigger page whose `go()` sends the page to `to` (`k=href`, `replace`, `popup` or `form`; `deferred` runs `href` itself after `defer` ms, default 3000), and `/canary/<tag>`: a document titled `canary` with the marker;
269
277
  - `/nest?n=&kind=` (and any path under `/nest`): the `C4.iframe-preattach` frame, with its `about:blank` or `about:srcdoc` child;
270
278
  - `/img`: a 1×1 PNG;
271
279
  - `/auth`: Basic `u`:`p`, then `authed`;
@@ -275,7 +283,7 @@ Popups are observed by `Target.targetCreated` with the opener's `openerId`, or b
275
283
 
276
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.
277
285
 
278
- `{ "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 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`. 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`.
279
287
 
280
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.
281
289
 
@@ -283,6 +291,8 @@ Popups are observed by `Target.targetCreated` with the opener's `openerId`, or b
283
291
 
284
292
  For remote/page harnesses, `fixtureClient(base)` is exported by `./guard-runner` and `./fixture-client`, or `run(set, { fixtureBase: base, ... })` constructs it. The bundled server serves CORS-safe `GET /log?since=N` as `{ from: N, entries: [...] }`, with an append-only history unaffected by `fixture.reset()`, and `POST /redirect` with a JSON-encoded string body (a simple text/plain request, without a preflight). Control requests do not appear in the log. Registration descriptors are `{ path, host?, search?, to?, code?, body, remove? }`: `path` starts with `/`, `host` selects the callback hostname, `search` distinguishes query-bearing callbacks, `to` creates a redirect with the four Location/Content-Location/Refresh/Link headers, `code` defaults to 302, an omitted `to` serves the callback body, and `remove: true` unregisters it. The bundled endpoint returns 204; custom servers must acknowledge with a successful status and actually serve the registered routes. Custom fixtures may instead supply an async `fixture.redirectSecret` hook directly; a separate log/control server can implement the same `GET /log` and `POST /redirect` contract. A Node harness using browser-only DNS aliases should pass a reachable `fixtureBase` or its direct hook.
285
293
 
294
+ `{ "bfcacheRestore": "observer" | "history", "origin": "{protectedTarget}", "as": "restore", "requires": ["inboundDelay"] }` runs the history-restore compound probe. `{protectedTarget}` is the row target on both legs, so the unguarded control exercises the same protected fixture origin.
295
+
286
296
  `{ "frameRead": "blank" | "srcdoc", "from", "as": "frames", "on"?, "requires"? }` runs the `C4.iframe-preattach` compound probe: the observer's page and reads, then the client's attach and reads, with boolean evidence for ordinary `result` checks.
287
297
 
288
298
  `{ "unparseable": true, "as": "unparseable" }` runs the C8 malformed-URL compound probe on the current ordinary page: independent browser ground truth, raw/canonical destination commands, discovery/attach and cookie checks. Boolean evidence is consumed by ordinary `result` checks, with native Chrome outcomes retained in its diagnostics.
@@ -415,7 +425,7 @@ Every target created by the runner’s own unguarded observer uses `Target.creat
415
425
 
416
426
  `refusalScale` (default 1) multiplies the bounds of waits for what the guarded leg expects to be absent, the beacon, request, title or navigation that the guard should block: settles that are not `required`, and `paused`, on the guarded leg. Such a wait runs to its full bound whenever the guard works, so `settleScale` leaves it alone. The trade-off: a larger `refusalScale` gives more sensitivity to a leak that arrives late on a slow host, because the observations after the wait see it, at the cost of that much more time per guarded row. Adopters should keep `refusalScale` at 1 or higher, never below 1; the runner refuses a `refusalScale` below 1 with `refusalScale must be a number of at least 1`.
417
427
 
418
- **Incomplete.** A probe that returns `incomplete` (only `errorPage`, `redirectSecret` and `frameRead` steps) records the reason in the row's `incomplete` as `<leg> <probe>: <reason>`. Its unmeasured observations read `"incomplete"`, and so does guarded liveness when the probe never measured it. A leg whose measured observations all match is then `incomplete`, never `pass`, and `live` is `incomplete` when only its evidence is missing; a measured mismatch still makes the leg `fail`, and any row error still fails both legs. `summary()` counts `incomplete` per leg, returns the number of rows with any `incomplete` status as `incomplete`, appends `incomplete <n>` to `line`, and never passes such a run. A leg that throws keeps the errors it recorded before the throw.
428
+ **Incomplete.** A probe that returns `incomplete` (including `errorPage`, `redirectSecret`, `frameRead` and `bfcacheRestore` steps) records the reason in the row's `incomplete` as `<leg> <probe>: <reason>`. Its unmeasured observations read `"incomplete"`, and so does guarded liveness when the probe never measured it. A leg whose measured observations all match is then `incomplete`, never `pass`, and `live` is `incomplete` when only its evidence is missing; a measured mismatch still makes the leg `fail`, and any row error still fails both legs. `summary()` counts `incomplete` per leg, returns the number of rows with any `incomplete` status as `incomplete`, appends `incomplete <n>` to `line`, and never passes such a run. A leg that throws keeps the errors it recorded before the throw.
419
429
 
420
430
  `onStart(row)` receives each row's `id`, `target`, `variant`, `transport` and `via` as the row starts. `run()` returns a promise that also has `inFlight()`, and its result has `inFlight` too. `inFlight()` lists the rows that are running now, each as `{ id, target, variant, leg, phase, step, clients }`, where `step` is the index of the current step in its `phase` (`setup`, `trigger` or `child`), and `clients` lists each client's pending CDP calls as `{ method, session, ageMs }` (`client`, plus `unguarded` when it is a separate client). With `rowTimeoutMs`, a row still running after that many milliseconds fails on its own. Its `errors` hold `row timed out after <ms>ms: <the in-flight view>`, `inFlight` holds the view, and the clients that `clientFactory` made for it are closed. The run then goes on to the next row. It is off by default, and a client passed as `client` is never closed.
421
431
 
@@ -0,0 +1,514 @@
1
+ import { outcome, waitFor } from './guard-driver.mjs';
2
+ import { createObserverTarget } from './observer-target.mjs';
3
+ import { containsSecret } from './redirect-secret.mjs';
4
+
5
+ const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
6
+ const refusal = (out) =>
7
+ !out.ok &&
8
+ /^(slicc: refused:)|session.*(closed|not found)|no session|target.*closed/i.test(
9
+ out.error?.message ?? ''
10
+ );
11
+ const value = (out) =>
12
+ out.ok && !out.result?.exceptionDetails ? out.result?.result?.value : undefined;
13
+ const png = (data) => {
14
+ try {
15
+ return typeof data === 'string' && atob(data).startsWith('\x89PNG\r\n\x1a\n');
16
+ } catch {
17
+ return false;
18
+ }
19
+ };
20
+ const methods = [
21
+ 'Runtime.evaluate',
22
+ 'Runtime.callFunctionOn',
23
+ 'DOM.getDocument',
24
+ 'Accessibility.getFullAXTree',
25
+ 'Input.dispatchKeyEvent',
26
+ 'Page.captureScreenshot',
27
+ 'IO.read',
28
+ ];
29
+
30
+ async function bounded(work, ms) {
31
+ let timer;
32
+ const expired = new Promise((resolve) => {
33
+ timer = setTimeout(() => resolve({ ok: false, error: { message: 'no answer' } }), ms);
34
+ });
35
+ const out = await Promise.race([outcome(work), expired]);
36
+ clearTimeout(timer);
37
+ return out;
38
+ }
39
+
40
+ class Restore {
41
+ constructor(options) {
42
+ Object.assign(this, options);
43
+ this.marker = `bfcache-marker-${this.tag}`;
44
+ this.url = `${this.protectedOrigin}/bfcache?n=${encodeURIComponent(this.tag)}`;
45
+ this.result = { probes: [], delay: { requestedMs: this.delayMs } };
46
+ this.missing = [];
47
+ this.pending = [];
48
+ this.probeId = 0;
49
+ }
50
+
51
+ missingEvidence(reason) {
52
+ this.missing.push(reason);
53
+ return false;
54
+ }
55
+
56
+ read(method, params, session = method.startsWith('Target.') ? undefined : this.observer) {
57
+ return bounded(this.reader.send(method, params, session), this.budget);
58
+ }
59
+
60
+ call(method, params) {
61
+ return bounded(this.client.send(method, params, this.session), this.budget);
62
+ }
63
+
64
+ async frame() {
65
+ return (await this.read('Page.getFrameTree', {})).result?.frameTree?.frame;
66
+ }
67
+
68
+ async state() {
69
+ return value(
70
+ await this.read('Runtime.evaluate', {
71
+ expression:
72
+ '({origin:location.origin,marker:globalThis.__bfcacheMarker,stored:sessionStorage.getItem("bfcache-marker"),persisted:globalThis.__bfcachePersisted,touched:globalThis.__touched,keys:globalThis.__bfcacheKeys||0})',
73
+ returnByValue: true,
74
+ })
75
+ );
76
+ }
77
+
78
+ loaded(destination) {
79
+ return waitFor(async () => {
80
+ const f = await this.frame();
81
+ return (
82
+ f?.url === destination &&
83
+ !f.unreachableUrl &&
84
+ value(
85
+ await this.read('Runtime.evaluate', {
86
+ expression: 'document.readyState',
87
+ returnByValue: true,
88
+ })
89
+ ) === 'complete'
90
+ );
91
+ }, this.budget);
92
+ }
93
+
94
+ async attach() {
95
+ this.session = (
96
+ await bounded(
97
+ this.client.send('Target.attachToTarget', { targetId: this.targetId, flatten: true }),
98
+ this.budget
99
+ )
100
+ ).result?.sessionId;
101
+ return Boolean(this.session);
102
+ }
103
+
104
+ async seed() {
105
+ this.observer = (
106
+ await this.read('Target.attachToTarget', { targetId: this.targetId, flatten: true })
107
+ ).result?.sessionId;
108
+ if (!this.observer) return this.missingEvidence('observer attach unanswered');
109
+ await this.read('Page.enable', {});
110
+ if (!(await this.loaded(`${this.ordinary}/bfcache?n=${this.tag}-a`)))
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
+ await this.read('Page.navigate', { url: this.url });
115
+ if (!(await this.loaded(this.url)))
116
+ return this.missingEvidence('protected seed did not commit');
117
+ const state = await this.state();
118
+ if (state?.marker !== this.marker || state.stored !== this.marker)
119
+ 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 };
126
+ const b = `${this.ordinary}/bfcache?n=${this.tag}-b`;
127
+ await this.read('Page.navigate', { url: b });
128
+ 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;
137
+ this.result.seed.ordinaryAnswer = live;
138
+ if (!this.result.sessionLive) return false;
139
+ await this.call('Page.enable', {});
140
+ const history = await this.read('Page.getNavigationHistory', {});
141
+ this.entry = history.result?.entries?.find((item) => item.url === this.url);
142
+ return Boolean(this.entry) || this.missingEvidence('protected history entry absent');
143
+ }
144
+
145
+ async install() {
146
+ const { targetId, session, delayMs: ms, side, client } = this;
147
+ const installed = await outcome(
148
+ this.delayClient.inboundDelay({ targetId, sessionId: session, ms, side, client, gate: true })
149
+ );
150
+ this.delay = installed.result;
151
+ this.gate = this.delay;
152
+ const delay = this.delay;
153
+ this.result.delay.seeding = {
154
+ protectedObserved: delay?.protectedObserved,
155
+ ordinaryObserved: delay?.ordinaryObserved,
156
+ };
157
+ this.result.delay.acknowledged =
158
+ installed.ok &&
159
+ delay?.ms === ms &&
160
+ delay.targetId === targetId &&
161
+ delay.sessionId === session &&
162
+ delay.protectedObserved === true &&
163
+ delay.ordinaryObserved === true &&
164
+ delay.gate === true;
165
+ return (
166
+ (this.result.delay.acknowledged && typeof delay.release === 'function') ||
167
+ this.missingEvidence('inbound delay not acknowledged')
168
+ );
169
+ }
170
+
171
+ async trigger() {
172
+ const params = { entryId: this.entry.id };
173
+ const out =
174
+ this.mode === 'history'
175
+ ? await this.call('Page.navigateToHistoryEntry', params)
176
+ : await this.read('Page.navigateToHistoryEntry', params);
177
+ this.result.trigger = out;
178
+ if (this.mode === 'history' && refusal(out))
179
+ await this.read('Page.navigateToHistoryEntry', params);
180
+ }
181
+
182
+ async probe(method, params, current) {
183
+ const sentAt = Date.now();
184
+ const record = { id: ++this.probeId, params, method, sentAt, protectedCurrent: current };
185
+ this.result.probes.push(record);
186
+ const work = this.client.send(method, params, this.session);
187
+ const out = await bounded(work, Math.min(this.budget, 250));
188
+ this.answer(record, out);
189
+ record.windowOutcome = record.outcome;
190
+ this.pending.push(
191
+ outcome(work).then((answer) => {
192
+ this.answer(record, answer);
193
+ record.answeredAt = Date.now();
194
+ })
195
+ );
196
+ return out;
197
+ }
198
+
199
+ answer(record, out) {
200
+ record.outcome = refusal(out)
201
+ ? 'refused'
202
+ : out.error?.message === 'no answer'
203
+ ? 'no answer'
204
+ : 'answered';
205
+ record.reply = out;
206
+ }
207
+
208
+ async round() {
209
+ const f = await this.frame();
210
+ const current = f?.url === this.url && !f.unreachableUrl;
211
+ const restored = this.reader.events
212
+ .slice(this.eventStart)
213
+ .some(
214
+ (event) =>
215
+ event.sessionId === this.observer &&
216
+ event.method === 'Page.frameNavigated' &&
217
+ event.params?.type === 'BackForwardCacheRestore' &&
218
+ event.params.frame?.url === this.url
219
+ );
220
+ if (restored && this.result.restoreAt === undefined) this.result.restoreAt = Date.now();
221
+ const expression =
222
+ 'globalThis.__touched=1; JSON.stringify({origin:location.origin,marker:globalThis.__bfcacheMarker,stored:sessionStorage.getItem("bfcache-marker")})';
223
+ const calls = [
224
+ this.probe('Runtime.evaluate', { expression, returnByValue: true }, current),
225
+ this.probe('DOM.getDocument', { depth: -1, pierce: true }, current),
226
+ this.probe('Accessibility.getFullAXTree', {}, current),
227
+ this.probe('Page.captureScreenshot', { format: 'png' }, current),
228
+ this.probe('IO.read', { handle: this.stream ?? 'unavailable-stream' }, current),
229
+ this.probe(
230
+ 'Input.dispatchKeyEvent',
231
+ { type: 'keyDown', key: 'x', code: 'KeyX', windowsVirtualKeyCode: 88 },
232
+ current
233
+ ),
234
+ ];
235
+ calls.push(
236
+ this.probe(
237
+ 'Runtime.callFunctionOn',
238
+ {
239
+ executionContextId: this.contextId,
240
+ functionDeclaration:
241
+ 'function(){this.__touched=1;return JSON.stringify({origin:location.origin,marker:this.__bfcacheMarker})}',
242
+ returnByValue: true,
243
+ },
244
+ current
245
+ )
246
+ );
247
+ await Promise.all(calls);
248
+ }
249
+
250
+ async window() {
251
+ this.eventStart = this.reader.events.length;
252
+ this.clientStart = this.client.events.length;
253
+ this.messageStart = this.client.messages?.length ?? 0;
254
+ this.result.delay.startedAt = Date.now();
255
+ const trigger = this.trigger();
256
+ const restored = await waitFor(async () => {
257
+ const f = await this.frame();
258
+ return f?.url === this.url && !f.unreachableUrl && (await this.state())?.persisted === true;
259
+ }, this.budget);
260
+ if (!restored) this.missingEvidence('protected restore before probes not observed');
261
+ const f = await this.frame();
262
+ const contextStart = this.reader.events.length;
263
+ await this.read('Runtime.enable', {});
264
+ this.contextId = this.reader.events
265
+ .slice(contextStart)
266
+ .find(
267
+ (event) =>
268
+ event.sessionId === this.observer &&
269
+ event.method === 'Runtime.executionContextCreated' &&
270
+ event.params?.context?.auxData?.isDefault &&
271
+ event.params.context.auxData.frameId === f?.id
272
+ )?.params.context.id;
273
+ this.result.screenshotProof = {
274
+ capturedAt: Date.now(),
275
+ url: f?.url,
276
+ reply: await this.read('Page.captureScreenshot', { format: 'png' }),
277
+ };
278
+ const resource = await this.read('Network.loadNetworkResource', {
279
+ frameId: f?.id,
280
+ url: this.url,
281
+ options: { disableCache: true, includeCredentials: false },
282
+ });
283
+ this.stream = resource.result?.resource?.stream;
284
+ this.result.probeStartedAt = Date.now();
285
+ do {
286
+ await this.round();
287
+ await pause(25);
288
+ } while (Date.now() - this.result.probeStartedAt < this.windowMs);
289
+ this.result.beforeRelease = await this.state();
290
+ await this.audit();
291
+ const released = await outcome(this.delay.release());
292
+ this.result.delay.release = released;
293
+ this.delay = undefined;
294
+ await trigger;
295
+ await this.settleProbes();
296
+ if (
297
+ !released.ok ||
298
+ !(released.result?.delayedEvents > 0) ||
299
+ !(released.result.firstDelayedAt >= this.result.delay.startedAt) ||
300
+ !(released.result.releasedAt >= released.result.firstDelayedAt + this.delayMs)
301
+ )
302
+ this.missingEvidence('held restore events not proven');
303
+ }
304
+
305
+ async settleProbes() {
306
+ await waitFor(
307
+ () => this.result.probes.every((probe) => probe.answeredAt !== undefined),
308
+ this.budget
309
+ );
310
+ await this.audit(true);
311
+ if (
312
+ this.result.probes.some(
313
+ (probe) =>
314
+ probe.answeredAt === undefined && !(this.finalAuditComplete && probe.forwarded === false)
315
+ )
316
+ )
317
+ this.missingEvidence('probe replies or terminal non-forwarding evidence unavailable');
318
+ }
319
+
320
+ async audit(final = false) {
321
+ if (!this.forwardAudit) {
322
+ this.result.commandsUnforwarded = 'not-supported';
323
+ this.notes.push('commandsUnforwarded: not-supported:forwardAudit');
324
+ return;
325
+ }
326
+ const audit = await outcome(
327
+ typeof this.gate.audit === 'function'
328
+ ? this.gate.audit({ probes: this.result.probes, final })
329
+ : Promise.reject(new Error('forwarding audit unavailable'))
330
+ );
331
+ this.result.delay[final ? 'finalAudit' : 'audit'] = audit;
332
+ const entries = Array.isArray(audit.result?.probes) ? audit.result.probes : [];
333
+ const complete =
334
+ audit.ok &&
335
+ audit.result?.complete === true &&
336
+ entries.length === this.result.probes.length &&
337
+ new Set(entries.map((entry) => entry.id)).size === entries.length &&
338
+ this.result.probes.every((probe) =>
339
+ entries.some((entry) => entry.id === probe.id && typeof entry.forwarded === 'boolean')
340
+ );
341
+ for (const probe of this.result.probes) {
342
+ const matches = entries.filter((entry) => entry.id === probe.id);
343
+ probe.forwarded = matches.some((entry) => entry.forwarded === true)
344
+ ? true
345
+ : matches.find((entry) => entry.forwarded === false)?.forwarded;
346
+ }
347
+ const forwarded =
348
+ this.result.commandsUnforwarded === false ||
349
+ this.result.probes.some((probe) => probe.forwarded);
350
+ this.result.commandsUnforwarded = forwarded ? false : complete ? true : 'incomplete';
351
+ if (final) this.finalAuditComplete = complete;
352
+ if (!complete) this.missingEvidence('forwarding audit unavailable');
353
+ }
354
+
355
+ async judge() {
356
+ const state = await this.state();
357
+ const { result, marker, protectedOrigin } = this;
358
+ result.sideEffect = state;
359
+ result.restoredObserved =
360
+ result.restoreAt !== undefined &&
361
+ state?.origin === protectedOrigin &&
362
+ state.marker === marker &&
363
+ state.stored === marker &&
364
+ state.persisted === true;
365
+ if (!result.restoredObserved) {
366
+ result.restoredObserved = 'incomplete';
367
+ this.missingEvidence('real protected BFCache restore not observed');
368
+ }
369
+ const delivered = [
370
+ ...result.probes.map((item) => item.reply),
371
+ ...this.client.events
372
+ .slice(this.clientStart)
373
+ .filter((event) => event.sessionId === this.session),
374
+ ...(this.client.messages?.slice(this.messageStart) ?? []),
375
+ ];
376
+ result.markerHidden = !containsSecret(delivered, marker);
377
+ result.originHidden = !containsSecret(
378
+ result.probes.map((item) => item.reply),
379
+ protectedOrigin
380
+ );
381
+ result.sideEffectAbsent = this.effects([result.beforeRelease, state]);
382
+ if (result.sideEffectAbsent === 'incomplete')
383
+ this.missingEvidence('side-effect read unavailable');
384
+ const protectedProbes = result.probes.filter((item) => item.protectedCurrent);
385
+ result.probesRefusedOrUnforwarded =
386
+ result.probes.length > 0 &&
387
+ result.probes.every((item) => ['refused', 'no answer'].includes(item.outcome));
388
+ result.probeRanInProtected = protectedProbes.some(
389
+ (item) =>
390
+ item.method === 'Runtime.evaluate' &&
391
+ String(value(item.reply)).includes(marker) &&
392
+ String(value(item.reply)).includes(protectedOrigin)
393
+ );
394
+ result.touched = state?.touched === 1;
395
+ result.methodsProven = methods.every((method) =>
396
+ protectedProbes.some((item) => item.method === method && this.proven(item, state))
397
+ );
398
+ if (this.side === 'control') this.control();
399
+ }
400
+
401
+ proven(probe, state) {
402
+ if (!probe.reply.ok || probe.reply.result?.exceptionDetails) return false;
403
+ if (probe.method === 'Input.dispatchKeyEvent') return state?.keys > 0;
404
+ if (probe.method === 'IO.read')
405
+ return (
406
+ this.stream &&
407
+ probe.params.handle === this.stream &&
408
+ containsSecret(probe.reply.result?.data, this.marker)
409
+ );
410
+ if (probe.method === 'Page.captureScreenshot') {
411
+ const proof = this.result.screenshotProof;
412
+ const data = probe.reply.result?.data;
413
+ return (
414
+ proof?.url === this.url && proof.reply.ok && png(data) && data === proof.reply.result?.data
415
+ );
416
+ }
417
+ return containsSecret(probe.reply, this.marker);
418
+ }
419
+
420
+ effects(states) {
421
+ const measured = (state) =>
422
+ state?.origin === this.protectedOrigin &&
423
+ state.marker === this.marker &&
424
+ Number.isInteger(state.keys);
425
+ if (
426
+ states.some(
427
+ (state) =>
428
+ state?.origin === this.protectedOrigin &&
429
+ state.marker === this.marker &&
430
+ (state.touched === 1 || state.keys > 0)
431
+ )
432
+ )
433
+ return false;
434
+ return states.every(measured) ? true : 'incomplete';
435
+ }
436
+
437
+ control() {
438
+ if (!this.result.methodsProven) {
439
+ this.result.methodsProven = 'incomplete';
440
+ this.missingEvidence('positive probe methods not observed');
441
+ }
442
+ if (!this.result.probeRanInProtected || !this.result.touched) {
443
+ if (!this.result.probeRanInProtected) this.result.probeRanInProtected = 'incomplete';
444
+ if (!this.result.touched) this.result.touched = 'incomplete';
445
+ this.missingEvidence('positive protected probes not observed');
446
+ }
447
+ }
448
+
449
+ async run() {
450
+ const opened = await outcome(
451
+ createObserverTarget(
452
+ this.reader,
453
+ { url: `${this.ordinary}/bfcache?n=${this.tag}-a`, background: true },
454
+ this.notes
455
+ )
456
+ );
457
+ if (!opened.ok) {
458
+ this.missingEvidence(opened.error.message);
459
+ } else {
460
+ this.targetId = opened.result.targetId;
461
+ try {
462
+ if ((await this.seed()) && (await this.install())) {
463
+ await this.window();
464
+ await this.judge();
465
+ }
466
+ } finally {
467
+ if (this.delay?.release) this.result.delay.release = await outcome(this.delay.release());
468
+ if (this.session)
469
+ await bounded(
470
+ this.client.send('Target.detachFromTarget', { sessionId: this.session }),
471
+ this.budget
472
+ );
473
+ if (this.stream) await this.read('IO.close', { handle: this.stream });
474
+ if (this.observer) await this.read('Target.detachFromTarget', { sessionId: this.observer });
475
+ await this.read('Target.closeTarget', { targetId: this.targetId }, undefined);
476
+ }
477
+ }
478
+ return this.missing.length
479
+ ? { ...this.result, incomplete: `bfcache-restore: ${this.missing.join('; ')}` }
480
+ : this.result;
481
+ }
482
+ }
483
+
484
+ export function bfcacheRestore({
485
+ client,
486
+ reader,
487
+ ordinary,
488
+ protectedOrigin,
489
+ tag,
490
+ mode,
491
+ side,
492
+ delayClient = client,
493
+ notes = [],
494
+ budget = 5000,
495
+ forwardAudit = true,
496
+ delayMs = 1500,
497
+ windowMs = delayMs + 1000,
498
+ }) {
499
+ return new Restore({
500
+ client,
501
+ reader,
502
+ ordinary,
503
+ protectedOrigin,
504
+ tag,
505
+ mode,
506
+ side,
507
+ delayClient,
508
+ notes,
509
+ budget,
510
+ forwardAudit,
511
+ delayMs,
512
+ windowMs,
513
+ }).run();
514
+ }
@@ -91,6 +91,16 @@ function route(req, url, send, html, redirects) {
91
91
  `<script>navigator.sendBeacon(${JSON.stringify(pre)})</script><iframe src="${escape(to)}"></iframe>`
92
92
  );
93
93
  },
94
+ '/bfcache': () => {
95
+ const marker = JSON.stringify(`bfcache-marker-${p.get('n') ?? ''}`).replaceAll(
96
+ '<',
97
+ '\\u003c'
98
+ );
99
+ return html(
100
+ 'bfcache',
101
+ `<p id="bfcache-marker"></p><script>globalThis.__bfcacheMarker=${marker};sessionStorage.setItem('bfcache-marker',globalThis.__bfcacheMarker);document.querySelector('p').textContent=globalThis.__bfcacheMarker;globalThis.__bfcachePersisted=false;addEventListener('pageshow',e=>{globalThis.__bfcachePersisted=e.persisted});addEventListener('keydown',()=>{globalThis.__bfcacheKeys=(globalThis.__bfcacheKeys||0)+1})</script>`
102
+ );
103
+ },
94
104
  '/doc': () => document(url, html),
95
105
  '/csp': () => document(url, html),
96
106
  };
package/guard-runner.mjs CHANGED
@@ -2,6 +2,7 @@ import { fixtureClient, prepareFixture } from './fixture-client.mjs';
2
2
 
3
3
  export { fixtureClient };
4
4
 
5
+ import { bfcacheRestore } from './bfcache-restore.mjs';
5
6
  import { extensionWorld } from './extension-world.mjs';
6
7
  import { frameRead } from './frame-read.mjs';
7
8
  import {
@@ -676,11 +677,12 @@ class Execution {
676
677
  'errorPage',
677
678
  'extensionWorld',
678
679
  'frameRead',
680
+ 'bfcacheRestore',
679
681
  ].find((key) => step[key]);
680
682
  if (probe && !out.ok) this.errors.push(`${probe}: ${out.error?.message ?? 'probe failed'}`);
681
683
  if (step.client && !out.ok) this.errors.push(`client exit: ${out.error.message}`);
682
684
  if (
683
- ['errorPage', 'redirectSecret', 'frameRead'].includes(probe) &&
685
+ ['errorPage', 'redirectSecret', 'frameRead', 'bfcacheRestore'].includes(probe) &&
684
686
  out.ok &&
685
687
  out.result?.incomplete
686
688
  )
@@ -776,6 +778,22 @@ class Execution {
776
778
  budget: this.bound(5000),
777
779
  })
778
780
  );
781
+ if (step.bfcacheRestore)
782
+ return outcome(
783
+ bfcacheRestore({
784
+ client: this.client,
785
+ reader: this.reader,
786
+ delayClient: this.clients.client,
787
+ ordinary: this.vars.ordinary,
788
+ protectedOrigin: step.origin,
789
+ tag: this.vars.tag,
790
+ mode: step.bfcacheRestore,
791
+ forwardAudit: supported(['forwardAudit'], this.options.capabilities),
792
+ side: this.side,
793
+ notes: this.notes,
794
+ budget: this.bound(5000),
795
+ })
796
+ );
779
797
  if (step.frameRead)
780
798
  return outcome(
781
799
  frameRead({
@@ -1301,6 +1319,11 @@ class Execution {
1301
1319
  errors: this.errors,
1302
1320
  notes: this.notes,
1303
1321
  incomplete: this.incomplete,
1322
+ evidence: Object.fromEntries(
1323
+ Object.entries(this.calls)
1324
+ .filter(([, out]) => out?.result?.delay && out.result.probes)
1325
+ .map(([name, out]) => [name, out.result])
1326
+ ),
1304
1327
  };
1305
1328
  } finally {
1306
1329
  this.stopped = true;
@@ -1443,6 +1466,7 @@ function variables(targets, target, side, slot, index) {
1443
1466
  tag,
1444
1467
  cookie: `canary-${tag}`,
1445
1468
  };
1469
+ vars.protectedTarget = targets[target];
1446
1470
  vars.target = side === 'control' ? vars.plain : targets[target];
1447
1471
  vars.targetHost = new URL(vars.target).hostname;
1448
1472
  vars.responseOrigin = vars.target === vars.ordinary ? '*' : vars.ordinary;
@@ -1524,6 +1548,14 @@ async function timedOut(stuck, base, made, work, opts) {
1524
1548
  return failed;
1525
1549
  }
1526
1550
 
1551
+ function probeEvidence(legs) {
1552
+ return [legs.control.evidence, legs.guarded.evidence].some(
1553
+ (evidence) => Object.keys(evidence ?? {}).length
1554
+ )
1555
+ ? { evidence: { control: legs.control.evidence, guarded: legs.guarded.evidence } }
1556
+ : {};
1557
+ }
1558
+
1527
1559
  async function executeRow(job, opts, made, set, slot, index, flights) {
1528
1560
  const { target, variant } = job;
1529
1561
  const calls = new Map();
@@ -1611,6 +1643,7 @@ async function executeRow(job, opts, made, set, slot, index, flights) {
1611
1643
  observations: { control: legs.control.observed, guarded: legs.guarded.observed },
1612
1644
  errors: [...legs.control.errors, ...legs.guarded.errors],
1613
1645
  designDependent: vector.designDependent ?? [],
1646
+ ...probeEvidence(legs),
1614
1647
  };
1615
1648
  const notes = [
1616
1649
  ...(legs.control.notes ?? []).map((note) => `control ${note}`),
@@ -1627,6 +1660,11 @@ async function executeRow(job, opts, made, set, slot, index, flights) {
1627
1660
  }
1628
1661
 
1629
1662
  function observerSkip(vector, opts, made, clients) {
1663
+ if (
1664
+ vector.requires?.includes('inboundDelay') &&
1665
+ typeof clients.client.inboundDelay !== 'function'
1666
+ )
1667
+ return 'not-supported:inboundDelay';
1630
1668
  if (needsObserver(vector) && !clients.unguarded) return 'not-supported:unguarded-observer';
1631
1669
  if (!exitsClient(vector)) return undefined;
1632
1670
  if (!opts.clientFactory) return 'not-supported:clientExit';
@@ -1710,8 +1748,7 @@ function settleIncomplete(row, vector, base, legs) {
1710
1748
  live: legs.control.live === true && [true, 'incomplete'].includes(legs.guarded.live),
1711
1749
  });
1712
1750
  for (const side of ['control', 'guarded'])
1713
- if (legs[side].incomplete?.length && row[side] === 'fail' && filled[side] === 'pass')
1714
- row[side] = 'incomplete';
1751
+ if (legs[side].incomplete?.length && filled[side] === 'pass') row[side] = 'incomplete';
1715
1752
  if (legs.guarded.live === 'incomplete' && filled.live === 'pass') row.live = 'incomplete';
1716
1753
  row.why = [filled.why, `incomplete: ${incomplete.join('; ')}`].filter(Boolean).join('; ');
1717
1754
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "setVersion": "1.17.0",
3
+ "setVersion": "1.18.1",
4
4
  "sources": [
5
5
  {
6
6
  "repo": "ai-ecoverse/slicc-cli",
@@ -99,10 +99,13 @@
99
99
  "discovery": "session-less Target.setDiscoverTargets and targetCreated/targetInfoChanged delivery; absent transports must emit neither event",
100
100
  "chromeUrlsAttach": "attaching to and driving chrome:// pages, which CDP over a DevTools port can and chrome.debugger can't",
101
101
  "clientExit": "closing the guarded client mid-row: the client has close(), the clientFactory makes a fresh guarded client for the next leg, and an independent unguarded observer outlives it",
102
- "siteIsolationOff": "the browser runs without site isolation, so a cross-site iframe shares its parent's process"
102
+ "siteIsolationOff": "the browser runs without site isolation, so a cross-site iframe shares its parent's process",
103
+ "inboundDelay": "a harness gate holding browser-to-guard messages until release during a history restore, with seed and release evidence",
104
+ "forwardAudit": "a complete browser-side forwarding ledger for every history-restore probe"
103
105
  },
104
106
  "placeholders": {
105
107
  "target": "the target's origin; {plain} on the control",
108
+ "protectedTarget": "the row target's origin on both legs of a history-restore probe",
106
109
  "targetHost": "{target}'s host",
107
110
  "seven": "the seven target's origin",
108
111
  "vault": "the vault target's origin",
@@ -126,6 +129,292 @@
126
129
  "browserUi": "the id of one of Chrome's own browser_ui targets, which the runner looks up with its own CDP"
127
130
  },
128
131
  "vectors": [
132
+ {
133
+ "id": "C8.protected-bfcache-restore",
134
+ "family": "C",
135
+ "kind": "cdp",
136
+ "scope": "any",
137
+ "defaultContext": true,
138
+ "setup": [
139
+ {
140
+ "page": "tab",
141
+ "url": "{ordinary}/doc?n={tag}-start"
142
+ }
143
+ ],
144
+ "trigger": [
145
+ {
146
+ "bfcacheRestore": "observer",
147
+ "origin": "{protectedTarget}",
148
+ "as": "restore",
149
+ "requires": ["inboundDelay"]
150
+ }
151
+ ],
152
+ "targets": ["seven", "vault"],
153
+ "requires": ["inboundDelay"],
154
+ "observe": {
155
+ "markerHidden": {
156
+ "means": "history restore: markerHidden",
157
+ "check": {
158
+ "result": "restore",
159
+ "path": "result.markerHidden",
160
+ "equals": true,
161
+ "observer": "client"
162
+ }
163
+ },
164
+ "originHidden": {
165
+ "means": "history restore: originHidden",
166
+ "check": {
167
+ "result": "restore",
168
+ "path": "result.originHidden",
169
+ "equals": true,
170
+ "observer": "client"
171
+ }
172
+ },
173
+ "sideEffectAbsent": {
174
+ "means": "history restore: sideEffectAbsent",
175
+ "check": {
176
+ "result": "restore",
177
+ "path": "result.sideEffectAbsent",
178
+ "equals": true,
179
+ "observer": "client"
180
+ }
181
+ },
182
+ "probesRefusedOrUnforwarded": {
183
+ "means": "history restore: probesRefusedOrUnforwarded",
184
+ "check": {
185
+ "result": "restore",
186
+ "path": "result.probesRefusedOrUnforwarded",
187
+ "equals": true,
188
+ "observer": "client"
189
+ }
190
+ },
191
+ "restoredObserved": {
192
+ "means": "history restore: restoredObserved",
193
+ "check": {
194
+ "result": "restore",
195
+ "path": "result.restoredObserved",
196
+ "equals": true,
197
+ "observer": "client"
198
+ }
199
+ },
200
+ "sessionLive": {
201
+ "means": "history restore: sessionLive",
202
+ "check": {
203
+ "result": "restore",
204
+ "path": "result.sessionLive",
205
+ "equals": true,
206
+ "observer": "client"
207
+ }
208
+ },
209
+ "probeRanInProtected": {
210
+ "means": "history restore: probeRanInProtected",
211
+ "check": {
212
+ "result": "restore",
213
+ "path": "result.probeRanInProtected",
214
+ "equals": true,
215
+ "observer": "client"
216
+ }
217
+ },
218
+ "touched": {
219
+ "means": "history restore: touched",
220
+ "check": {
221
+ "result": "restore",
222
+ "path": "result.touched",
223
+ "equals": true,
224
+ "observer": "client"
225
+ }
226
+ },
227
+ "methodsProven": {
228
+ "means": "the unguarded control exercises every probe method",
229
+ "check": {
230
+ "result": "restore",
231
+ "path": "result.methodsProven",
232
+ "equals": true,
233
+ "observer": "client"
234
+ }
235
+ },
236
+ "commandsUnforwarded": {
237
+ "means": "no protected-window probe reached the browser while inbound delivery was held",
238
+ "requires": ["forwardAudit"],
239
+ "check": {
240
+ "result": "restore",
241
+ "path": "result.commandsUnforwarded",
242
+ "equals": true,
243
+ "observer": "client"
244
+ }
245
+ }
246
+ },
247
+ "live": "the guarded session answers on ordinary B before the history trigger",
248
+ "liveCheck": {
249
+ "result": "restore",
250
+ "path": "result.sessionLive",
251
+ "equals": true,
252
+ "observer": "client"
253
+ },
254
+ "control": {
255
+ "origin": "ordinary",
256
+ "via": "unguarded",
257
+ "expected": {
258
+ "restoredObserved": true,
259
+ "probeRanInProtected": true,
260
+ "touched": true,
261
+ "sessionLive": true,
262
+ "methodsProven": true
263
+ }
264
+ },
265
+ "expected": {
266
+ "markerHidden": true,
267
+ "originHidden": true,
268
+ "sideEffectAbsent": true,
269
+ "probesRefusedOrUnforwarded": true,
270
+ "restoredObserved": true,
271
+ "sessionLive": true,
272
+ "commandsUnforwarded": true
273
+ }
274
+ },
275
+ {
276
+ "id": "C8.protected-bfcache-restore.history",
277
+ "family": "C",
278
+ "kind": "cdp",
279
+ "scope": "any",
280
+ "defaultContext": true,
281
+ "setup": [
282
+ {
283
+ "page": "tab",
284
+ "url": "{ordinary}/doc?n={tag}-start"
285
+ }
286
+ ],
287
+ "trigger": [
288
+ {
289
+ "bfcacheRestore": "history",
290
+ "origin": "{protectedTarget}",
291
+ "as": "restore",
292
+ "requires": ["inboundDelay"]
293
+ }
294
+ ],
295
+ "targets": ["seven", "vault"],
296
+ "requires": ["inboundDelay"],
297
+ "observe": {
298
+ "markerHidden": {
299
+ "means": "history restore: markerHidden",
300
+ "check": {
301
+ "result": "restore",
302
+ "path": "result.markerHidden",
303
+ "equals": true,
304
+ "observer": "client"
305
+ }
306
+ },
307
+ "originHidden": {
308
+ "means": "history restore: originHidden",
309
+ "check": {
310
+ "result": "restore",
311
+ "path": "result.originHidden",
312
+ "equals": true,
313
+ "observer": "client"
314
+ }
315
+ },
316
+ "sideEffectAbsent": {
317
+ "means": "history restore: sideEffectAbsent",
318
+ "check": {
319
+ "result": "restore",
320
+ "path": "result.sideEffectAbsent",
321
+ "equals": true,
322
+ "observer": "client"
323
+ }
324
+ },
325
+ "probesRefusedOrUnforwarded": {
326
+ "means": "history restore: probesRefusedOrUnforwarded",
327
+ "check": {
328
+ "result": "restore",
329
+ "path": "result.probesRefusedOrUnforwarded",
330
+ "equals": true,
331
+ "observer": "client"
332
+ }
333
+ },
334
+ "restoredObserved": {
335
+ "means": "history restore: restoredObserved",
336
+ "check": {
337
+ "result": "restore",
338
+ "path": "result.restoredObserved",
339
+ "equals": true,
340
+ "observer": "client"
341
+ }
342
+ },
343
+ "sessionLive": {
344
+ "means": "history restore: sessionLive",
345
+ "check": {
346
+ "result": "restore",
347
+ "path": "result.sessionLive",
348
+ "equals": true,
349
+ "observer": "client"
350
+ }
351
+ },
352
+ "probeRanInProtected": {
353
+ "means": "history restore: probeRanInProtected",
354
+ "check": {
355
+ "result": "restore",
356
+ "path": "result.probeRanInProtected",
357
+ "equals": true,
358
+ "observer": "client"
359
+ }
360
+ },
361
+ "touched": {
362
+ "means": "history restore: touched",
363
+ "check": {
364
+ "result": "restore",
365
+ "path": "result.touched",
366
+ "equals": true,
367
+ "observer": "client"
368
+ }
369
+ },
370
+ "methodsProven": {
371
+ "means": "the unguarded control exercises every probe method",
372
+ "check": {
373
+ "result": "restore",
374
+ "path": "result.methodsProven",
375
+ "equals": true,
376
+ "observer": "client"
377
+ }
378
+ },
379
+ "commandsUnforwarded": {
380
+ "means": "no protected-window probe reached the browser while inbound delivery was held",
381
+ "requires": ["forwardAudit"],
382
+ "check": {
383
+ "result": "restore",
384
+ "path": "result.commandsUnforwarded",
385
+ "equals": true,
386
+ "observer": "client"
387
+ }
388
+ }
389
+ },
390
+ "live": "the guarded session answers on ordinary B before the history trigger",
391
+ "liveCheck": {
392
+ "result": "restore",
393
+ "path": "result.sessionLive",
394
+ "equals": true,
395
+ "observer": "client"
396
+ },
397
+ "control": {
398
+ "origin": "ordinary",
399
+ "via": "unguarded",
400
+ "expected": {
401
+ "restoredObserved": true,
402
+ "probeRanInProtected": true,
403
+ "touched": true,
404
+ "sessionLive": true,
405
+ "methodsProven": true
406
+ }
407
+ },
408
+ "expected": {
409
+ "markerHidden": true,
410
+ "originHidden": true,
411
+ "sideEffectAbsent": true,
412
+ "probesRefusedOrUnforwarded": true,
413
+ "restoredObserved": true,
414
+ "sessionLive": true,
415
+ "commandsUnforwarded": true
416
+ }
417
+ },
129
418
  {
130
419
  "id": "C2.redirect-body",
131
420
  "family": "C",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-ecoverse/slicc-browsers",
3
- "version": "1.17.0",
3
+ "version": "1.18.1",
4
4
  "description": "SLICC's shared browser catalog, JSON schemas and discovery conformance corpus",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -59,6 +59,7 @@
59
59
  "redirect-fragments.mjs",
60
60
  "observer-target.mjs",
61
61
  "frame-read.mjs",
62
+ "bfcache-restore.mjs",
62
63
  "fixture-client.mjs"
63
64
  ],
64
65
  "scripts": {
@@ -67,22 +68,22 @@
67
68
  "validate": "node scripts/validate.mjs",
68
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",
69
70
  "format": "biome check --write . && gofmt -w . && cargo fmt",
70
- "test": "npm run validate && node --test 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 && go test -race -count=1 ./test/integration/... && cargo test --all-features",
71
- "test:unit": "go test -race -cover ./...",
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",
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 ./...",
72
73
  "build": "go build ./... && cargo build --all-features && cargo build --target wasm32-wasip1 --all-features && npm pack --dry-run",
73
74
  "trust": "npx fledgling sync -y --skip-publish",
74
75
  "trust:dry": "npx fledgling sync --dry-run -y --skip-publish",
75
76
  "release": "semantic-release",
76
77
  "test:guard": "npm run test:guard-integration && npm run test:guard-sweep",
77
- "test:guard-integration": "node --test --test-concurrency=2 test/integration/guard-lifecycle.test.mjs test/integration/guard-fit.test.mjs test/integration/guard-chain.test.mjs test/integration/guard-validation.test.mjs test/integration/guard-liveness.test.mjs test/integration/guard-observations.test.mjs test/integration/guard-child-latency.test.mjs test/integration/guard-adoption.test.mjs test/integration/guard-scoping.test.mjs test/integration/guard-late-pause.test.mjs test/integration/guard-legs.test.mjs test/integration/guard-fetch-enable.test.mjs test/integration/guard-diagnostics.test.mjs test/integration/guard-chrome.test.mjs test/integration/guard-worlds.test.mjs test/integration/guard-world-discovery.test.mjs test/integration/guard-world-guesses.test.mjs test/integration/guard-unparseable.test.mjs test/integration/guard-redirect-secret.test.mjs test/integration/guard-error-page.test.mjs test/integration/guard-discovery.test.mjs test/integration/guard-browser-page.test.mjs test/integration/guard-scales.test.mjs test/integration/guard-shared-log.test.mjs test/integration/guard-pre-beacon.test.mjs test/integration/guard-world-pause.test.mjs test/integration/guard-parallel.test.mjs test/integration/guard-client-exit.test.mjs test/integration/self-contained.test.mjs",
78
+ "test:guard-integration": "node --test --test-concurrency=2 test/integration/guard-lifecycle.test.mjs test/integration/guard-fit.test.mjs test/integration/guard-chain.test.mjs test/integration/guard-validation.test.mjs test/integration/guard-liveness.test.mjs test/integration/guard-observations.test.mjs test/integration/guard-child-latency.test.mjs test/integration/guard-adoption.test.mjs test/integration/guard-scoping.test.mjs test/integration/guard-late-pause.test.mjs test/integration/guard-legs.test.mjs test/integration/guard-fetch-enable.test.mjs test/integration/guard-diagnostics.test.mjs test/integration/guard-chrome.test.mjs test/integration/guard-worlds.test.mjs test/integration/guard-world-discovery.test.mjs test/integration/guard-world-guesses.test.mjs test/integration/guard-unparseable.test.mjs test/integration/guard-redirect-secret.test.mjs test/integration/guard-error-page.test.mjs test/integration/guard-discovery.test.mjs test/integration/guard-bfcache-chrome.test.mjs test/integration/guard-browser-page.test.mjs test/integration/guard-scales.test.mjs test/integration/guard-shared-log.test.mjs test/integration/guard-pre-beacon.test.mjs test/integration/guard-world-pause.test.mjs test/integration/guard-parallel.test.mjs test/integration/guard-client-exit.test.mjs test/integration/self-contained.test.mjs",
78
79
  "test:guard-sweep": "node scripts/test-guards.mjs",
79
80
  "test:gates": "node --test test/integration/gates.test.mjs",
80
81
  "test:guard-target": "node --test test/integration/guard-runner.test.mjs",
81
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:guard": "mkdir -p coverage/unit && node --experimental-vm-modules --no-warnings --experimental-test-coverage --test --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=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"
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"
83
84
  },
84
85
  "devDependencies": {
85
- "@ai-ecoverse/slicc-shared-web": "2.7.5",
86
+ "@ai-ecoverse/slicc-shared-web": "2.7.8",
86
87
  "@biomejs/biome": "2.5.15",
87
88
  "ajv": "8.20.0",
88
89
  "ajv-formats": "3.0.1",
@@ -1,4 +1,4 @@
1
- import { outcome, waitFor } from './guard-driver.mjs';
1
+ import { outcome, parseURL, waitFor } from './guard-driver.mjs';
2
2
  import { createObserverTarget } from './observer-target.mjs';
3
3
 
4
4
  const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -104,6 +104,43 @@ async function seed(reader, observer, loaded, initial, allowed) {
104
104
  return out.ok ? out.result : `observer setup failed: ${out.error.message}`;
105
105
  }
106
106
 
107
+ const encoded = (url) =>
108
+ Array.from(
109
+ new TextEncoder().encode(url),
110
+ (byte) => `%${byte.toString(16).toUpperCase().padStart(2, '0')}`
111
+ ).join('');
112
+
113
+ async function scripted(client, session, ordinary, url) {
114
+ const out = await outcome(
115
+ client.send(
116
+ 'Page.navigate',
117
+ { url: `${ordinary}/page?k=deferred&defer=0&to=${encoded(url)}` },
118
+ session
119
+ )
120
+ );
121
+ return out.ok ? undefined : `client navigation failed: ${out.error.message}`;
122
+ }
123
+
124
+ async function recover({ client, session, targetId, ordinary, failedURL, start, result, reached }) {
125
+ if (result.blank) {
126
+ const unsent = await scripted(client, session, ordinary, failedURL);
127
+ if (unsent) return unsent;
128
+ result.closeError = await reached();
129
+ }
130
+ result.attached &&= !client.events
131
+ .slice(start)
132
+ .some((e) => e.method === 'Target.detachedFromTarget' && e.params.sessionId === session);
133
+ const close = await outcome(client.send('Target.closeTarget', { targetId }));
134
+ result.close = result.closeError === true && close.result?.success === true;
135
+ }
136
+
137
+ function answer(event, failing, origin) {
138
+ const { requestId, request } = event.params;
139
+ return failing && parseURL(request?.url)?.origin === origin
140
+ ? ['Fetch.failRequest', { requestId, errorReason: 'BlockedByClient' }]
141
+ : ['Fetch.continueRequest', { requestId }];
142
+ }
143
+
107
144
  const secrets = {
108
145
  all: (tag) => `/doc/path-secret-${tag}?query-secret=${tag}#fragment-secret-${tag}`,
109
146
  query: (tag) => `/doc?query-secret=${tag}`,
@@ -126,6 +163,7 @@ export async function protectedErrorPage({
126
163
  }) {
127
164
  const initial = `${protectedOrigin}/doc?n=${tag}-history`;
128
165
  const failedURL = `${protectedOrigin}${secrets[secret](tag)}`;
166
+ const origin = parseURL(protectedOrigin)?.origin;
129
167
  const result = {};
130
168
  const until = (check) => waitFor(check, budget);
131
169
  const missing = (reason) => ({ ...result, incomplete: `protected-error-page: ${reason}` });
@@ -147,15 +185,7 @@ export async function protectedErrorPage({
147
185
  for (const event of reader.events.slice(cursor)) {
148
186
  cursor++;
149
187
  if (event.method === 'Fetch.requestPaused' && event.sessionId === observer)
150
- await outcome(
151
- reader.send(
152
- failing ? 'Fetch.failRequest' : 'Fetch.continueRequest',
153
- failing
154
- ? { requestId: event.params.requestId, errorReason: 'BlockedByClient' }
155
- : { requestId: event.params.requestId },
156
- observer
157
- )
158
- );
188
+ await outcome(reader.send(...answer(event, failing, origin), observer));
159
189
  }
160
190
  await pause(5);
161
191
  }
@@ -179,11 +209,8 @@ export async function protectedErrorPage({
179
209
  observer
180
210
  );
181
211
  }
182
- await client.send(
183
- 'Runtime.evaluate',
184
- { expression: `setTimeout(()=>location.href=${JSON.stringify(failedURL)},0)` },
185
- session
186
- );
212
+ const unsent = await scripted(client, session, ordinary, failedURL);
213
+ if (unsent) return missing(unsent);
187
214
  let evidence;
188
215
  const live = await until(async () => {
189
216
  const f = await frame();
@@ -219,32 +246,25 @@ export async function protectedErrorPage({
219
246
  .slice(start)
220
247
  .some((e) => e.method === 'Target.detachedFromTarget' && e.params.sessionId === session);
221
248
  if (mode === 'recover') {
222
- if (result.blank) {
223
- await client.send(
224
- 'Runtime.evaluate',
225
- { expression: `setTimeout(()=>location.href=${JSON.stringify(failedURL)},0)` },
226
- session
227
- );
228
- result.closeError = await until(async () => (await frame()).unreachableUrl === failedURL);
229
- }
230
- result.attached &&= !client.events
231
- .slice(start)
232
- .some((e) => e.method === 'Target.detachedFromTarget' && e.params.sessionId === session);
233
- const close = await outcome(client.send('Target.closeTarget', { targetId }));
234
- result.close = result.closeError === true && close.result?.success === true;
235
- return result;
249
+ const unclosed = await recover({
250
+ client,
251
+ session,
252
+ targetId,
253
+ ordinary,
254
+ failedURL,
255
+ start,
256
+ result,
257
+ reached: () => until(async () => (await frame()).unreachableUrl === failedURL),
258
+ });
259
+ return unclosed ? missing(unclosed) : result;
236
260
  }
237
261
  if (side === 'control') await reader.send('Fetch.disable', {}, observer);
238
262
  failing = false;
239
- const { entries, currentIndex } = await reader.send('Page.getNavigationHistory', {}, observer);
240
- const index = entries.findIndex((e) => e.url === initial);
241
- if (index < 0) return missing('missing protected history');
263
+ const { entries } = await reader.send('Page.getNavigationHistory', {}, observer);
264
+ const entry = entries.find((e) => e.url === initial);
265
+ if (!entry) return missing('missing protected history');
242
266
  const beforeRestore = reader.events.length;
243
- await reader.send(
244
- 'Runtime.evaluate',
245
- { expression: `setTimeout(() => history.go(${index - currentIndex}), 0)` },
246
- observer
247
- );
267
+ await outcome(reader.send('Page.navigateToHistoryEntry', { entryId: entry.id }, observer));
248
268
  const restored = await until(() =>
249
269
  reader.events
250
270
  .slice(beforeRestore)
@@ -89,7 +89,8 @@
89
89
  "extensionWorld",
90
90
  "discovery",
91
91
  "clientExit",
92
- "siteIsolationOff"
92
+ "siteIsolationOff",
93
+ "inboundDelay"
93
94
  ],
94
95
  "properties": {
95
96
  "autoAttach": {
@@ -111,7 +112,9 @@
111
112
  "minLength": 1
112
113
  },
113
114
  "clientExit": { "type": "string", "minLength": 1 },
114
- "siteIsolationOff": { "type": "string", "minLength": 1 }
115
+ "siteIsolationOff": { "type": "string", "minLength": 1 },
116
+ "inboundDelay": { "type": "string", "minLength": 1 },
117
+ "forwardAudit": { "type": "string", "minLength": 1 }
115
118
  }
116
119
  },
117
120
  "placeholders": {
@@ -142,7 +145,9 @@
142
145
  "extensionWorld",
143
146
  "discovery",
144
147
  "clientExit",
145
- "siteIsolationOff"
148
+ "siteIsolationOff",
149
+ "inboundDelay",
150
+ "forwardAudit"
146
151
  ]
147
152
  },
148
153
  "requires": {
@@ -436,6 +441,21 @@
436
441
  },
437
442
  "step": {
438
443
  "oneOf": [
444
+ {
445
+ "type": "object",
446
+ "additionalProperties": false,
447
+ "required": ["bfcacheRestore", "origin", "as", "requires"],
448
+ "properties": {
449
+ "bfcacheRestore": { "enum": ["observer", "history"] },
450
+ "origin": { "type": "string", "minLength": 1 },
451
+ "as": { "$ref": "#/$defs/name" },
452
+ "requires": {
453
+ "$ref": "#/$defs/requires",
454
+ "type": "array",
455
+ "contains": { "const": "inboundDelay" }
456
+ }
457
+ }
458
+ },
439
459
  {
440
460
  "type": "object",
441
461
  "additionalProperties": false,