@ai-ecoverse/slicc-browsers 1.15.0 → 1.15.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
@@ -193,7 +193,7 @@ The `C9` foreign extension isolated-world family requires `extensionWorld`: a br
193
193
 
194
194
  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.
195
195
 
196
- `C2.redirect-secret` probes ordinary fixture 302s to seven’s `/auth/callback?code=…&state=…` and vault’s `/oauth/mcp-callback?code=…`, plus `#access_token=…` variants of both. It navigates with Page and scripted `location.href`, fetches a subresource and sends an XHR. The 302 carries `Location`, `Content-Location`, `Refresh: 0;url=…` and `Link: <…>; rel=preconnect`. Page, Network (including ExtraInfo), Runtime, Log, Audits and both Fetch stages are enabled. Every received raw message is scanned, including replies but not the probe’s own observer session traffic, across an eight-second tail, for raw, percent-encoded, JSON-escaped and base64 marker representations. Both response-body APIs are attempted on each hop; guarded bodies need matched refusals. `Network.getResponseBody` is attempted, once per id, with every network id the client learns for a 301–308 hop: the `requestId` of a Network event carrying the redirect status and the `networkId` of the Fetch response-stage pause. For each registered hop whose id the client learned neither way, the probe reads the hop’s real id from the harness’s own unguarded tap (the observer session, which enables Network on the guarded leg) and attempts the read on the guarded session with it. The evidence’s `networkBodyCarriers` counts the ids per carrier, and `networkBodyPath` names the weakest carrier any id needed: `withheld+tap`, then `fetch-networkId`, then `network`, or `none` without any id. These reads never hold up a Fetch pause, and the probe gives them five seconds after `Fetch.disable` to settle. A read still unanswered then is not a refusal. `networkBodyAttempted` and `networkBodyRefused` hold only when every attempt is refused. On the guarded leg a `fetch-networkId` or `withheld+tap` carrier also needs the same row’s unguarded control to have seen the redirect’s Network events. Without that proof, or with no id at all, both read `incomplete` and the probe returns `redirect-secret: network body leg <reason>`; an attempt that is not refused is measured and fails. Every request the client sees for a registered redirect URL, through its initial `Network.requestWillBeSent` or a redirect `requestWillBeSent` whose `redirectResponse.url` is that URL, must end within the step in a redirect `requestWillBeSent`, `loadingFinished` or `loadingFailed` for that `requestId`. The evidence’s `hops` records per `requestId` the `delivery` (`redacted` for an origin-only protected redirect in place, `withheld` for a terminal without the redirect, `ordinary`, `raw` or `unterminated`) and the `terminal` event method. `requestsTerminated` is false on any `unterminated` request, a measured guard failure rather than missing evidence; `hopsRedacted` is false on any `raw` or `ordinary` hop, so a delivered protected hop must be origin-only, never the raw or a substituted URL; the guarded leg expects it. Page frame trees, resource trees and navigation history are read before recovery to catch error-page leftovers. A separate structural check requires every protected `Network.Request.urlFragment` and `Page.Frame.urlFragment` to be empty or absent, even if its value contains no marker; the ordinary `#access_token` control independently requires fragment evidence in request events, frame events, frame-tree replies and resource-tree replies. The ordinary session must survive. The unguarded ordinary redirect control preserves all four headers in `redirectResponse`, ExtraInfo’s headers and `headersText`, and Fetch’s response headers. `C2.redirect-secret-mix` repeats that matrix with 301/303/307/308. The protected leg requires in-place ExtraInfo delivery with origin-only header values, not merely the absence of a marker.
196
+ `C2.redirect-secret` probes ordinary fixture 302s to seven’s `/auth/callback?code=…&state=…` and vault’s `/oauth/mcp-callback?code=…`, plus `#access_token=…` variants of both. It navigates with Page and scripted `location.href`, fetches a subresource and sends an XHR. After each one it waits, at least 250 ms and up to two seconds scaled by `settleScale` (stall-tolerant), for the fixture to log the redirect URL and every request seen for it to end, before it navigates away. On the guarded leg, once one action's wait times out, the remaining actions skip it, so a guard that never terminates costs that bound once per leg. The 302 carries `Location`, `Content-Location`, `Refresh: 0;url=…` and `Link: <…>; rel=preconnect`. Page, Network (including ExtraInfo), Runtime, Log, Audits and both Fetch stages are enabled. Every received raw message is scanned, including replies but not the probe’s own observer session traffic, across an eight-second tail, for raw, percent-encoded, JSON-escaped and base64 marker representations. Both response-body APIs are attempted on each hop; guarded bodies need matched refusals. `Network.getResponseBody` is attempted, once per id, with every network id the client learns for a 301–308 hop: the `requestId` of a Network event carrying the redirect status and the `networkId` of the Fetch response-stage pause. For each registered hop whose id the client learned neither way, the probe reads the hop’s real id from the harness’s own unguarded tap (the observer session, which enables Network on the guarded leg) and attempts the read on the guarded session with it. The evidence’s `networkBodyCarriers` counts the ids per carrier, and `networkBodyPath` names the weakest carrier any id needed: `withheld+tap`, then `fetch-networkId`, then `network`, or `none` without any id. These reads never hold up a Fetch pause, and the probe gives them five seconds after `Fetch.disable` to settle, and again after the terminal wait for reads that late events started. A read still unanswered then is not a refusal. `networkBodyAttempted` and `networkBodyRefused` hold only when every attempt is refused. On the guarded leg a `fetch-networkId` or `withheld+tap` carrier also needs the same row’s unguarded control to have seen the redirect’s Network events. Without that proof, or with no id at all, both read `incomplete` and the probe returns `redirect-secret: network body leg <reason>`; an attempt that is not refused is measured and fails. Every request the client sees for a registered redirect URL, through its initial `Network.requestWillBeSent` or a redirect `requestWillBeSent` whose `redirectResponse.url` is that URL, must end within the step in a redirect `requestWillBeSent`, `loadingFinished` or `loadingFailed` for that `requestId`. Before the evidence is evaluated, on every leg, the probe waits up to five seconds scaled by `settleScale` (stall-tolerant) for every seen request to reach one of those events. The evidence’s `hops` records per `requestId` the `delivery` (`redacted` for an origin-only protected redirect in place, `withheld` for a terminal without the redirect, `ordinary`, `raw` or `unterminated`) and the `terminal` event method. On the guarded leg `requestsTerminated` is false on any `unterminated` request after that wait, a measured guard failure rather than missing evidence; on the unguarded control an `unterminated` request is missing evidence, so `requestsTerminated` reads `incomplete` and the probe returns `redirect-secret: control requests <ids> unterminated`; `hopsRedacted` is false on any `raw` or `ordinary` hop, so a delivered protected hop must be origin-only, never the raw or a substituted URL; the guarded leg expects it. Page frame trees, resource trees and navigation history are read before recovery to catch error-page leftovers. A separate structural check requires every protected `Network.Request.urlFragment` and `Page.Frame.urlFragment` to be empty or absent, even if its value contains no marker; the ordinary `#access_token` control independently requires fragment evidence in request events, frame events, frame-tree replies and resource-tree replies. The ordinary session must survive. The unguarded ordinary redirect control preserves all four headers in `redirectResponse`, ExtraInfo’s headers and `headersText`, and Fetch’s response headers. `C2.redirect-secret-mix` repeats that matrix with 301/303/307/308. The protected leg requires in-place ExtraInfo delivery with origin-only header values, not merely the absence of a marker.
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
 
