@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 +4 -4
- package/guard-runner.mjs +1 -0
- package/guard-vectors.json +1 -1
- package/package.json +3 -3
- package/redirect-secret.mjs +69 -17
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`).
|
|
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.
|
|
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.
|
|
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
package/guard-vectors.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-ecoverse/slicc-browsers",
|
|
3
|
-
"version": "1.15.
|
|
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/
|
|
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.
|
|
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",
|
package/redirect-secret.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
(
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
|
239
|
-
)
|
|
240
|
-
|
|
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: !
|
|
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
|
|
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.
|
|
540
|
-
...this.hops(events),
|
|
592
|
+
...this.legs(events),
|
|
541
593
|
extraInPlace,
|
|
542
594
|
...redirectFragments({
|
|
543
595
|
messages,
|