@ai-ecoverse/slicc-browsers 1.7.3 → 1.7.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -87,7 +87,7 @@ Every feature builds for `wasm32-wasip1`.
87
87
  - `names`: an endpoint's name when it isn't from a known browser's user-data dir: `electron` when the user agent contains `electronUserAgent`, else `products[<product>]` for the product before the `/` in `/json/version`'s `Browser`, else that product, else `fallback`;
88
88
  - `process`: the process scan, see [The process source](#the-process-source).
89
89
  - `seven`:
90
- - `originScheme` and `originHostPattern`: a seven origin is `https://<host>` with a host matching the pattern, except for the `excludedLabels` (`www`, `vault`, `vault-preview`, `get`, `mcpcat`) and the `excludedLabelPrefixes` (`vault-`, which excludes `vault-feature-x` and any other vault branch preview). The bare apex (`sliccy.ai`, i.e. an empty label) never matches. An origin is compared without the scheme's default port (443 for `https`, 80 for `http`), so `https://seven.sliccy.ai:443` is seven and `https://seven.sliccy.ai:8443` is not. The same goes for extra seven origins;
90
+ - `originScheme` and `originHostPattern`: a seven origin is `https://<host>` with a host matching the pattern, except for the `excludedLabels` (`www`, `vault`, `vault-preview`, `get`, `mcpcat`, `echo`) and the `excludedLabelPrefixes` (`vault-`, which excludes `vault-feature-x` and any other vault branch preview). The bare apex (`sliccy.ai`, i.e. an empty label) never matches. An origin is compared without the scheme's default port (443 for `https`, 80 for `http`), so `https://seven.sliccy.ai:443` is seven and `https://seven.sliccy.ai:8443` is not. The same goes for extra seven origins;
91
91
  - `sitePattern` (`[*.]sliccy.ai`);
92
92
  - `switches` and `disabledFeatures`: the seven flags, all `--disable-features` values merged into one switch;
93
93
  - `prefs`: values merged into a profile's `Default/Preferences` at the `path` of keys;
@@ -112,7 +112,7 @@ Browser control must never navigate to, open or attach to a target on seven's ow
112
112
  3. A scheme in `schemes` is protected when its host is one of `hosts`, or one of `domains` or under one, at any depth and on any port. The host is lower-cased, and one trailing dot is dropped.
113
113
  4. Any other URL is protected when its origin is one of the program's **extra origins**: the seven origins it accepts besides `sliccy.ai` (slicc-cli's `--origin` and `--page` origins), compared as `scheme://host[:port]` without the scheme's default port, with the host as in 3.
114
114
 
115
- This is wider than the rule for finding seven (`seven.originHostPattern`, which leaves out the `excludedLabels` and `excludedLabelPrefixes`—`www`, `vault`, `vault-preview`, `get`, `mcpcat`, and any `vault-*` prefix). Every `sliccy.ai` host shares the `sliccy.ai` cookies, and cookies ignore the port and, without `Secure`, the scheme. SLICC six lives on `www.sliccy.ai` and the apex, and its cookies matter. `*.kernel.localhost` is not protected: each port is its own origin, and the agent's own servers live there. An extra origin is matched exactly, so `http://127.0.0.1:5710` doesn't protect `http://127.0.0.1:5711`.
115
+ This is wider than the rule for finding seven (`seven.originHostPattern`, which leaves out the `excludedLabels` and `excludedLabelPrefixes`—`www`, `vault`, `vault-preview`, `get`, `mcpcat`, `echo`, and any `vault-*` prefix). Every `sliccy.ai` host shares the `sliccy.ai` cookies, and cookies ignore the port and, without `Secure`, the scheme. SLICC six lives on `www.sliccy.ai` and the apex, and its cookies matter. `*.kernel.localhost` is not protected: each port is its own origin, and the agent's own servers live there. An extra origin is matched exactly, so `http://127.0.0.1:5710` doesn't protect `http://127.0.0.1:5711`.
116
116
 
117
117
  **The vault.** `vault.sliccy.ai` is in `hosts` by name, and vault branch previews (`vault-preview.sliccy.ai`, `vault-*.sliccy.ai`) are covered by the `sliccy.ai` domain rule. `sliccy.ai` already covers them all, but vault must stay protected even if `domains` ever changes: the vault must never be a CDP target for agent tools. The one exception is slicc-cli's own pairing attach for the vault, which is the CLI's own connection and not browser control handed to the agent.
118
118
 
@@ -150,7 +150,7 @@ Every CDP guard runs the vectors in `guard-vectors.json` (schema `schema/guard-v
150
150
  - `scope`: `any`, or the one guard the vector is for;
151
151
  - `setup` and `trigger`, both lists of steps;
152
152
  - `targets`: the protected targets it runs against;
153
- - `requires?`: the capabilities it needs, the union of its steps' and observations' `requires`. A runner whose transport lacks one reports the vector as `skip` with the reason `not-supported:<capability>`;
153
+ - `requires?`: the capabilities it needs, the union of its steps' and observations' `requires`. A runner whose transport lacks one reports the vector as `skip` with the reason `not-supported:<capability>`. A row with a `page-or-browser` cookie step that would carry a `browserContextId` reports `not-supported:browserSession` without a browser session, because that parameter is only allowed on the browser target;
154
154
  - `observe?`: each observed name, as `{ "means", "dependsOn"?, "requires"?, "eitherWhen"? }`. `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);
155
155
  - `live`: the liveness check;
156
156
  - `control` (`origin: "ordinary"`, `via`, `expected`) and `expected`: an object of names and booleans, or text;
@@ -170,7 +170,7 @@ Any `cdp`, `page` or `children` step can take `requires`, the capabilities it ne
170
170
  - `eval`: evaluate `expression`.
171
171
  `hops` routes `url` through `/hop` on each `origin`, with its `code`.
