@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/README.md CHANGED
@@ -183,9 +183,9 @@ It also offers `storageKey(key)` and `unparseable(text)`. A Go or Rust implement
183
183
 
184
184
  ## Guard vectors
185
185
 
186
- A guard sits between a CDP client, such as an agent's browser tool, and the browser, and keeps the client away from protected origins. `guard-vectors.json` holds more than a hundred attacks on that promise, and every guard must pass them all. They cover navigation, redirect chains, popups, frames, request interception, cookies, storage, privileged and error pages, foreign extension worlds, debugger tricks and session overrides that would leak into a protected page.
186
+ A guard sits between a CDP client and the browser and keeps the client away from protected origins. `guard-vectors.json` holds the attacks every guard must pass: navigation, redirects, popups, frames, interception, cookies, storage, privileged and error pages, foreign worlds, debugger tricks and session overrides.
187
187
 
188
- Each vector runs twice. The **control** runs it against an ordinary origin, where it must work. The **guarded** leg runs it through the guard against a protected target, where it must be refused or have no effect. A **liveness** check, made outside the guard, proves the protected target was really there. A row passes only when all three do, so a guard can't pass by breaking everything, and a test can't pass by never reaching the target.
188
+ Each vector runs twice. The **control** runs against an ordinary origin and must work. The **guarded** leg runs through the guard against a protected target and must be refused or have no effect. A **liveness** check outside the guard proves the target was really there. All three must pass.
189
189
 
190
190
  A guard may refuse every client navigation to an `about:` URL that inherits an origin, such as `about:blank`. Refusing an ordinary navigation away from a protected page still fails.
191
191
 