@@ -666,15 +666,15 @@ Changing a table in `slicc-cli/internal/browser` means changing `catalog.json` h
666
666
 
667
667
  ## Releases
668
668
 
669
- semantic-release cuts one version from `main` for all three packages (`.github/workflows/release.yml`). The maintenance branch `1.13.x` (`.releaserc.json`: range `1.13.x`, channel `release-1.13.x`) cuts `1.13.N` patches the same way; it takes only `fix:` commits, cherry-picked from `main` after they merge there, through pull requests that merge on green CI.
669
+ semantic-release cuts one version from `main` for all three packages (`.github/workflows/release.yml`).
670
670
  1. The tag `vX.Y.Z` and a GitHub release are the Go module's release. `scripts/major-guard.mjs` fails the release when the major doesn't fit the module path in `go.mod` (no suffix allows v0 and v1, `/vN` only vN). The workflow then asks `proxy.golang.org` for the new version and `pkg.go.dev` to fetch it; both only warn.
671
- 2. **npm:** with the repository variable `SLICC_BROWSERS_NPM_PUBLISH` set to `true`, the package is stamped with the version and published through npm trusted publishing (OIDC, with provenance). A version that is already on npm is skipped. `scripts/dist-tag.mjs <version> <ref> <latest>` picks the dist-tag: `latest` for a run from `main` whose version is not below the current `latest`, otherwise `release-X.Y.x`; a run from `main` fails when it cannot read `latest`. So a `1.13.x` release publishes to `release-1.13.x` and never moves `latest`. The GitHub release of a maintenance version is not marked latest, and the Go proxy and crates.io order by version.
671
+ 2. **npm:** with the repository variable `SLICC_BROWSERS_NPM_PUBLISH` set to `true`, the package is stamped with the version and published through npm trusted publishing (OIDC, with provenance). A version that is already on npm is skipped. It goes out on the `latest` dist-tag without `--tag`, so npm refuses a version below the current `latest`.
672
672
  3. **crates.io:** with the repository variable `SLICC_BROWSERS_CRATES_PUBLISH` set to `true`, `Cargo.toml` is stamped with the version and `cargo publish` runs with a short-lived token from crates.io trusted publishing (`rust-lang/crates-io-auth-action`). A version that is already on crates.io is skipped.
