@ai-ecoverse/slicc-browsers 1.22.0 → 1.23.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-ecoverse/slicc-browsers",
3
- "version": "1.22.0",
3
+ "version": "1.23.0",
4
4
  "description": "SLICC's shared browser catalog, JSON schemas and discovery conformance corpus",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -65,6 +65,8 @@
65
65
  "observer-target.mjs",
66
66
  "frame-read.mjs",
67
67
  "bfcache-restore.mjs",
68
+ "close-protected-shape.mjs",
69
+ "protected-worker.mjs",
68
70
  "fixture-client.mjs"
69
71
  ],
70
72
  "scripts": {
@@ -73,7 +75,7 @@
73
75
  "validate": "node scripts/validate.mjs",
74
76
  "lint": "slicc-lint && test -z \"$(gofmt -l .)\" && go mod tidy -diff && go vet ./... && GOOS=darwin go vet ./... && GOOS=windows go vet ./... && golangci-lint run && cargo fmt --check && cargo clippy --all-features --all-targets -- -D warnings && cargo check --target wasm32-wasip1 --no-default-features && cargo check --target wasm32-wasip1 --features serde && cargo check --target wasm32-wasip1 --all-features",
75
77
  "format": "biome check --write . && gofmt -w . && cargo fmt",
76
- "test": "export PATH=\"$HOME/.cargo/bin:$PATH\" && node --test --test-concurrency=1 test/integration/debugger-stage1-packaging.test.mjs test/integration/debugger-raw-control.test.mjs test/integration/debugger-cleanup.test.mjs test/integration/debugger-stage1-install-vectors.test.mjs test/integration/protected-premise.test.mjs test/integration/debugger-stage1-vectors.test.mjs test/integration/self-contained.test.mjs test/integration/seven-corpus.test.mjs test/integration/browser-runner.test.mjs test/integration/fixture-client.test.mjs test/integration/guard-redirect-carrier.test.mjs test/integration/guard-redirect-session-end.test.mjs test/integration/guard-redirect-history.test.mjs test/integration/guard-redirect-iframe.test.mjs test/integration/guard-incomplete.test.mjs test/integration/guard-unparseable-wipe.test.mjs test/integration/guard-browser-ui.test.mjs test/integration/guard-snapshot-settle.test.mjs test/integration/guard-stall.test.mjs test/integration/guard-forged.test.mjs test/integration/guard-trusted-emulation.test.mjs test/integration/guard-exit-steps.test.mjs test/integration/guard-exit-refusal.test.mjs test/integration/guard-frame-read.test.mjs test/integration/guard-bfcache.test.mjs test/integration/guard-bfcache-marker.test.mjs test/integration/guard-bfcache-history.test.mjs test/integration/guard-bfcache-review.test.mjs test/integration/guard-bfcache-quarantine.test.mjs test/integration/guard-error-page-navigation.test.mjs test/integration/guard-error-page-events.test.mjs test/integration/guard-error-page-reload.test.mjs test/integration/guard-close-protected.test.mjs test/integration/guard-world-paint.test.mjs test/integration/gates.test.mjs test/integration/validate.test.mjs test/integration/validate-guard-stock-setup.test.mjs test/integration/go.test.mjs test/integration/cargo.test.mjs test/integration/workflows.test.mjs test/integration/guard-refusable-nav.test.mjs test/integration/guard-sweep-union.test.mjs && node --test --test-concurrency=2 test/integration/guard-lifecycle.test.mjs test/integration/chromium-launch.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-bfcache-chrome.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/guard-reveal-overlap.test.mjs test/integration/guard-extension-frame-mouse.test.mjs test/integration/guard-extension-frames.test.mjs test/integration/guard-stock-setup.test.mjs test/integration/guard-stock-attack.test.mjs test/integration/guard-parallel.test.mjs test/integration/guard-client-exit.test.mjs test/integration/guard-runner.test.mjs test/integration/guard-sweep-ordinary.test.mjs test/integration/guard-sweep-seven.test.mjs test/integration/guard-sweep-vault.test.mjs test/integration/guard-sweep-vault-x.test.mjs test/integration/guard-sweep-apex.test.mjs test/integration/guard-sweep-others.test.mjs",
78
+ "test": "export PATH=\"$HOME/.cargo/bin:$PATH\" && node --test --test-concurrency=1 test/integration/debugger-stage1-packaging.test.mjs test/integration/debugger-raw-control.test.mjs test/integration/debugger-cleanup.test.mjs test/integration/debugger-stage1-install-vectors.test.mjs test/integration/protected-premise.test.mjs test/integration/debugger-stage1-vectors.test.mjs test/integration/self-contained.test.mjs test/integration/seven-corpus.test.mjs test/integration/browser-runner.test.mjs test/integration/fixture-client.test.mjs test/integration/guard-redirect-carrier.test.mjs test/integration/guard-redirect-session-end.test.mjs test/integration/guard-redirect-history.test.mjs test/integration/guard-redirect-iframe.test.mjs test/integration/guard-incomplete.test.mjs test/integration/guard-unparseable-wipe.test.mjs test/integration/guard-browser-ui.test.mjs test/integration/guard-snapshot-settle.test.mjs test/integration/guard-stall.test.mjs test/integration/guard-forged.test.mjs test/integration/guard-trusted-emulation.test.mjs test/integration/guard-exit-steps.test.mjs test/integration/guard-exit-refusal.test.mjs test/integration/guard-frame-read.test.mjs test/integration/guard-bfcache.test.mjs test/integration/guard-bfcache-marker.test.mjs test/integration/guard-bfcache-history.test.mjs test/integration/guard-bfcache-review.test.mjs test/integration/guard-bfcache-quarantine.test.mjs test/integration/guard-error-page-navigation.test.mjs test/integration/guard-error-page-events.test.mjs test/integration/guard-error-page-reload.test.mjs test/integration/guard-close-protected.test.mjs test/integration/guard-close-protected-shapes.test.mjs test/integration/guard-close-protected-shapes-chrome.test.mjs test/integration/guard-world-paint.test.mjs test/integration/gates.test.mjs test/integration/validate.test.mjs test/integration/validate-guard-stock-setup.test.mjs test/integration/go.test.mjs test/integration/cargo.test.mjs test/integration/workflows.test.mjs test/integration/guard-refusable-nav.test.mjs test/integration/guard-protected-worker.test.mjs test/integration/guard-protected-worker-chrome-narrow.test.mjs test/integration/guard-protected-worker-chrome-hide.test.mjs test/integration/guard-protected-worker-chrome-hide-cut.test.mjs test/integration/guard-protected-worker-chrome-admit.test.mjs test/integration/guard-protected-worker-chrome-late.test.mjs test/integration/guard-protected-worker-chrome-rewrite.test.mjs test/integration/guard-protected-worker-chrome-rewrite-late.test.mjs test/integration/guard-sweep-union.test.mjs && node --test --test-concurrency=2 test/integration/guard-lifecycle.test.mjs test/integration/chromium-launch.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-bfcache-chrome.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/guard-reveal-overlap.test.mjs test/integration/guard-extension-frame-mouse.test.mjs test/integration/guard-extension-frames.test.mjs test/integration/guard-stock-setup.test.mjs test/integration/guard-stock-attack.test.mjs test/integration/guard-parallel.test.mjs test/integration/guard-client-exit.test.mjs test/integration/guard-runner.test.mjs test/integration/guard-sweep-ordinary.test.mjs test/integration/guard-sweep-seven.test.mjs test/integration/guard-sweep-vault.test.mjs test/integration/guard-sweep-vault-x.test.mjs test/integration/guard-sweep-apex.test.mjs test/integration/guard-sweep-others.test.mjs",
77
79
  "test:unit": "mkdir -p coverage/unit && node --experimental-test-coverage --test --test-concurrency=1 --test-reporter=lcov --test-reporter-destination=coverage/unit/lcov.info test/unit/*.test.mjs && go test -race -cover ./...",
78
80
  "build": "go build ./... && cargo build --all-features && cargo build --target wasm32-wasip1 --all-features && npm pack --dry-run",
79
81
  "prepublishOnly": "node scripts/set-crate-version.mjs \"$npm_package_version\"",
@@ -84,9 +86,9 @@
84
86
  "test:unit:guard": "mkdir -p coverage/unit && node --experimental-vm-modules --no-warnings --experimental-test-coverage --test --test-concurrency=1 --test-coverage-include=guard-runner.mjs --test-coverage-include=guard-driver.mjs --test-coverage-include=extension-frame.mjs --test-coverage-include=fixture-server.mjs --test-coverage-include=fixture-client.mjs --test-coverage-include=observer-target.mjs --test-coverage-include=frame-read.mjs --test-coverage-include=bfcache-restore.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"
85
87
  },
