@ai-ecoverse/slicc-browsers 1.17.0 → 1.18.0

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,6 +207,14 @@ 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.
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
+
210
218
  `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.
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.
@@ -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.0",
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.0",
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 && 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",
@@ -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,