@ai-ecoverse/slicc-browsers 1.10.0 → 1.10.2

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
@@ -182,7 +182,7 @@ The `C9` foreign extension isolated-world family requires `extensionWorld`: a br
182
182
 
183
183
  The integration launcher loads shared-web 1.25.2’s harness extension exactly once through `Extensions.loadUnpacked` (`ankmbodmgimjemnojmeddfcdlbocolob`), whose content script runs on HTTP(S) pages. shared-web 1.25.2 also loads it by default in its own harness. A transport unable to host a foreign content-script extension omits `extensionWorld` and reports `not-supported:extensionWorld`; an advertised capability with no observed world fails, rather than silently skipping.
184
184
 
185
- `C8.unparseable` probes both raw and canonical space/U+200D/invalid-punycode URLs through creation and navigation. Every guarded destination needs a matched refusal. Its unguarded controls record `document` or `native-rejection` outcomes, including Chrome builds that reject raw U+200D instead of committing it. Both raw and encoded-space forms must commit real fixture documents, because that is the proven cookie leak. An independent observer loads the encoded-space document and confirms it shares a uniquely seeded `.sliccy.ai` cookie; the guarded client must neither list nor attach to it, read its cookie, query that cookie by URL, nor cause a cookie-bearing destination request. The guard does not erase the observer’s private browser state. The row requires `browserSession`, uses the default context, and cleans up its own targets and cookie. The HTTP fixture accepts Chrome’s encoded-space Host header without reparsing it as a WHATWG origin.
185
+ `C8.unparseable` probes both raw and canonical space/U+200D/invalid-punycode URLs through creation and navigation. Every guarded destination needs a matched refusal. Its unguarded controls record `document` or `native-rejection` outcomes, including Chrome builds that reject raw U+200D instead of committing it. Both raw and encoded-space forms must commit real fixture documents, because that is the proven cookie leak. An independent observer loads the encoded-space document and confirms it shares a uniquely seeded `.sliccy.ai` cookie; the guarded client must neither list nor attach to it, read its cookie, query that cookie by URL, nor cause a cookie-bearing destination request. The guard does not erase the observer’s private browser state. The row runs on both browser-session and page-session-only transports, uses the default context, and cleans up its own targets and cookie. Cookie seeding always uses the unguarded observer’s browser session, never the guarded client. Only guarded browser-session target listing requires `browserSession`; without it, `listed` records `not-supported` explicitly in probe and observation evidence, and the result lists that field in `notSupported`. Creation, navigation, attachment and `Network.getCookies` with `urls` still require matched refusals; filtering cookies without refusing fails `cookieRefused`. Fixture URLs omit the colon when the port is empty, and reported URLs are compared after canonicalisation, including Chrome’s WHATWG-unparseable encoded-space hosts. Launchers must resolve the raw/canonical space, U+200D/punycode and invalid-punycode `.sliccy.ai` fixture hosts to the fixture server, including for default-port HTTP or HTTPS origins. The HTTP fixture accepts Chrome’s encoded-space Host header without reparsing it as a WHATWG origin.
186
186
 
187
187
  `C8.chrome-search` requests `chrome-search://most-visited/` through `Page.navigate` and requires an explicit guard refusal. Its unguarded control requires a CDP response, not a successful document commit: Chrome can answer with `errorText` when that internal data source is unavailable outside an Instant process. Browser-side load failure is not a guard refusal, and merely hiding the target must fail this row.
188
188
 
@@ -206,8 +206,8 @@ A client may register `Fetch.enable` patterns naming a protected origin in eithe
206
206
  - `scope`: `any`, or the one guard the vector is for;
207
207
  - `setup` and `trigger`, both lists of steps;
208
208
  - `targets`: the protected or reachability targets it runs against;