86
88
  "devDependencies": {
87
- "@ai-ecoverse/slicc-cdp": "1.47.5",
89
+ "@ai-ecoverse/slicc-cdp": "1.48.7",
88
90
  "@ai-ecoverse/slicc-shared-web": "2.14.2",
89
- "@biomejs/biome": "2.6.4",
91
+ "@biomejs/biome": "2.6.5",
90
92
  "ajv": "8.20.0",
91
93
  "ajv-formats": "3.0.1",
92
94
  "lefthook": "2.2.2",
@@ -0,0 +1,511 @@
1
+ import { outcome, waitFor } from './guard-driver.mjs';
2
+ import { createObserverTarget } from './observer-target.mjs';
3
+
4
+ async function until(check, ms, step = 25) {
5
+ const deadline = Date.now() + ms;
6
+ for (;;) {
7
+ const value = await check();
8
+ if (value) return value;
9
+ if (Date.now() > deadline) return null;
10
+ await new Promise((resolve) => setTimeout(resolve, step));
11
+ }
12
+ }
13
+
14
+ const kinds = {
15
+ dedicated: {
16
+ type: 'worker',
17
+ path: '/worker.js',
18
+ expression: (url) =>
19
+ `(() => { window.__sliccW = new Worker(${JSON.stringify(url)}); return 'spawned'; })()`,
20
+ exists: `typeof window.__sliccW === 'object' && window.__sliccW !== null`,
21
+ },
22
+ shared: {
23
+ type: 'shared_worker',
24
+ path: '/shared-worker.js',
25
+ expression: (url) =>
26
+ `(() => { window.__sliccW = new SharedWorker(${JSON.stringify(url)}); return 'spawned'; })()`,
27
+ exists: `typeof window.__sliccW === 'object' && window.__sliccW !== null`,
28
+ },
29
+ service: {
30
+ type: 'service_worker',
31
+ path: '/sw.js',
32
+ expression: (url) =>
33
+ `(async()=>{ try { await navigator.serviceWorker.register(${JSON.stringify(url)},{scope:'/'}); return 'registered'; } catch(e){ return 'err:'+e.message; } })()`,
34
+ exists: `(async()=>{ try { const r=await navigator.serviceWorker.getRegistration(); return !!(r&&String(r.scope||'').length); } catch { return false; } })()`,
35
+ },
36
+ };
37
+
38
+ async function frameContext(reader, sessionId, frameId, budget) {
39
+ const found = await until(
40
+ () => {
41
+ for (const event of reader.events) {
42
+ if (event.sessionId !== sessionId) continue;
43
+ if (event.method !== 'Runtime.executionContextCreated') continue;
44
+ const context = event.params?.context;
45
+ if (context?.auxData?.frameId === frameId && context.auxData?.isDefault) return context.id;
46
+ }
47
+ return null;
48
+ },
49
+ Math.min(budget, 1500)
50
+ );
51
+ if (found) return found;
52
+ const worlds = await outcome(
53
+ reader.send(
54
+ 'Page.createIsolatedWorld',
55
+ { frameId, worldName: `slicc-pw-${frameId}` },
56
+ sessionId
57
+ )
58
+ );
59
+ if (!worlds.ok) return null;
60
+ return worlds.result?.executionContextId ?? null;
61
+ }
62
+
63
+ async function framedEvaluate(reader, sessionId, pageUrl, budget, sessions) {
64
+ const prefix = pageUrl.split('?')[0];
65
+ const framed = await outcome(
66
+ reader.send(
67
+ 'Runtime.evaluate',
68
+ {
69
+ expression: `(() => { const f=document.createElement('iframe'); f.src=${JSON.stringify(pageUrl)}; document.body.append(f); return true; })()`,
70
+ returnByValue: true,
71
+ },
72
+ sessionId
73
+ )
74
+ );
75
+ if (!framed.ok || framed.result?.result?.value !== true)
76
+ throw new Error(`protected worker iframe insert failed: ${framed.error?.message ?? 'unknown'}`);
77
+ const iframeTarget = await until(async () => {
78
+ const listed = await outcome(reader.send('Target.getTargets', {}));
79
+ if (!listed.ok) return null;
80
+ return (
81
+ listed.result.targetInfos?.find(
82
+ (info) =>
83
+ info.type === 'iframe' &&
84
+ typeof info.url === 'string' &&
85
+ info.url.startsWith(prefix) &&
86
+ info.targetId
87
+ ) ?? null
88
+ );
89
+ }, budget);
90
+ if (iframeTarget) {
91
+ const attached = await outcome(
92
+ reader.send('Target.attachToTarget', {
93
+ targetId: iframeTarget.targetId,
94
+ flatten: true,
95
+ })
96
+ );
97
+ if (!attached.ok || !attached.result?.sessionId)
98
+ throw new Error(
99
+ `protected worker iframe attach failed: ${attached.error?.message ?? 'no session'}`
100
+ );
101
+ const iframeSession = attached.result.sessionId;
102
+ sessions.push(iframeSession);
103
+ await outcome(reader.send('Runtime.runIfWaitingForDebugger', {}, iframeSession));
104
+ await outcome(reader.send('Page.enable', {}, iframeSession));
105
+ await outcome(reader.send('Runtime.enable', {}, iframeSession));
106
+ const ready = await waitFor(async () => {
107
+ const read = await outcome(
108
+ reader.send(
109
+ 'Runtime.evaluate',
110
+ { expression: 'document.readyState', returnByValue: true },
111
+ iframeSession
112
+ )
113
+ );
114
+ return read.ok && ['interactive', 'complete'].includes(read.result?.result?.value);
115
+ }, budget);
116
+ if (!ready) throw new Error('protected worker iframe document did not become ready');
117
+ return {
118
+ frameTargetId: iframeTarget.targetId,
119
+ frameId: iframeTarget.targetId,
120
+ evaluate: (expression) =>
121
+ reader.send(
122
+ 'Runtime.evaluate',
123
+ { expression, awaitPromise: true, returnByValue: true },
124
+ iframeSession
125
+ ),
126
+ };
127
+ }
128
+ const child = await until(async () => {
129
+ const tree = await pageFrameTree(reader, sessionId, 'host page');
130
+ if (!tree) return null;
131
+ return (
132
+ (tree.childFrames ?? []).find((node) => String(node.frame?.url ?? '').startsWith(prefix)) ??
133
+ null
134
+ );
135
+ }, budget);
136
+ if (!child) throw new Error('protected worker iframe frame did not appear');
137
+ const contextId = await frameContext(reader, sessionId, child.frame.id, budget);
138
+ if (!contextId) throw new Error('protected worker iframe context missing');
139
+ return {
140
+ frameTargetId: undefined,
141
+ frameId: child.frame.id,
142
+ evaluate: (expression) =>
143
+ reader.send(
144
+ 'Runtime.evaluate',
145
+ { expression, awaitPromise: true, returnByValue: true, contextId },
146
+ sessionId
147
+ ),
148
+ };
149
+ }
150
+
151
+ async function pageFrameTree(reader, sessionId, label) {
152
+ const read = await outcome(reader.send('Page.getFrameTree', {}, sessionId));
153
+ if (!read.ok)
154
+ throw new Error(
155
+ `Page.getFrameTree timed out on ${label} session ${sessionId}: ${read.error?.message ?? 'unknown'}`
156
+ );
157
+ return read.result?.frameTree;
158
+ }
159
+
160
+ export function workerProbeExpression(path, tag) {
161
+ const tagged = `[?&]n=${String(tag).replace(/[.*+?^$${}()|[\]\\]/g, '\\$&')}`;
162
+ return `(() => { try { const href = String(self.location && self.location.href || ''); const tagged = new RegExp(${JSON.stringify(tagged)}).test(href); return (typeof WorkerGlobalScope === 'function' || tagged) && href.includes(${JSON.stringify(path)}); } catch { return false; } })()`;
163
+ }
164
+
165
+ const harnessProbeError = (error) => {
166
+ const message = String(error?.message ?? error ?? '');
167
+ if (
168
+ /wasn't found|not allowed|refused|cannot find context|no target with given id|session closed|detached/i.test(
169
+ message
170
+ )
171
+ )
172
+ return false;
173
+ return /timed out|timeout|ECONN|ENOTFOUND|EPIPE|socket hang up|fetch failed/i.test(message);
174
+ };
175
+
176
+ const probeMs = 3000;
177
+
178
+ export function probeHarnessIncomplete(side, incomplete) {
179
+ if (side !== 'guarded' || !incomplete) return undefined;
180
+ return incomplete;
181
+ }
182
+
183
+ async function probeSession(client, sessionId, path, tag) {
184
+ await outcome(client.send('Runtime.runIfWaitingForDebugger', {}, sessionId));
185
+ let timer;
186
+ try {
187
+ return await outcome(
188
+ Promise.race([
189
+ client.send(
190
+ 'Runtime.evaluate',
191
+ { expression: workerProbeExpression(path, tag), returnByValue: true },
192
+ sessionId
193
+ ),
194
+ new Promise((_, reject) => {
195
+ timer = setTimeout(() => reject(new Error('CDP timed out: Runtime.evaluate')), probeMs);
196
+ }),
197
+ ])
198
+ );
199
+ } finally {
200
+ clearTimeout(timer);
201
+ }
202
+ }
203
+
204
+ export const persistentWorkerTypes = new Set(['shared_worker', 'service_worker']);
205
+ export const attachedWorkerTypes = new Set(['shared_worker', 'service_worker', 'worker']);
206
+
207
+ export function workerTypeForKind(kind) {
208
+ return kinds[kind]?.type;
209
+ }
210
+
211
+ export async function releasePersistentWorkerSessions({
212
+ client,
213
+ reader,
214
+ events,
215
+ from = 0,
216
+ tag,
217
+ targets = [],
218
+ attachSessions = [],
219
+ types = attachedWorkerTypes,
220
+ }) {
221
+ const closer = reader ?? client;
222
+ const seen = new Map();
223
+ for (const event of events.slice(from)) {
224
+ if (event.method !== 'Target.attachedToTarget') continue;
225
+ const info = event.params?.targetInfo;
226
+ if (!types.has(info?.type)) continue;
227
+ const sessionId = event.params?.sessionId;
228
+ if (!sessionId || seen.has(sessionId)) continue;
229
+ seen.set(sessionId, info?.targetId);
230
+ }
231
+ for (const [sessionId] of seen) {
232
+ await outcome(client.send('Runtime.runIfWaitingForDebugger', {}, sessionId));
233
+ await outcome(client.send('Target.detachFromTarget', { sessionId }));
234
+ }
235
+ const listed = await outcome(closer.send('Target.getTargets', {}));
236
+ const byId = new Map((listed.result?.targetInfos ?? []).map((info) => [info.targetId, info]));
237
+ for (const targetId of new Set(targets)) {
238
+ if (byId.get(targetId)?.type === 'iframe') continue;
239
+ await outcome(closer.send('Target.closeTarget', { targetId }));
240
+ }
241
+ for (const info of listed.result?.targetInfos ?? []) {
242
+ if (types.has(info.type) && typeof info.url === 'string' && tag && info.url.includes(tag))
243
+ await outcome(closer.send('Target.closeTarget', { targetId: info.targetId }));
244
+ }
245
+ for (const session of attachSessions)
246
+ await outcome(
247
+ client.send(
248
+ 'Target.setAutoAttach',
249
+ { autoAttach: false, waitForDebuggerOnStart: false, flatten: true },
250
+ session
251
+ )
252
+ );
253
+ }
254
+
255
+ export async function leftoverPersistentWorker(reader, { type, url, targetId } = {}) {
256
+ if (!type && !url && !targetId) return undefined;
257
+ const listed = await outcome(reader.send('Target.getTargets', {}));
258
+ if (!listed.ok) return undefined;
259
+ return (listed.result?.targetInfos ?? []).find((info) => {
260
+ if (type && info.type !== type) return false;
261
+ if (!url && !targetId) return true;
262
+ if (targetId && info.targetId === targetId) return true;
263
+ if (url && info.url === url) return true;
264
+ return false;
265
+ });
266
+ }
267
+
268
+ export async function probeWorkerSessions({ client, sessions, path, tag, detached = () => false }) {
269
+ const ids = [...new Set(sessions.filter(Boolean))];
270
+ if (!ids.length) return { reachable: false, probed: 0, sessions: [], probedIds: [] };
271
+ let reachable = false;
272
+ let probed = 0;
273
+ let harnessErrors = 0;
274
+ const probedIds = [];
275
+ for (const sessionId of ids) {
276
+ if (detached(sessionId)) {
277
+ probedIds.push(sessionId);
278
+ continue;
279
+ }
280
+ probed++;
281
+ let out;
282
+ try {
283
+ out = await probeSession(client, sessionId, path, tag);
284
+ } catch (error) {
285
+ probedIds.push(sessionId);
286
+ if (harnessProbeError(error)) harnessErrors++;
287
+ continue;
288
+ }
289
+ probedIds.push(sessionId);
290
+ if (!out.ok) {
291
+ if (harnessProbeError(out.error)) harnessErrors++;
292
+ continue;
293
+ }
294
+ if (out.result?.result?.value === true) reachable = true;
295
+ }
296
+ if (reachable) return { reachable: true, probed, sessions: ids, probedIds };
297
+ if (harnessErrors > 0)
298
+ return {
299
+ reachable: false,
300
+ probed,
301
+ sessions: ids,
302
+ probedIds,
303
+ incomplete: 'probeSessions harness error without a reachable worker',
304
+ };
305
+ return { reachable: false, probed, sessions: ids, probedIds };
306
+ }
307
+
308
+ async function spawnContext(reader, kind) {
309
+ if (!(kind === 'shared' || kind === 'service'))
310
+ return { browserContextId: undefined, ownsContext: false };
311
+ const opened = await outcome(reader.send('Target.createBrowserContext', {}));
312
+ if (!opened.ok || !opened.result?.browserContextId)
313
+ throw new Error(
314
+ `protected worker context failed: ${opened.error?.message ?? 'no browserContextId'}`
315
+ );
316
+ return { browserContextId: opened.result.browserContextId, ownsContext: true };
317
+ }
318
+
319
+ async function readyEvaluate(reader, sessionId, pageUrl, hostUrl, budget, sessions) {
320
+ await reader.send('Page.enable', {}, sessionId);
321
+ await reader.send('Runtime.enable', {}, sessionId);
322
+ const nav = await outcome(reader.send('Page.navigate', { url: hostUrl ?? pageUrl }, sessionId));
323
+ if (!nav.ok || nav.result?.errorText)
324
+ throw new Error(
325
+ `protected worker page navigate failed: ${nav.error?.message ?? nav.result?.errorText ?? 'unknown'}`
326
+ );
327
+ const loaded = await waitFor(async () => {
328
+ const read = await outcome(
329
+ reader.send(
330
+ 'Runtime.evaluate',
331
+ { expression: 'document.readyState', returnByValue: true },
332
+ sessionId
333
+ )
334
+ );
335
+ return read.ok && ['interactive', 'complete'].includes(read.result?.result?.value);
336
+ }, budget);
337
+ if (!loaded) throw new Error('protected worker page did not become ready');
338
+ if (hostUrl) return framedEvaluate(reader, sessionId, pageUrl, budget, sessions);
339
+ return {
340
+ frameTargetId: undefined,
341
+ frameId: undefined,
342
+ evaluate: (expression) =>
343
+ reader.send(
344
+ 'Runtime.evaluate',
345
+ { expression, awaitPromise: true, returnByValue: true },
346
+ sessionId
347
+ ),
348
+ };
349
+ }
350
+
351
+ function describeTargets(infos) {
352
+ return (infos ?? [])
353
+ .map((info) => {
354
+ const parts = [
355
+ info.type,
356
+ `url=${String(info.url ?? '')}`,
357
+ info.openerId ? `openerId=${info.openerId}` : 'openerId=',
358
+ info.parentId ? `parentId=${info.parentId}` : 'parentId=',
359
+ info.openerFrameId ? `openerFrameId=${info.openerFrameId}` : null,
360
+ ].filter((part) => part !== null);
361
+ return parts.join(',');
362
+ })
363
+ .join(' | ');
364
+ }
365
+
366
+ function urlMatches(info, spec, tag) {
367
+ return (
368
+ typeof info.url === 'string' &&
369
+ info.url.length > 0 &&
370
+ info.url.includes(spec.path) &&
371
+ info.url.includes(tag)
372
+ );
373
+ }
374
+
375
+ function relatedTo(info, owners) {
376
+ if (!owners?.size) return false;
377
+ if (info.openerId && owners.has(info.openerId)) return true;
378
+ if (info.parentId && owners.has(info.parentId)) return true;
379
+ if (info.openerFrameId && owners.has(info.openerFrameId)) return true;
380
+ return false;
381
+ }
382
+
383
+ export function pickSpawnedWorker(infos, { type, path, tag, beforeIds, owners }) {
384
+ const newcomers = (infos ?? []).filter(
385
+ (info) => info.type === type && info.targetId && !beforeIds.has(info.targetId)
386
+ );
387
+ if (!newcomers.length) return null;
388
+ const related = newcomers.filter((info) => relatedTo(info, owners));
389
+ const pool = related.length ? related : newcomers;
390
+ return pool.find((info) => urlMatches(info, { path }, tag)) ?? pool[0];
391
+ }
392
+
393
+ async function findWorker(reader, spec, tag, budget, beforeIds, owners) {
394
+ return until(async () => {
395
+ const listed = await outcome(reader.send('Target.getTargets', {}));
396
+ if (!listed.ok) return null;
397
+ return pickSpawnedWorker(listed.result.targetInfos ?? [], {
398
+ type: spec.type,
399
+ path: spec.path,
400
+ tag,
401
+ beforeIds,
402
+ owners,
403
+ });
404
+ }, budget);
405
+ }
406
+
407
+ async function spawnAndFind(reader, spec, kind, pageUrl, tag, budget, notes, owners, evaluate) {
408
+ const beforeListed = await outcome(reader.send('Target.getTargets', {}));
409
+ const beforeInfos = beforeListed.ok ? (beforeListed.result?.targetInfos ?? []) : [];
410
+ const beforeIds = new Set(beforeInfos.map((info) => info.targetId).filter(Boolean));
411
+ const script = new URL(spec.path, pageUrl);
412
+ script.searchParams.set('n', tag);
413
+ const spawn = await outcome(evaluate(spec.expression(script.href)));
414
+ const value = spawn.result?.result?.value;
415
+ if (!spawn.ok || (kind === 'service' ? value !== 'registered' : value !== 'spawned'))
416
+ throw new Error(`protected worker spawn failed: ${spawn.error?.message ?? value ?? 'unknown'}`);
417
+ const found = await findWorker(reader, spec, tag, budget, beforeIds, owners);
418
+ if (found) return found;
419
+ const afterListed = await outcome(reader.send('Target.getTargets', {}));
420
+ notes.push(
421
+ `protected worker ${kind} getTargets before: ${describeTargets(beforeInfos) || '(none)'}`
422
+ );
423
+ notes.push(
424
+ `protected worker ${kind} getTargets after: ${describeTargets(afterListed.result?.targetInfos) || '(none)'}`
425
+ );
426
+ throw new Error(`protected ${kind} worker target did not appear`);
427
+ }
428
+
429
+ export async function protectedWorker({
430
+ reader,
431
+ kind,
432
+ pageUrl,
433
+ hostUrl,
434
+ tag,
435
+ notes = [],
436
+ budget = 5000,
437
+ }) {
438
+ const spec = kinds[kind];
439
+ if (!spec) throw new Error(`unknown protected worker kind ${kind}`);
440
+ if (!reader) throw new Error('protected worker needs an unguarded observer');
441
+ const { browserContextId, ownsContext } = await spawnContext(reader, kind);
442
+ let pageTargetId;
443
+ let sessionId;
444
+ const sessions = [];
445
+ try {
446
+ const created = await createObserverTarget(
447
+ reader,
448
+ {
449
+ url: 'about:blank',
450
+ background: true,
451
+ ...(browserContextId ? { browserContextId } : {}),
452
+ },
453
+ notes
454
+ );
455
+ pageTargetId = created.targetId;
456
+ ({ sessionId } = await reader.send('Target.attachToTarget', {
457
+ targetId: pageTargetId,
458
+ flatten: true,
459
+ }));
460
+ await outcome(
461
+ reader.send(
462
+ 'Target.setAutoAttach',
463
+ { autoAttach: true, waitForDebuggerOnStart: false, flatten: true },
464
+ sessionId
465
+ )
466
+ );
467
+ const { evaluate, frameTargetId, frameId } = await readyEvaluate(
468
+ reader,
469
+ sessionId,
470
+ pageUrl,
471
+ hostUrl,
472
+ budget,
473
+ sessions
474
+ );
475
+ const owners = new Set([pageTargetId, frameTargetId, frameId].filter(Boolean));
476
+ const found = await spawnAndFind(
477
+ reader,
478
+ spec,
479
+ kind,
480
+ pageUrl,
481
+ tag,
482
+ budget,
483
+ notes,
484
+ owners,
485
+ evaluate
486
+ );
487
+ const exists = await outcome(evaluate(spec.exists));
488
+ if (!exists.ok || exists.result?.result?.value !== true)
489
+ throw new Error(`protected ${kind} worker existence check failed`);
490
+ notes.push(
491
+ `protected worker ${kind}${hostUrl ? ' framed' : ''}: page ${pageTargetId}, worker ${found.targetId}, url ${found.url ?? ''}`
492
+ );
493
+ return {
494
+ exists: true,
495
+ pageTargetId,
496
+ workerTargetId: found.targetId,
497
+ workerUrl: found.url,
498
+ type: spec.type,
499
+ framed: Boolean(hostUrl),
500
+ ...(ownsContext ? { browserContextId } : {}),
501
+ };
502
+ } catch (error) {
503
+ if (ownsContext)
504
+ await outcome(reader.send('Target.disposeBrowserContext', { browserContextId }));
505
+ throw error;
506
+ } finally {
507
+ for (const sid of sessions)
508
+ await outcome(reader.send('Target.detachFromTarget', { sessionId: sid }));
509
+ if (sessionId) await outcome(reader.send('Target.detachFromTarget', { sessionId }));
510
+ }
511
+ }
@@ -231,6 +231,9 @@
231
231
  },