673
673
  4. **Cert line:** after the npm publish, the workflow runs `npm pack @ai-ecoverse/slicc-browsers@X.Y.Z` against the registry and appends `cert: @ai-ecoverse/slicc-browsers@X.Y.Z sha256=<hex> gitHead=<sha>` to the `vX.Y.Z` GitHub release and the job summary, once; the cert lane copies it from there.
674
674
 
675
675
  GitHub sometimes rejects the push of `refs/notes/semantic-release-vX.Y.Z` with a bare `(failed)` right after the tag push; no ruleset covers `refs/notes/*`. The semantic-release step runs with `pipefail` and puts `scripts/git-retry.mjs` first on `PATH` as `git`: it runs the real git (`REAL_GIT`) and retries a push of a `refs/notes/` ref up to five times, 5, 10, 15 and 20 seconds apart; any other git command runs once. When semantic-release still fails, the job fails before npm and crates.io publish. Recover without moving the tag: create the GitHub release on the existing tag, then run `gh workflow run release.yml --ref main -f tag=vX.Y.Z`. A tag without its note counts as on the default channel (semantic-release reads a missing note as `channels: [null]`), so the next release builds on it. `test/integration/git-retry.test.mjs` tests the retry with a fake git.
676
676
 
677
- A run started by hand publishes what's missing without a new release: `gh workflow run release.yml --ref main` when `main`'s head has its tag, or `gh workflow run release.yml --ref main -f tag=vX.Y.Z` for any existing tag. `gh workflow run release.yml --ref main -f cert-only=true` only adds the cert line for the latest npm version. On the maintenance line, use `--ref 1.13.x -f tag=v1.13.N`.
677
+ A run started by hand publishes what's missing without a new release: `gh workflow run release.yml --ref main` when `main`'s head has its tag, or `gh workflow run release.yml --ref main -f tag=vX.Y.Z` for any existing tag. `gh workflow run release.yml --ref main -f cert-only=true` only adds the cert line for the latest npm version.
678
678
 
679
679
  **Go module versions.** A pushed tag is the release: `proxy.golang.org` caches it and `sum.golang.org` records its hash for good. Never move or delete a pushed tag; retract a bad version in `go.mod` and release anew. A `BREAKING CHANGE` needs a `/vN` module path first.
680
680
 