209
- - `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;
210
- - `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);
209
+ - `requires?`: the capabilities needed for the whole vector, including its steps' `requires`. Observation-only capabilities may be omitted here to keep independent legs runnable. 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;
210
+ - `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);
211
211
  - `live`: the liveness check;
212
212
  - `control` (`origin: "ordinary"`, `via`, `expected`) and `expected`: an object of names and booleans, or text;
213
213
  - `skip?` (`target`, `reason`): `reason` starts with a code, as in `not-applicable: …`.
@@ -247,7 +247,7 @@ Popups are observed by `Target.targetCreated` with the opener's `openerId`, or b
247
247
 
248
248
  `{ "extensionWorld": "hidden" | "select" | "debugger" | "drivable", "as": "world", "requires": ["extensionWorld"] }` runs the C9 compound probe on the current ordinary page. The runner attaches an independent observer to discover actual context ids, unique ids and names, and sends probes through the client session. Discovery matches the expected fixture extension’s exact origin, never the first extension world: `run()`’s optional `extensionWorldId` defaults to shared-web’s harness id `ankmbodmgimjemnojmeddfcdlbocolob`. Multiple unrelated content-script worlds are ignored for fixture selection, while guards must still protect foreign worlds. A custom fixture must provide the same content-script marker contract. Object handles are minted and validated on the unguarded control session, then replayed as guesses on the guarded leg; Chrome object handles and DOM node ids are session-local, so observer handles must never be mistaken for client handles. Numeric ids from the control are also replayed alongside fresh observer-discovered selectors, but numeric ids are local to a tab: a control id may name the next tab’s exposed default context, which must work and return no extension marker. The same-tab hidden world’s actual numeric id and every extension unique-id guess still require a matched refusal. Unknown numeric ids and stale object handles (`not exposed`) need explicit refusals rather than Chrome’s invalid-id errors. `guessesRefused` aggregates only required refusals; `guessesReached` must be false on the guarded leg, and `exposedContextsWork` must remain true. Its result contains boolean evidence consumed by ordinary `result` checks. The unguarded leg validates those selectors against Chrome itself before guarded evidence can pass.
249
249
 
250
- `{ "unparseable": true, "as": "unparseable", "requires": ["browserSession"] }` runs the C8 malformed-URL compound probe on the current ordinary page: independent browser ground truth, raw/canonical destination commands, discovery/attach and cookie checks. Boolean evidence is consumed by ordinary `result` checks, with native Chrome outcomes retained in its diagnostics.
250
+ `{ "unparseable": true, "as": "unparseable" }` runs the C8 malformed-URL compound probe on the current ordinary page: independent browser ground truth, raw/canonical destination commands, discovery/attach and cookie checks. Boolean evidence is consumed by ordinary `result` checks, with native Chrome outcomes retained in its diagnostics.
251
251
 
252
252
  **The control rule.** Every vector has a control on an ordinary origin: the same setup and trigger against an unprotected origin must succeed. A missing or failing control fails the id. With `via: "guard"`, the control runs through the guard. With `via: "unguarded"`, the runner runs setup and trigger through its own CDP against the ordinary origin and against the protected stand-in, and both must show the effect. The runner reports the control's transport as `via`.
253
253
 
@@ -281,8 +281,10 @@ It exports:
281
281
  - `refusals(vector)`: the vector's refusal names;
282
282
  - `outcome`, `call(client, step, sessionId)` and `isRefusal(outcome, step)`;
283
283
  - `instrument(client, sessionId, log, tag)`, `load(client, sessionId, url)` and `popup(client, openerId)`;
284
- - `judge(vector, side, observed)`: `{ pass, refused, skipped, mismatches }`, where `refused` lists the leg's refusals and `skipped` the observations that depend on them;
284
+ - `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;
285
285
  - `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;
286
+ - `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, the fixture and `protected.mjs` goes through it, so an unparseable entry never throws;
287
+ - `owned(entries, tag)`: the fixture log entries whose raw URL contains `tag`, the row's attribution through its `n=`, `tag=` or `to=` query;
286
288
  - `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, 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 …`.
287
289
 
288
290
  **`npm run validate`** checks that:
@@ -294,7 +296,7 @@ It exports:
294
296
  - a skip names one of the vector's targets;
295
297
  - `observe` defines every expected name;