172
172
  - `{ "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.
173
- - `{ "children": steps }`: run these steps on each target auto-attached from then on, then resume it.
173
+ - `{ "children": steps, "where"? }`: run these steps on each target auto-attached from then on, then resume it. `where` scopes them to the row's own target: `{ "openerId": "page" }` (opened by the row's page) or `{ "urlPrefix": url }`; other attached targets are resumed and otherwise left alone.
174
174
  - `{ "settle": { "beacon"?, "title"?, "ms"?, "variant"?, "session"? } }`: wait for a beacon from `beacon`, for the page `title`, or for the session to detach. It waits up to `ms` (10000 by default), then another 600 ms. `variant` waits the run's detach variant instead.
175
175
  - `{ "wait": ms }`.
176
176
  - `{ "guess": { "between", "cdp", "params" }, "as" }`: the call for each request id between the ids paused for the two URLs.
@@ -253,7 +253,10 @@ error as `error.cdp`, or an error message. `client` carries the guarded leg;
253
253
  `unguarded`, when supplied, carries unguarded controls and observation reads.
254
254
  There is no fallback from observer reads to the guarded client. A bare client
255
255
  means guarded transport only; rows requiring an observer report
256
- `skip` with `not-supported:unguarded-observer`, counted in `summary().observerSkipped`.
256
+ `skip` with `not-supported:unguarded-observer`, counted in `summary().observerSkipped`. The run's `summary()` also reports
257
+ `skips`, the number of skipped rows per reason, and appends
258
+ `skips <n> (<reason>=<count>, ...)` to `line` whenever any row was skipped, so a
259
+ certification line never reads skipped rows as coverage.
257
260
  `clientFactory({ variant, detachDelay, vector, target, slot })` is called per row
258
261
  and may return either a client or `{ client, unguarded, close, transportVariant }`.
259
262
  The factory can configure the guard's own transport delay from this metadata;
@@ -270,13 +273,16 @@ of supported names. `capabilities.scope` selects owner-specific rows.
270
273
  Map `ordinary`, `ordinary-same-site`, `seven`, `vault`, `vault-x`, `apex`, `own`
271
274
  and `client` to origins. Optionally map `plain`, `cross` and `log`; their defaults
272
275
  are `ordinary`, `client` and `ordinary`. Origins must preserve the site
273
- relationships of the data. Explicitly map an unavailable target to `null`: every
276
+ relationships of the data. Map an unavailable target to `null`, or leave it out; a
277
+ missing or `undefined` origin is treated exactly like `null`: every
274
278
  row needing that origin is reported as `skip` for control, guarded and live, with
275
279
  reason `not-applicable:no-<target>-origin`; no substitute origin is used and the
276
280
  row never passes. A guard whose whole site is protected, such as the extension
277
281
  where every `sliccy.ai` host is protected, passes `ordinary-same-site: null`.
278
- This also applies to origins referenced in setup, triggers, observations and
279
- liveness checks, not only the row target. Tags and cookie names are unique per leg. `variant`
282
+ This also applies to origins referenced in setup and triggers, not only the row
283
+ target. In observations and liveness checks, an `any` alternative that references
284
+ a `null` origin is dropped and the rest are evaluated; the row skips only when
285
+ every alternative of an `any`, or a required (`all`, `not` or plain) check, needs it. Tags and cookie names are unique per leg. `variant`
280
286
  selects one variant or an array; omission selects all. `parallel` defaults to 1.
281
287
  `log(row)` receives completed rows. A variant supplies the wait before an
282
288
  explicit `Target.detachFromTarget` step and for a `settle.variant` step.
@@ -322,7 +328,17 @@ A step that the guard refuses in every context, such as `Network.clearBrowserCoo
322
328
  in `C6.clear-cookies`, is refusable, so refusing it on both legs passes and allowing it
323
329
  is judged on its effect. A child step that fails after its child session detached is
324
330
  recorded as detached, not as a refusal, and the runner awaits a child's started
325
- `children` steps before any later `session: "child"` step and before the trigger. Redirect
331
+ `children` steps before any later `session: "child"` step and before the trigger. A page
332
+ navigation waits for the load of its own `loaderId` (its `Page.frameNavigated` and then a
333
+ load event), and a settle waits for a main-frame commit followed by its load, so a stale
334
+ load event never ends a wait. After a child's `children` steps that include `Page.enable`,
335
+ the runner waits, bounded at 5 s, for the child's first document to commit and load before
336
+ any later child step. `{ "settle": { "beacon", "required": true, "dependsOn"? } }` waits
337
+ for that beacon at the fixture and fails the row with `settle: no beacon from <origin>
338
+ within <ms>ms` when it never arrives, unless a refusal it `dependsOn` was recorded; the rows
339
+ whose `allowedBeacon` comes from an earlier document settle on it before their trigger.
340
+ Beacons use `navigator.sendBeacon`, with a `fetch(…, { keepalive: true })` fallback. An observation that reads a background step's `as` result
341
+ waits for that step, up to 10 s, and fails with `<as> did not complete within …` past it. Redirect
326
342
  vectors list every nested hop URL and the final destination because Chromium
327
343
  reports the URL intercepted by the guard. A matching block still requires the
328
344
  pre-action beacon alongside it; an unrelated error cannot certify the chain.
@@ -344,7 +360,7 @@ fixture log. A setup beacon on the same origin and tag cannot settle an unfinish
344
360
  redirect chain. This does not change the observations used to judge either leg.
345
361
 
346
362
  The separately exported Node module `@ai-ecoverse/slicc-browsers/fixture` exposes
347
- `fixtureServer({ host, port, tls })`, defaulting to a random loopback port.
363
+ `fixtureServer({ host, port, tls, latency })`, defaulting to a random loopback port. `latency(url)` returns milliseconds to hold that response, for timing tests.
348
364
  Pass `tls: { key, cert }` to serve HTTPS using Node TLS key and certificate
