@ai-ecoverse/slicc-browsers 1.14.9 → 1.15.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
@@ -185,6 +185,9 @@ Every CDP guard runs the vectors in `guard-vectors.json` (schema `schema/guard-v
185
185
  - the cert lane's E and B rows;
186
186
  - `C5.continue-request` and `C5.continue-response`, added next to `C5.document`;
187
187
  - the `C8` rows for privileged pages: `C8.navigate` (a page navigates to `chrome://settings`, `chrome://password-manager`, an extension page or `file:///`; refused), `C8.view-source` (the same through `view-source:`; refused), `C8.create` (`Target.createTarget` straight on those pages; refused), `C8.browser-ui-listed` (`Target.getTargets` lists no `browser_ui` target) and `C8.browser-ui-attach` (`Target.attachToTarget` by a `browser_ui` target's id; refused). All five have an unguarded control, since the guard refuses them only on the privileged target. Two cases aren't rows, because Chrome itself blocks them, so an unguarded client can't tell a guard from none: a page's `window.open('chrome://settings')`, and an `about:` alias such as `about:settings`, which `Page.navigate` and `Target.createTarget` both turn into `about:blank#blocked`. The `about:` aliases are in `protected.json` instead.
188
+ - slicc-cdp's owner vectors in `test/integration/guard-native.test.mjs` at `d85ac3c`: `C5.script`, a protected script subresource under the client's own `Fetch.enable` `*` at both stages (the guard neither pauses it for the client nor lets the client fulfil it, and the page runs the stand-in's real `/script`, while the control's paused request carries its headers as an object with `User-Agent` and runs the forged script), and `X1.after-exit`, the client lifecycle row below.
189
+
190
+ `X1.after-exit` (family `X`, client lifecycle) needs `clientExit`. Through the guard, the client opens an ordinary tab, registers `Page.addScriptToEvaluateOnNewDocument` with `{beacon}`, `Network.setExtraHTTPHeaders` with `x-probe: {tag}` and `Fetch.enable` `*` with a rule that fulfils the destination with a forged `FULFILLED` page, then loads the `/page` trigger whose `go()` navigates to `{target}/canary/{tag}`. Before the exit it must see its init-script beacon, its header on the trigger request and the trigger's `Fetch.requestPaused`: those are the positive evidence on both legs. Then the client exits, the unguarded observer calls `go()`, and the destination must be served and read as the real `canary` document, with no beacon from `{target}`, no `x-probe` on its request, no forged body and no `Fetch.requestPaused` for it reaching the client: nothing the client registered may outlive it. The control runs the same on `{plain}` through the guard, and expects the same, since an ordinary navigation after the exit must be just as clean. A guard that keeps its upstream session, or its own init scripts, headers or interception, past the client's exit fails the row.
188
191
 
189
192
  The `C9` foreign extension isolated-world family requires `extensionWorld`: a browser hosting a foreign content-script extension on the ordinary fixture page. `C9.world-hidden` requires the page default context to remain visible after `Runtime.enable`, while the extension context and Runtime/Debugger identifiers remain hidden without cutting or detaching the session. `C9.world-select` probes numeric and unique context selectors, object and argument handles, promises, properties, named bindings and DOM node resolution; each command selecting the hidden world must fail with the `slicc: refused:` prefix, never an ordinary browser error. Numeric guesses also sweep ids 1–32: an id not exposed to this client must be refused, while every exposed numeric context must remain usable; none of those guarded probes may reach the fixture content-script marker. The marker combines the content script’s global lexical `root` binding with its extension-id DOM marker and `chrome.runtime.id`, and is independently required to be absent from the page default world. `C9.world-debugger` pauses inside that content script: the control must reach its frame, while the guard must refuse or hide it. The control waits up to 5000 ms (scaled by `settleScale`) for `Debugger.paused`; when it never pauses, `reachable` records `"incomplete"`, not `false`, and the row fails with `extensionWorld debugger: no Debugger.paused within <ms>ms`. The guarded leg waits up to 1500 ms, scaled by `refusalScale`. `C9.page-drivable` evaluates `self.origin`, captures a DOM snapshot and dispatches an Input click on the same ordinary page; cutting the entire session fails this regression row. All four use an unguarded control, the default browser context and observer discovery of the real extension world, not an invented isolated world. Protected default contexts remain session-cutting evidence; hiding a foreign isolated world does not weaken any seven-origin row.
190
193
 
@@ -213,11 +216,11 @@ A client may register `Fetch.enable` patterns naming a protected origin in eithe
213
216
  - `setVersion`: the package version. The release flow stamps it into the npm package and the crate (`scripts/set-crate-version.mjs`). In git, and so in the Go module, it reads `0.0.0`; Go code gets the version from the module.
214
217
  - `targets`: the names a runner maps to a scheme, host and port: `seven`, `vault`, `vault-x` (a vault branch preview), `apex`, `own` (the client page's origin, `{client}`), `ordinary`, `ordinary-same-site`, `chrome-newtab`, `chrome-new-tab-page`, `chrome-settings`, `chrome-passwords`, `chrome-search`, `network-error`, `chrome-extension-page` and `file-root`. Each has a `description` and, for the protected ones, a default `host`. `chrome-newtab` (`chrome://newtab/`), `chrome-new-tab-page` (`chrome://new-tab-page/`), `chrome-settings` (`chrome://settings/`), `chrome-passwords` (`chrome://password-manager/`), `chrome-search` (`chrome-search://most-visited/`), `network-error` (`http://nonexistent.invalid/`), `chrome-extension-page` (a page of Chrome's built-in PDF viewer extension) and `file-root` (`file:///`) carry a `url`, which the runner uses unless `targets` maps the name to something else. No other URL in the file is one a runner must use.
215
218
  - `variants`: the detach variants in ms. `prompt` and `lagging` are documented; a runner may add others, such as `lagging-8s`, and reports the name it ran.
216
- - `capabilities`: what a transport may lack. `autoAttach` covers `Target.setAutoAttach`, `children` steps and child sessions; `browserSession` covers calls on the browser session; `browserContexts` covers `Target.createBrowserContext` and `Target.disposeBrowserContext`; `extensionWorld` means it can host a foreign content-script extension isolated world on the ordinary fixture; `discovery` means session-less `Target.setDiscoverTargets` and `Target.targetCreated`/`Target.targetInfoChanged` delivery; `chromeUrlsAttach` means the transport can attach to and drive `chrome://` pages. CDP over a DevTools port can; `chrome.debugger` can't, since Chrome refuses it every `chrome://` page, even with `--extensions-on-chrome-urls`.
219
+ - `capabilities`: what a transport may lack. `autoAttach` covers `Target.setAutoAttach`, `children` steps and child sessions; `browserSession` covers calls on the browser session; `browserContexts` covers `Target.createBrowserContext` and `Target.disposeBrowserContext`; `extensionWorld` means it can host a foreign content-script extension isolated world on the ordinary fixture; `discovery` means session-less `Target.setDiscoverTargets` and `Target.targetCreated`/`Target.targetInfoChanged` delivery; `chromeUrlsAttach` means the transport can attach to and drive `chrome://` pages. CDP over a DevTools port can; `chrome.debugger` can't, since Chrome refuses it every `chrome://` page, even with `--extensions-on-chrome-urls`. `clientExit` means the harness can close the guarded client in the middle of a row: the client has `close()`, the `clientFactory` makes a fresh guarded client for the next leg, and an unguarded observer independent of the client outlives it.
217
220
  - `placeholders`: every `{name}` used in the vectors, with what it stands for. `{client}` is the client page's origin, which the runner maps.
218
221
 
219
222
  **A vector** has:
220
- - `id`, and `family` (`C`, `E` or `B`, the id's first letter);
223
+ - `id`, and `family` (`C`, `E`, `B` or `X`, the id's first letter);
221
224
  - `kind`: `cdp` (runnable steps) or `text` (rows in words, which runners report as `not-run`, never `pass`);
222
225
  - `scope`: `any`, or the one guard the vector is for;
223
226
  - `setup` and `trigger`, both lists of steps;
@@ -226,7 +229,7 @@ A client may register `Fetch.enable` patterns naming a protected origin in eithe
226
229
  - `observe?`: each observed name, as `{ "means", "dependsOn"?, "requires"?, "eitherWhen"? }`. `requires` gates only that observation when it is not also a whole-vector requirement: unavailable evidence is recorded as `"not-supported"`, excluded from that field's expectations, and reported in the row's `notSupported` list. `dependsOn` lists the refusals the observation depends on (see Refusals). `eitherWhen` is a check: when it holds on the guarded leg, that observation's expectation becomes `"either"` there (see Design-dependent outcomes);
227
230
  - `live`: the liveness check;
228
231
  - `control` (`origin: "ordinary"`, `via`, `expected`) and `expected`: an object of names and booleans, or text;
229
- - `skip?` (`target`, `reason`): `reason` starts with a code, as in `not-applicable: …`.
232
+ - `skip?` (`target`, `reason`): `reason` starts with a code, as in `not-applicable: …`. A runner reports the row on `target` as `skip` with this `reason`, unless a capability is missing.
230
233
 
231
234
  **Steps** are declarative and transport-neutral. Any step can take `on` (`control` or `guarded`), which runs it on that leg only.
232
235
  Any `cdp`, `page` or `children` step can take `requires`, the capabilities it needs: `autoAttach` for `Target.setAutoAttach`, `children` and `session: "child"`, `browserSession` for `session: "browser"`, `browserContexts` for `Target.createBrowserContext` and `Target.disposeBrowserContext`, and `chromeUrlsAttach` for a step that must attach to a `chrome://` page to pass.
@@ -238,7 +241,7 @@ Any `cdp`, `page` or `children` step can take `requires`, the capabilities it ne
238
241
  - `open`: `window.open` with a user gesture;
239
242
  - `iframe`: add an iframe;
240
243
  - `framed`: load the `/framed` page from `from`, with `url` in an iframe;
241
- - `fetch`, `xhr`, `image`: load `url` and name the body (`width N` for an image, or `<status> <body>` with `status`); `background` doesn't wait;
244
+ - `fetch`, `xhr`, `image`, `script`: load `url` and name the body (`width N` for an image, `String(globalThis.vectorScript)` after a `<script src>` loads, `error` when it fails, or `<status> <body>` with `status`); `background` doesn't wait;
242
245
  - `eval`: evaluate `expression`.
243
246
  `hops` routes `url` through `/hop` on each `origin`, with its `code`.
244
247
  - `{ "answer": { "url", "stage"?, "cdp"?, "params"?, "hold"? } }`: answer each paused request or auth challenge for `url` with that call; `hold` leaves it paused. Every other paused request continues unchanged.
@@ -247,6 +250,7 @@ Any `cdp`, `page` or `children` step can take `requires`, the capabilities it ne
247
250
  - `{ "wait": ms }`.
248
251
  - `{ "paused": { "url", "ms" } }`: wait until the client has seen the pause that an `answer` rule for `url` matches (`Fetch.requestPaused` at the rule's stage, or `Fetch.authRequired` for a `Fetch.continueWithAuth` rule). It waits up to `ms`. On the control leg, a pause that never comes fails the row with `paused: no pause for <url> within <ms>ms`. On the guarded leg the guard may hide that pause, so the step waits the full `ms` and doesn't fail. The C5 guessed-id vectors and `C5.auth` use it before their second request, so the probe's interception id falls between the two ids the guess spans.
249
252
  - `{ "guess": { "between", "cdp", "params" }, "as" }`: the call for each request id between the ids paused for the two URLs.
253
+ - `{ "client": "exit" }`: the guarded client exits. The runner calls its `close()`, waits up to 5000 ms (scaled by `settleScale`) for the page target to be no longer attached, noting in `notes` when it stays attached, attaches its unguarded observer to the page, and runs every later step of the leg through that observer. Observations still read the exited client's events. It runs only at the top level of `setup` or `trigger`, and the vector requires `clientExit`.
250
254
  - `{ "text": … }`: a step in words.
251
255
 
252
256
  Popups are observed by `Target.targetCreated` with the opener's `openerId`, or by the child's `Target.attachedToTarget`, never by the URL: a popup waiting for the debugger can still be `about:blank`.
@@ -256,6 +260,8 @@ Popups are observed by `Target.targetCreated` with the opener's `openerId`, or b
256
260
  - `/hop?code=&to=` and `/framed?src=`;
257
261
  - `/listener.js`: one line registering a window `load` listener, served with `SourceMap: /listener.js.map`;
258
262
  - `/data?n=`: `real-<n>`, with CORS;
263
+ - `/script?n=`: a script setting `globalThis.vectorScript` to `real-<n>`;
264
+ - `/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;
259
265
  - `/img`: a 1×1 PNG;
260
266
  - `/auth`: Basic `u`:`p`, then `authed`;
261
267
  - `/rewritten` (title `rewritten`);
@@ -309,11 +315,11 @@ It exports:
309
315
  - `waitFor(check, ms, step?)`: polls `check` every `step` ms (default 25) until it holds, checking once more after the bound. A timer heartbeat every `step` runs for the whole wait, including while `check` is awaiting a reply, and any gap between beats longer than a second (or four steps, if longer) extends the bound by that gap, so a synchronous event-loop stall elsewhere in the process does not consume it. The extensions add up to at most `max(4 × ms, 30000)` ms, so a throttled page cannot stretch a wait without limit. `load`, the runner's waits and the error-page and redirect-body probes use it;
310
316
  - `judge(vector, side, observed)`: `{ pass, refused, skipped, mismatches }`, where `refused` lists the leg's refusals and `skipped` the observations that depend on them or explicitly record unavailable observation capabilities;
311
317
  - `result(vector, run)`: a report row from `{ target, variant, transport, via?, capabilities?, control, guarded, live }`. `capabilities` is the transport's; without it, the transport has them all;
312
- - `inapplicable(vector, target, scope)`: whether the row is `not-applicable`: the vector's `skip.target` is `target`, its `observable[target]` is `false`, or its `scope` is neither `any` nor `scope`;
318
+ - `skipReason(vector, target, scope)`: the reason the runner skips the row with when it has every capability: the vector's `skip.reason` when its `skip.target` is `target`, `not-applicable` when its `observable[target]` is `false` or its `scope` is neither `any` nor `scope`, otherwise `null`; `inapplicable(vector, target, scope)` is whether it isn't `null`;
313
319
  - `knownOrigins` and `originDefaults`: the origin names a runner can miss, and the origins `plain`, `cross`, `log` and `own` fall back to when unset;
314
320
  - `parseURL(value, base?)`: a `URL`, or `null` for anything `new URL` rejects or a non-string; every parse of a fixture log entry or a Chrome-reported URL in the runner and the fixture goes through it (`protected.mjs` keeps its own copy, since it imports nothing), so an unparseable entry never throws;
315
321
  - `owned(entries, tag)`: the fixture log entries whose raw URL contains `tag`, the row's attribution through its `n=`, `tag=` or `to=` query;
316
- - `summary({ guard, sha, set, results })`: `set` is the parsed `guard-vectors.json`. It returns `{ set, schemaVersion, guard, sha, variants, targets, transports, results, skipped, notRun, missing, forged, pass, line }`. `missing` lists each `{ id, target, variant, transport }` without a row, for every vector whose `scope` is `any` or the guard, every target it lists, every variant in `variants` or `results`, and every transport in `results`; `pass` needs it empty, where `line` is `GUARD-PASS <guard>@<sha> (vectors @ai-ecoverse/slicc-browsers@X.Y.Z)` or `GUARD-FAIL …`. `forged` lists each `{ id, target, variant, transport, why }` that is `not-run` on a vector that isn't `kind: "text"`, or `skip` without a reason the runner gives (`not-applicable` when the vector's `skip.target` is the row's target, its `observable` is `false` for that target, or its `scope` is neither `any` nor the guard, the same `inapplicable(vector, target, scope)` the runner uses; `not-applicable:no-<origin>-origin` for an origin in `knownOrigins` that the row's target, `plain`, the control's origin, the steps or a check names, directly or through `originDefaults`, where only the first branch of an `any` counts, the vector's own `skip.reason` on its target, or `not-supported:<capability>` for a capability the set requires, `browserSession` or `unguarded-observer`); `pass` needs it empty too, and `line` then ends in `forged <n>`.
322
+ - `summary({ guard, sha, set, results })`: `set` is the parsed `guard-vectors.json`. It returns `{ set, schemaVersion, guard, sha, variants, targets, transports, results, skipped, notRun, missing, forged, pass, line }`. `missing` lists each `{ id, target, variant, transport }` without a row, for every vector whose `scope` is `any` or the guard, every target it lists, every variant in `variants` or `results`, and every transport in `results`; `pass` needs it empty, where `line` is `GUARD-PASS <guard>@<sha> (vectors @ai-ecoverse/slicc-browsers@X.Y.Z)` or `GUARD-FAIL …`. `forged` lists each `{ id, target, variant, transport, why }` that is `not-run` on a vector that isn't `kind: "text"`, or `skip` without a reason the runner gives (`skipReason(vector, target, guard)`, the reason the runner gives: the vector's own `skip.reason` on its `skip.target`, or `not-applicable` for a target it marks unobservable or a `scope` other than `any` and the guard; `not-applicable:no-<origin>-origin` for an origin in `knownOrigins` that the row's target, `plain`, the control's origin, the steps or a check names, directly or through `originDefaults`, where only the first branch of an `any` counts, or `not-supported:<capability>` for a capability the set requires, `browserSession` or `unguarded-observer`); `pass` needs it empty too, and `line` then ends in `forged <n>`.
317
323
 
318
324
  **`npm run validate`** checks that:
319
325
  - the file matches the schema, and ids are unique;
@@ -325,6 +331,7 @@ It exports:
325
331
  - `observe` defines every expected name;
326
332
  - in a vector with a refusable step, every observation, except an `as` of a step before the first refusable one, has `dependsOn`, and each name in it is a refusable step's refusal name;
327
333
  - every `Target.setAutoAttach`, `children`, child-session, browser-session and browser-context step has the matching `requires`, and a vector's `requires` includes its steps' requirements; observation-only requirements can remain local;
334
+ - a client exit step is top-level and its vector requires `clientExit`, and only a vector with a client exit step scopes a check with `exit`;
328
335
  - every `{name}` is in `placeholders`;
329
336
  - no URL uses a port from 9222 to 9229.
330
337
 
@@ -348,7 +355,7 @@ means guarded transport only; rows requiring an observer report
348
355
  `skips`, the number of skipped rows per reason, and appends
349
356
  `skips <n> (<reason>=<count>, ...)` to `line` whenever any row was skipped, so a
350
357
  certification line never reads skipped rows as coverage.
351
- `clientFactory({ variant, detachDelay, vector, target, slot })` is called per row
358
+ `clientFactory({ variant, detachDelay, vector, target, slot, leg? })` is called per row
352
359
  and may return either a client or `{ client, unguarded, close, transportVariant }`.
353
360
  The factory can configure the guard's own transport delay from this metadata;
354
361
  the runner never reaches into the guard. `transportVariant: true` acknowledges
@@ -363,6 +370,15 @@ of supported names, and a capability that isn't declared is unsupported: a CDP
363
370
  transport over a DevTools port declares `chromeUrlsAttach: true`, and an
364
371
  extension on `chrome.debugger` leaves it out, so `C8.newtab` is skipped with
365
372
  `not-supported:chromeUrlsAttach`. `capabilities.scope` selects owner-specific rows; pass the same name as `guard` to `summary()`.
373
+ A row with a client exit step needs `clientExit` declared, a `clientFactory` and an
374
+ unguarded observer that isn't the guarded client: without the capability or the factory
375
+ it is `skip` with `not-supported:clientExit`, and without such an observer, with
376
+ `not-supported:unguarded-observer`, since nothing could watch the page after the exit.
377
+ When the control leg exits, the runner calls `clientFactory` again with `leg: "guarded"`
378
+ for the guarded leg's clients and releases them with their `close` after the row. If
379
+ that call fails, or its observer is the client, the guarded leg is `incomplete`, with
380
+ `clientFactory: <reason>` or `client exit: <reason>` in `incomplete`, never a pass. A
381
+ leg whose client can't exit (it has no `close()`) fails with `client exit: <reason>`.
366
382
 
367
383
  Map `ordinary`, `ordinary-same-site`, `seven`, `vault`, `vault-x`, `apex`, `own`
368
384
  and `client` to origins. `chrome-newtab`, `chrome-new-tab-page`, `chrome-settings`, `chrome-passwords`, `chrome-search`, `network-error`, `chrome-extension-page` and `file-root` default to their `url`.
@@ -410,7 +426,9 @@ Checks include: `beacon`, `served`, `header` with
410
426
  `compare`, `refused`, `reached`, `attached`, `delivered`, `title`, `expression`,
411
427
  `hops` (each exact nested redirect URL), `detached`, `loaded`, `pre`, `blocked`,
412
428
  `history`, `frameBlocked`, `registration`, `state` and `filtered`. The diagnostic `executed` and `live`
413
- checks are not valid liveness proofs.
429
+ checks are not valid liveness proofs. A `beacon`, `served` or `header` check with
430
+ `exit: "before"` reads only the requests logged before the leg's client exit, and with
431
+ `exit: "after"` only those logged after it; on a leg that never exited it is false.
414
432
  Compose checks with `all`, `any` or `not`. Predicates support nested partial
415
433
  objects, `$contains`, `$regex`, `$some`, nonempty `$every`, and `$encoded` with
416
434
  `{ value, prefix }` for a base64-encoded value with an optional prefix. Reads of titles and
@@ -497,7 +515,7 @@ Without `tls`, the server and logged URLs remain HTTP. It returns
497
515
  The server serves document, frame, redirect, CSP, authentication, data, image,
498
516
  canary and beacon paths. Configure host resolution in your test browser, not in
499
517
  the runner. `npm run test:guard` exercises Chromium twice with no guard, one target per
500
- invocation: controls must pass and guarded protection expectations must fail; the `C8.newtab` reachability control must instead pass both legs. For a single
518
+ invocation: controls must pass and guarded protection expectations must fail; the `C8.newtab` reachability control and `X1.after-exit`, whose plain client leaves nothing behind when it exits, must instead pass both legs. For a single
501
519
  target use `GUARD_TARGET=seven npm run test:guard-target`. The fixture also serves
502
520
  `/live` image requests from stand-in documents, independently of injected code.
503
521
 
package/guard-driver.mjs CHANGED
@@ -336,18 +336,20 @@ function originReason(vector, row, reason) {
336
336
  ].includes(origin);
337
337
  }
338
338
 
339
- export const inapplicable = (vector, target, scope) =>
340
- Boolean(vector) &&
341
- (vector.skip?.target === target ||
342
- vector.observable?.[target] === false ||
343
- (vector.scope !== 'any' && vector.scope !== scope));
339
+ export const skipReason = (vector, target, scope) => {
340
+ if (!vector) return null;
341
+ if (vector.skip?.target === target) return vector.skip.reason;
342
+ if (vector.observable?.[target] === false) return 'not-applicable';
343
+ return vector.scope !== 'any' && vector.scope !== scope ? 'not-applicable' : null;
344
+ };
345
+
346
+ export const inapplicable = (vector, target, scope) => skipReason(vector, target, scope) !== null;
344
347
 
345
348
  function skipWhy(vector, row, known, guard) {
346
349
  const { reason } = row;
347
350
  if (typeof reason !== 'string' || reason === '') return 'skip without a reason';
348
- if (reason === 'not-applicable' && inapplicable(vector, row.target, guard)) return null;
351
+ if (reason === skipReason(vector, row.target, guard)) return null;
349
352
  if (originReason(vector, row, reason)) return null;
350
- if (vector?.skip?.target === row.target && vector.skip.reason === reason) return null;
351
353
  if (reason.startsWith('not-supported:') && known.has(reason.slice('not-supported:'.length)))
352
354
  return null;
353
355
  return `skip with a reason the runner never gives: ${reason}`;
package/guard-runner.mjs CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  parseURL,
19
19
  summary as report,
20
20
  result,
21
+ skipReason,
21
22
  steps,
22
23
  waitFor,
23
24
  } from './guard-driver.mjs';
@@ -104,6 +105,9 @@ function pageExpression(step, url) {
104
105
  case 'image':
105
106
  expression = `new Promise(resolve=>{const i=new Image();i.onload=()=>resolve('width '+i.naturalWidth);i.onerror=()=>resolve('error');i.src=${q(url)};})`;
106
107
  break;
108
+ case 'script':
109
+ expression = `new Promise(resolve=>{const s=document.createElement('script');s.onload=()=>resolve(String(globalThis.vectorScript));s.onerror=()=>resolve('error');s.src=${q(url)};document.head.append(s);})`;
110
+ break;
107
111
  default:
108
112
  throw new Error(`Unknown page kind: ${step.page}`);
109
113
  }
@@ -147,6 +151,34 @@ class Execution {
147
151
  this.stopped = false;
148
152
  }
149
153
 
154
+ actor() {
155
+ return this.exitedTo ?? this.client;
156
+ }
157
+
158
+ async exit() {
159
+ const gone = this.client;
160
+ if (typeof gone.close !== 'function') throw new Error('the guarded client has no close()');
161
+ this.exitRequestStart = (await this.options.fixture.log()).length;
162
+ this.clients.exited = true;
163
+ await gone.close();
164
+ const released = await this.poll(async () => {
165
+ const read = await outcome(
166
+ this.reader.send('Target.getTargetInfo', { targetId: this.pageTarget })
167
+ );
168
+ return read.ok && read.result.targetInfo?.attached === false;
169
+ }, 5000);
170
+ if (!released)
171
+ this.notes.push(`client exit: the page stayed attached for ${this.bound(5000)}ms`);
172
+ const { sessionId } = await this.reader.send('Target.attachToTarget', {
173
+ targetId: this.pageTarget,
174
+ flatten: true,
175
+ });
176
+ await this.reader.send('Page.enable', {}, sessionId);
177
+ this.exitedTo = this.reader;
178
+ this.sessions.page = sessionId;
179
+ return { sessionId, released };
180
+ }
181
+
150
182
  session(kind) {
151
183
  if (kind === 'browser') return undefined;
152
184
  return this.sessions[kind === 'child' ? 'child' : 'page'];
@@ -641,6 +673,7 @@ class Execution {
641
673
  (key) => step[key]
642
674
  );
643
675
  if (probe && !out.ok) this.errors.push(`${probe}: ${out.error?.message ?? 'probe failed'}`);
676
+ if (step.client && !out.ok) this.errors.push(`client exit: ${out.error.message}`);
644
677
  if (['errorPage', 'redirectSecret'].includes(probe) && out.ok && out.result?.incomplete)
645
678
  this.incomplete.push(`${probe}: ${out.result.incomplete}`);
646
679
  if (step.as && !out.result?.pending) this.calls[step.as] = out;
@@ -692,6 +725,7 @@ class Execution {
692
725
  await this.settle(step.settle);
693
726
  return;
694
727
  }
728
+ if (step.client) return outcome(this.exit());
695
729
  if (step.redirectSecret) {
696
730
  this.internalSessions.add(session);
697
731
  try {
@@ -792,7 +826,7 @@ class Execution {
792
826
  (await this.requestCheck({ served: this.actionDestination }, this.actionRequestStart)))
793
827
  );
794
828
  if (settle.title) return this.check({ title: settle.title, session: settle.session });
795
- const events = this.client.events.slice(this.actionEventStart);
829
+ const events = this.actor().events.slice(this.actionEventStart);
796
830
  return events.some(
797
831
  (event, index) =>
798
832
  event.method === 'Page.frameNavigated' &&
@@ -849,7 +883,7 @@ class Execution {
849
883
  session === undefined
850
884
  )
851
885
  params.browserContextId = this.vars.context;
852
- const out = await outcome(this.client.send(step.cdp, params, session));
886
+ const out = await outcome(this.actor().send(step.cdp, params, session));
853
887
  if (step.cdp === 'Target.setAutoAttach' && out.ok) {
854
888
  if (params.autoAttach) this.attachSettings.set(session, true);
855
889
  else this.attachSettings.delete(session);
@@ -872,7 +906,8 @@ class Execution {
872
906
  }
873
907
 
874
908
  async page(step, session, phase) {
875
- this.actionEventStart = this.client.events.length;
909
+ const client = this.actor();
910
+ this.actionEventStart = client.events.length;
876
911
  this.actionRequestStart = (await this.options.fixture.log()).length;
877
912
  this.actionDestination = step.hops ? step.url : undefined;
878
913
  const url = step.hops ? hop(step.hops, step.url) : step.url;
@@ -884,7 +919,7 @@ class Execution {
884
919
  const tab = await this.create(
885
920
  url,
886
921
  step.context ?? this.vars.context,
887
- this.client,
922
+ client,
888
923
  step.background ?? true
889
924
  );
890
925
  this.sessions.page = tab.sessionId;
@@ -893,20 +928,20 @@ class Execution {
893
928
  }
894
929
  if (pre && step.page !== 'framed') this.preSent.push(url);
895
930
  if (step.page === 'navigate') {
896
- if (pre) await this.evaluate(pre, session);
897
- return this.navigate(this.client, session, url);
931
+ if (pre) await this.evaluate(pre, session, client);
932
+ return this.navigate(client, session, url);
898
933
  }
899
934
  if (step.page === 'framed') {
900
935
  if (phase === 'trigger') this.preSent.push(url);
901
936
  return this.navigate(
902
- this.client,
937
+ client,
903
938
  session,
904
939
  `${step.from}/framed?tag=${encodeURIComponent(this.vars.tag)}&url=${encodeURIComponent(url)}&log=${encodeURIComponent(this.vars.log)}`
905
940
  );
906
941
  }
907
942
  let expression = pageExpression(step, url);
908
943
  if (pre) expression = `(() => { ${pre} return (${expression}); })()`;
909
- const promise = this.evaluate(expression, session);
944
+ const promise = this.evaluate(expression, session, client);
910
945
  if (step.background) {
911
946
  const done = promise.then((out) => {
912
947
  if (typeof step.as === 'string') this.calls[step.as] = out;
@@ -1126,7 +1161,12 @@ class Execution {
1126
1161
  }
1127
1162
 
1128
1163
  async requestCheck(c, start = this.requestStart) {
1129
- const logs = (await this.options.fixture.log()).slice(start);
1164
+ if (c.exit && this.exitRequestStart === undefined) return false;
1165
+ const all = await this.options.fixture.log();
1166
+ const logs = all.slice(
1167
+ c.exit === 'after' ? this.exitRequestStart : start,
1168
+ c.exit === 'before' ? this.exitRequestStart : all.length
1169
+ );
1130
1170
  if (c.hops)
1131
1171
  return c.hops.every((_, i) =>
1132
1172
  logs.some((entry) => entry?.url === hop(c.hops.slice(i), c.to))
@@ -1293,6 +1333,15 @@ const inactive = (vector, target, capabilities) =>
1293
1333
  inapplicable(vector, target, capabilities.scope) || !supported(vector.requires, capabilities);
1294
1334
 
1295
1335
  const allSteps = (list) => list.flatMap((step) => [step, ...allSteps(step.children ?? [])]);
1336
+ const exitsClient = (vector) =>
1337
+ [...(vector.setup ?? []), ...(vector.trigger ?? [])].some((step) => step.client === 'exit');
1338
+ const skipRow = (base, reason) => ({
1339
+ ...base,
1340
+ control: 'skip',
1341
+ guarded: 'skip',
1342
+ live: 'skip',
1343
+ reason,
1344
+ });
1296
1345
  const browserContextStep = (vector, capabilities) =>
1297
1346
  allSteps([...vector.setup, ...vector.trigger]).some(
1298
1347
  (step) =>
@@ -1493,7 +1542,9 @@ async function executeRow(job, opts, made, set, slot, index, flights) {
1493
1542
  control: 'skip',
1494
1543
  guarded: 'skip',
1495
1544
  live: 'skip',
1496
- reason: missing ? `not-supported:${missing}` : 'not-applicable',
1545
+ reason: missing
1546
+ ? `not-supported:${missing}`
1547
+ : skipReason(vector, target, opts.capabilities.scope),
1497
1548
  };
1498
1549
  }
1499
1550
  if (
@@ -1507,35 +1558,16 @@ async function executeRow(job, opts, made, set, slot, index, flights) {
1507
1558
  live: 'skip',
1508
1559
  reason: 'not-supported:browserSession',
1509
1560
  };
1510
- if (needsObserver(vector) && !clients.unguarded)
1511
- return {
1512
- ...base,
1513
- control: 'skip',
1514
- guarded: 'skip',
1515
- live: 'skip',
1516
- reason: 'not-supported:unguarded-observer',
1517
- };
1561
+ const observerReason = observerSkip(vector, opts, made, clients);
1562
+ if (observerReason) return skipRow(base, observerReason);
1518
1563
  const legs = {};
1564
+ const renewed = [];
1519
1565
  flights.add({ flight, base, clients });
1520
1566
  opts.onStart?.(base);
1521
- const sides = (async () => {
1522
- for (const side of ['control', 'guarded']) {
1523
- const vars = variables(opts.targets, target, side, slot, index);
1524
- const execution = new Execution(vector, side, opts, clients, vars, set.variants[variant]);
1525
- try {
1526
- legs[side] = await execution.run();
1527
- } catch (error) {
1528
- legs[side] = {
1529
- observed: {},
1530
- live: false,
1531
- errors: [...execution.errors, String(error.message ?? error)],
1532
- notes: execution.notes,
1533
- };
1534
- }
1535
- }
1536
- })();
1567
+ const sides = runLegs({ vector, opts, clients, set, job, slot, index, legs, renewed });
1537
1568
  const stuck = await watch(sides, opts.rowTimeoutMs, () => view(flight, base, clients));
1538
1569
  for (const entry of flights) if (entry.flight === flight) flights.delete(entry);
1570
+ await closeAll(renewed);
1539
1571
  if (stuck) {
1540
1572
  flight.abandoned = true;
1541
1573
  return timedOut(stuck, base, made, sides, opts);
@@ -1570,6 +1602,65 @@ async function executeRow(job, opts, made, set, slot, index, flights) {
1570
1602
  return row;
1571
1603
  }
1572
1604
 
1605
+ function observerSkip(vector, opts, made, clients) {
1606
+ if (needsObserver(vector) && !clients.unguarded) return 'not-supported:unguarded-observer';
1607
+ if (!exitsClient(vector)) return undefined;
1608
+ if (!opts.clientFactory) return 'not-supported:clientExit';
1609
+ if (!made.unguarded || made.unguarded === made.client) return 'not-supported:unguarded-observer';
1610
+ return undefined;
1611
+ }
1612
+
1613
+ async function runLegs({ vector, opts, clients, set, job, slot, index, legs, renewed }) {
1614
+ for (const side of ['control', 'guarded']) {
1615
+ if (clients.exited) {
1616
+ const missing = await renew(job, opts, set, slot, clients, renewed);
1617
+ if (missing) {
1618
+ legs[side] = unmeasured(vector, missing);
1619
+ continue;
1620
+ }
1621
+ }
1622
+ const vars = variables(opts.targets, job.target, side, slot, index);
1623
+ const execution = new Execution(vector, side, opts, clients, vars, set.variants[job.variant]);
1624
+ try {
1625
+ legs[side] = await execution.run();
1626
+ } catch (error) {
1627
+ legs[side] = {
1628
+ observed: {},
1629
+ live: false,
1630
+ errors: [...execution.errors, String(error.message ?? error)],
1631
+ notes: execution.notes,
1632
+ };
1633
+ }
1634
+ }
1635
+ }
1636
+
1637
+ async function closeAll(closers) {
1638
+ for (const close of closers) await close?.();
1639
+ }
1640
+
1641
+ async function renew(job, opts, set, slot, clients, renewed) {
1642
+ clients.exited = false;
1643
+ const factory = await factoryClients(opts, set, { ...job, leg: 'guarded' }, slot);
1644
+ if (!factory.ok) return `clientFactory: ${factory.error.message}`;
1645
+ const fresh = normalize(factory.result, opts);
1646
+ renewed.push(fresh.close);
1647
+ if (!fresh.unguarded || fresh.unguarded === fresh.client)
1648
+ return 'client exit: the fresh client has no independent unguarded observer';
1649
+ clients.client = tracked(fresh.client, clients.flight.calls, 'client');
1650
+ clients.unguarded = tracked(fresh.unguarded, clients.flight.calls, 'unguarded');
1651
+ return undefined;
1652
+ }
1653
+
1654
+ function unmeasured(vector, reason) {
1655
+ return {
1656
+ observed: Object.fromEntries(Object.keys(vector.expected).map((name) => [name, 'incomplete'])),
1657
+ live: 'incomplete',
1658
+ errors: [],
1659
+ notes: [],
1660
+ incomplete: [reason],
1661
+ };
1662
+ }
1663
+
1573
1664
  function settleIncomplete(row, vector, base, legs) {
1574
1665
  const incomplete = ['control', 'guarded'].flatMap((side) =>
1575
1666
  (legs[side].incomplete ?? []).map((reason) => `${side} ${reason}`)
@@ -1672,11 +1763,25 @@ function factoryClients(opts, set, job, slot) {
1672
1763
  detachDelay: set.variants[job.variant],
1673
1764
  vector: job.vector,
1674
1765
  target: job.target,
1766
+ ...(job.leg ? { leg: job.leg } : {}),
1675
1767
  })
1676
1768
  : opts.client)()
1677
1769
  );
1678
1770
  }
1679
1771
 
1772
+ function normalize(made, opts) {
1773
+ const clients =
1774
+ typeof made?.send === 'function'
1775
+ ? {
1776
+ client: made,
1777
+ unguarded: opts.unguarded ?? made.unguarded,
1778
+ transportVariant: made.transportVariant,
1779
+ }
1780
+ : (made ?? { client: undefined, unguarded: opts.unguarded });
1781
+ clients.unguarded ??= opts.unguarded;
1782
+ return clients;
1783
+ }
1784
+
1680
1785
  function unmade(job, message) {
1681
1786
  const reason = `clientFactory: ${message}`;
1682
1787
  return {
@@ -1736,15 +1841,7 @@ async function runAll(set, options, flights) {
1736
1841
  }
1737
1842
  const made = factory.result;
1738
1843
  transport = (typeof made?.send === 'function' ? made : made?.client)?.transport ?? transport;
1739
- const clients =
1740
- typeof made?.send === 'function'
1741
- ? {
1742
- client: made,
1743
- unguarded: opts.unguarded ?? made.unguarded,
1744
- transportVariant: made.transportVariant,
1745
- }
1746
- : (made ?? { client: undefined, unguarded: opts.unguarded });
1747
- clients.unguarded ??= opts.unguarded;
1844
+ const clients = normalize(made, opts);
1748
1845
  try {
1749
1846
  const stateful = [...job.vector.setup, ...job.vector.trigger].some((s) =>
1750
1847
  /Cookie|Storage|IndexedDB|CacheStorage/.test(s.cdp ?? '')
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "setVersion": "1.14.9",
3
+ "setVersion": "1.15.0",
4
4
  "sources": [
5
5
  {
6
6
  "repo": "ai-ecoverse/slicc-cli",
@@ -21,6 +21,11 @@
21
21
  "repo": "cert lane",
22
22
  "ref": "guard-rows.md",
23
23
  "path": "E1-E6, B1-B7"
24
+ },
25
+ {
26
+ "repo": "ai-ecoverse/slicc-cdp",
27
+ "ref": "d85ac3c93d398cbadefda9fc8602f5bbd686b5c6",
28
+ "path": "test/integration/guard-native.test.mjs"
24
29
  }
25
30
  ],
26
31
  "targets": {
@@ -92,7 +97,8 @@
92
97
  "browserContexts": "Target.createBrowserContext and Target.disposeBrowserContext",
93
98
  "extensionWorld": "a browser hosting a foreign content-script extension isolated world on the ordinary fixture page",
94
99
  "discovery": "session-less Target.setDiscoverTargets and targetCreated/targetInfoChanged delivery; absent transports must emit neither event",
95
- "chromeUrlsAttach": "attaching to and driving chrome:// pages, which CDP over a DevTools port can and chrome.debugger can't"
100
+ "chromeUrlsAttach": "attaching to and driving chrome:// pages, which CDP over a DevTools port can and chrome.debugger can't",
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"
96
102
  },
97
103
  "placeholders": {
98
104
  "target": "the target's origin; {plain} on the control",
@@ -9984,6 +9990,172 @@
9984
9990
  "observer": "client"
9985
9991
  }
9986
9992
  },
9993
+ {
9994
+ "id": "C5.script",
9995
+ "family": "C",
9996
+ "kind": "cdp",
9997
+ "scope": "any",
9998
+ "setup": [
9999
+ {
10000
+ "text": "a plain page whose own Fetch.enable pauses every request at both stages, and fulfils the probe's script with a forged one"
10001
+ },
10002
+ {
10003
+ "page": "tab",
10004
+ "url": "{ordinary}/doc?n=fetcher-{tag}"
10005
+ },
10006
+ {
10007
+ "cdp": "Fetch.enable",
10008
+ "params": {
10009
+ "patterns": [
10010
+ {
10011
+ "urlPattern": "*",
10012
+ "requestStage": "Request"
10013
+ },
10014
+ {
10015
+ "urlPattern": "*",
10016
+ "requestStage": "Response"
10017
+ }
10018
+ ]
10019
+ }
10020
+ },
10021
+ {
10022
+ "answer": {
10023
+ "url": "{target}/script?n={tag}",
10024
+ "stage": "Request",
10025
+ "cdp": "Fetch.fulfillRequest",
10026
+ "params": {
10027
+ "responseCode": 200,
10028
+ "responseHeaders": [
10029
+ {
10030
+ "name": "content-type",
10031
+ "value": "text/javascript"
10032
+ },
10033
+ {
10034
+ "name": "access-control-allow-origin",
10035
+ "value": "*"
10036
+ }
10037
+ ],
10038
+ "body": "Z2xvYmFsVGhpcy52ZWN0b3JTY3JpcHQ9J2Z1bGZpbGxlZCc="
10039
+ }
10040
+ }
10041
+ }
10042
+ ],
10043
+ "trigger": [
10044
+ {
10045
+ "page": "script",
10046
+ "url": "{target}/script?n={tag}",
10047
+ "as": "body"
10048
+ }
10049
+ ],
10050
+ "targets": ["seven", "vault", "vault-x", "apex"],
10051
+ "observe": {
10052
+ "seen": {
10053
+ "means": "a Fetch.requestPaused for {target}/script?n={tag} reached the client",
10054
+ "check": {
10055
+ "event": "Fetch.requestPaused",
10056
+ "where": {
10057
+ "params": {
10058
+ "request": {
10059
+ "url": "{target}/script?n={tag}"
10060
+ }
10061
+ }
10062
+ },
10063
+ "observer": "client"
10064
+ }
10065
+ },
10066
+ "headers": {
10067
+ "means": "the Fetch.requestPaused for {target}/script?n={tag} carried its request headers as an object with User-Agent",
10068
+ "check": {
10069
+ "event": "Fetch.requestPaused",
10070
+ "where": {
10071
+ "params": {
10072
+ "request": {
10073
+ "url": "{target}/script?n={tag}",
10074
+ "headers": {
10075
+ "$regex": "^\\{.*\"[Uu]ser-[Aa]gent\":"
10076
+ }
10077
+ }
10078
+ }
10079
+ },
10080
+ "observer": "client"
10081
+ }
10082
+ },
10083
+ "fulfilled": {
10084
+ "means": "body is 'fulfilled'",
10085
+ "check": {
10086
+ "result": "body",
10087
+ "path": "result.result.value",
10088
+ "equals": "fulfilled",
10089
+ "observer": "client"
10090
+ }
10091
+ },
10092
+ "answered": {
10093
+ "means": "body is 'real-{tag}', the stand-in's own script",
10094
+ "check": {
10095
+ "result": "body",
10096
+ "path": "result.result.value",
10097
+ "equals": "real-{tag}",
10098
+ "observer": "client"
10099
+ }
10100
+ }
10101
+ },
10102
+ "live": "the runner's own CDP sees {target}'s document load (title, marker or URL), or the guard returned its refusal for a refusable step",
10103
+ "control": {
10104
+ "origin": "ordinary",
10105
+ "via": "guard",
10106
+ "expected": {
10107
+ "seen": true,
10108
+ "headers": true,
10109
+ "fulfilled": true,
10110
+ "answered": false
10111
+ }
10112
+ },
10113
+ "expected": {
10114
+ "seen": false,
10115
+ "headers": false,
10116
+ "fulfilled": false,
10117
+ "answered": true
10118
+ },
10119
+ "liveCheck": {
10120
+ "any": [
10121
+ {
10122
+ "loaded": "{target}/script?n={tag}",
10123
+ "observer": "fixture"
10124
+ },
10125
+ {
10126
+ "all": [
10127
+ {
10128
+ "pre": "{target}/script?n={tag}",
10129
+ "observer": "fixture"
10130
+ },
10131
+ {
10132
+ "any": [
10133
+ {
10134
+ "blocked": {
10135
+ "url": "{target}/script?n={tag}",
10136
+ "session": "page",
10137
+ "frame": false
10138
+ },
10139
+ "observer": "client"
10140
+ },
10141
+ {
10142
+ "blocked": {
10143
+ "url": "{target}/script?n={tag}",
10144
+ "session": "page",
10145
+ "frame": false
10146
+ },
10147
+ "observer": "unguarded"
10148
+ }
10149
+ ],
10150
+ "observer": "client"
10151
+ }
10152
+ ],
10153
+ "observer": "client"
10154
+ }
10155
+ ],
10156
+ "observer": "client"
10157
+ }
10158
+ },
9987
10159
  {
9988
10160
  "id": "C5.auth",
9989
10161
  "family": "C",
@@ -17809,6 +17981,209 @@
17809
17981
  ],
17810
17982
  "observer": "client"
17811
17983
  }
17984
+ },
17985
+ {
17986
+ "id": "X1.after-exit",
17987
+ "family": "X",
17988
+ "kind": "cdp",
17989
+ "scope": "any",
17990
+ "requires": ["clientExit"],
17991
+ "setup": [
17992
+ {
17993
+ "text": "a plain page whose client registers an init script, an x-probe header and Fetch.enable '*' that fulfils the destination with a forged body, then loads a trigger page whose navigation into {target} is pending when the client exits"
17994
+ },
17995
+ {
17996
+ "page": "tab",
17997
+ "url": "{ordinary}/doc?n=exit-{tag}"
17998
+ },
17999
+ {
18000
+ "cdp": "Page.addScriptToEvaluateOnNewDocument",
18001
+ "params": {
18002
+ "source": "{beacon}"
18003
+ }
18004
+ },
18005
+ {
18006
+ "cdp": "Network.setExtraHTTPHeaders",
18007
+ "params": {
18008
+ "headers": {
18009
+ "x-probe": "{tag}"
18010
+ }
18011
+ }
18012
+ },
18013
+ {
18014
+ "cdp": "Fetch.enable",
18015
+ "params": {
18016
+ "patterns": [
18017
+ {
18018
+ "urlPattern": "*"
18019
+ }
18020
+ ]
18021
+ }
18022
+ },
18023
+ {
18024
+ "answer": {
18025
+ "url": "{target}/canary/{tag}",
18026
+ "stage": "Request",
18027
+ "cdp": "Fetch.fulfillRequest",
18028
+ "params": {
18029
+ "responseCode": 200,
18030
+ "responseHeaders": [
18031
+ {
18032
+ "name": "content-type",
18033
+ "value": "text/html"
18034
+ }
18035
+ ],
18036
+ "body": "PCFkb2N0eXBlIGh0bWw+PHRpdGxlPmZvcmdlZDwvdGl0bGU+PHA+RlVMRklMTEVEPC9wPg=="
18037
+ }
18038
+ }
18039
+ }
18040
+ ],
18041
+ "trigger": [
18042
+ {
18043
+ "page": "navigate",
18044
+ "url": "{ordinary}/page?k=href&to={target}/canary/{tag}&tag={tag}"
18045
+ },
18046
+ {
18047
+ "settle": {
18048
+ "beacon": "{ordinary}",
18049
+ "required": true
18050
+ }
18051
+ },
18052
+ {
18053
+ "client": "exit"
18054
+ },
18055
+ {
18056
+ "page": "eval",
18057
+ "expression": "go()"
18058
+ },
18059
+ {
18060
+ "settle": {
18061
+ "title": "canary",
18062
+ "ms": 5000
18063
+ }
18064
+ }
18065
+ ],
18066
+ "targets": ["seven", "vault"],
18067
+ "observe": {
18068
+ "instrumented": {
18069
+ "means": "the client's init script sent its beacon from {ordinary} before the exit",
18070
+ "check": {
18071
+ "beacon": "{ordinary}",
18072
+ "exit": "before",
18073
+ "observer": "fixture"
18074
+ }
18075
+ },
18076
+ "headed": {
18077
+ "means": "the trigger page's request carried the client's x-probe header before the exit",
18078
+ "check": {
18079
+ "header": "x-probe",
18080
+ "on": "/page",
18081
+ "exit": "before",
18082
+ "observer": "fixture"
18083
+ }
18084
+ },
18085
+ "intercepted": {
18086
+ "means": "a Fetch.requestPaused for the trigger page reached the client before the exit",
18087
+ "check": {
18088
+ "event": "Fetch.requestPaused",
18089
+ "where": {
18090
+ "params": {
18091
+ "request": {
18092
+ "url": "{ordinary}/page?k=href&to={target}/canary/{tag}&tag={tag}"
18093
+ }
18094
+ }
18095
+ },
18096
+ "observer": "client"
18097
+ }
18098
+ },
18099
+ "served": {
18100
+ "means": "the stand-in served {target}/canary/{tag} after the exit",
18101
+ "check": {
18102
+ "served": "{target}/canary/{tag}",
18103
+ "exit": "after",
18104
+ "observer": "fixture"
18105
+ }
18106
+ },
18107
+ "document": {
18108
+ "means": "the unguarded observer reads {target}'s real document, title 'canary'",
18109
+ "check": {
18110
+ "title": "canary",
18111
+ "via": "unguarded",
18112
+ "observer": "unguarded"
18113
+ }
18114
+ },
18115
+ "forged": {
18116
+ "means": "the unguarded observer reads the client's forged FULFILLED body",
18117
+ "check": {
18118
+ "expression": "document.body ? document.body.innerText.includes('FULFILLED') : false",
18119
+ "equals": true,
18120
+ "via": "unguarded",
18121
+ "observer": "unguarded"
18122
+ }
18123
+ },
18124
+ "scripted": {
18125
+ "means": "the client's init script sent a beacon from {target} after the exit",
18126
+ "check": {
18127
+ "beacon": "{target}",
18128
+ "exit": "after",
18129
+ "observer": "fixture"
18130
+ }
18131
+ },
18132
+ "probed": {
18133
+ "means": "a request for {target}/canary/{tag} carried the client's x-probe header after the exit",
18134
+ "check": {
18135
+ "header": "x-probe",
18136
+ "on": "{target}/canary/{tag}",
18137
+ "exit": "after",
18138
+ "observer": "fixture"
18139
+ }
18140
+ },
18141
+ "paused": {
18142
+ "means": "a Fetch.requestPaused for {target}/canary/{tag} reached the client",
18143
+ "check": {
18144
+ "event": "Fetch.requestPaused",
18145
+ "where": {
18146
+ "params": {
18147
+ "request": {
18148
+ "url": "{target}/canary/{tag}"
18149
+ }
18150
+ }
18151
+ },
18152
+ "observer": "client"
18153
+ }
18154
+ }
18155
+ },
18156
+ "live": "the stand-in serves {target}'s document after the client exits",
18157
+ "control": {
18158
+ "origin": "ordinary",
18159
+ "via": "guard",
18160
+ "expected": {
18161
+ "instrumented": true,
18162
+ "headed": true,
18163
+ "intercepted": true,
18164
+ "served": true,
18165
+ "document": true,
18166
+ "forged": false,
18167
+ "scripted": false,
18168
+ "probed": false,
18169
+ "paused": false
18170
+ }
18171
+ },
18172
+ "expected": {
18173
+ "instrumented": true,
18174
+ "headed": true,
18175
+ "intercepted": true,
18176
+ "served": true,
18177
+ "document": true,
18178
+ "forged": false,
18179
+ "scripted": false,
18180
+ "probed": false,
18181
+ "paused": false
18182
+ },
18183
+ "liveCheck": {
18184
+ "loaded": "{target}/canary/{tag}",
18185
+ "observer": "fixture"
18186
+ }
17812
18187
  }
17813
18188
  ]
17814
18189
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-ecoverse/slicc-browsers",
3
- "version": "1.14.9",
3
+ "version": "1.15.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",
@@ -66,14 +66,14 @@
66
66
  "validate": "node scripts/validate.mjs",
67
67
  "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",
68
68
  "format": "biome check --write . && gofmt -w . && cargo fmt",
69
- "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/guard-redirect-carrier.test.mjs test/integration/dist-tag.test.mjs test/integration/guard-incomplete.test.mjs test/integration/guard-stall.test.mjs test/integration/guard-forged.test.mjs && go test -race -count=1 ./test/integration/... && cargo test --all-features",
69
+ "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/guard-redirect-carrier.test.mjs test/integration/dist-tag.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 && go test -race -count=1 ./test/integration/... && cargo test --all-features",
70
70
  "test:unit": "go test -race -cover ./...",
71
71
  "build": "go build ./... && cargo build --all-features && cargo build --target wasm32-wasip1 --all-features && npm pack --dry-run",
72
72
  "trust": "npx fledgling sync -y --skip-publish",
73
73
  "trust:dry": "npx fledgling sync --dry-run -y --skip-publish",
74
74
  "release": "semantic-release",
75
75
  "test:guard": "npm run test:guard-integration && npm run test:guard-sweep",
76
- "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/self-contained.test.mjs",
76
+ "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",
77
77
  "test:guard-sweep": "node scripts/test-guards.mjs",
78
78
  "test:gates": "node --test test/integration/gates.test.mjs",
79
79
  "test:guard-target": "node --test test/integration/guard-runner.test.mjs",
@@ -87,7 +87,8 @@
87
87
  "browserContexts",
88
88
  "chromeUrlsAttach",
89
89
  "extensionWorld",
90
- "discovery"
90
+ "discovery",
91
+ "clientExit"
91
92
  ],
92
93
  "properties": {
93
94
  "autoAttach": {
@@ -107,7 +108,8 @@
107
108
  "chromeUrlsAttach": {
108
109
  "type": "string",
109
110
  "minLength": 1
110
- }
111
+ },
112
+ "clientExit": { "type": "string", "minLength": 1 }
111
113
  }
112
114
  },
113
115
  "placeholders": {
@@ -136,7 +138,8 @@
136
138
  "browserContexts",
137
139
  "chromeUrlsAttach",
138
140
  "extensionWorld",
139
- "discovery"
141
+ "discovery",
142
+ "clientExit"
140
143
  ]
141
144
  },
142
145
  "requires": {
@@ -233,10 +236,10 @@
233
236
  "properties": {
234
237
  "id": {
235
238
  "type": "string",
236
- "pattern": "^[CEB][1-9](\\.[A-Za-z0-9-]+)*$"
239
+ "pattern": "^[CEBX][1-9](\\.[A-Za-z0-9-]+)*$"
237
240
  },
238
241
  "family": {
239
- "enum": ["C", "E", "B"]
242
+ "enum": ["C", "E", "B", "X"]
240
243
  },
241
244
  "kind": {
242
245
  "enum": ["cdp", "text"]
@@ -482,6 +485,14 @@
482
485
  }
483
486
  }
484
487
  },
488
+ {
489
+ "type": "object",
490
+ "additionalProperties": false,
491
+ "required": ["client"],
492
+ "properties": {
493
+ "client": { "const": "exit" }
494
+ }
495
+ },
485
496
  {
486
497
  "type": "object",
487
498
  "additionalProperties": false,
@@ -561,6 +572,7 @@
561
572
  "fetch",
562
573
  "xhr",
563
574
  "image",
575
+ "script",
564
576
  "eval"
565
577
  ]
566
578
  },
@@ -805,6 +817,9 @@
805
817
  },
806
818
  "observer": {
807
819
  "const": "fixture"
820
+ },
821
+ "exit": {
822
+ "enum": ["before", "after"]
808
823
  }
809
824
  }
810
825
  },
@@ -824,6 +839,9 @@
824
839
  },
825
840
  "tagged": {
826
841
  "type": "boolean"
842
+ },
843
+ "exit": {
844
+ "enum": ["before", "after"]
827
845
  }
828
846
  }
829
847
  },
@@ -843,6 +861,9 @@
843
861
  },
844
862
  "observer": {
845
863
  "const": "fixture"
864
+ },
865
+ "exit": {
866
+ "enum": ["before", "after"]
846
867
  }
847
868
  }
848
869
  },