@ai-ecoverse/slicc-browsers 1.16.1 → 1.16.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
@@ -197,6 +197,8 @@ The integration launcher loads shared-web 1.25.2’s harness extension exactly o
197
197
 
198
198
  `C2.redirect-body` calls `Network.loadNetworkResource` on an ordinary redirect with `includeCredentials: true`, reads any stream with `IO.read`, replays a redirected XHR, requests Page resource content, and captures Page/MHTML and DOM snapshots after a main-frame redirect. It also captures an ordinary parent containing an iframe redirected to vault, including its error document, and overrides a paused ordinary Fetch request to the allowed redirect URL. No protected marker may appear in any message or body, and no request from those operations may send the independently seeded seven cookie. The ordinary control must return the body, resource content and snapshots, and accept the URL override. Its network-body carrier, `networkBodyAttempted`, `networkBodyRefused`, `hops`, `requestsTerminated` and `hopsRedacted` follow `C2.redirect-secret`. Each probe cleans up its redirect registrations, observer session and cookie. The focused Chrome tests use HTTPS with SPKI-pinned fixture trust and default-port protected URLs; host resolver rules route seven and vault to the fixture rather than the public service.
199
199
 
200
+ Every C2 redirect probe reports `sessionEnded`. It becomes true when the guarded client’s session ends during the probe: a `Target.detachedFromTarget` for that session, or a CDP reply that the session is closed or not found, or that the target, websocket or transport is closed. `Inspected target navigated or closed` is an ordinary reply on a live session, not a session end. When an expected Fetch pause or XHR event is missing, the probe sends one `Runtime.evaluate` to check that the session is still alive. Once the session has ended, the probe sends nothing more on it, skips the eight-second tail and the termination wait, and evaluates what the client received before the end. `secretHidden`, `protectedBodyHidden`, `cookieHidden`, `fragmentsHidden` and `hopsRedacted` are measured on those messages, replies, bodies and fixture logs, so a guard that leaked before the end still fails them. A clean end passes them. `sessionSurvives` reads false. Checks that need evidence from after the end read `incomplete`, with `redirect-secret: session ended (<how>)` in the probe’s `incomplete`. If the end cuts the probe’s action matrix short, or leaves any body read pending or unanswered, then `fetchBodyAttempted`, `fetchBodyRefused` and the network-body leg read `incomplete`, even after some refused reads. Any answer that isn’t a refusal still fails its refusal check. `requestsTerminated` reads `incomplete` when requests are unterminated or the matrix was cut short. A short but origin-only `extraLocation` reads `incomplete`. So does each attempt or override check whose command never reached the client: it wasn’t sent because the session had already ended, or its reply was the session end. On the unguarded control, a session end is an error: the probe throws `redirect-secret: control session ended (<how>)`.
201
+
200
202
  `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.
201
203
 
202
204
  `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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "setVersion": "1.16.1",