349
365
  values. The returned origin, request logs, redirect URLs and document heartbeats
350
366
  use `https://<Host>` with TLS, so `served`, `hops` and `liveCheck` compare against
package/catalog.json CHANGED
@@ -642,7 +642,7 @@
642
642
  "seven": {
643
643
  "originScheme": "https",
644
644
  "originHostPattern": "^([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)\\.sliccy\\.ai$",
645
- "excludedLabels": ["www", "vault", "vault-preview", "get", "mcpcat"],
645
+ "excludedLabels": ["www", "vault", "vault-preview", "get", "mcpcat", "echo"],
646
646
  "excludedLabelPrefixes": ["vault-"],
647
647
  "sitePattern": "[*.]sliccy.ai",
648
648
  "switches": [
@@ -76,7 +76,7 @@ function route(req, url, send, html) {
76
76
  return send(404, 'missing', 'text/plain');
77
77
  }
78
78
 
79
- export async function fixtureServer({ host = '127.0.0.1', port = 0, tls } = {}) {
79
+ export async function fixtureServer({ host = '127.0.0.1', port = 0, tls, latency } = {}) {
80
80
  let entries = [];
81
81
  const scheme = tls ? 'https' : 'http';
82
82
  const handler = (req, res) => {
@@ -105,7 +105,9 @@ export async function fixtureServer({ host = '127.0.0.1', port = 0, tls } = {})
105
105
  'text/html',
106
106
  headers
107
107
  );
108
- route(req, url, send, html);
108
+ const wait = latency?.(url) ?? 0;
109
+ if (wait > 0) setTimeout(() => route(req, url, send, html), wait);
110
+ else route(req, url, send, html);
109
111
  };
110
112
  const server = tls ? createSecureServer(tls, handler) : createServer(handler);
111
113
  await new Promise((resolve, reject) => {
package/guard-driver.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  const q = JSON.stringify;
2
2
 
3
3
  export const beacon = (log, tag) =>
4
- `try { navigator.sendBeacon(${q(log)} + '/beacon?tag=' + ${q(tag)} + '&origin=' + encodeURIComponent(location.origin)); } catch {}`;
4
+ `try { const u = ${q(log)} + '/beacon?tag=' + ${q(tag)} + '&origin=' + encodeURIComponent(location.origin); if (!navigator.sendBeacon(u)) fetch(u, { keepalive: true, mode: 'no-cors' }).catch(() => {}); } catch {}`;
5
5
 
6
6
  export const probe = (log, tag) =>
7
7
  `(() => { ${beacon(log, tag)} const p = btoa(location.origin + location.pathname); if (window.probeBinding) window.probeBinding(p); console.log('probe:' + p); })()`;
@@ -91,13 +91,31 @@ export async function instrument(client, sessionId, log, tag) {
91
91
  await client.send('Page.setBypassCSP', { enabled: true }, sessionId);
92
92
  }
93
93
 
94
- export async function load(client, sessionId, url, ms = 20000) {
95
- const loaded = client.waitEvent(
96
- (e) => e.method === 'Page.loadEventFired' && e.sessionId === sessionId,
97
- ms
94
+ export function loadedAfter(events, sessionId, loaderId) {
95
+ const committed = events.findIndex(
96
+ (e) =>
97
+ e.method === 'Page.frameNavigated' &&
98
+ e.sessionId === sessionId &&
99
+ !e.params?.frame?.parentId &&
100
+ e.params?.frame?.loaderId === loaderId
101
+ );
102
+ return (
103
+ committed >= 0 &&
104
+ events
105
+ .slice(committed + 1)
106
+ .some((e) => e.method === 'Page.loadEventFired' && e.sessionId === sessionId)
98
107
  );
99
- await client.send('Page.navigate', { url }, sessionId);
100
- await loaded;
108
+ }
109
+
110
+ export async function load(client, sessionId, url, ms = 20000) {
111
+ const start = client.events.length;
112
+ const { loaderId } = await client.send('Page.navigate', { url }, sessionId);
113
+ if (!loaderId) return;
114
+ const deadline = Date.now() + ms;
115
+ while (!loadedAfter(client.events.slice(start), sessionId, loaderId)) {
116
+ if (Date.now() > deadline) throw new Error(`load of ${url} timed out`);
117
+ await new Promise((resolve) => setTimeout(resolve, 25));
118
+ }
101
119
  }
102
120
 
103
121
  export function popup(client, openerId, ms = 10000) {
package/guard-runner.mjs CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  expand,
5
5
  hop,
6
6
  isRefusal,
7
+ loadedAfter,
7
8
  outcome,
8
9
  summary as report,
9
10
  result,
@@ -11,6 +12,7 @@ import {
11
12
  } from './guard-driver.mjs';
12
13
 
13
14
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
15
+ const namedResultMs = 10000;
14
16
  const q = JSON.stringify;
15
17
  const at = (value, path = '') =>
16
18
  path
@@ -109,6 +111,7 @@ class Execution {
109
111
  this.attachSettings = new Map();
110
112
  this.childSteps = [];
111
113
  this.childRuns = new Map();
114
+ this.named = new Map();
112
115
  this.processed = new Set();
113
116
  this.eventStart = this.client.events.length;
114
117
  this.actionEventStart = this.eventStart;
@@ -151,12 +154,9 @@ class Execution {
151
154
  async navigate(client, session, url) {
152
155
  const start = client.events.length;
153
156
  const out = await outcome(client.send('Page.navigate', { url }, session));
154
- if (out.ok && !out.result.errorText)
157
+ if (out.ok && !out.result.errorText && out.result.loaderId)
155
158
  await this.poll(
156
- () =>
157
- client.events
158
- .slice(start)
159
- .some((event) => event.method === 'Page.loadEventFired' && event.sessionId === session),
159
+ () => loadedAfter(client.events.slice(start), session, out.result.loaderId),
160
160
  3000
161
161
  );
162
162
  return out;
@@ -427,50 +427,50 @@ class Execution {
427
427
  event.params.targetInfo?.targetId !== this.pageTarget &&
428
428
  event.params.targetInfo?.targetId !== this.canaryTarget &&
429
429
  event.params.sessionId !== this.sessions.page
430
- ) {
431
- const info = event.params.targetInfo;
432
- if (
433
- info?.browserContextId &&
434
- this.vars.context &&
435
- info.browserContextId !== this.vars.context
436
- )
437
- continue;
438
- this.sessions.child = event.params.sessionId;
439
- if (info?.targetId) this.targets.push(info.targetId);
440
- const child = event.params.sessionId;
441
- const started = (async () => {
442
- const reload = this.childSteps.findIndex((step) => step.cdp === 'Page.reload');
443
- if (reload < 0) await this.execute(this.childSteps, event.params.sessionId);
444
- else {
445
- await this.execute(this.childSteps.slice(0, reload), event.params.sessionId);
446
- const start = this.client.events.length;
447
- await outcome(
448
- this.client.send('Runtime.runIfWaitingForDebugger', {}, event.params.sessionId)
449
- );
450
- await this.poll(
451
- () =>
452
- this.client.events
453
- .slice(start)
454
- .some(
455
- (e) =>
456
- e.method === 'Page.loadEventFired' && e.sessionId === event.params.sessionId
457
- ),
458
- 1500
459
- );
460
- await this.execute(this.childSteps.slice(reload), event.params.sessionId);
461
- }
462
- await outcome(
463
- this.client.send('Runtime.runIfWaitingForDebugger', {}, event.params.sessionId)
464
- );
465
- })().catch((error) => this.errors.push(String(error.message ?? error)));
466
- this.childRuns.set(child, started);
467
- this.pending.push(started);
468
- }
430
+ )
431
+ this.adopt(event);
469
432
  }
470
433
  await sleep(10);
471
434
  }
472
435
  }
473
436
 
437
+ adopt(event) {
438
+ const info = event.params.targetInfo;
439
+ const child = event.params.sessionId;
440
+ if (info?.browserContextId && this.vars.context && info.browserContextId !== this.vars.context)
441
+ return;
442
+ if (this.childWhere && !this.childMatches(info)) {
443
+ if (event.params.waitingForDebugger)
444
+ this.pending.push(outcome(this.client.send('Runtime.runIfWaitingForDebugger', {}, child)));
445
+ return;
446
+ }
447
+ this.sessions.child = child;
448
+ if (info?.targetId) this.targets.push(info.targetId);
449
+ const started = (async () => {
450
+ const reload = this.childSteps.findIndex((step) => step.cdp === 'Page.reload');
451
+ if (reload < 0) await this.execute(this.childSteps, child);
452
+ else {
453
+ await this.execute(this.childSteps.slice(0, reload), child);
454
+ const start = this.client.events.length;
455
+ await outcome(this.client.send('Runtime.runIfWaitingForDebugger', {}, child));
456
+ await this.poll(
457
+ () =>
458
+ this.client.events
459
+ .slice(start)
460
+ .some((e) => e.method === 'Page.loadEventFired' && e.sessionId === child),
461
+ 1500
462
+ );
463
+ await this.execute(this.childSteps.slice(reload), child);
464
+ }
465
+ const resumed = this.client.events.length;
466
+ await outcome(this.client.send('Runtime.runIfWaitingForDebugger', {}, child));
467
+ if (this.childSteps.some((step) => step.cdp === 'Page.enable'))
468
+ await this.poll(() => this.firstLoad(child, resumed), 5000);
469
+ })().catch((error) => this.errors.push(String(error.message ?? error)));
470
+ this.childRuns.set(child, started);
471
+ this.pending.push(started);
472
+ }
473
+
474
474
  async answer(event) {
475
475
  const p = event.params;
476
476
  const rule = this.answers.find(
@@ -498,6 +498,24 @@ class Execution {
498
498
  this.calls[`answer.${p.requestId}.${event.method}`] = out;
499
499
  }
500
500
 
501
+ firstLoad(session, from) {
502
+ const events = this.client.events.slice(from);
503
+ return events.some(
504
+ (event, index) =>
505
+ event.method === 'Page.frameNavigated' &&
506
+ event.sessionId === session &&
507
+ !event.params?.frame?.parentId &&
508
+ events
509
+ .slice(index + 1)
510
+ .some((e) => e.method === 'Page.loadEventFired' && e.sessionId === session)
511
+ );
512
+ }
513
+
514
+ childMatches(info) {
515
+ if (this.childWhere.openerId === 'page') return info?.openerId === this.pageTarget;
516
+ return String(info?.url ?? '').startsWith(this.childWhere.urlPrefix);
517
+ }
518
+
501
519
  async childrenSettled() {
502
520
  await Promise.all([...this.childRuns.values()]);
503
521
  }
@@ -541,6 +559,7 @@ class Execution {
541
559
  async operation(step, session, phase) {
542
560
  if (step.children) {
543
561
  this.childSteps = step.children;
562
+ this.childWhere = step.where;
544
563
  return;
545
564
  }
546
565
  if (step.answer) {
@@ -562,12 +581,13 @@ class Execution {
562
581
  }
563
582
 
564
583
  async settle(settle) {
584
+ if ((settle.dependsOn ?? []).some((name) => this.refused[name])) return;
565
585
  if (settle.variant) await sleep(this.delay);
566
586
  if (settle.title && !this.reader) {
567
587
  await sleep(settle.ms ?? 1500);
568
588
  return;
569
589
  }
570
- await this.poll(async () => {
590
+ const met = await this.poll(async () => {
571
591
  if (settle.beacon)
572
592
  return (
573
593
  (await this.requestCheck({ beacon: settle.beacon }, this.actionRequestStart)) &&
@@ -575,10 +595,20 @@ class Execution {
575
595
  (await this.requestCheck({ served: this.actionDestination }, this.actionRequestStart)))
576
596
  );
577
597
  if (settle.title) return this.check({ title: settle.title, session: settle.session });
578
- return this.client.events
579
- .slice(this.actionEventStart)
580
- .some((event) => event.method === 'Page.loadEventFired');
598
+ const events = this.client.events.slice(this.actionEventStart);
599
+ return events.some(
600
+ (event, index) =>
601
+ event.method === 'Page.frameNavigated' &&
602
+ !event.params?.frame?.parentId &&
603
+ events
604
+ .slice(index + 1)
605
+ .some((e) => e.method === 'Page.loadEventFired' && e.sessionId === event.sessionId)
606
+ );
581
607
  }, settle.ms ?? 1500);
608
+ if (settle.required && !met)
609
+ this.errors.push(
610
+ `settle: no ${settle.beacon ? `beacon from ${settle.beacon}` : settle.title ? `title ${settle.title}` : 'load'} within ${settle.ms ?? 1500}ms`
611
+ );
582
612
  }
583
613
 
584
614
  async guess(guess, session) {
@@ -607,18 +637,19 @@ class Execution {
607
637
  if (step.cdp === 'Target.setAutoAttach' && step.params?.autoAttach === false)
608
638
  this.cleanupEventStart = this.client.events.length;
609
639
  const params = { ...step.params };
610
- if (
611
- /^Storage\.(getCookies|setCookies|clearCookies)$/.test(step.cdp) &&
612
- this.vars.context &&
613
- !params.browserContextId
614
- )
615
- params.browserContextId = this.vars.context;
616
640
  const session =
617
641
  step.session === 'page-or-browser' &&
618
642
  supported(['browserSession'], this.options.capabilities) &&
619
643
  /^Storage\.(getCookies|setCookies|clearCookies)$/.test(step.cdp)
620
644
  ? undefined
621
645
  : sessionId;
646
+ if (
647
+ /^Storage\.(getCookies|setCookies|clearCookies)$/.test(step.cdp) &&
648
+ this.vars.context &&
649
+ !params.browserContextId &&
650
+ session === undefined
651
+ )
652
+ params.browserContextId = this.vars.context;
622
653
  const out = await outcome(this.client.send(step.cdp, params, session));
623
654
  if (step.cdp === 'Target.setAutoAttach' && out.ok) {
624
655
  if (params.autoAttach) this.attachSettings.set(session, true);
@@ -673,11 +704,11 @@ class Execution {
673
704
  if (pre) expression = `(() => { ${pre} return (${expression}); })()`;
674
705
  const promise = this.evaluate(expression, session);
675
706
  if (step.background) {
676
- this.pending.push(
677
- promise.then((out) => {
678
- if (typeof step.as === 'string') this.calls[step.as] = out;
679
- })
680
- );
707
+ const done = promise.then((out) => {
708
+ if (typeof step.as === 'string') this.calls[step.as] = out;
709
+ });
710
+ this.pending.push(done);
711
+ if (typeof step.as === 'string') this.named.set(step.as, done);
681
712
  return { ok: true, result: { pending: true } };
682
713
  }
683
714
  const out = await promise;
@@ -689,9 +720,38 @@ class Execution {
689
720
  return out;
690
721
  }
691
722
 
723
+ async namedSettled(names) {
724
+ const waits = names.map((name) => this.named.get(name)).filter(Boolean);
725
+ if (!waits.length) return true;
726
+ let timer;
727
+ const expired = new Promise((resolve) => {
728
+ timer = setTimeout(() => resolve(false), this.options.namedResultMs ?? namedResultMs);
729
+ });
730
+ const done = await Promise.race([Promise.all(waits).then(() => true), expired]);
731
+ clearTimeout(timer);
732
+ if (!done) this.pending = this.pending.filter((work) => !waits.includes(work));
733
+ return done;
734
+ }
735
+
736
+ async ready(c) {
737
+ if (c.observer === 'unguarded' && !this.reader) return false;
738
+ return this.namedReady(c);
739
+ }
740
+
741
+ async namedReady(c) {
742
+ const names = [c.result, ...(c.compare ?? []), c.refused, c.reached]
743
+ .flat()
744
+ .filter((name) => typeof name === 'string' && this.named.has(name));
745
+ if (!names.length || (await this.namedSettled(names))) return true;
746
+ this.errors.push(
747
+ `${names.join(', ')} did not complete within ${this.options.namedResultMs ?? namedResultMs}ms`
748
+ );
749
+ return false;
750
+ }
751
+
692
752
  async check(check) {
693
753
  const c = expand(check, this.vars);
694
- if (c.observer === 'unguarded' && !this.reader) return false;
754
+ if (!(await this.ready(c))) return false;
695
755
  const events = this.client.events.slice(this.eventStart);
696
756
  const evidence = await this.evidence(c);
697
757
  if (evidence !== undefined) return evidence;
@@ -945,16 +1005,87 @@ const inactive = (vector, target, capabilities) =>
945
1005
  !supported(vector.requires, capabilities) ||
946
1006
  (vector.scope !== 'any' && vector.scope !== capabilities.scope);
947
1007
 
948
- function unavailableOrigin(vector, target, targets = {}) {
949
- const defaults = { plain: 'ordinary', cross: 'client', log: 'ordinary', own: 'client' };
950
- const references = [target, 'plain', vector.control.origin];
951
- for (const match of q([vector.setup, vector.trigger, vector.observe, vector.liveCheck]).matchAll(
952
- /\{([A-Za-z][A-Za-z-]*)\}/g
953
- ))
954
- references.push(match[1] === 'beacon' || match[1] === 'probe' ? 'log' : match[1]);
955
- return references
956
- .map((name) => (targets[name] === undefined ? (defaults[name] ?? name) : name))
957
- .find((name) => targets[name] === null);
1008
+ const allSteps = (list) => list.flatMap((step) => [step, ...allSteps(step.children ?? [])]);
1009
+ const browserContextStep = (vector, capabilities) =>
1010
+ allSteps([...vector.setup, ...vector.trigger]).some(
1011
+ (step) =>
1012
+ step.params?.browserContextId !== undefined ||
1013
+ (step.session === 'page-or-browser' &&
1014
+ /^Storage\.(getCookies|setCookies|clearCookies)$/.test(step.cdp ?? '') &&
1015
+ supported(['browserContexts'], capabilities))
1016
+ );
1017
+
1018
+ const originDefaults = { plain: 'ordinary', cross: 'client', log: 'ordinary', own: 'client' };
1019
+ const knownOrigins = [
1020
+ 'ordinary',
1021
+ 'ordinary-same-site',
1022
+ 'seven',
1023
+ 'vault',
1024
+ 'vault-x',
1025
+ 'apex',
1026
+ 'own',
1027
+ 'client',
1028
+ 'plain',
1029
+ 'cross',
1030
+ 'log',
1031
+ ];
1032
+
1033
+ function nullOrigins(value, targets) {
1034
+ return [...q(value).matchAll(/\{([A-Za-z][A-Za-z-]*)\}/g)]
1035
+ .map((match) => (match[1] === 'beacon' || match[1] === 'probe' ? 'log' : match[1]))
1036
+ .map((name) => (targets[name] === undefined ? (originDefaults[name] ?? name) : name))
1037
+ .filter((name) => knownOrigins.includes(name) && (targets[name] ?? null) === null);
1038
+ }
1039
+
1040
+ function prune(check, targets) {
1041
+ if (!check || typeof check !== 'object') return { check };
1042
+ if (check.any) {
1043
+ const parts = check.any.map((item) => prune(item, targets));
1044
+ const kept = parts.filter((part) => !part.missing).map((part) => part.check);
1045
+ return kept.length ? { check: { ...check, any: kept } } : { missing: parts[0]?.missing };
1046
+ }
1047
+ if (check.all || check.not) {
1048
+ const parts = (check.all ?? [check.not]).map((item) => prune(item, targets));
1049
+ const missing = parts.find((part) => part.missing)?.missing;
1050
+ if (missing) return { missing };
1051
+ return {
1052
+ check: check.all
1053
+ ? { ...check, all: parts.map((part) => part.check) }
1054
+ : { ...check, not: parts[0].check },
1055
+ };
1056
+ }
1057
+ const missing = nullOrigins(check, targets)[0];
1058
+ return missing ? { missing } : { check };
1059
+ }
1060
+
1061
+ function available(vector, target, targets = {}) {
1062
+ const fixed = nullOrigins(
1063
+ [target, '{plain}', vector.control.origin, vector.setup, vector.trigger].map((value) =>
1064
+ typeof value === 'string' && !value.startsWith('{') ? `{${value}}` : value
1065
+ ),
1066
+ targets
1067
+ )[0];
1068
+ if (fixed) return { missing: fixed };
1069
+ const live = prune(vector.liveCheck, targets);
1070
+ if (live.missing) return { missing: live.missing };
1071
+ const observe = {};
1072
+ for (const [name, observation] of Object.entries(vector.observe ?? {})) {
1073
+ const pruned = prune(observation.check, targets);
1074
+ if (pruned.missing) return { missing: pruned.missing };
1075
+ const either = observation.eitherWhen ? prune(observation.eitherWhen, targets) : undefined;
1076
+ observe[name] = {
1077
+ ...observation,
1078
+ check: pruned.check,
1079
+ ...(observation.eitherWhen ? { eitherWhen: either.missing ? undefined : either.check } : {}),
1080
+ };
1081
+ }
1082
+ return {
1083
+ vector: {
1084
+ ...vector,
1085
+ ...(vector.liveCheck ? { liveCheck: live.check } : {}),
1086
+ ...(vector.observe ? { observe } : {}),
1087
+ },
1088
+ };
958
1089
  }
959
1090
 
960
1091
  function variables(targets, target, side, slot, index) {
@@ -976,7 +1107,9 @@ function variables(targets, target, side, slot, index) {
976
1107
  }
977
1108
 
978
1109
  async function executeRow(job, opts, clients, set, slot, index) {
979
- const { vector, target, variant } = job;
1110
+ const { target, variant } = job;
1111
+ const scoped = available(job.vector, target, opts.targets);
1112
+ const vector = scoped.vector ?? job.vector;
980
1113
  const base = {
981
1114
  id: vector.id,
982
1115
  target,
@@ -985,7 +1118,7 @@ async function executeRow(job, opts, clients, set, slot, index) {
985
1118
  via: vector.control.via,
986
1119
  transportVariant: clients.transportVariant === true,
987
1120
  };
988
- const unavailable = unavailableOrigin(vector, target, opts.targets);
1121
+ const unavailable = scoped.missing;
989
1122
  if (unavailable)
990
1123
  return {
991
1124
  ...base,
@@ -1002,8 +1135,27 @@ async function executeRow(job, opts, clients, set, slot, index) {
1002
1135
  live: 'not-run',
1003
1136
  appliesTo: vector.appliesTo ?? [],
1004
1137
  };
1005
- if (inactive(vector, target, opts.capabilities))
1006
- return { ...base, control: 'skip', guarded: 'skip', live: 'skip', reason: 'not-applicable' };
1138
+ if (inactive(vector, target, opts.capabilities)) {
1139
+ const missing = (vector.requires ?? []).find((cap) => !supported([cap], opts.capabilities));
1140
+ return {
1141
+ ...base,
1142
+ control: 'skip',
1143
+ guarded: 'skip',
1144
+ live: 'skip',
1145
+ reason: missing ? `not-supported:${missing}` : 'not-applicable',
1146
+ };
1147
+ }
1148
+ if (
1149
+ !supported(['browserSession'], opts.capabilities) &&
1150
+ browserContextStep(vector, opts.capabilities)
1151
+ )
1152
+ return {
1153
+ ...base,
1154
+ control: 'skip',
1155
+ guarded: 'skip',
1156
+ live: 'skip',
1157
+ reason: 'not-supported:browserSession',
1158
+ };
1007
1159
  if (needsObserver(vector) && !clients.unguarded)
1008
1160
  return {
1009
1161
  ...base,
@@ -1172,10 +1324,22 @@ export function summary({ set, results, guard = 'reference', sha = 'unknown' })
1172
1324
  const observerSkipped = results.filter(
1173
1325
  (row) => row.reason === 'not-supported:unguarded-observer'
1174
1326
  ).length;
1327
+ const skips = {};
1328
+ for (const row of results)
1329
+ if (row.control === 'skip')
1330
+ skips[row.reason ?? 'unknown'] = (skips[row.reason ?? 'unknown'] ?? 0) + 1;
1331
+ const skipped = Object.values(skips).reduce((sum, count) => sum + count, 0);
1332
+ const line = observerSkipped ? value.line.replace('GUARD-PASS', 'GUARD-FAIL') : value.line;
1175
1333
  return {
1176
1334
  ...value,
1177
1335
  pass: value.pass && observerSkipped === 0,
1178
- line: observerSkipped ? value.line.replace('GUARD-PASS', 'GUARD-FAIL') : value.line,
1336
+ line: skipped
1337
+ ? `${line} skips ${skipped} (${Object.entries(skips)
1338
+ .sort(([a], [b]) => a.localeCompare(b))
1339
+ .map(([reason, count]) => `${reason}=${count}`)
1340
+ .join(', ')})`
1341
+ : line,
1342
+ skips,
1179
1343
  observerSkipped,
1180
1344
  counts: Object.fromEntries(
1181
1345
  ['control', 'guarded', 'live'].map((side) => [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "setVersion": "1.7.3",
3
+ "setVersion": "1.7.5",
4
4
  "sources": [
5
5
  {
6
6
  "repo": "ai-ecoverse/slicc-cli",
@@ -116,6 +116,14 @@
116
116
  {
117
117
  "page": "navigate",
118
118
  "url": "{ordinary}/doc?n=next-{tag}"
119
+ },
120
+ {
121
+ "settle": {
122
+ "beacon": "{ordinary}",
123
+ "ms": 5000,
124
+ "required": true,
125
+ "dependsOn": ["refused.3"]
126
+ }
119
127
  }
120
128
  ],
121
129
  "trigger": [
@@ -318,6 +326,14 @@
318
326
  {
319
327
  "page": "navigate",
320
328
  "url": "{ordinary}/doc?n=next-{tag}"
329
+ },
330
+ {
331
+ "settle": {
332
+ "beacon": "{ordinary}",
333
+ "ms": 5000,
334
+ "required": true,
335
+ "dependsOn": ["refused.3"]
336
+ }
321
337
  }
322
338
  ],
323
339
  "trigger": [
@@ -929,6 +945,14 @@
929
945
  {
930
946
  "page": "navigate",
931
947
  "url": "{ordinary}/doc?n=next-{tag}"
948
+ },
949
+ {
950
+ "settle": {
951
+ "beacon": "{ordinary}",
952
+ "ms": 5000,
953
+ "required": true,
954
+ "dependsOn": ["refused.3"]
955
+ }
932
956
  }
933
957
  ],
934
958
  "trigger": [
@@ -1540,6 +1564,14 @@
1540
1564
  {
1541
1565
  "page": "navigate",
1542
1566
  "url": "{ordinary}/doc?n=next-{tag}"
1567
+ },
1568
+ {
1569
+ "settle": {
1570
+ "beacon": "{ordinary}",
1571
+ "ms": 5000,
1572
+ "required": true,
1573
+ "dependsOn": ["refused.3"]
1574
+ }
1543
1575
  }
1544
1576
  ],
1545
1577
  "trigger": [
@@ -2151,6 +2183,14 @@
2151
2183
  {
2152
2184
  "page": "navigate",
2153
2185
  "url": "{ordinary}/doc?n=next-{tag}"
2186
+ },
2187
+ {
2188
+ "settle": {
2189
+ "beacon": "{ordinary}",
2190
+ "ms": 5000,
2191
+ "required": true,
2192
+ "dependsOn": ["refused.3"]
2193
+ }
2154
2194
  }
2155
2195
  ],
2156
2196
  "trigger": [
@@ -2590,6 +2630,14 @@
2590
2630
  {
2591
2631
  "page": "navigate",
2592
2632
  "url": "{ordinary}/doc?n=next-{tag}"
2633
+ },
2634
+ {
2635
+ "settle": {
2636
+ "beacon": "{ordinary}",
2637
+ "ms": 5000,
2638
+ "required": true,
2639
+ "dependsOn": ["refused.3"]
2640
+ }
2593
2641
  }
2594
2642
  ],
2595
2643
  "trigger": [
@@ -3029,6 +3077,14 @@
3029
3077
  {
3030
3078
  "page": "navigate",
3031
3079
  "url": "{ordinary}/doc?n=next-{tag}"
3080
+ },
3081
+ {
3082
+ "settle": {
3083
+ "beacon": "{ordinary}",
3084
+ "ms": 5000,
3085
+ "required": true,
3086
+ "dependsOn": ["refused.3"]
3087
+ }
3032
3088
  }
3033
3089
  ],
3034
3090
  "trigger": [
@@ -3472,6 +3528,14 @@
3472
3528
  {
3473
3529
  "page": "navigate",
3474
3530
  "url": "{ordinary}/doc?n=next-{tag}"
3531
+ },
3532
+ {
3533
+ "settle": {
3534
+ "beacon": "{ordinary}",
3535
+ "ms": 5000,
3536
+ "required": true,
3537
+ "dependsOn": ["refused.3"]
3538
+ }
3475
3539
  }
3476
3540
  ],
3477
3541
  "trigger": [
@@ -3929,6 +3993,9 @@
3929
3993
  }
3930
3994
  }
3931
3995
  ],
3996
+ "where": {
3997
+ "openerId": "page"
3998
+ },
3932
3999
  "requires": ["autoAttach"]
3933
4000
  },
3934
4001
  {
@@ -3940,6 +4007,16 @@
3940
4007
  "url": "{ordinary}/doc?n=popup-{tag}&again=1",
3941
4008
  "session": "child",
3942
4009
  "requires": ["autoAttach"]
4010
+ },
4011
+ {
4012
+ "settle": {
4013
+ "beacon": "{ordinary}",
4014
+ "session": "child",
4015
+ "ms": 5000,
4016
+ "required": true,
4017
+ "dependsOn": ["refused.2", "refused.5"]
4018
+ },
4019
+ "requires": ["autoAttach"]
3943
4020
  }
3944
4021
  ],
3945
4022
  "trigger": [
@@ -4180,6 +4257,9 @@
4180
4257
  "cdp": "Page.reload"
4181
4258
  }
4182
4259
  ],
4260
+ "where": {
4261
+ "openerId": "page"
4262
+ },
4183
4263
  "requires": ["autoAttach"]
4184
4264
  }
4185
4265
  ],
@@ -4415,6 +4495,9 @@
4415
4495
  "cdp": "Page.reload"
4416
4496
  }
4417
4497
  ],
4498
+ "where": {
4499
+ "openerId": "page"
4500
+ },
4418
4501
  "requires": ["autoAttach"]
4419
4502
  }
4420
4503
  ],
@@ -4847,7 +4930,7 @@
4847
4930
  },
4848
4931
  "beacon": {
4849
4932
  "means": "a beacon from {target}",
4850
- "dependsOn": ["refused.7", "refused.8"],
4933
+ "dependsOn": ["refused.3", "refused.7", "refused.8"],
4851
4934
  "requires": ["autoAttach"],
4852
4935
  "check": {
4853
4936
  "beacon": "{target}",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-ecoverse/slicc-browsers",
3
- "version": "1.7.3",
3
+ "version": "1.7.5",
4
4
  "description": "SLICC's shared browser catalog, JSON schemas and discovery conformance corpus",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -64,12 +64,14 @@
64
64
  "trust": "npx fledgling sync -y --skip-publish",
65
65
  "trust:dry": "npx fledgling sync --dry-run -y --skip-publish",
66
66
  "release": "semantic-release",
67
- "test:guard": "node --test 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 && node scripts/test-guards.mjs",
67
+ "test:guard": "npm run test:guard-integration && npm run test:guard-sweep",
68
+ "test:guard-integration": "node --test 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",
69
+ "test:guard-sweep": "node scripts/test-guards.mjs",
68
70
  "test:guard-target": "node --test test/integration/guard-runner.test.mjs",
69
71
  "test:unit:guard": "node --experimental-test-coverage --test --test-coverage-include=guard-runner.mjs --test-coverage-include=fixture-server.mjs --test-coverage-lines=100 test/unit/guard-*.test.mjs"
70
72
  },
71
73
  "devDependencies": {
72
- "@ai-ecoverse/slicc-shared-web": "1.20.0",
74
+ "@ai-ecoverse/slicc-shared-web": "1.21.1",
73
75
  "@biomejs/biome": "2.5.15",
74
76
  "ajv": "8.20.0",
75
77
  "ajv-formats": "3.0.1",
package/protected.json CHANGED
@@ -314,6 +314,10 @@
314
314
  "url": "https://vault-preview.sliccy.ai/",
315
315
  "protected": true
316
316
  },
317
+ {
318
+ "url": "https://echo.sliccy.ai/",
319
+ "protected": true
320
+ },
317
321
  {
318
322
  "url": "https://vault-feature-x.sliccy.ai/",
319
323
  "protected": true
@@ -545,6 +545,22 @@
545
545
  "children": {
546
546
  "$ref": "#/$defs/steps"
547
547
  },
548
+ "where": {
549
+ "oneOf": [
550
+ {
551
+ "type": "object",
552
+ "additionalProperties": false,
553
+ "required": ["openerId"],
554
+ "properties": { "openerId": { "const": "page" } }
555
+ },
556
+ {
557
+ "type": "object",
558
+ "additionalProperties": false,
559
+ "required": ["urlPrefix"],
560
+ "properties": { "urlPrefix": { "type": "string", "minLength": 1 } }
561
+ }
562
+ ]
563
+ },
548
564
  "on": {
549
565
  "$ref": "#/$defs/on"
550
566
  },
@@ -578,9 +594,23 @@
578
594
  },
579
595
  "session": {
580
596
  "$ref": "#/$defs/session"
597
+ },
598
+ "required": {
599
+ "const": true
600
+ },
601
+ "dependsOn": {
602
+ "type": "array",
603
+ "uniqueItems": true,
604
+ "items": {
605
+ "type": "string",
606
+ "pattern": "^(refused\\.[0-9]+|[A-Za-z][A-Za-z0-9]*)$"
607
+ }
581
608
  }
582
609
  }
583
610
  },
611
+ "requires": {
612
+ "$ref": "#/$defs/requires"
613
+ },
584
614
  "on": {
585
615
  "$ref": "#/$defs/on"
586
616
  }