@ai-ecoverse/slicc-browsers 1.13.0 → 1.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -67,6 +67,8 @@ Every feature builds for `wasm32-wasip1`.
67
67
 
68
68
  `protected.mjs` is the reference matcher for [protected origins](#protected-origins): `import { protectedOrigins } from '@ai-ecoverse/slicc-browsers/protected.mjs'`, then `protectedOrigins(catalog.protectedOrigins, extraOrigins)` returns `{ url(text), target({ url, previous, opener, type, documentURL, unreachableURL, navigating }), cookieDomain(domain), storageKey(key), unparseable(text) }`, each a boolean. `url(text)` uses the load/loaded-URL fail-closed rule; `url(text, { readOnly: true })` preserves the old parse-failure result for read-only keys and value redaction, and `storageKey(key)` uses that mode internally. `unparseable(text)` identifies parse or unwrap failures without changing whether a parsed origin is protected. It needs only the WHATWG `URL`, so it runs in Node and in a browser. `protected.mjs` and `seven.mjs` import nothing and re-export nothing, so an extension or service worker can ship either file alone; `npm run validate` (through `scripts/self-contained.mjs`) evaluates each as a module in a fresh `node:vm` context that has only `URL`, with no linker resolution allowed.
69
69
 
70
+ The complete static import graph of `guard-runner.mjs` is browser-safe too: it uses ordinary browser globals (`URL`, timers, `fetch`, `atob`/`btoa`, `Uint8Array`, `TextEncoder` and `TextDecoder`), never `Buffer`, `process`, `require` or `node:` imports. Base64 bodies and postData are decoded with `atob` and UTF-8 bytes rather than a Node shim. `npm run validate` loads and executes that graph in a fresh `node:vm` browser-like context, including an encoded-body exercise and a drivable page row. The separate host-side `./fixture` server intentionally uses Node HTTP/TLS APIs and is not imported by the browser runner.
71
+
70
72
  ## `catalog.json`
71
73
 
72
74
  `"schema": 1`. A minor release only adds fields; a field that changes meaning or goes away needs schema 2 and a major release. Relative paths are arrays of path segments, joined with the platform's separator. Top-level fields:
@@ -209,7 +211,7 @@ A client may register `Fetch.enable` patterns naming a protected origin in eithe
209
211
  - `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.
210
212
  - `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.
211
213
  - `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.
212
- - `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; `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`.
214
+ - `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`.
213
215
  - `placeholders`: every `{name}` used in the vectors, with what it stands for. `{client}` is the client page's origin, which the runner maps.
214
216
 
215
217
  **A vector** has:
@@ -259,9 +261,13 @@ Popups are observed by `Target.targetCreated` with the opener's `openerId`, or b
259
261
 
260
262
  `{ "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.
261
263
 
262
- `{ "errorPage": "recover", "secret"?: "all" | "query" | "fragment", "as": "errorPage" }` (also modes `restore` and `sticky`; `secret` defaults to `all`) runs the protected-error-page compound probe with independent commit evidence, discovery redaction and recovery or sticky-lock checks. The unguarded observer seeds the protected history and failed commit, so the rows run on page-session-only transports too. The guarded client uses only `Target.getTargetInfo` with a `targetId`, `Target.getTargets`, `Target.setDiscoverTargets`, `Target.attachToTarget`, `Target.detachFromTarget` and `Target.closeTarget` without a session, which page-session transports answer themselves, and everything else on its page session; the rows require no capability. The runner leaves this probe’s independent Fetch sessions to the probe rather than answering their pauses itself.
264
+ `{ "errorPage": "recover", "secret"?: "all" | "query" | "fragment", "as": "errorPage" }` (also modes `restore` and `sticky`; `secret` defaults to `all`) runs the protected-error-page compound probe with independent commit evidence, discovery redaction and recovery or sticky-lock checks. The unguarded observer seeds the protected history and failed commit, so the rows run on page-session-only transports too. The guarded client uses only `Target.getTargetInfo` with a `targetId`, `Target.getTargets`, `Target.setDiscoverTargets`, `Target.attachToTarget`, `Target.detachFromTarget` and `Target.closeTarget` without a session, which page-session transports answer themselves, and everything else on its page session; the rows require no whole-vector capability. Only `eventsRedacted` requires `discovery`: without it the probe does not call `Target.setDiscoverTargets`, that one field is `not-supported` in both legs, and the notes say `not-supported:discovery`; info, list, attach, recovery and lock checks still run. Discovery absence is structural: a guarded transport delivering either `Target.targetCreated` or `Target.targetInfoChanged` during such a row without declaring `discovery` fails the row, even when the event names an ordinary target. `Target.attachedToTarget` remains independently checked by `attachRedacted`. The runner leaves this probe’s independent Fetch sessions to the probe rather than answering their pauses itself.
265
+
266
+ `{ "redirectSecret": "headers" | "mix" | "body", "as": "redirect" }` runs a C2 compound probe and owns its Fetch pauses. The client must expose `messages`, an append-only array of every raw received CDP text frame, recorded before parsing; an events-only or reply-only tap fails `rawRecorded`. Whenever a run includes a `C2.redirect-*` row or a `redirectSecret` step, `run()` retains an explicit `fixture.redirectSecret` hook or automatically installs a fetch-only `POST /redirect` bridge. Its base is `fixtureBase`, then `fixture.origin`, then `targets.ordinary`; a browser-page alias or reverse proxy must forward that endpoint to the bundled fixture. The bundled `fixtureServer()` always supplies both its direct hook and HTTP control endpoint, so no custom registration method is needed. Missing, unreachable or non-successful registration is an explicit row/summary error containing `redirectSecret fixture registration required`, never a pass or a skipped observation. Fixture logs must include headers for the cookie check. The guard’s events and raw messages must represent the same delivered stream.
263
267
 
264
- `{ "redirectSecret": "headers" | "mix" | "body", "as": "redirect" }` runs a C2 compound probe and owns its Fetch pauses. The client must expose `messages`, an append-only array of every raw received CDP text frame, recorded before parsing; an events-only or reply-only tap fails `rawRecorded`. The fixture must implement `redirectSecret({ path, host?, search?, to?, code?, body, remove? })`: registering an allowed hop without putting the protected marker in the initiating URL, and registering the callback body on the protected host. `fixtureServer()` supplies it. Fixture logs must include headers for the cookie check. The guard’s events and raw messages must represent the same delivered stream.
268
+ **Host-harness requirements for every guard.** Host Node processes may use `fixtureServer()` from `./fixture`; browser pages must import only the browser-safe runner graph, served with JavaScript MIME types, and need no Node-global shim. All guards supply a guarded client, an independent unguarded observer, mapped fixture origins and the relevant advertised capabilities. C2 clients additionally record every received raw CDP frame in `client.messages` before parsing. Route protected seven/vault hosts to the fixture with `--host-resolver-rules`, including default-port callback URLs; a live protected-body negative needs HTTPS fixture trust (the reference harness pins its SPKI).
269
+
270
+ For remote/page harnesses, `fixtureClient(base)` is exported by `./guard-runner` and `./fixture-client`, or `run(set, { fixtureBase: base, ... })` constructs it. The bundled server serves CORS-safe `GET /log?since=N` as `{ from: N, entries: [...] }`, with an append-only history unaffected by `fixture.reset()`, and `POST /redirect` with a JSON-encoded string body (a simple text/plain request, without a preflight). Control requests do not appear in the log. Registration descriptors are `{ path, host?, search?, to?, code?, body, remove? }`: `path` starts with `/`, `host` selects the callback hostname, `search` distinguishes query-bearing callbacks, `to` creates a redirect with the four Location/Content-Location/Refresh/Link headers, `code` defaults to 302, an omitted `to` serves the callback body, and `remove: true` unregisters it. The bundled endpoint returns 204; custom servers must acknowledge with a successful status and actually serve the registered routes. Custom fixtures may instead supply an async `fixture.redirectSecret` hook directly; a separate log/control server can implement the same `GET /log` and `POST /redirect` contract. A Node harness using browser-only DNS aliases should pass a reachable `fixtureBase` or its direct hook.
265
271
 
266
272
  `{ "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.
267
273
 
@@ -374,6 +380,8 @@ every alternative of an `any`, or a required (`all`, `not` or plain) check, need
374
380
  selects one variant or an array; omission selects all. `parallel` defaults to 1.
375
381
  With the `browserContexts` capability each leg runs in its own browser context. A `Target.createBrowserContext` that fails with a timeout is retried once, and the row's `notes` records it; any other failure fails the row.
376
382
 
383
+ Every target created by the runner’s own unguarded observer uses `Target.createTarget` with `newWindow: true`, then verifies through `Target.getTargetInfo` that `openerId` is absent. A supplied opener or missing target evidence fails and cleans up that target, rather than inheriting the active client tab (which could be seven). This includes ordinary control setup, storage canaries, protected-error history seeds and the malformed-URL observer. The row notes record `newWindow=true, openerId absent`. Intentional guarded-client creation commands and popup/opener tests keep their original parameters.
384
+
377
385
  `settleScale` (default 1) multiplies the bounds of waits for events the leg expects to happen: settles (beacon, served, title and load) on the control leg and `required` settles on either leg, `paused` on the control leg, page and child loads, the wait for a background step's `as` result (`<as> did not complete within <ms>ms`), the wait for the pre-action beacons, the C9 control's `Debugger.paused`, and the wait for an observation the leg expects to be `true`. Adopters on slow hosts can set 3 or 4. Every wait polls and returns as soon as its event arrives, so a larger bound only costs time when something is really missing, and each timeout error names the bound it used. An observation read from the fixture (`observer: "fixture"`, such as a beacon or a served request) that the leg expects to be `true` is polled for up to 5000 ms (scaled) instead of read once. One that the leg expects to be `false` is still read once.
378
386
 
379
387
  `refusalScale` (default 1) multiplies the bounds of waits for what the guarded leg expects to be absent, the beacon, request, title or navigation that the guard should block: settles that are not `required`, and `paused`, on the guarded leg. Such a wait runs to its full bound whenever the guard works, so `settleScale` leaves it alone. The trade-off: a larger `refusalScale` gives more sensitivity to a leak that arrives late on a slow host, because the observations after the wait see it, at the cost of that much more time per guarded row. Adopters should keep `refusalScale` at 1 or higher, never below 1; the runner refuses a `refusalScale` below 1 with `refusalScale must be a number of at least 1`.
@@ -499,6 +507,7 @@ const capabilities = {
499
507
  browserSession: true,
500
508
  browserContexts: true,
501
509
  chromeUrlsAttach: true,
510
+ discovery: true,
502
511
  };
503
512
  const execution = await run(set, {
504
513
  clientFactory: (metadata) => connectGuardAndObserver(metadata),
@@ -0,0 +1,51 @@
1
+ export function fixtureClient(base, { fetch: request = globalThis.fetch } = {}) {
2
+ const entries = [];
3
+ let pending;
4
+ return {
5
+ log: async () => {
6
+ if (!pending)
7
+ pending = (async () => {
8
+ const response = await request(new URL(`/log?since=${entries.length}`, base));
9
+ if (!response.ok) throw new Error(`fixture.log: HTTP ${response.status}`);
10
+ const batch = await response.json();
11
+ if (batch.from !== entries.length || !Array.isArray(batch.entries))
12
+ throw new Error('fixture.log: invalid append-only log response');
13
+ entries.push(...batch.entries);
14
+ return [...entries];
15
+ })().finally(() => {
16
+ pending = undefined;
17
+ });
18
+ return pending;
19
+ },
20
+ redirectSecret: async (registration) => {
21
+ try {
22
+ const response = await request(new URL('/redirect', base), {
23
+ method: 'POST',
24
+ body: JSON.stringify(registration),
25
+ });
26
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
27
+ } catch (error) {
28
+ throw new Error(
29
+ `redirectSecret fixture registration required: POST /redirect failed: ${error.message}`
30
+ );
31
+ }
32
+ },
33
+ };
34
+ }
35
+
36
+ export function prepareFixture(set, options) {
37
+ const fixture =
38
+ options.fixture ?? (options.fixtureBase ? fixtureClient(options.fixtureBase) : undefined);
39
+ const needsRedirect = set.vectors.some(
40
+ (vector) =>
41
+ vector.id.startsWith('C2.redirect') || vector.trigger?.some((step) => step.redirectSecret)
42
+ );
43
+ if (!needsRedirect || typeof fixture?.redirectSecret === 'function' || !fixture?.log)
44
+ return fixture;
45
+ const control = fixtureClient(options.fixtureBase ?? fixture.origin ?? options.targets?.ordinary);
46
+ return {
47
+ ...fixture,
48
+ log: (...args) => fixture.log(...args),
49
+ redirectSecret: control.redirectSecret,
50
+ };
51
+ }
@@ -108,16 +108,22 @@ function requestURL(req, scheme) {
108
108
 
109
109
  export async function fixtureServer({ host = '127.0.0.1', port = 0, tls, latency } = {}) {
110
110
  let entries = [];
111
+ const history = [];
111
112
  const redirects = new Map();
113
+ const redirectSecret = async ({ path, host = '', search = '', remove, ...value }) => {
114
+ if (
115
+ typeof path !== 'string' ||
116
+ !path.startsWith('/') ||
117
+ (!remove && typeof value.body !== 'string')
118
+ )
119
+ throw new Error('invalid redirect registration');
120
+ const key = `${host}${path}${search}`;
121
+ if (remove) redirects.delete(key);
122
+ else redirects.set(key, value);
123
+ };
112
124
  const scheme = tls ? 'https' : 'http';
113
- const handler = (req, res) => {
125
+ const handler = async (req, res) => {
114
126
  const url = requestURL(req, scheme);
115
- entries.push({
116
- url: url.href,
117
- method: req.method,
118
- headers: { ...req.headers },
119
- time: Date.now(),
120
- });
121
127
  const p = url.searchParams;
122
128
  const cors = {
123
129
  'access-control-allow-origin': req.headers.origin ?? '*',
@@ -136,6 +142,30 @@ export async function fixtureServer({ host = '127.0.0.1', port = 0, tls, latency
136
142
  'text/html',
137
143
  headers
138
144
  );
145
+ if (req.method === 'GET' && url.pathname === '/log') {
146
+ const from = Number(p.get('since') ?? 0);
147
+ if (!Number.isInteger(from) || from < 0 || from > history.length)
148
+ return send(400, 'invalid log cursor', 'text/plain');
149
+ return send(200, JSON.stringify({ from, entries: history.slice(from) }), 'application/json');
150
+ }
151
+ if (req.method === 'POST' && url.pathname === '/redirect') {
152
+ try {
153
+ const chunks = [];
154
+ for await (const chunk of req) chunks.push(chunk);
155
+ await redirectSecret(JSON.parse(Buffer.concat(chunks).toString('utf8')));
156
+ return send(204, '', 'text/plain');
157
+ } catch {
158
+ return send(400, 'invalid redirect registration', 'text/plain');
159
+ }
160
+ }
161
+ const entry = {
162
+ url: url.href,
163
+ method: req.method,
164
+ headers: { ...req.headers },
165
+ time: Date.now(),
166
+ };
167
+ entries.push(entry);
168
+ history.push(entry);
139
169
  const wait = latency?.(url) ?? 0;
140
170
  if (wait > 0) setTimeout(() => route(req, url, send, html, redirects), wait);
141
171
  else route(req, url, send, html, redirects);
@@ -148,11 +178,7 @@ export async function fixtureServer({ host = '127.0.0.1', port = 0, tls, latency
148
178
  const address = server.address();
149
179
  return {
150
180
  redirects,
151
- redirectSecret: async ({ path, host = '', search = '', remove, ...value }) => {
152
- const key = `${host}${path}${search}`;
153
- if (remove) redirects.delete(key);
154
- else redirects.set(key, value);
155
- },
181
+ redirectSecret,
156
182
  port: address.port,
157
183
  origin: `${scheme}://${host}:${address.port}`,
158
184
  log: () => structuredClone(entries),
package/guard-runner.mjs CHANGED
@@ -1,3 +1,7 @@
1
+ import { fixtureClient, prepareFixture } from './fixture-client.mjs';
2
+
3
+ export { fixtureClient };
4
+
1
5
  import { extensionWorld } from './extension-world.mjs';
2
6
  import {
3
7
  beacon,
@@ -13,6 +17,7 @@ import {
13
17
  result,
14
18
  steps,
15
19
  } from './guard-driver.mjs';
20
+ import { createObserverTarget, discoveryViolation } from './observer-target.mjs';
16
21
  import { protectedErrorPage } from './protected-error-page.mjs';
17
22
  import { redirectSecret } from './redirect-secret.mjs';
18
23
  import { canonicalURL, unparseableURLs } from './unparseable-url.mjs';
@@ -153,11 +158,15 @@ class Execution {
153
158
  }
154
159
 
155
160
  async create(url, context = this.vars.context, client = this.client, background = true) {
156
- const { targetId } = await client.send('Target.createTarget', {
161
+ const params = {
157
162
  url: 'about:blank',
158
163
  background,
159
164
  ...(context ? { browserContextId: context } : {}),
160
- });
165
+ };
166
+ const { targetId } =
167
+ client === this.reader
168
+ ? await createObserverTarget(this.reader, params, this.notes)
169
+ : await client.send('Target.createTarget', params);
161
170
  this.targets.push(targetId);
162
171
  this.pageTarget = targetId;
163
172
  this.vars.pageTarget = targetId;
@@ -200,10 +209,14 @@ class Execution {
200
209
  ...(this.vars.context ? { browserContextId: this.vars.context } : {}),
201
210
  });
202
211
  if (!state.storage) return;
203
- const { targetId } = await this.reader.send('Target.createTarget', {
204
- url: 'about:blank',
205
- ...(this.vars.context ? { browserContextId: this.vars.context } : {}),
206
- });
212
+ const { targetId } = await createObserverTarget(
213
+ this.reader,
214
+ {
215
+ url: 'about:blank',
216
+ ...(this.vars.context ? { browserContextId: this.vars.context } : {}),
217
+ },
218
+ this.notes
219
+ );
207
220
  this.canaryTarget = targetId;
208
221
  this.targets.push(targetId);
209
222
  const { sessionId } = await this.reader.send('Target.attachToTarget', {
@@ -711,6 +724,8 @@ class Execution {
711
724
  mode: step.errorPage,
712
725
  side: this.side,
713
726
  secret: step.secret,
727
+ discovery: supported(['discovery'], this.options.capabilities),
728
+ notes: this.notes,
714
729
  })
715
730
  );
716
731
  if (step.unparseable)
@@ -724,6 +739,7 @@ class Execution {
724
739
  tag: this.vars.tag,
725
740
  fixture: this.options.fixture,
726
741
  browserSession: supported(['browserSession'], this.options.capabilities),
742
+ notes: this.notes,
727
743
  })
728
744
  );
729
745
  if (step.extensionWorld)
@@ -1219,6 +1235,16 @@ class Execution {
1219
1235
  (this.reader ?? this.client).send('Target.disposeBrowserContext', { browserContextId })
1220
1236
  );
1221
1237
  await Promise.allSettled(this.pending);
1238
+ if (
1239
+ this.side === 'guarded' &&
1240
+ this.vector.observe?.eventsRedacted?.requires?.includes('discovery')
1241
+ ) {
1242
+ const violation = discoveryViolation(
1243
+ this.client.events.slice(this.eventStart),
1244
+ supported(['discovery'], this.options.capabilities)
1245
+ );
1246
+ if (violation) this.errors.push(violation);
1247
+ }
1222
1248
  }
1223
1249
  }
1224
1250
  }
@@ -1613,6 +1639,7 @@ export function run(set, options) {
1613
1639
 
1614
1640
  async function runAll(set, options, flights) {
1615
1641
  const opts = { capabilities: {}, variant: Object.keys(set.variants), parallel: 1, ...options };
1642
+ opts.fixture = prepareFixture(set, opts);
1616
1643
  opts.targets = {
1617
1644
  ...Object.fromEntries(
1618
1645
  Object.entries(set.targets ?? {})
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "setVersion": "1.13.0",
3
+ "setVersion": "1.13.1",
4
4
  "sources": [
5
5
  {
6
6
  "repo": "ai-ecoverse/slicc-cli",
@@ -91,6 +91,7 @@
91
91
  "browserSession": "CDP calls on the browser session",
92
92
  "browserContexts": "Target.createBrowserContext and Target.disposeBrowserContext",
93
93
  "extensionWorld": "a browser hosting a foreign content-script extension isolated world on the ordinary fixture page",
94
+ "discovery": "session-less Target.setDiscoverTargets and targetCreated/targetInfoChanged delivery; absent transports must emit neither event",
94
95
  "chromeUrlsAttach": "attaching to and driving chrome:// pages, which CDP over a DevTools port can and chrome.debugger can't"
95
96
  },
96
97
  "placeholders": {
@@ -820,6 +821,7 @@
820
821
  },
821
822
  "eventsRedacted": {
822
823
  "means": "protected error-page recover: eventsRedacted",
824
+ "requires": ["discovery"],
823
825
  "check": {
824
826
  "result": "errorPage",
825
827
  "path": "result.eventsRedacted",
@@ -968,6 +970,7 @@
968
970
  },
969
971
  "eventsRedacted": {
970
972
  "means": "protected error-page recover (query): eventsRedacted",
973
+ "requires": ["discovery"],
971
974
  "check": {
972
975
  "result": "errorPage",
973
976
  "path": "result.eventsRedacted",
@@ -1116,6 +1119,7 @@
1116
1119
  },
1117
1120
  "eventsRedacted": {
1118
1121
  "means": "protected error-page recover (fragment): eventsRedacted",
1122
+ "requires": ["discovery"],
1119
1123
  "check": {
1120
1124
  "result": "errorPage",
1121
1125
  "path": "result.eventsRedacted",
@@ -1263,6 +1267,7 @@
1263
1267
  },
1264
1268
  "eventsRedacted": {
1265
1269
  "means": "protected error-page restore: eventsRedacted",
1270
+ "requires": ["discovery"],
1266
1271
  "check": {
1267
1272
  "result": "errorPage",
1268
1273
  "path": "result.eventsRedacted",
@@ -1443,6 +1448,7 @@
1443
1448
  },
1444
1449
  "eventsRedacted": {
1445
1450
  "means": "protected error-page sticky: eventsRedacted",
1451
+ "requires": ["discovery"],
1446
1452
  "check": {
1447
1453
  "result": "errorPage",
1448
1454
  "path": "result.eventsRedacted",
@@ -1635,6 +1641,7 @@
1635
1641
  },
1636
1642
  "eventsRedacted": {
1637
1643
  "means": "protected error-page sticky (query): eventsRedacted",
1644
+ "requires": ["discovery"],
1638
1645
  "check": {
1639
1646
  "result": "errorPage",
1640
1647
  "path": "result.eventsRedacted",
@@ -1827,6 +1834,7 @@
1827
1834
  },
1828
1835
  "eventsRedacted": {
1829
1836
  "means": "protected error-page sticky (fragment): eventsRedacted",
1837
+ "requires": ["discovery"],
1830
1838
  "check": {
1831
1839
  "result": "errorPage",
1832
1840
  "path": "result.eventsRedacted",
@@ -0,0 +1,23 @@
1
+ import { outcome } from './guard-driver.mjs';
2
+
3
+ export async function createObserverTarget(reader, params, notes = []) {
4
+ const result = await reader.send('Target.createTarget', { ...params, newWindow: true });
5
+ try {
6
+ const { targetInfo } = await reader.send('Target.getTargetInfo', { targetId: result.targetId });
7
+ if (!targetInfo || Object.hasOwn(targetInfo, 'openerId'))
8
+ throw new Error('observer target must have no openerId');
9
+ notes.push(`observer target ${result.targetId}: newWindow=true, openerId absent`);
10
+ return result;
11
+ } catch (error) {
12
+ await outcome(reader.send('Target.closeTarget', { targetId: result.targetId }));
13
+ throw error;
14
+ }
15
+ }
16
+
17
+ export function discoveryViolation(events, declared) {
18
+ if (declared) return null;
19
+ const event = events.find((entry) =>
20
+ ['Target.targetCreated', 'Target.targetInfoChanged'].includes(entry.method)
21
+ );
22
+ return event ? `discovery not declared, but transport delivered ${event.method}` : null;
23
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-ecoverse/slicc-browsers",
3
- "version": "1.13.0",
3
+ "version": "1.13.1",
4
4
  "description": "SLICC's shared browser catalog, JSON schemas and discovery conformance corpus",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -38,7 +38,8 @@
38
38
  "./package.json": "./package.json",
39
39
  "./guard-runner.mjs": "./guard-runner.mjs",
40
40
  "./guard-runner": "./guard-runner.mjs",
41
- "./fixture": "./fixture-server.mjs"
41
+ "./fixture": "./fixture-server.mjs",
42
+ "./fixture-client": "./fixture-client.mjs"
42
43
  },
43
44
  "files": [
44
45
  "catalog.json",
@@ -55,7 +56,9 @@
55
56
  "unparseable-url.mjs",
56
57
  "protected-error-page.mjs",
57
58
  "redirect-secret.mjs",
58
- "redirect-fragments.mjs"
59
+ "redirect-fragments.mjs",
60
+ "observer-target.mjs",
61
+ "fixture-client.mjs"
59
62
  ],
60
63
  "scripts": {
61
64
  "prepare": "lefthook install",
@@ -63,22 +66,22 @@
63
66
  "validate": "node scripts/validate.mjs",
64
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",
65
68
  "format": "biome check --write . && gofmt -w . && cargo fmt",
66
- "test": "npm run validate && node --test test/integration/self-contained.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 && go test -race -count=1 ./test/integration/... && cargo test --all-features",
67
70
  "test:unit": "go test -race -cover ./...",
68
71
  "build": "go build ./... && cargo build --all-features && cargo build --target wasm32-wasip1 --all-features && npm pack --dry-run",
69
72
  "trust": "npx fledgling sync -y --skip-publish",
70
73
  "trust:dry": "npx fledgling sync --dry-run -y --skip-publish",
71
74
  "release": "semantic-release",
72
75
  "test:guard": "npm run test:guard-integration && npm run test:guard-sweep",
73
- "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-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/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/self-contained.test.mjs",
74
77
  "test:guard-sweep": "node scripts/test-guards.mjs",
75
78
  "test:gates": "node --test test/integration/gates.test.mjs",
76
79
  "test:guard-target": "node --test test/integration/guard-runner.test.mjs",
77
80
  "test:unit:redirect": "mkdir -p coverage/unit && node --experimental-test-coverage --test --test-coverage-include=redirect-secret.mjs --test-coverage-include=redirect-fragments.mjs --test-reporter=lcov --test-reporter-destination=coverage/unit/redirect.lcov test/unit/guard-redirect-*.test.mjs && slicc-diff-cover coverage/unit/redirect.lcov",
78
- "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"
81
+ "test:unit:guard": "mkdir -p coverage/unit && node --experimental-vm-modules --no-warnings --experimental-test-coverage --test --test-coverage-include=guard-runner.mjs --test-coverage-include=fixture-server.mjs --test-coverage-include=fixture-client.mjs --test-coverage-include=observer-target.mjs --test-coverage-include=protected-error-page.mjs --test-coverage-include=unparseable-url.mjs --test-coverage-include=scripts/browser-runner.mjs --test-coverage-include=scripts/validate.mjs --test-reporter=lcov --test-reporter-destination=coverage/unit/guard.lcov test/unit/guard-*.test.mjs && slicc-diff-cover coverage/unit/guard.lcov"
79
82
  },
80
83
  "devDependencies": {
81
- "@ai-ecoverse/slicc-shared-web": "2.0.0",
84
+ "@ai-ecoverse/slicc-shared-web": "2.0.2",
82
85
  "@biomejs/biome": "2.5.15",
83
86
  "ajv": "8.20.0",
84
87
  "ajv-formats": "3.0.1",
@@ -1,4 +1,5 @@
1
1
  import { outcome } from './guard-driver.mjs';
2
+ import { createObserverTarget } from './observer-target.mjs';
2
3
 
3
4
  const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
4
5
  const refused = (out) => !out.ok && /^slicc: refused:/.test(out.error?.message ?? '');
@@ -43,7 +44,15 @@ async function lockChecks({ client, session, targetId, result }) {
43
44
  result.closeRefused = refused(await outcome(client.send('Target.closeTarget', { targetId })));
44
45
  }
45
46
 
46
- async function redaction({ client, targetId, failedURL, protectedOrigin, start, result }) {
47
+ async function redaction({
48
+ client,
49
+ targetId,
50
+ failedURL,
51
+ protectedOrigin,
52
+ start,
53
+ result,
54
+ discovery,
55
+ }) {
47
56
  const info = await outcome(client.send('Target.getTargetInfo', { targetId }));
48
57
  const list = await outcome(client.send('Target.getTargets'));
49
58
  const before = client.events.length;
@@ -71,11 +80,13 @@ async function redaction({ client, targetId, failedURL, protectedOrigin, start,
71
80
  .slice(start)
72
81
  .filter(
73
82
  (e) =>
83
+ ['Target.targetCreated', 'Target.targetInfoChanged'].includes(e.method) &&
74
84
  e.params?.targetInfo?.targetId === targetId &&
75
85
  e.params.targetInfo.url.includes(new URL(protectedOrigin).host)
76
86
  );
77
- result.eventsRedacted =
78
- targetEvents.length > 0 && targetEvents.every((e) => only(e.params.targetInfo));
87
+ result.eventsRedacted = discovery
88
+ ? targetEvents.length > 0 && targetEvents.every((e) => only(e.params.targetInfo))
89
+ : 'not-supported';
79
90
  }
80
91
 
81
92
  const secrets = {
@@ -94,12 +105,19 @@ export async function protectedErrorPage({
94
105
  side,
95
106
  internalSessions = new Set(),
96
107
  secret = 'all',
108
+ discovery = true,
109
+ notes = [],
97
110
  }) {
98
111
  const initial = `${protectedOrigin}/doc?n=${tag}-history`;
99
112
  const failedURL = `${protectedOrigin}${secrets[secret](tag)}`;
100
113
  const result = {};
114
+ if (!discovery) notes.push('eventsRedacted: not-supported:discovery');
101
115
  const start = client.events.length;
102
- const { targetId } = await reader.send('Target.createTarget', { url: initial, background: true });
116
+ const { targetId } = await createObserverTarget(
117
+ reader,
118
+ { url: initial, background: true },
119
+ notes
120
+ );
103
121
  let session;
104
122
  let observer;
105
123
  let failing = false;
@@ -142,7 +160,7 @@ export async function protectedErrorPage({
142
160
  if (!session) return { live: false, attached: false };
143
161
  await client.send('Page.enable', {}, session);
144
162
  await client.send('Runtime.enable', {}, session);
145
- await client.send('Target.setDiscoverTargets', { discover: true });
163
+ if (discovery) await client.send('Target.setDiscoverTargets', { discover: true });
146
164
  if (side === 'control') {
147
165
  failing = true;
148
166
  await reader.send(
@@ -172,7 +190,7 @@ export async function protectedErrorPage({
172
190
  throw new Error(
173
191
  `protected-error-page: missing error commit ${JSON.stringify(result.evidence)}`
174
192
  );
175
- await redaction({ client, targetId, failedURL, protectedOrigin, start, result });
193
+ await redaction({ client, targetId, failedURL, protectedOrigin, start, result, discovery });
176
194
  const beforeReload = (await frame()).loaderId;
177
195
  result.reload = (await outcome(client.send('Page.reload', {}, session))).ok;
178
196
  if (result.reload) {
@@ -2,6 +2,23 @@ import { load, outcome } from './guard-driver.mjs';
2
2
  import { redirectFragments } from './redirect-fragments.mjs';
3
3
 
4
4
  const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
5
+ const decodeBase64 = (text) =>
6
+ new TextDecoder().decode(Uint8Array.from(atob(text), (char) => char.charCodeAt(0)));
7
+ const base64Slices = (value) => {
8
+ const normalized = value.replaceAll('-', '+').replaceAll('_', '/').replace(/=+$/, '');
9
+ const candidates = [];
10
+ for (let offset = 0; offset < 4; offset++) {
11
+ const part = normalized.slice(offset);
12
+ const usable = part.slice(0, part.length - (part.length % 4 === 1 ? 1 : 0));
13
+ candidates.push(usable.padEnd(Math.ceil(usable.length / 4) * 4, '='));
14
+ candidates.push(part.slice(0, Math.floor(part.length / 4) * 4));
15
+ }
16
+ return [...new Set(candidates)].filter(Boolean);
17
+ };
18
+ const decodedBase64 = (value) =>
19
+ [...value.matchAll(/[A-Za-z0-9+/_-]{12,}={0,2}/g)].flatMap(([text]) =>
20
+ base64Slices(text).map(decodeBase64)
21
+ );
5
22
  const refused = (out) => !out.ok && /^slicc: refused:/.test(out.error?.message ?? '');
6
23
  const redirects = [301, 302, 303, 307, 308];
7
24
  const redirectHeaders = (to) => ({
@@ -45,8 +62,7 @@ export function containsSecret(value, marker) {
45
62
  try {
46
63
  decoded.push(JSON.parse(`"${value}"`));
47
64
  } catch {}
48
- for (const match of value.matchAll(/[A-Za-z0-9+/]{12,}={0,2}/g))
49
- decoded.push(Buffer.from(match[0], 'base64').toString('utf8'));
65
+ decoded.push(...decodedBase64(value));
50
66
  return decoded.some((text) => text !== value && visit(text, depth + 1));
51
67
  }
52
68
  if (value && typeof value === 'object')
@@ -212,7 +228,7 @@ class RedirectProbe {
212
228
  for (let i = 0; i < 32; i++) {
213
229
  const read = await this.send('IO.read', { handle });
214
230
  text += read.result?.base64Encoded
215
- ? Buffer.from(read.result.data, 'base64').toString()
231
+ ? decodeBase64(read.result.data)
216
232
  : (read.result?.data ?? '');
217
233
  if (!read.ok || read.result.eof) break;
218
234
  }
@@ -86,7 +86,8 @@
86
86
  "browserSession",
87
87
  "browserContexts",
88
88
  "chromeUrlsAttach",
89
- "extensionWorld"
89
+ "extensionWorld",
90
+ "discovery"
90
91
  ],
91
92
  "properties": {
92
93
  "autoAttach": {
@@ -102,6 +103,7 @@
102
103
  "minLength": 1
103
104
  },
104
105
  "extensionWorld": { "type": "string", "minLength": 1 },
106
+ "discovery": { "type": "string", "minLength": 1 },
105
107
  "chromeUrlsAttach": {
106
108
  "type": "string",
107
109
  "minLength": 1
@@ -133,7 +135,8 @@
133
135
  "browserSession",
134
136
  "browserContexts",
135
137
  "chromeUrlsAttach",
136
- "extensionWorld"
138
+ "extensionWorld",
139
+ "discovery"
137
140
  ]
138
141
  },
139
142
  "requires": {
@@ -1,4 +1,5 @@
1
1
  import { outcome, owned, parseURL } from './guard-driver.mjs';
2
+ import { createObserverTarget } from './observer-target.mjs';
2
3
 
3
4
  const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
4
5
  const refused = (out) => !out.ok && /^slicc: refused:/.test(out.error?.message ?? '');
@@ -151,6 +152,7 @@ export async function unparseableURLs({
151
152
  tag,
152
153
  fixture,
153
154
  browserSession,
155
+ notes = [],
154
156
  }) {
155
157
  const root = new URL(origin);
156
158
  const cookie = `seven-unparseable-${tag}`;
@@ -162,10 +164,14 @@ export async function unparseableURLs({
162
164
  cookies: [{ name: cookie, value, domain: '.sliccy.ai', path: '/', secure: false }],
163
165
  });
164
166
  const url = rootURL(root, 'exa%20mple.sliccy.ai', `/doc?n=${tag}-observer`);
165
- const { targetId: probeId } = await reader.send('Target.createTarget', {
166
- url,
167
- background: true,
168
- });
167
+ const { targetId: probeId } = await createObserverTarget(
168
+ reader,
169
+ {
170
+ url,
171
+ background: true,
172
+ },
173
+ notes
174
+ );
169
175
  targets.add(probeId);
170
176
  const ground = await document(reader, probeId, url);
171
177
  observer = (await reader.send('Target.attachToTarget', { targetId: probeId, flatten: true }))