@@ -195,7 +195,7 @@ For stock Playwright, Puppeteer and raw CDP clients, a guard that declares `stoc
195
195
  - `Page.addScriptToEvaluateOnNewDocument` whose `source` matches `^(?:[ \t\n]|//[^\n\r\u2028\u2029]*)*$` on the whole string without the multiline flag (no code), with any `worldName` or `runImmediately`, and its removal;
196
196
  - exactly `Emulation.setFocusEmulationEnabled`, `setEmulatedMedia`, `setDeviceMetricsOverride` and `setTouchEmulationEnabled`;
197
197
  - `Page.createIsolatedWorld` on a frame the guard already classifies as ordinary: strip `grantUniveralAccess` and `contentSecurityPolicy`, return no `executionContextId` for any other frame; `Runtime.addBinding` scoped to such a world by a non-empty `executionContextName` or its `executionContextId`, with `Runtime.bindingCalled` only from those worlds;
198
- - `Target.setAutoAttach`: accept the call but narrow the filter to tab targets, hold pages under the tab like pages (same URL redaction in `targetInfo`); a transport that synthesizes it declares `syntheticAutoAttach`.
198
+ - `Target.setAutoAttach`: either narrow the filter to tab targets or hide per target; no protected-origin page, iframe or worker (dedicated, shared or service) may reach the client; pages under the tab are held like pages (same URL redaction in `targetInfo`); a transport that synthesizes it declares `syntheticAutoAttach`.
199
199
 
200
200
  The strip holds whether or not a given Chrome lets that world reach another origin. Code init scripts, main-world bindings and other overrides stay refused or harmless. Admitted display overrides and no-code init scripts must not remain in effect on a protected document — refuse that navigation, or clear them before it runs. Undeclared, they skip, never pass.
201
201
 
@@ -239,7 +239,7 @@ You supply:
239
239
  | `siteIsolationOff`, `inboundDelay`, `forwardAudit` | no site isolation; hold inbound; audit forwarded commands |
240
240
  | `secureOrigin` | HTTPS origins (service workers) |
241
241
  | `stockSetup`, `syntheticAutoAttach` | admit stock clients' setup calls; synthesize auto-attach |
242
- | `closeProtected` | close a protected tab |
242
+ | `closeProtected` | close a protected tab, except live vault/seven-UI disguises |
243
243
 
244
244
  ### Reading the result
245
245
 
@@ -0,0 +1,563 @@
1
+ import { outcome, parseURL, waitFor } from './guard-driver.mjs';
2
+ import { createObserverTarget } from './observer-target.mjs';
3
+
4
+ const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
5
+ const refused = (out) => !out.ok && /^slicc: refused:/.test(out.error?.message ?? '');
6
+
7
+ const peel = (text) => {
8
+ let u = parseURL(text);
9
+ for (
10
+ let i = 0;
11
+ u && ['blob', 'filesystem', 'view-source'].includes(u.protocol.slice(0, -1)) && i < 8;
12
+ i++
13
+ )
14
+ u = parseURL(u.href.slice(u.protocol.length));
15
+ return u;
16
+ };
17
+
18
+ const hostOf = (u) => {
19
+ const host = (u?.hostname ?? '').toLowerCase();
20
+ return host.endsWith('.') ? host.slice(0, -1) : host;
21
+ };
22
+
23
+ export function keptDocument(text) {
24
+ const u = peel(text);
25
+ if (!u || !['http:', 'https:'].includes(u.protocol)) return false;
26
+ const host = hostOf(u);
27
+ if (host === 'vault.sliccy.ai' || (host.startsWith('vault-') && host.endsWith('.sliccy.ai')))
28
+ return true;
29
+ const path = u.pathname || '/';
30
+ return host === 'seven.sliccy.ai' && path.startsWith('/os/') && path !== '/os/callback.html';
31
+ }
32
+
33
+ export function keptSecurityOrigin(url, origin) {
34
+ if (keptDocument(origin)) return true;
35
+ const u = parseURL(url);
36
+ const opaque =
37
+ u?.protocol === 'about:' && ['blank', 'srcdoc'].includes((u.pathname || '').toLowerCase());
38
+ if (!opaque) return false;
39
+ const o = peel(origin);
40
+ if (!o || !['http:', 'https:'].includes(o.protocol)) return false;
41
+ const host = hostOf(o);
42
+ return (
43
+ host === 'seven.sliccy.ai' ||
44
+ host === 'vault.sliccy.ai' ||
45
+ (host.startsWith('vault-') && host.endsWith('.sliccy.ai'))
46
+ );
47
+ }
48
+
49
+ export function unclosableShape({ url, documentURL, securityOrigin, navigating, navigationURL }) {
50
+ if (navigating && (!navigationURL || keptDocument(navigationURL))) return true;
51
+ const error =
52
+ documentURL === 'chrome-error://chromewebdata/' ||
53
+ (typeof documentURL === 'string' && documentURL.startsWith('chrome-error:'));
54
+ const keptNow =
55
+ keptDocument(url) ||
56
+ keptDocument(documentURL) ||
57
+ keptSecurityOrigin(documentURL ?? url, securityOrigin ?? '');
58
+ if (!keptNow) return false;
59
+ if (!error) return true;
60
+ return keptDocument(documentURL) || keptSecurityOrigin(documentURL, securityOrigin ?? '');
61
+ }
62
+
63
+ async function lockChecks({
64
+ client,
65
+ reader,
66
+ session,
67
+ targetId,
68
+ result,
69
+ until,
70
+ closeOnly,
71
+ skipEvaluate,
72
+ attachRefused,
73
+ }) {
74
+ if (!closeOnly) {
75
+ if (session && !skipEvaluate) {
76
+ result.evaluateRefused = refused(
77
+ await outcome(
78
+ client.send(
79
+ 'Runtime.evaluate',
80
+ { expression: 'document.title', returnByValue: true },
81
+ session
82
+ )
83
+ )
84
+ );
85
+ }
86
+ result.attachRefused = attachRefused;
87
+ }
88
+ await measureClose({ client, reader, targetId, result, until });
89
+ }
90
+
91
+ async function measureClose({ client, reader, targetId, result, until }) {
92
+ const close = await outcome(client.send('Target.closeTarget', { targetId }));
93
+ const gone = await until(
94
+ async () =>
95
+ !(await reader.send('Target.getTargets')).targetInfos.some((i) => i.targetId === targetId)
96
+ );
97
+ result.closed = gone;
98
+ result.closeRefused = refused(close) && !gone;
99
+ }
100
+
101
+ async function attachClient(client, targetId) {
102
+ const attached = await outcome(client.send('Target.attachToTarget', { targetId, flatten: true }));
103
+ const session = attached.result?.sessionId;
104
+ if (session) {
105
+ await outcome(client.send('Page.enable', {}, session));
106
+ await outcome(client.send('Runtime.enable', {}, session));
107
+ }
108
+ return { session, attachRefused: refused(attached) };
109
+ }
110
+
111
+ async function observerPage(reader, url, notes, internalSessions) {
112
+ const opened = await outcome(
113
+ createObserverTarget(reader, { url }, notes).then(async (created) => {
114
+ const { sessionId } = await reader.send('Target.attachToTarget', {
115
+ targetId: created.targetId,
116
+ flatten: true,
117
+ });
118
+ await reader.send('Page.enable', {}, sessionId);
119
+ await reader.send('Runtime.enable', {}, sessionId);
120
+ return { targetId: created.targetId, sessionId };
121
+ })
122
+ );
123
+ if (!opened.ok) return opened;
124
+ internalSessions.add(opened.result.sessionId);
125
+ return opened;
126
+ }
127
+
128
+ function noteShape(notes, label, evidence) {
129
+ notes.push(`${label}: ${JSON.stringify(evidence)}`);
130
+ }
131
+
132
+ async function shapeViewSource({ reader, shapedURL, notes, internalSessions, until }) {
133
+ const opened = await observerPage(reader, 'about:blank', notes, internalSessions);
134
+ if (!opened.ok) return opened;
135
+ const { targetId, sessionId } = opened.result;
136
+ await reader.send('Page.navigate', { url: shapedURL }, sessionId);
137
+ let info;
138
+ const live = await until(async () => {
139
+ ({ targetInfo: info } = await reader.send('Target.getTargetInfo', { targetId }));
140
+ return info.url === shapedURL || info.url.startsWith('view-source:');
141
+ });
142
+ noteShape(notes, 'view-source', {
143
+ targetId,
144
+ type: info?.type,
145
+ url: info?.url,
146
+ });
147
+ return { ok: true, result: { targetId, sessionId, live, shaped: live } };
148
+ }
149
+
150
+ async function shapeBlob({ reader, origin, tag, notes, internalSessions, until }) {
151
+ const page = `${origin}/blob-nav?n=${encodeURIComponent(tag)}`;
152
+ const opened = await observerPage(reader, page, notes, internalSessions);
153
+ if (!opened.ok) return opened;
154
+ const { targetId, sessionId } = opened.result;
155
+ let info;
156
+ const live = await until(async () => {
157
+ ({ targetInfo: info } = await reader.send('Target.getTargetInfo', { targetId }));
158
+ return info.url.startsWith('blob:');
159
+ });
160
+ noteShape(notes, 'blob', { targetId, type: info?.type, url: info?.url });
161
+ return { ok: true, result: { targetId, sessionId, live, shaped: live } };
162
+ }
163
+
164
+ async function shapeUserinfo({ reader, shapedURL, notes, internalSessions, until }) {
165
+ const opened = await observerPage(reader, 'about:blank', notes, internalSessions);
166
+ if (!opened.ok) return opened;
167
+ const { targetId, sessionId } = opened.result;
168
+ await reader.send('Page.navigate', { url: shapedURL }, sessionId);
169
+ let info;
170
+ let frame;
171
+ const live = await until(async () => {
172
+ ({
173
+ frameTree: { frame },
174
+ } = await reader.send('Page.getFrameTree', {}, sessionId));
175
+ ({ targetInfo: info } = await reader.send('Target.getTargetInfo', { targetId }));
176
+ return (
177
+ info.url.includes('@') &&
178
+ info.url.includes('/os/') &&
179
+ frame.url.includes('@') &&
180
+ !frame.unreachableUrl
181
+ );
182
+ });
183
+ noteShape(notes, 'userinfo', {
184
+ targetId,
185
+ type: info?.type,
186
+ url: info?.url,
187
+ frameUrl: frame?.url,
188
+ hasUserinfo: Boolean(info?.url?.includes('@')),
189
+ });
190
+ if (!live && info?.url?.includes('/os/') && !info.url.includes('@'))
191
+ return {
192
+ ok: true,
193
+ result: { incomplete: 'userinfo stripped from top-level target URL' },
194
+ };
195
+ return { ok: true, result: { targetId, sessionId, live, shaped: live } };
196
+ }
197
+
198
+ const raced = (promise, ms) => Promise.race([promise, pause(ms).then(() => undefined)]);
199
+
200
+ async function shapeNavigating({
201
+ reader,
202
+ ordinary,
203
+ destination,
204
+ notes,
205
+ internalSessions,
206
+ hold,
207
+ releaseSlow,
208
+ tag,
209
+ }) {
210
+ const started = Date.now();
211
+ const waitBudget = 8000;
212
+ const start = `${ordinary}/blank?n=close-nav-start`;
213
+ notes.push(`navigating.start: destination=${destination} budgetMs=${waitBudget}`);
214
+ notes.push('navigating.fetch: skipped; fixture /slow holds Document (no Fetch.enable)');
215
+ const opened = await raced(observerPage(reader, start, notes, internalSessions), 3000);
216
+ if (!opened?.ok)
217
+ return {
218
+ ok: true,
219
+ result: {
220
+ incomplete: `navigating shape did not appear: observer setup ${opened?.error?.message ?? 'timed out'}`,
221
+ },
222
+ };
223
+ const { targetId, sessionId } = opened.result;
224
+ hold.targetId = targetId;
225
+ const sequence = [];
226
+ let cursor = reader.events.length;
227
+ let stopped = false;
228
+ const elapsed = () => Date.now() - started;
229
+ const readInfo = async () => {
230
+ const info = await raced(reader.send('Target.getTargetInfo', { targetId }), 400);
231
+ const listed = await raced(reader.send('Target.getTargets'), 400);
232
+ const row = listed?.targetInfos?.find((i) => i.targetId === targetId);
233
+ return info?.targetInfo ?? row;
234
+ };
235
+ const snap = async (label) => {
236
+ const targetInfo = await readInfo();
237
+ notes.push(
238
+ `navigating.${label}: ${JSON.stringify({
239
+ atMs: elapsed(),
240
+ targetId,
241
+ type: targetInfo?.type,
242
+ url: targetInfo?.url,
243
+ title: targetInfo?.title,
244
+ attached: targetInfo?.attached,
245
+ openerId: targetInfo?.openerId,
246
+ pendingUrl: destination,
247
+ observerSession: sessionId,
248
+ sequence: sequence.slice(-12),
249
+ })}`
250
+ );
251
+ return targetInfo;
252
+ };
253
+ hold.release = async () => {
254
+ stopped = true;
255
+ releaseSlow?.(tag);
256
+ await pause(20);
257
+ };
258
+ hold.abort = hold.release;
259
+ await snap('0s');
260
+ const pump = (async () => {
261
+ while (!stopped) {
262
+ for (const event of reader.events.slice(cursor)) {
263
+ cursor++;
264
+ if (
265
+ event.method === 'Fetch.requestPaused' ||
266
+ event.method === 'Page.frameStartedNavigating' ||
267
+ event.method === 'Page.frameRequestedNavigation' ||
268
+ event.method === 'Page.frameNavigated'
269
+ ) {
270
+ const row = {
271
+ atMs: elapsed(),
272
+ method: event.method,
273
+ sessionId: event.sessionId,
274
+ url: event.params.request?.url ?? event.params.url ?? event.params.frame?.url,
275
+ resourceType: event.params.resourceType,
276
+ requestStage: event.params.requestStage,
277
+ frameId: event.params.frameId ?? event.params.frame?.id,
278
+ };
279
+ sequence.push(row);
280
+ notes.push(`navigating.event: ${JSON.stringify(row)}`);
281
+ }
282
+ }
283
+ await pause(5);
284
+ }
285
+ })();
286
+ await raced(reader.send('Page.enable', {}, sessionId), 500);
287
+ notes.push(`navigating.navigate: not-awaited url=${destination} session=${sessionId}`);
288
+ reader
289
+ .send('Page.navigate', { url: destination }, sessionId)
290
+ .then((result) => {
291
+ notes.push(
292
+ `navigating.navigateResult: ${JSON.stringify({
293
+ atMs: elapsed(),
294
+ frameId: result?.frameId,
295
+ loaderId: result?.loaderId,
296
+ errorText: result?.errorText,
297
+ })}`
298
+ );
299
+ })
300
+ .catch((error) => {
301
+ notes.push(`navigating.navigateSettled: ${error?.message ?? error}`);
302
+ });
303
+ const marks = [1000, 2000, 5000];
304
+ let mark = 0;
305
+ let targetInfo;
306
+ let pending = false;
307
+ const deadline = started + waitBudget;
308
+ while (Date.now() < deadline && !pending) {
309
+ pending = sequence.some(
310
+ (event) =>
311
+ (event.method === 'Page.frameStartedNavigating' ||
312
+ event.method === 'Page.frameRequestedNavigation' ||
313
+ event.method === 'Fetch.requestPaused') &&
314
+ (event.url === destination || String(event.url ?? '').includes('/slow'))
315
+ );
316
+ if (!pending) {
317
+ targetInfo = await readInfo();
318
+ pending = targetInfo?.url === destination || String(targetInfo?.url ?? '').includes('/slow');
319
+ }
320
+ while (mark < marks.length && elapsed() >= marks[mark]) {
321
+ await snap(`${marks[mark] / 1000}s`);
322
+ mark++;
323
+ }
324
+ if (!pending) await pause(25);
325
+ }
326
+ stopped = true;
327
+ await Promise.race([pump, pause(20)]);
328
+ targetInfo = targetInfo ?? (await readInfo());
329
+ const shaped = pending;
330
+ notes.push(
331
+ `navigating.done: ${JSON.stringify({
332
+ atMs: elapsed(),
333
+ shaped,
334
+ url: targetInfo?.url,
335
+ title: targetInfo?.title,
336
+ attached: targetInfo?.attached,
337
+ pendingUrl: destination,
338
+ sequence: sequence.slice(-20),
339
+ })}`
340
+ );
341
+ if (!shaped) {
342
+ notes.push(`navigating.giveUp: no pending /slow within ${waitBudget}ms`);
343
+ return {
344
+ ok: true,
345
+ result: {
346
+ incomplete: `navigating shape did not appear: no pending /slow within ${waitBudget}ms`,
347
+ targetId,
348
+ sessionId,
349
+ },
350
+ };
351
+ }
352
+ return {
353
+ ok: true,
354
+ result: {
355
+ targetId,
356
+ sessionId,
357
+ live: true,
358
+ shaped: true,
359
+ navigating: true,
360
+ navigationURL: destination,
361
+ closeOnly: true,
362
+ },
363
+ };
364
+ }
365
+
366
+ async function shapeBlankInherited({ reader, origin, tag, notes, internalSessions, until }) {
367
+ const page = `${origin}/blank-open?n=${encodeURIComponent(tag)}`;
368
+ const opened = await observerPage(reader, page, notes, internalSessions);
369
+ if (!opened.ok) return opened;
370
+ const parent = opened.result;
371
+ const before = new Set(
372
+ (await reader.send('Target.getTargets')).targetInfos.map((info) => info.targetId)
373
+ );
374
+ const openedBlank = await outcome(
375
+ reader.send(
376
+ 'Runtime.evaluate',
377
+ { expression: "window.open(''); undefined", returnByValue: true, userGesture: true },
378
+ parent.sessionId
379
+ )
380
+ );
381
+ if (!openedBlank.ok)
382
+ return {
383
+ ok: true,
384
+ result: {
385
+ incomplete: `window.open failed: ${openedBlank.error?.message ?? openedBlank.error}`,
386
+ parentId: parent.targetId,
387
+ },
388
+ };
389
+ let child;
390
+ const found = await until(async () => {
391
+ const listed = await reader.send('Target.getTargets');
392
+ child = listed.targetInfos.find(
393
+ (info) =>
394
+ !before.has(info.targetId) &&
395
+ info.type === 'page' &&
396
+ (info.url === 'about:blank' || info.url === '') &&
397
+ info.openerId === parent.targetId
398
+ );
399
+ return Boolean(child);
400
+ });
401
+ if (!found) {
402
+ noteShape(notes, 'blank-inherited', {
403
+ parentId: parent.targetId,
404
+ popup: null,
405
+ reason: 'no about:blank page target with opener',
406
+ });
407
+ return {
408
+ ok: true,
409
+ result: {
410
+ incomplete: 'about:blank popup with opener was not created',
411
+ parentId: parent.targetId,
412
+ },
413
+ };
414
+ }
415
+ const { sessionId } = await reader.send('Target.attachToTarget', {
416
+ targetId: child.targetId,
417
+ flatten: true,
418
+ });
419
+ internalSessions.add(sessionId);
420
+ await reader.send('Page.enable', {}, sessionId);
421
+ let securityOrigin;
422
+ try {
423
+ const { frameTree } = await reader.send('Page.getFrameTree', {}, sessionId);
424
+ securityOrigin = frameTree.frame.securityOrigin;
425
+ } catch {
426
+ securityOrigin = undefined;
427
+ }
428
+ noteShape(notes, 'blank-inherited', {
429
+ targetId: child.targetId,
430
+ type: child.type,
431
+ url: child.url,
432
+ openerId: child.openerId,
433
+ parentId: parent.targetId,
434
+ securityOrigin,
435
+ });
436
+ return {
437
+ ok: true,
438
+ result: {
439
+ targetId: child.targetId,
440
+ sessionId,
441
+ parentId: parent.targetId,
442
+ live: true,
443
+ shaped: true,
444
+ securityOrigin,
445
+ },
446
+ };
447
+ }
448
+
449
+ export async function closeProtectedShape({
450
+ client,
451
+ reader,
452
+ ordinary,
453
+ protectedOrigin,
454
+ shape,
455
+ tag,
456
+ internalSessions = new Set(),
457
+ notes = [],
458
+ budget = 5000,
459
+ releaseSlow,
460
+ }) {
461
+ const until = (check) => waitFor(check, budget);
462
+ const result = {};
463
+ const hold = {};
464
+ let shaped;
465
+ const missing = async (reason) => {
466
+ hold.abort?.();
467
+ await hold.release?.();
468
+ const targetId = shaped?.result?.targetId ?? hold.targetId;
469
+ const parentId = shaped?.result?.parentId ?? hold.parentId;
470
+ if (targetId) await outcome(reader.send('Target.closeTarget', { targetId }));
471
+ if (parentId) await outcome(reader.send('Target.closeTarget', { targetId: parentId }));
472
+ return { ...result, incomplete: `close-protected-shape: ${reason}` };
473
+ };
474
+ if (!reader) return missing('missing unguarded reader');
475
+ const origin = protectedOrigin;
476
+ if (shape === 'view-source') {
477
+ shaped = await shapeViewSource({
478
+ reader,
479
+ shapedURL: `view-source:${origin}/doc?n=${encodeURIComponent(tag)}`,
480
+ notes,
481
+ internalSessions,
482
+ until,
483
+ });
484
+ } else if (shape === 'blob') {
485
+ shaped = await shapeBlob({ reader, origin, tag, notes, internalSessions, until });
486
+ } else if (shape === 'userinfo') {
487
+ const host = parseURL(origin)?.host;
488
+ const shapedURL = `${parseURL(origin)?.protocol}//user:pass@${host}/os/index.html?n=${encodeURIComponent(tag)}`;
489
+ shaped = await shapeUserinfo({ reader, shapedURL, notes, internalSessions, until });
490
+ } else if (shape === 'navigating') {
491
+ const navigating = shapeNavigating({
492
+ reader,
493
+ ordinary,
494
+ destination: `${origin}/slow?n=${encodeURIComponent(tag)}&ms=15000`,
495
+ notes,
496
+ internalSessions,
497
+ hold,
498
+ releaseSlow,
499
+ tag,
500
+ }).then((value) => ({ timedOut: false, value }));
501
+ let hardTimer;
502
+ const hard = new Promise((resolve) => {
503
+ hardTimer = setTimeout(() => resolve({ timedOut: true }), 9000);
504
+ });
505
+ const winner = await Promise.race([navigating, hard]);
506
+ clearTimeout(hardTimer);
507
+ if (winner.timedOut) {
508
+ notes.push('navigating.giveUp: hard 9s shape budget');
509
+ hold.abort?.();
510
+ await hold.release?.();
511
+ shaped = {
512
+ ok: true,
513
+ result: {
514
+ incomplete: 'navigating shape did not appear: hard 9s shape budget',
515
+ targetId: hold.targetId,
516
+ },
517
+ };
518
+ } else shaped = winner.value;
519
+ } else if (shape === 'blank-inherited') {
520
+ shaped = await shapeBlankInherited({ reader, origin, tag, notes, internalSessions, until });
521
+ } else {
522
+ return await missing(`unknown shape ${shape}`);
523
+ }
524
+ if (!shaped.ok)
525
+ return await missing(`observer setup failed: ${shaped.error?.message ?? shaped.error}`);
526
+ if (shaped.result.incomplete) return await missing(shaped.result.incomplete);
527
+ if (!shaped.result.live) return await missing(`shape ${shape} did not appear`);
528
+ Object.assign(result, {
529
+ shaped: shaped.result.shaped,
530
+ live: shaped.result.live,
531
+ });
532
+ const { targetId } = shaped.result;
533
+ const closeOnly = shaped.result.closeOnly === true;
534
+ const { session, attachRefused } = await attachClient(client, targetId);
535
+ if (!session) {
536
+ if (!closeOnly) result.attachRefused = attachRefused;
537
+ await measureClose({ client, reader, targetId, result, until });
538
+ await hold.release?.();
539
+ await outcome(reader.send('Target.closeTarget', { targetId }));
540
+ if (shaped.result.parentId)
541
+ await outcome(reader.send('Target.closeTarget', { targetId: shaped.result.parentId }));
542
+ return result;
543
+ }
544
+ try {
545
+ await lockChecks({
546
+ client,
547
+ reader,
548
+ session,
549
+ targetId,
550
+ result,
551
+ until,
552
+ closeOnly,
553
+ skipEvaluate: true,
554
+ attachRefused,
555
+ });
556
+ return result;
557
+ } finally {
558
+ await hold.release?.();
559
+ await outcome(reader.send('Target.closeTarget', { targetId }));
560
+ if (shaped.result.parentId)
561
+ await outcome(reader.send('Target.closeTarget', { targetId: shaped.result.parentId }));
562
+ }
563
+ }
@@ -76,6 +76,16 @@ function route(req, url, send, html, redirects) {
76
76
  { sourcemap: '/listener.js.map' }
77
77
  ),
78
78
  '/page': () => html('trigger', trigger(p)),
79
+ '/blob-nav': () =>
80
+ html(
81
+ 'blob-nav',
82
+ `<p>blob-nav ${escape(p.get('n') ?? '')}</p><script>const b=URL.createObjectURL(new Blob(['<!doctype html><title>blob</title><p>blob ${escape(p.get('n') ?? '')}</p>'],{type:'text/html'}));location.replace(b);</script>`
83
+ ),
84
+ '/blank': () => html('blank', `<p>blank ${escape(p.get('n') ?? '')}</p>`),
85
+ '/blank-open': () => html('blank-open', `<p>blank-open ${escape(p.get('n') ?? '')}</p>`),
86
+ '/os/': () => html('os', `<p>os ${escape(p.get('n') ?? '')}</p>`),
87
+ '/os/index.html': () => html('os', `<p>os ${escape(p.get('n') ?? '')}</p>`),
88
+ '/os/callback.html': () => html('os-callback', `<p>callback ${escape(p.get('n') ?? '')}</p>`),
79
89
  '/api/cookies': () => send(200, req.headers.cookie ?? 'none', 'text/plain'),
80
90
  '/plant': () =>
81
91
  html('plant', '<script>localStorage.setItem("canary","planted")</script>', {
@@ -105,6 +115,13 @@ function route(req, url, send, html, redirects) {
105
115
  '/csp': () => document(url, html),
106
116
  '/sw.js': () =>
107
117
  send(200, "self.addEventListener('install',()=>self.skipWaiting())", 'text/javascript'),
118
+ '/worker.js': () => send(200, 'onmessage=e=>postMessage(e.data)', 'text/javascript'),
119
+ '/shared-worker.js': () =>
120
+ send(
121
+ 200,
122
+ 'onconnect=e=>{e.ports[0].onmessage=m=>e.ports[0].postMessage(m.data)}',
123
+ 'text/javascript'
124
+ ),
108
125
  };
109
126
  if (req.method === 'OPTIONS') return send(204, '');
110
127
  if (url.pathname.startsWith('/auth')) {
@@ -140,10 +157,31 @@ function requestURL(req, scheme) {
140
157
  };
141
158
  }
142
159
 
160
+ async function holdSlow({ req, res, url, p, html, entries, history, slows }) {
161
+ const n = p.get('n') ?? '';
162
+ const ms = Math.min(Math.max(Number(p.get('ms') ?? 15000) || 15000, 1), 60000);
163
+ const entry = {
164
+ time: Date.now(),
165
+ url: url.href,
166
+ method: req.method,
167
+ headers: { ...req.headers },
168
+ };
169
+ entries.push(entry);
170
+ history.push(entry);
171
+ await new Promise((resolve) => {
172
+ const timer = setTimeout(resolve, ms);
173
+ slows.set(n, { resolve, timer });
174
+ });
175
+ slows.delete(n);
176
+ if (res.writableEnded) return;
177
+ return html('slow', `<p>slow ${escape(n)}</p>`);
178
+ }
179
+
143
180
  export async function fixtureServer({ host = '127.0.0.1', port = 0, tls, latency } = {}) {
144
181
  let entries = [];
145
182
  const history = [];
146
183
  const redirects = new Map();
184
+ const slows = new Map();
147
185
  const redirectSecret = async ({ path, host = '', search = '', remove, ...value }) => {
148
186
  if (
149
187
  typeof path !== 'string' ||
@@ -155,6 +193,14 @@ export async function fixtureServer({ host = '127.0.0.1', port = 0, tls, latency
155
193
  if (remove) redirects.delete(key);
156
194
  else redirects.set(key, value);
157
195
  };
196
+ const releaseSlow = (n) => {
197
+ const entry = slows.get(String(n ?? ''));
198
+ if (!entry) return false;
199
+ clearTimeout(entry.timer);
200
+ entry.resolve();
201
+ slows.delete(String(n ?? ''));
202
+ return true;
203
+ };
158
204
  const scheme = tls ? 'https' : 'http';
159
205
  const handler = async (req, res) => {
160
206
  const url = requestURL(req, scheme);
@@ -176,6 +222,10 @@ export async function fixtureServer({ host = '127.0.0.1', port = 0, tls, latency
176
222
  'text/html',
177
223
  headers
178
224
  );
225
+ if (req.method === 'GET' && url.pathname === '/slow') {
226
+ await holdSlow({ req, res, url, p, html, entries, history, slows });
227
+ return;
228
+ }
179
229
  if (req.method === 'GET' && url.pathname === '/log') {
180
230
  const from = Number(p.get('since') ?? 0);
181
231
  if (!Number.isInteger(from) || from < 0 || from > history.length)
@@ -213,6 +263,7 @@ export async function fixtureServer({ host = '127.0.0.1', port = 0, tls, latency
213
263
  return {
214
264
  redirects,
215
265
  redirectSecret,
266
+ releaseSlow,
216
267
  port: address.port,
217
268
  origin: `${scheme}://${host}:${address.port}`,
218
269
  log: () => structuredClone(entries),
@@ -221,6 +272,7 @@ export async function fixtureServer({ host = '127.0.0.1', port = 0, tls, latency
221
272
  },
222
273
  close: () =>
223
274
  new Promise((resolve, reject) => {
275
+ for (const n of [...slows.keys()]) releaseSlow(n);
224
276
  server.closeAllConnections();
225
277
  server.close((error) => (error ? reject(error) : resolve()));
226
278
  }),