296
298
  - 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;
297
- - every `Target.setAutoAttach`, `children`, child-session, browser-session and browser-context step has the matching `requires`, and a vector's `requires` is the union of its steps' and observations';
299
+ - 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;
298
300
  - every `{name}` is in `placeholders`;
299
301
  - no URL uses a port from 9222 to 9229.
300
302
 
@@ -362,7 +364,7 @@ With the `browserContexts` capability each leg runs in its own browser context.
362
364
 
363
365
  `onStart(row)` receives each row's `id`, `target`, `variant`, `transport` and `via` as the row starts. `run()` returns a promise that also has `inFlight()`, and its result has `inFlight` too. `inFlight()` lists the rows that are running now, each as `{ id, target, variant, leg, phase, step, clients }`, where `step` is the index of the current step in its `phase` (`setup`, `trigger` or `child`), and `clients` lists each client's pending CDP calls as `{ method, session, ageMs }` (`client`, plus `unguarded` when it is a separate client). With `rowTimeoutMs`, a row still running after that many milliseconds fails on its own. Its `errors` hold `row timed out after <ms>ms: <the in-flight view>`, `inFlight` holds the view, and the clients that `clientFactory` made for it are closed. The run then goes on to the next row. It is off by default, and a client passed as `client` is never closed.
364
366
 
365
- `log(row)` receives completed rows. Besides `control`, `guarded`, `live`, `why` and `errors`, a row may carry `ran` (steps that ran on one leg only) and `notes` (settles skipped because their registration was refused). Both are informational: they explain a row's verdict, and adopters (bios, slicc-cli, slicc-cdp) must not count them as failures, or as passes. The verdict is `control`, `guarded` and `live`. A variant supplies the wait before an
367
+ `log(row)` receives completed rows. Besides `control`, `guarded`, `live`, `why` and `errors`, a row may carry `notSupported` (unavailable observation fields), `ran` (steps that ran on one leg only) and `notes` (settles skipped because their registration was refused). Both are informational: they explain a row's verdict, and adopters (bios, slicc-cli, slicc-cdp) must not count them as failures, or as passes. The verdict is `control`, `guarded` and `live`. A variant supplies the wait before an
366
368
  explicit `Target.detachFromTarget` step and for a `settle.variant` step.
367
369
 
368
370
  Every runnable vector has a declarative `liveCheck`; every observation has a
@@ -437,6 +439,14 @@ redirect chains it also requires the exact final destination request in the
437
439
  fixture log. A setup beacon on the same origin and tag cannot settle an unfinished
438
440
  redirect chain. This does not change the observations used to judge either leg.
439
441
 
442
+ The fixture log is shared by every row in a run, and `C8.unparseable` leaves entries such
443
+ as `http://exa%20mple.sliccy.ai:<port>/doc?n=<tag>-create-0` that `new URL` rejects. A
444
+ row reads only the entries that carry its own tag; a tagged `liveCheck`, `served`,
445
+ `on` or `beacon` check never sees another row's entries, and an entry that does not
446
+ parse is matched only by its raw or canonical string, never parsed into a throw.
447
+ `hops` checks compare exact URLs. `"tagged": false` reads every entry and skips the ones
448
+ that don't parse.
449
+
440
450
  The separately exported Node module `@ai-ecoverse/slicc-browsers/fixture` exposes
441
451
  `fixtureServer({ host, port, tls, latency })`, defaulting to a random loopback port. `latency(url)` returns milliseconds to hold that response, for timing tests.
442
452
  Pass `tls: { key, cert }` to serve HTTPS using Node TLS key and certificate
@@ -1,5 +1,6 @@
1
1
  import { createServer } from 'node:http';
2
2
  import { createServer as createSecureServer } from 'node:https';
3
+ import { parseURL } from './guard-driver.mjs';
3
4
 