232
232
  "eitherWhen": {
233
233
  "$ref": "#/$defs/check"
234
+ },
235
+ "absencePoll": {
236
+ "type": "boolean"
234
237
  }
235
238
  }
236
239
  },
@@ -545,6 +548,80 @@
545
548
  }
546
549
  }
547
550
  },
551
+ {
552
+ "type": "object",
553
+ "additionalProperties": false,
554
+ "required": ["closeProtectedShape", "as"],
555
+ "properties": {
556
+ "closeProtectedShape": {
557
+ "enum": ["view-source", "blob", "userinfo", "navigating", "blank-inherited"]
558
+ },
559
+ "as": {
560
+ "$ref": "#/$defs/name"
561
+ },
562
+ "requires": {
563
+ "$ref": "#/$defs/requires"
564
+ }
565
+ }
566
+ },
567
+ {
568
+ "type": "object",
569
+ "additionalProperties": false,
570
+ "required": ["spawnWorker", "as"],
571
+ "properties": {
572
+ "spawnWorker": {
573
+ "enum": ["dedicated", "shared", "service"]
574
+ },
575
+ "url": {
576
+ "type": "string",
577
+ "minLength": 1
578
+ },
579
+ "frame": {
580
+ "type": "string",
581
+ "minLength": 1
582
+ },
583
+ "requires": {
584
+ "$ref": "#/$defs/requires"
585
+ },
586
+ "as": {
587
+ "$ref": "#/$defs/name"
588
+ }
589
+ }
590
+ },
591
+ {
592
+ "type": "object",
593
+ "additionalProperties": false,
594
+ "required": ["probeSessions", "as"],
595
+ "properties": {
596
+ "probeSessions": {
597
+ "type": "object",
598
+ "additionalProperties": false,
599
+ "required": ["since", "path"],
600
+ "properties": {
601
+ "since": {
602
+ "$ref": "#/$defs/name"
603
+ },
604
+ "path": {
605
+ "type": "string",
606
+ "minLength": 1
607
+ },
608
+ "attach": {
609
+ "type": "array",
610
+ "minItems": 1,
611
+ "items": {
612
+ "$ref": "#/$defs/name"
613
+ }
614
+ }
615
+ }
616
+ },
617
+ "requires": {
618
+ "$ref": "#/$defs/requires"
619
+ },
620
+ "as": {
621
+ "$ref": "#/$defs/name"
622
+ }
623
+ }
624
+ },
548
625
  {
549
626
  "type": "object",
550
627
  "additionalProperties": false,
@@ -676,6 +753,9 @@
676
753
  "refusable": {
677
754
  "const": true
678
755
  },
756
+ "reuseSession": {
757
+ "const": true
758
+ },
679
759
  "refusal": {
680
760
  "type": "object",
681
761
  "additionalProperties": false,
@@ -851,6 +931,21 @@
851
931
  "minLength": 1
852
932
  }
853
933
  }
934
+ },
935
+ {
936
+ "type": "object",
937
+ "additionalProperties": false,
938
+ "required": ["types"],
939
+ "properties": {
940
+ "types": {
941
+ "type": "array",
942
+ "minItems": 1,
943
+ "items": {
944
+ "type": "string",
945
+ "minLength": 1
946
+ }
947
+ }
948
+ }
854
949
  }
855
950
  ]
856
951
  },