3
+ "setVersion": "1.16.2",
4
4
  "sources": [
5
5
  {
6
6
  "repo": "ai-ecoverse/slicc-cli",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-ecoverse/slicc-browsers",
3
- "version": "1.16.1",
3
+ "version": "1.16.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",
@@ -66,7 +66,7 @@
66
66
  "validate": "node scripts/validate.mjs",
67
67
  "lint": "slicc-lint && test -z \"$(gofmt -l .)\" && go mod tidy -diff && go vet ./... && golangci-lint run && cargo fmt --check && cargo clippy --all-features --all-targets -- -D warnings",
68
68
  "format": "biome check --write . && gofmt -w . && cargo fmt",
69
- "test": "npm run validate && node --test test/integration/self-contained.test.mjs test/integration/browser-runner.test.mjs test/integration/fixture-client.test.mjs test/integration/git-retry.test.mjs test/integration/release-line.test.mjs test/integration/guard-redirect-carrier.test.mjs test/integration/guard-incomplete.test.mjs test/integration/guard-stall.test.mjs test/integration/guard-forged.test.mjs test/integration/guard-exit-steps.test.mjs && go test -race -count=1 ./test/integration/... && cargo test --all-features",
69
+ "test": "npm run validate && node --test test/integration/self-contained.test.mjs test/integration/browser-runner.test.mjs test/integration/fixture-client.test.mjs test/integration/git-retry.test.mjs test/integration/release-line.test.mjs test/integration/guard-redirect-carrier.test.mjs test/integration/guard-redirect-session-end.test.mjs test/integration/guard-incomplete.test.mjs test/integration/guard-stall.test.mjs test/integration/guard-forged.test.mjs test/integration/guard-exit-steps.test.mjs && go test -race -count=1 ./test/integration/... && cargo test --all-features",
70
70
  "test:unit": "go test -race -cover ./...",
71
71
  "build": "go build ./... && cargo build --all-features && cargo build --target wasm32-wasip1 --all-features && npm pack --dry-run",
72
72
  "trust": "npx fledgling sync -y --skip-publish",
@@ -20,6 +20,12 @@ const decodedBase64 = (value) =>
20
20
  base64Slices(text).map(decodeBase64)
21
21
  );
22
22
  const refused = (out) => !out.ok && /^slicc: refused:/.test(out.error?.message ?? '');
23
+ const gone = (out) =>
24
+ !out.ok &&
25
+ !refused(out) &&
26
+ /session with given id not found|no session with given id|\bsession (?:has been |is |was )?(?:closed|detached)|\btarget closed|target page, context or browser has been closed|\b(?:cdp|connection|transport|websocket|socket) (?:has been |is |was )?closed|websocket is not open/i.test(
27
+ out.error?.message ?? ''
28
+ );
23
29
  const redirects = [301, 302, 303, 307, 308];
24
30
  const redirectHeaders = (to) => ({
25
31
  location: to,
@@ -85,6 +91,7 @@ class RedirectProbe {
85
91
  this.messagesStart = this.client.messages?.length;
86
92
  this.eventsStart = this.client.events.length;
87
93
  this.cursor = this.eventsStart;
94
+ this.detachScan = this.eventsStart;
88
95
  this.responses = [];
89
96
  this.attempts = [];
90
97
  this.jobs = new Set();
@@ -107,8 +114,41 @@ class RedirectProbe {
107
114
  : this.protectedURLs;
108
115
  }
109
116
 
117
+ ended() {
118
+ const events = this.client.events;
119
+ for (; !this.end && this.detachScan < events.length; this.detachScan++) {
120
+ const event = events[this.detachScan];
121
+ if (event.method === 'Target.detachedFromTarget' && event.params?.sessionId === this.session)
122
+ this.end = 'Target.detachedFromTarget';
123
+ }
124
+ return Boolean(this.end);
125
+ }
126
+
127
+ cut() {
128
+ return Boolean(this.end) && !this.complete;
129
+ }
130
+
131
+ async lost() {
132
+ if (!this.ended()) await this.send('Runtime.evaluate', { expression: 'void 0' });
133
+ return this.ended();
134
+ }
135
+
136
+ answers(method) {
137
+ const all = this.attempts.filter((entry) => entry.method === method);
138
+ const answered = all.filter((entry) => !(this.end && (entry.pending || entry.ended)));
139
+ const short =
140
+ Boolean(this.end) && (!this.complete || !answered.length || answered.length < all.length);
141
+ return { answered, short };
142
+ }
143
+
110
144
  async send(method, params = {}) {
145
+ if (this.ended())
146
+ return { ok: false, ended: true, error: { message: `session ended (${this.end})` } };
111
147
  const out = await outcome(this.client.send(method, params, this.session));
148
+ if (gone(out)) {
149
+ this.end ??= out.error.message;
150
+ return { ...out, ended: true };
151
+ }
112
152
  this.responses.push({ method, ...out });
113
153
  return out;
114
154
  }
@@ -151,7 +191,7 @@ class RedirectProbe {
151
191
 
152
192
  async networkBody(requestId, source) {
153
193
  this.learned[source].add(requestId);
154
- if (this.networkBodies.has(requestId)) return;
194
+ if (this.networkBodies.has(requestId) || this.ended()) return;
155
195
  this.networkBodies.add(requestId);
156
196
  const entry = { method: 'Network.getResponseBody', source, pending: true };
157
197
  this.attempts.push(entry);
@@ -191,15 +231,17 @@ class RedirectProbe {
191
231
  carriers.filter((entry) => entry === carrier).length,
192
232
  ])
193
233
  );
194
- const attempts = this.attempts.filter((entry) => entry.method === 'Network.getResponseBody');
234
+ const { answered: attempts, short } = this.answers('Network.getResponseBody');
195
235
  const refusedAll = attempts.length > 0 && attempts.every(refused);
196
236
  const proven = path === 'network' || this.side !== 'guarded' || this.controls?.networkEvents;
197
237
  const reason =
198
238
  path === 'none'
199
239
  ? 'no network id for the redirect'
200
- : proven
201
- ? undefined
202
- : 'control did not prove the redirect Network events';
240
+ : !proven
241
+ ? 'control did not prove the redirect Network events'
242
+ : short
243
+ ? `session ended (${this.end}) before every network body read was answered`
244
+ : undefined;
203
245
  if (reason && (!attempts.length || refusedAll))
204
246
  return {
205
247
  networkBodyPath: path,
@@ -254,11 +296,12 @@ class RedirectProbe {
254
296
  async land(url, start) {
255
297
  const path = new URL(url).pathname;
256
298
  await pause(250);
257
- if (this.stalled) return;
299
+ if (this.stalled || this.ended()) return;
258
300
  const landed = await waitFor(
259
301
  async () =>
260
- (await this.fixture.log()).some((entry) => entry.url.includes(path)) &&
261
- !this.open(this.client.events.slice(start), (seen) => seen === url).length,
302
+ this.ended() ||
303
+ ((await this.fixture.log()).some((entry) => entry.url.includes(path)) &&
304
+ !this.open(this.client.events.slice(start), (seen) => seen === url).length),
262
305
  Math.round(2000 * this.scale),
263
306
  50
264
307
  );
@@ -267,7 +310,7 @@ class RedirectProbe {
267
310
 
268
311
  async terminated() {
269
312
  await waitFor(
270
- () => !this.open(this.client.events.slice(this.eventsStart)).length,
313
+ () => this.ended() || !this.open(this.client.events.slice(this.eventsStart)).length,
271
314
  Math.round(5000 * this.scale),
272
315
  50
273
316
  );
@@ -300,13 +343,15 @@ class RedirectProbe {
300
343
  );
301
344
  const deliveries = Object.values(hops).map((entry) => entry.delivery);
302
345
  const open = Object.keys(hops).filter((id) => hops[id].delivery === 'unterminated');
303
- const missing = open.length > 0 && this.side === 'control';
346
+ const missing = (open.length > 0 && (this.side === 'control' || this.end)) || this.cut();
347
+ const who = this.end ? `session ended (${this.end}) with requests` : 'control requests';
348
+ const detail = open.length
349
+ ? `${who} ${open.join(', ')} unterminated`
350
+ : `session ended (${this.end}) before every request was issued`;
304
351
  return {
305
352
  hops,
306
353
  requestsTerminated: missing ? 'incomplete' : !open.length,
307
- ...(missing
308
- ? { incomplete: `redirect-secret: control requests ${open.join(', ')} unterminated` }
309
- : {}),
354
+ ...(missing ? { incomplete: `redirect-secret: ${detail}` } : {}),
310
355
  hopsRedacted: !deliveries.some((delivery) => ['raw', 'ordinary'].includes(delivery)),
311
356
  };
312
357
  }
@@ -388,6 +433,7 @@ class RedirectProbe {
388
433
  async headers() {
389
434
  for (const [index, to] of this.destinations.entries()) {
390
435
  for (const kind of ['navigate', 'href', 'fetch', 'xhr']) {
436
+ if (this.ended()) return;
391
437
  const code = this.mode === 'mix' ? [301, 303, 307, 308][index] : 302;
392
438
  const url = await this.register(`${index}-${kind}`, to, code);
393
439
  const start = this.client.events.length;
@@ -439,9 +485,9 @@ class RedirectProbe {
439
485
  const snapshot = await this.send('Page.captureSnapshot', { format: 'mhtml' });
440
486
  const dom = await this.send('DOMSnapshot.captureSnapshot', { computedStyles: [] });
441
487
  this.responses.push({
442
- resourceContentAttempted: true,
443
- snapshotAttempted: true,
444
- domSnapshotAttempted: true,
488
+ resourceContentAttempted: !resource.ended,
489
+ snapshotAttempted: !snapshot.ended,
490
+ domSnapshotAttempted: !dom.ended,
445
491
  ordinaryResource: this.contains(resource),
446
492
  ordinarySnapshot: this.contains(snapshot),
447
493
  ordinaryDOM: this.contains(dom),
@@ -455,16 +501,21 @@ class RedirectProbe {
455
501
  await this.send('Runtime.evaluate', {
456
502
  expression: `fetch(${JSON.stringify(this.holdURL)}).catch(()=>{});void 0`,
457
503
  });
458
- if (!(await waitFor(() => Boolean(this.held), 5000, 10)))
504
+ await waitFor(() => Boolean(this.held) || this.ended(), 5000, 10);
505
+ if (!this.held) {
506
+ this.holdURL = undefined;
507
+ if (await this.lost()) return;
459
508
  throw new Error('redirect body: Fetch override pause missing');
509
+ }
460
510
  const requestId = this.held.params.requestId;
461
511
  const out = await this.send('Fetch.continueRequest', { requestId, url });
512
+ this.holdURL = undefined;
513
+ if (out.ended) return;
462
514
  this.responses.push({
463
515
  overrideMethod: 'Fetch.continueRequest',
464
516
  overrideRefused: refused(out),
465
517
  overrideWorked: out.ok,
466
518
  });
467
- this.holdURL = undefined;
468
519
  if (!out.ok) await this.send('Fetch.continueRequest', { requestId });
469
520
  await pause(500);
470
521
  }
@@ -475,6 +526,15 @@ class RedirectProbe {
475
526
  const tree = await this.send('Page.getFrameTree');
476
527
  const frameId = tree.result?.frameTree.frame.id;
477
528
  const start = (await this.fixture.log()).length;
529
+ await this.reads(url, to, frameId);
530
+ this.responses.push({
531
+ cookieSent: (await this.fixture.log())
532
+ .slice(start)
533
+ .some((entry) => entry.headers.cookie?.includes(`${this.cookie}=`)),
534
+ });
535
+ }
536
+
537
+ async reads(url, to, frameId) {
478
538
  const resource = await this.send('Network.loadNetworkResource', {
479
539
  frameId,
480
540
  url,
@@ -496,18 +556,21 @@ class RedirectProbe {
496
556
  event.params.type === 'XHR' &&
497
557
  event.params.request.url === url
498
558
  );
499
- if (!xhr) throw new Error('redirect body: XHR request was not observed');
500
- await this.send('Network.replayXHR', { requestId: xhr.params.requestId });
501
- this.responses.push({ replayAttempted: true });
559
+ if (!xhr) {
560
+ if (await this.lost()) return;
561
+ throw new Error('redirect body: XHR request was not observed');
562
+ }
563
+ const replay = await this.send('Network.replayXHR', { requestId: xhr.params.requestId });
564
+ this.responses.push({ replayAttempted: !replay.ended });
502
565
  await pause(500);
503
- await this.snapshots(url, to, frameId);
504
- await this.iframe();
505
- await this.override(url);
506
- this.responses.push({
507
- cookieSent: (await this.fixture.log())
508
- .slice(start)
509
- .some((entry) => entry.headers.cookie?.includes(`${this.cookie}=`)),
510
- });
566
+ for (const step of [
567
+ () => this.snapshots(url, to, frameId),
568
+ () => this.iframe(),
569
+ () => this.override(url),
570
+ ]) {
571
+ if (this.ended()) return;
572
+ await step();
573
+ }
511
574
  }
512
575
 
513
576
  async iframe() {
@@ -521,15 +584,15 @@ class RedirectProbe {
521
584
  const snapshot = await this.send('Page.captureSnapshot', { format: 'mhtml' });
522
585
  const dom = await this.send('DOMSnapshot.captureSnapshot', { computedStyles: [] });
523
586
  this.responses.push({
524
- iframeSnapshotAttempted: true,
587
+ iframeSnapshotAttempted: !snapshot.ended && !dom.ended,
525
588
  ordinaryIframeSnapshot: this.contains(snapshot),
526
589
  ordinaryIframeDOM: this.contains(dom),
527
590
  });
528
591
  }
529
592
 
530
593
  async evidence() {
531
- await pause(8000);
532
- this.tap();
594
+ if (!this.ended()) await pause(8000);
595
+ if (!this.ended()) this.tap();
533
596
  if (this.side === 'control' && this.controls)
534
597
  this.controls.networkEvents = this.learned.network.size > 0;
535
598
  await this.send('Fetch.disable');
@@ -560,20 +623,24 @@ class RedirectProbe {
560
623
  event.method === 'Network.responseReceivedExtraInfo' &&
561
624
  redirects.includes(event.params.statusCode)
562
625
  );
626
+ const extraHeld = extra.every((event) => {
627
+ const headers = event.params.headers;
628
+ const origin = headers.location ?? headers.Location;
629
+ return (
630
+ this.protectedURLs.some((url) => new URL(url).origin === origin) &&
631
+ headersMatch(headers, origin) &&
632
+ headersTextMatch(event.params.headersText, origin)
633
+ );
634
+ });
563
635
  const extraInPlace =
564
- extra.length >= this.destinations.length * 4 &&
565
- extra.every((event) => {
566
- const headers = event.params.headers;
567
- const origin = headers.location ?? headers.Location;
568
- return (
569
- this.protectedURLs.some((url) => new URL(url).origin === origin) &&
570
- headersMatch(headers, origin) &&
571
- headersTextMatch(event.params.headersText, origin)
572
- );
573
- });
636
+ extraHeld &&
637
+ (extra.length >= this.destinations.length * 4 || (this.end ? 'incomplete' : false));
638
+ const unseen = this.end ? 'incomplete' : false;
574
639
  const found = (key) => this.responses.some((entry) => entry[key]);
575
- const fetchAttempts = this.attempts.filter((entry) => entry.method === 'Fetch.getResponseBody');
576
- return {
640
+ const fetchReads = this.answers('Fetch.getResponseBody');
641
+ const fetchLeaked = fetchReads.answered.some((entry) => !refused(entry));
642
+ const overrides = this.responses.filter((entry) => entry.overrideMethod);
643
+ const evidence = {
577
644
  live: (await this.fixture.log()).some((entry) =>
578
645
  entry.url.includes(`/redirect-secret/${this.tag}`)
579
646
  ),
@@ -587,8 +654,13 @@ class RedirectProbe {
587
654
  ),
588
655
  secretHidden: !replyLeaks.includes(true),
589
656
  sessionSurvives: survives.ok && survives.result?.result?.value === 'doc',
590
- fetchBodyAttempted: fetchAttempts.length > 0,
591
- fetchBodyRefused: fetchAttempts.length > 0 && fetchAttempts.every(refused),
657
+ sessionEnded: this.ended(),
658
+ fetchBodyAttempted: fetchReads.short ? 'incomplete' : fetchReads.answered.length > 0,
659
+ fetchBodyRefused: fetchLeaked
660
+ ? false
661
+ : fetchReads.short
662
+ ? 'incomplete'
663
+ : fetchReads.answered.length > 0,
592
664
  ...this.legs(events),
593
665
  extraInPlace,
594
666
  ...redirectFragments({
@@ -632,26 +704,23 @@ class RedirectProbe {
632
704
  'iframeSnapshotAttempted',
633
705
  'ordinaryIframeSnapshot',
634
706
  'ordinaryIframeDOM',
635
- ].map((key) => [key, found(key)])
707
+ ].map((key) => [key, found(key) || unseen])
636
708
  ),
637
- overridesRefused:
638
- this.responses.some((entry) => entry.overrideMethod) &&
639
- this.responses
640
- .filter((entry) => entry.overrideMethod)
641
- .every((entry) => entry.overrideRefused),
642
- overridesWorked:
643
- this.responses.some((entry) => entry.overrideMethod) &&
644
- this.responses
645
- .filter((entry) => entry.overrideMethod)
646
- .every((entry) => entry.overrideWorked),
709
+ overridesRefused: overrides.length
710
+ ? overrides.every((entry) => entry.overrideRefused)
711
+ : unseen,
712
+ overridesWorked: overrides.length ? overrides.every((entry) => entry.overrideWorked) : unseen,
647
713
  };
714
+ if (!this.end || !Object.values(evidence).includes('incomplete')) return evidence;
715
+ const incomplete = [evidence.incomplete, `redirect-secret: session ended (${this.end})`];
716
+ return { ...evidence, incomplete: incomplete.filter(Boolean).join('; ') };
648
717
  }
649
718
 
650
719
  async close() {
651
720
  this.stopped = true;
652
721
  await this.watching;
653
722
  await this.settle();
654
- await outcome(this.client.send('Fetch.disable', {}, this.session));
723
+ if (!this.ended()) await outcome(this.client.send('Fetch.disable', {}, this.session));
655
724
  if (this.observer) {
656
725
  await outcome(
657
726
  this.reader.send(
@@ -680,7 +749,11 @@ export async function redirectSecret(options) {
680
749
  await probe.setup();
681
750
  if (options.mode === 'body') await probe.body();
682
751
  else await probe.headers();
683
- return await probe.evidence();
752
+ probe.complete = !probe.ended();
753
+ const evidence = await probe.evidence();
754
+ if (probe.end && options.side !== 'guarded')
755
+ throw new Error(`redirect-secret: ${options.side} session ended (${probe.end})`);
756
+ return evidence;
684
757
  } finally {
685
758
  await probe.close();
686
759
  }