4
5
  const tall = Buffer.from(
5
6
  'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
@@ -78,19 +79,18 @@ function route(req, url, send, html) {
78
79
 
79
80
  function requestURL(req, scheme) {
80
81
  const origin = `${scheme}://${req.headers.host}`;
81
- try {
82
- return new URL(req.url, origin);
83
- } catch {
84
- const path = new URL(req.url, `${scheme}://fixture.invalid`);
85
- return {
86
- pathname: path.pathname,
87
- searchParams: path.searchParams,
88
- href: `${origin}${path.pathname}${path.search}${path.hash}`,
89
- origin,
90
- host: req.headers.host,
91
- hostname: req.headers.host.replace(/:\d+$/, ''),
92
- };
93
- }
82
+ const parsed = parseURL(req.url, origin);
83
+ if (parsed) return parsed;
84
+ const path =
85
+ parseURL(req.url, `${scheme}://fixture.invalid`) ?? new URL(`${scheme}://fixture.invalid/`);
86
+ return {
87
+ pathname: path.pathname,
88
+ searchParams: path.searchParams,
89
+ href: `${origin}${path.pathname}${path.search}${path.hash}`,
90
+ origin,
91
+ host: req.headers.host,
92
+ hostname: String(req.headers.host ?? '').replace(/:\d+$/, ''),
93
+ };
94
94
  }
95
95
 
96
96
  export async function fixtureServer({ host = '127.0.0.1', port = 0, tls, latency } = {}) {
package/guard-driver.mjs CHANGED
@@ -139,8 +139,10 @@ export function judge(vector, side, observed) {
139
139
  const mismatches = observed === true ? [] : [want];
140
140
  return { pass: mismatches.length === 0, refused, skipped: [], mismatches };
141
141
  }
142
- const skipped = Object.keys(want).filter((k) =>
143
- (vector.observe?.[k]?.dependsOn ?? []).some((d) => refused.includes(d))
142
+ const skipped = Object.keys(want).filter(
143
+ (k) =>
144
+ (observed?.[k] === 'not-supported' && vector.observe?.[k]?.requires?.length > 0) ||
145
+ (vector.observe?.[k]?.dependsOn ?? []).some((d) => refused.includes(d))
144
146
  );
145
147
  const mismatches = Object.entries(want)
146
148
  .filter(
@@ -227,6 +229,13 @@ export function result(vector, run) {
227
229
  guarded,
228
230
  live,
229
231
  ...(Object.keys(ran).length ? { ran } : {}),
232
+ ...(Object.values(run.guarded ?? {}).includes('not-supported')
233
+ ? {
234
+ notSupported: Object.keys(run.guarded).filter(
235
+ (name) => run.guarded[name] === 'not-supported'
236
+ ),
237
+ }
238
+ : {}),
230
239
  ...(why.length ? { why: why.join('; ') } : {}),
231
240
  };
232
241
  }
@@ -266,3 +275,15 @@ export function summary({ guard, sha, set, results }) {
266
275
  line: `${pass ? 'GUARD-PASS' : 'GUARD-FAIL'} ${guard}@${sha} (vectors ${name})`,
267
276
  };
268
277
  }
278
+
279
+ export function parseURL(value, base) {
280
+ if (typeof value !== 'string') return null;
281
+ try {
282
+ return new URL(value, base);
283
+ } catch {
284
+ return null;
285
+ }
286
+ }
287
+
288
+ export const owned = (entries, tag) =>
289
+ (entries ?? []).filter((entry) => typeof entry?.url === 'string' && entry.url.includes(tag));
package/guard-runner.mjs CHANGED
@@ -7,11 +7,13 @@ import {
7
7
  isRefusal,
8
8
  loadedAfter,
9
9
  outcome,
10
+ owned,
11
+ parseURL,
10
12
  summary as report,
11
13
  result,
12
14
  steps,
13
15
  } from './guard-driver.mjs';
14
- import { unparseableURLs } from './unparseable-url.mjs';
16
+ import { canonicalURL, unparseableURLs } from './unparseable-url.mjs';
15
17
 
16
18
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
17
19
  const namedResultMs = 10000;
@@ -233,13 +235,13 @@ class Execution {
233
235
  }
234
236
 
235
237
  async attemptCheck(c) {
236
- const logs = (await this.options.fixture.log()).slice(this.requestStart);
238
+ const logs = owned((await this.options.fixture.log()).slice(this.requestStart), this.vars.tag);
237
239
  if (c.pre) {
238
240
  const url = address(c.pre);
239
241
  return logs.some((entry) => {
240
- const request = new URL(entry.url);
242
+ const request = parseURL(entry.url);
241
243
  return (
242
- request.pathname === '/live' &&
244
+ request?.pathname === '/live' &&
243
245
  request.searchParams.get('tag') === this.vars.tag &&
244
246
  request.searchParams.get('step') === 'pre' &&
245
247
  request.searchParams.get('to') === url
@@ -247,15 +249,16 @@ class Execution {
247
249
  });
248
250
  }
249
251
  const url = address(c.loaded);
252
+ const origin = parseURL(url)?.origin;
250
253
  return logs.some((entry) => {
251
- const request = new URL(entry.url);
254
+ if (entry.url === url || canonicalURL(entry.url) === canonicalURL(url)) return true;
255
+ const request = parseURL(entry.url);
252
256
  return (
253
- entry.url === url ||
254
- (request.pathname === '/live' &&
255
- request.origin === new URL(url).origin &&
256
- request.searchParams.get('tag') === this.vars.tag &&
257
- request.searchParams.get('to') === url &&
258
- request.searchParams.get('step') !== 'pre')
257
+ request?.pathname === '/live' &&
258
+ request.origin === origin &&
259
+ request.searchParams.get('tag') === this.vars.tag &&
260
+ request.searchParams.get('to') === url &&
261
+ request.searchParams.get('step') !== 'pre'
259
262
  );
260
263
  });
261
264
  }
@@ -664,6 +667,7 @@ class Execution {
664
667
  origin: this.vars.ordinary,
665
668
  tag: this.vars.tag,
666
669
  fixture: this.options.fixture,
670
+ browserSession: supported(['browserSession'], this.options.capabilities),
667
671
  })
668
672
  );
669
673
  if (step.extensionWorld)
@@ -1034,22 +1038,27 @@ class Execution {
1034
1038
  async requestCheck(c, start = this.requestStart) {
1035
1039
  const logs = (await this.options.fixture.log()).slice(start);
1036
1040
  if (c.hops)
1037
- return c.hops.every((_, i) => logs.some((entry) => entry.url === hop(c.hops.slice(i), c.to)));
1038
- return logs.some((entry) => {
1039
- const url = new URL(entry.url);
1041
+ return c.hops.every((_, i) =>
1042
+ logs.some((entry) => entry?.url === hop(c.hops.slice(i), c.to))
1043
+ );
1044
+ const mine =
1045
+ c.tagged === false
1046
+ ? logs.filter((entry) => typeof entry?.url === 'string')
1047
+ : owned(logs, this.vars.tag);
1048
+ return mine.some((entry) => {
1049
+ const url = parseURL(entry.url);
1040
1050
  if (c.beacon)
1041
1051
  return (
1042
- url.pathname === '/beacon' &&
1052
+ url?.pathname === '/beacon' &&
1043
1053
  url.searchParams.get('tag') === this.vars.tag &&
1044
1054
  url.searchParams.get('origin') === c.beacon
1045
1055
  );
1046
- if (c.tagged !== false && !entry.url.includes(this.vars.tag)) return false;
1047
- if (c.origin && url.origin !== c.origin) return false;
1056
+ if (c.origin && url?.origin !== c.origin) return false;
1048
1057
  if (c.served)
1049
- return c.served.startsWith('http') ? entry.url === c.served : url.pathname === c.served;
1058
+ return c.served.startsWith('http') ? entry.url === c.served : url?.pathname === c.served;
1050
1059
  return (
1051
- (c.on.startsWith('http') ? entry.url === c.on : url.pathname === c.on) &&
1052
- entry.headers[c.header.toLowerCase()] === (c.value ?? this.vars.tag)
1060
+ (c.on.startsWith('http') ? entry.url === c.on : url?.pathname === c.on) &&
1061
+ entry.headers?.[c.header.toLowerCase()] === (c.value ?? this.vars.tag)
1053
1062
  );
1054
1063
  });
1055
1064
  }
@@ -1086,7 +1095,10 @@ class Execution {
1086
1095
  await sleep(100);
1087
1096
  const observed = { ...this.refused };
1088
1097
  for (const [name, observation] of Object.entries(this.vector.observe ?? {})) {
1089
- if (!supported(observation.requires, this.options.capabilities)) continue;
1098
+ if (!supported(observation.requires, this.options.capabilities)) {
1099
+ observed[name] = 'not-supported';
1100
+ continue;
1101
+ }
1090
1102
  if ((observation.dependsOn ?? []).some((dep) => this.refused[dep])) continue;
1091
1103
  observed[name] = await this.observe(name, observation.check);
1092
1104
  if (observation.eitherWhen && this.side === 'guarded')
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "setVersion": "1.10.0",
3
+ "setVersion": "1.10.2",
4
4
  "sources": [
5
5
  {
6
6
  "repo": "ai-ecoverse/slicc-cli",
@@ -133,12 +133,10 @@
133
133
  "trigger": [
134
134
  {
135
135
  "unparseable": true,
136
- "as": "unparseable",
137
- "requires": ["browserSession"]
136
+ "as": "unparseable"
138
137
  }
139
138
  ],
140
139
  "targets": ["ordinary"],
141
- "requires": ["browserSession"],
142
140
  "observe": {
143
141
  "live": {
144
142
  "means": "unparseable URL protection live",
@@ -169,6 +167,7 @@
169
167
  },
170
168
  "listed": {
171
169
  "means": "unparseable URL protection listed",
170
+ "requires": ["browserSession"],
172
171
  "check": {
173
172
  "result": "unparseable",
174
173
  "path": "result.listed",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-ecoverse/slicc-browsers",
3
- "version": "1.10.0",
3
+ "version": "1.10.2",
4
4
  "description": "SLICC's shared browser catalog, JSON schemas and discovery conformance corpus",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -67,14 +67,14 @@
67
67
  "trust:dry": "npx fledgling sync --dry-run -y --skip-publish",
68
68
  "release": "semantic-release",
69
69
  "test:guard": "npm run test:guard-integration && npm run test:guard-sweep",
70
- "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-scales.test.mjs",
70
+ "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-scales.test.mjs test/integration/guard-shared-log.test.mjs",
71
71
  "test:guard-sweep": "node scripts/test-guards.mjs",
72
72
  "test:gates": "node --test test/integration/gates.test.mjs",
73
73
  "test:guard-target": "node --test test/integration/guard-runner.test.mjs",
74
74
  "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"
75
75
  },
76
76
  "devDependencies": {
77
- "@ai-ecoverse/slicc-shared-web": "1.25.2",
77
+ "@ai-ecoverse/slicc-shared-web": "1.27.0",
78
78
  "@biomejs/biome": "2.5.15",
79
79
  "ajv": "8.20.0",
80
80
  "ajv-formats": "3.0.1",
package/protected.mjs CHANGED
@@ -1,10 +1,4 @@
1
- const parse = (text) => {
2
- try {
3
- return new URL(text);
4
- } catch {
5
- return null;
6
- }
7
- };
1
+ import { parseURL as parse } from './guard-driver.mjs';
8
2
 
9
3
  const scheme = (u) => u.protocol.slice(0, -1);
10
4
 
@@ -430,7 +430,7 @@
430
430
  {
431
431
  "type": "object",
432
432
  "additionalProperties": false,
433
- "required": ["unparseable", "as", "requires"],
433
+ "required": ["unparseable", "as"],
434
434
  "properties": {
435
435
  "unparseable": {
436
436
  "const": true
@@ -439,11 +439,7 @@
439
439
  "$ref": "#/$defs/name"
440
440
  },
441
441
  "requires": {
442
- "$ref": "#/$defs/requires",
443
- "type": "array",
444
- "contains": {
445
- "const": "browserSession"
446
- }
442
+ "$ref": "#/$defs/requires"
447
443
  }
448
444
  }
449
445
  },
@@ -1,4 +1,4 @@
1
- import { outcome } from './guard-driver.mjs';
1
+ import { outcome, owned, parseURL } from './guard-driver.mjs';
2
2
 
3
3
  const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
4
4
  const refused = (out) => !out.ok && /^slicc: refused:/.test(out.error?.message ?? '');
@@ -10,7 +10,25 @@ const hosts = [
10
10
  ['xn--a.sliccy.ai', 'xn--a.sliccy.ai'],
11
11
  ];
12
12
 
13
+ export const rootURL = (root, host, path) =>
14
+ `${root.protocol}//${host}${root.port ? `:${root.port}` : ''}${path}`;
15
+
16
+ export function canonicalURL(value) {
17
+ if (typeof value !== 'string') return value;
18
+ const direct = parseURL(value);
19
+ if (direct) return direct.href;
20
+ const match = /^(https?:)\/\/([^/?#]+)(.*)$/.exec(value);
21
+ if (!match) return value;
22
+ const [, protocol, authority, path] = match;
23
+ const host = authority.replace(/:\d*$/, '').replaceAll(' ', '%20');
24
+ const port = /:(\d*)$/.exec(authority)?.[1];
25
+ const parsed = parseURL(`${protocol}//example.test${port ? `:${port}` : ''}${path}`);
26
+ if (!parsed) return value;
27
+ return rootURL(parsed, host, `${parsed.pathname}${parsed.search}${parsed.hash}`);
28
+ }
29
+
13
30
  async function document(reader, targetId, expected, allowNativeRejection = false) {
31
+ expected = canonicalURL(expected);
14
32
  const { sessionId } = await reader.send('Target.attachToTarget', { targetId, flatten: true });
15
33
  try {
16
34
  const deadline = Date.now() + 5000;
@@ -19,7 +37,7 @@ async function document(reader, targetId, expected, allowNativeRejection = false
19
37
  do {
20
38
  const { targetInfo } = await reader.send('Target.getTargetInfo', { targetId });
21
39
  targetURL = targetInfo.url;
22
- if (targetInfo.url !== expected) {
40
+ if (canonicalURL(targetInfo.url) !== expected) {
23
41
  last = { url: targetInfo.url, title: targetInfo.title };
24
42
  await pause(25);
25
43
  continue;
@@ -41,7 +59,7 @@ async function document(reader, targetId, expected, allowNativeRejection = false
41
59
  );
42
60
  const value = out.result?.result?.value;
43
61
  last = value ?? out;
44
- if (out.ok && value?.title === 'doc' && value.href === expected) return value;
62
+ if (out.ok && value?.title === 'doc' && canonicalURL(value.href) === expected) return value;
45
63
  await pause(25);
46
64
  } while (Date.now() < deadline);
47
65
  if (allowNativeRejection && ['', 'about:blank', 'about:blank#blocked'].includes(targetURL))
@@ -75,10 +93,10 @@ async function destinations({ client, reader, root, tag, targets, session, targe
75
93
  out.error?.code === -32000 && /invalid URL/i.test(out.error?.message ?? '');
76
94
  for (const [index, [input, canonical]] of hosts.entries()) {
77
95
  const strict = canonical === 'exa%20mple.sliccy.ai';
78
- const base = `${root.protocol}//${canonical}:${root.port}/doc?n=${tag}`;
96
+ const base = rootURL(root, canonical, `/doc?n=${tag}`);
79
97
  const created = await outcome(
80
98
  client.send('Target.createTarget', {
81
- url: `${root.protocol}//${input}:${root.port}/doc?n=${tag}-create-${index}`,
99
+ url: rootURL(root, input, `/doc?n=${tag}-create-${index}`),
82
100
  background: true,
83
101
  })
84
102
  );
@@ -95,7 +113,7 @@ async function destinations({ client, reader, root, tag, targets, session, targe
95
113
  const navigated = await outcome(
96
114
  client.send(
97
115
  'Page.navigate',
98
- { url: `${root.protocol}//${input}:${root.port}/doc?n=${tag}-navigate-${index}` },
116
+ { url: rootURL(root, input, `/doc?n=${tag}-navigate-${index}`) },
99
117
  session
100
118
  )
101
119
  );
@@ -116,7 +134,7 @@ async function destinations({ client, reader, root, tag, targets, session, targe
116
134
  await outcome(
117
135
  client.send(
118
136
  'Page.navigate',
119
- { url: `${root.origin}/doc?n=${tag}-ordinary-${index}` },
137
+ { url: rootURL(root, root.hostname, `/doc?n=${tag}-ordinary-${index}`) },
120
138
  session
121
139
  )
122
140
  );
@@ -124,7 +142,16 @@ async function destinations({ client, reader, root, tag, targets, session, targe
124
142
  return result;
125
143
  }
126
144
 
127
- export async function unparseableURLs({ client, reader, session, targetId, origin, tag, fixture }) {
145
+ export async function unparseableURLs({
146
+ client,
147
+ reader,
148
+ session,
149
+ targetId,
150
+ origin,
151
+ tag,
152
+ fixture,
153
+ browserSession,
154
+ }) {
128
155
  const root = new URL(origin);
129
156
  const cookie = `seven-unparseable-${tag}`;
130
157
  const value = `secret-${tag}`;
@@ -134,7 +161,7 @@ export async function unparseableURLs({ client, reader, session, targetId, origi
134
161
  await reader.send('Storage.setCookies', {
135
162
  cookies: [{ name: cookie, value, domain: '.sliccy.ai', path: '/', secure: false }],
136
163
  });
137
- const url = `${root.protocol}//exa%20mple.sliccy.ai:${root.port}/doc?n=${tag}-observer`;
164
+ const url = rootURL(root, 'exa%20mple.sliccy.ai', `/doc?n=${tag}-observer`);
138
165
  const { targetId: probeId } = await reader.send('Target.createTarget', {
139
166
  url,
140
167
  background: true,
@@ -143,7 +170,9 @@ export async function unparseableURLs({ client, reader, session, targetId, origi
143
170
  const ground = await document(reader, probeId, url);
144
171
  observer = (await reader.send('Target.attachToTarget', { targetId: probeId, flatten: true }))
145
172
  .sessionId;
146
- const listed = await outcome(client.send('Target.getTargets', {}));
173
+ const listed = browserSession
174
+ ? await outcome(client.send('Target.getTargets', {}))
175
+ : 'not-supported';
147
176
  const attached = await outcome(
148
177
  client.send('Target.attachToTarget', { targetId: probeId, flatten: true })
149
178
  );
@@ -168,7 +197,10 @@ export async function unparseableURLs({ client, reader, session, targetId, origi
168
197
  const result = {
169
198
  browserVersion: (await reader.send('Browser.getVersion', {})).product,
170
199
  live: ground.cookie.includes(`${cookie}=${value}`),
171
- listed: listed.ok && listed.result.targetInfos?.some((info) => info.targetId === probeId),
200
+ listed:
201
+ listed === 'not-supported'
202
+ ? listed
203
+ : listed.ok && listed.result.targetInfos?.some((info) => info.targetId === probeId),
172
204
  attached: attached.ok,
173
205
  attachRefused: refused(attached),
174
206
  readCookie,
@@ -180,7 +212,7 @@ export async function unparseableURLs({ client, reader, session, targetId, origi
180
212
  };
181
213
  await pause(100);
182
214
  const requests = await fixture.log();
183
- result.cookieRequest = requests.some((entry) =>
215
+ result.cookieRequest = owned(requests, tag).some((entry) =>
184
216
  entry.url.includes(`${tag}-create-`) || entry.url.includes(`${tag}-navigate-`)
185
217
  ? entry.headers.cookie?.includes(`${cookie}=${value}`)
186
218
  : false