package/guard-runner.mjs CHANGED
@@ -740,6 +740,7 @@ class Execution {
740
740
  fixture: this.options.fixture,
741
741
  mode: step.redirectSecret,
742
742
  side: this.side,
743
+ settleScale: this.scale,
743
744
  controls: (this.clients.redirectSecretControls ??= {}),
744
745
  })
745
746
  );
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "setVersion": "1.15.0",
3
+ "setVersion": "1.15.1",
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.15.0",
3
+ "version": "1.15.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",
@@ -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/guard-redirect-carrier.test.mjs test/integration/dist-tag.test.mjs test/integration/guard-incomplete.test.mjs test/integration/guard-stall.test.mjs test/integration/guard-forged.test.mjs test/integration/guard-exit-steps.test.mjs && go test -race -count=1 ./test/integration/... && cargo test --all-features",
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",
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",
@@ -81,7 +81,7 @@
81
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"
82
82
  },
83
83
  "devDependencies": {
84
- "@ai-ecoverse/slicc-shared-web": "2.4.0",
84
+ "@ai-ecoverse/slicc-shared-web": "2.4.5",
85
85
  "@biomejs/biome": "2.5.15",
86
86
  "ajv": "8.20.0",
87
87
  "ajv-formats": "3.0.1",
@@ -92,6 +92,7 @@ class RedirectProbe {
92
92
  this.learned = { network: new Set(), fetch: new Set(), tap: new Set() };
93
93
  this.registrations = [];
94
94
  this.seen = new Map();
95
+ this.scale = this.settleScale ?? 1;
95
96
  this.protectedURLs = [
96
97
  `https://seven.sliccy.ai/auth/callback?code=${encodeURIComponent(this.marker)}&state=${encodeURIComponent(this.marker)}`,
97
98
  `https://vault.sliccy.ai/oauth/mcp-callback?code=${encodeURIComponent(this.marker)}`,
@@ -215,31 +216,69 @@ class RedirectProbe {
215
216
  };
216
217
  }
217
218
 
218
- hops(events) {
219
+ requests(events, match = (url) => url?.startsWith(this.prefix)) {
219
220
  const own = events.filter((event) => event.sessionId === this.session);
220
221
  const seen = new Set(
221
222
  own
222
223
  .filter(
223
224
  (event) =>
224
225
  event.method === 'Network.requestWillBeSent' &&
225
- (event.params.redirectResponse
226
- ? event.params.redirectResponse.url?.startsWith(this.prefix)
227
- : event.params.request?.url?.startsWith(this.prefix))
226
+ match(
227
+ event.params.redirectResponse
228
+ ? event.params.redirectResponse.url
229
+ : event.params.request?.url
230
+ )
228
231
  )
229
232
  .map((event) => event.params.requestId)
230
233
  );
231
- const protectedOrigins = this.protectedURLs.map((url) => new URL(url).origin);
232
- const hops = Object.fromEntries(
233
- [...seen].map((requestId) => {
234
- const mine = own.filter((event) => event.params?.requestId === requestId);
235
- const hop = mine.find(
234
+ return [...seen].map((requestId) => {
235
+ const mine = own.filter((event) => event.params?.requestId === requestId);
236
+ return {
237
+ requestId,
238
+ hop: mine.find(
236
239
  (event) =>
237
240
  event.method === 'Network.requestWillBeSent' &&
238
- event.params.redirectResponse?.url?.startsWith(this.prefix)
239
- );
240
- const end = mine.find((event) =>
241
+ match(event.params.redirectResponse?.url)
242
+ ),
243
+ end: mine.find((event) =>
241
244
  ['Network.loadingFinished', 'Network.loadingFailed'].includes(event.method)
242
- );
245
+ ),
246
+ };
247
+ });
248
+ }
249
+
250
+ open(events, match) {
251
+ return this.requests(events, match).filter(({ hop, end }) => !hop && !end);
252
+ }
253
+
254
+ async land(url, start) {
255
+ const path = new URL(url).pathname;
256
+ await pause(250);
257
+ if (this.stalled) return;
258
+ const landed = await waitFor(
259
+ async () =>
260
+ (await this.fixture.log()).some((entry) => entry.url.includes(path)) &&
261
+ !this.open(this.client.events.slice(start), (seen) => seen === url).length,
262
+ Math.round(2000 * this.scale),
263
+ 50
264
+ );
265
+ this.stalled = !landed && this.side === 'guarded';
266
+ }
267
+
268
+ async terminated() {
269
+ await waitFor(
270
+ () => !this.open(this.client.events.slice(this.eventsStart)).length,
271
+ Math.round(5000 * this.scale),
272
+ 50
273
+ );
274
+ await waitFor(() => this.cursor >= this.client.events.length, 1000, 5);
275
+ await this.settle();
276
+ }
277
+
278
+ hops(events) {
279
+ const protectedOrigins = this.protectedURLs.map((url) => new URL(url).origin);
280
+ const hops = Object.fromEntries(
281
+ this.requests(events).map(({ requestId, hop, end }) => {
243
282
  const url = hop?.params.request?.url ?? '';
244
283
  const origin = protectedOrigins.find((value) => url.startsWith(value));
245
284
  return [
@@ -260,13 +299,25 @@ class RedirectProbe {
260
299
  })
261
300
  );
262
301
  const deliveries = Object.values(hops).map((entry) => entry.delivery);
302
+ const open = Object.keys(hops).filter((id) => hops[id].delivery === 'unterminated');
303
+ const missing = open.length > 0 && this.side === 'control';
263
304
  return {
264
305
  hops,
265
- requestsTerminated: !deliveries.includes('unterminated'),
306
+ requestsTerminated: missing ? 'incomplete' : !open.length,
307
+ ...(missing
308
+ ? { incomplete: `redirect-secret: control requests ${open.join(', ')} unterminated` }
309
+ : {}),
266
310
  hopsRedacted: !deliveries.some((delivery) => ['raw', 'ordinary'].includes(delivery)),
267
311
  };
268
312
  }
269
313
 
314
+ legs(events) {
315
+ const network = this.networkLeg();
316
+ const hops = this.hops(events);
317
+ const incomplete = [network.incomplete, hops.incomplete].filter(Boolean).join('; ');
318
+ return { ...network, ...hops, ...(incomplete ? { incomplete } : {}) };
319
+ }
320
+
270
321
  async register(key, to, code = 302) {
271
322
  const url = `${this.ordinary}/redirect-secret/${this.tag}-${key}`;
272
323
  const path = new URL(url).pathname;
@@ -339,6 +390,7 @@ class RedirectProbe {
339
390
  for (const kind of ['navigate', 'href', 'fetch', 'xhr']) {
340
391
  const code = this.mode === 'mix' ? [301, 303, 307, 308][index] : 302;
341
392
  const url = await this.register(`${index}-${kind}`, to, code);
393
+ const start = this.client.events.length;
342
394
  if (kind === 'navigate') await this.send('Page.navigate', { url });
343
395
  else {
344
396
  await this.reset();
@@ -350,7 +402,7 @@ class RedirectProbe {
350
402
  : this.xhrExpression(url);
351
403
  await this.send('Runtime.evaluate', { expression });
352
404
  }
353
- await pause(250);
405
+ await this.land(url, start);
354
406
  await this.send('Page.getFrameTree');
355
407
  await this.send('Page.getResourceTree');
356
408
  await this.send('Page.getNavigationHistory');
@@ -487,6 +539,7 @@ class RedirectProbe {
487
539
  returnByValue: true,
488
540
  });
489
541
  await this.settle();
542
+ await this.terminated();
490
543
  const tapped = `"sessionId":"${this.observer}"`;
491
544
  const events = this.client.events
492
545
  .slice(this.eventsStart)
@@ -536,8 +589,7 @@ class RedirectProbe {
536
589
  sessionSurvives: survives.ok && survives.result?.result?.value === 'doc',
537
590
  fetchBodyAttempted: fetchAttempts.length > 0,
538
591
  fetchBodyRefused: fetchAttempts.length > 0 && fetchAttempts.every(refused),
539
- ...this.networkLeg(),
540
- ...this.hops(events),
592
+ ...this.legs(events),
541
593
  extraInPlace,
542
594
  ...redirectFragments({
543
595
  messages,