@ai-ecoverse/slicc-browsers 1.20.3 → 1.21.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,17 +183,21 @@ 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 include navigation, redirect chains, popups and frames, request interception, cookie and storage access, privileged pages, error pages, foreign extension worlds, debugger and instrumentation tricks, and session overrides that would leak into a protected page.
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.
187
187
 
188
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.
189
189
 
190
- When a vector compares ordinary and protected fixture markers, those markers do not contain one another. Protected-marker checks still examine complete probe replies, including errors, and the vector's client events and messages, including encoded values.
191
-
192
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.
193
191
 
194
- Client-exit controls use the unguarded observer; persistent-capability refusals are valid evidence, granted instruments must leave no effects after exit, and missing answers remain incomplete.
192
+ ### What a guard must let through
193
+
194
+ For stock Playwright, Puppeteer and raw CDP clients, a guard that declares `stockSetup` admits on ordinary pages:
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
+ - exactly `Emulation.setFocusEmulationEnabled`, `setEmulatedMedia`, `setDeviceMetricsOverride` and `setTouchEmulationEnabled`;
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`.
195
199
 
196
- Forwarding audits retain early snapshots as evidence. A complete final ledger supersedes an incomplete early snapshot, but any forwarding reported in either audit remains a failure. An incomplete final audit remains missing evidence.
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.
197
201
 
198
202
  ### Running them
199
203
 
@@ -225,20 +229,20 @@ You supply:
225
229
  | capability | means the transport can |
226
230
  | --- | --- |
227
231
  | `autoAttach` | auto-attach to child targets such as popups and iframes |
228
- | `browserSession` | send commands to the browser session, not only to a page |
232
+ | `browserSession` | send commands on the browser session, not only a page |
229
233
  | `browserContexts` | create isolated browser contexts |
230
- | `discovery` | report target creation and changes without a session |
231
- | `chromeUrlsAttach` | attach to `chrome://` pages (CDP over a DevTools port can; `chrome.debugger` can't) |
232
- | `extensionWorld` | host a foreign extension's content script on the test page |
233
- | `extensionFrame` | host the harness extension, whose frame an ordinary test page embeds |
234
- | `clientExit` | close the guarded client in the middle of a row |
235
- | `siteIsolationOff`, `inboundDelay`, `forwardAudit` | run without site isolation, hold the browser's messages to the guard, and audit what the guard forwarded |
236
-
237
- `forwardAudit` permits observer restore after a client history timeout only with terminal `historyTrigger: { id: "history-trigger", complete: true, forwarded: false }` proof from `audit({ historyTrigger: command, probes: [], final: false })`, keeping the probe ledger open; missing proof, forwarding or a late successful reply never qualifies as refusal.
234
+ | `discovery` | report target create/change without a session |
235
+ | `chromeUrlsAttach` | attach to `chrome://` pages (DevTools-port CDP; not `chrome.debugger`) |
236
+ | `extensionWorld` | host a foreign extension content script on the test page |
237
+ | `extensionFrame` | host the harness extension frame an ordinary test page embeds |
238
+ | `clientExit` | close the guarded client mid-row |
239
+ | `siteIsolationOff`, `inboundDelay`, `forwardAudit` | no site isolation; hold inbound; audit forwarded commands |
240
+ | `secureOrigin` | HTTPS origins (service workers) |
241
+ | `stockSetup`, `syntheticAutoAttach` | admit stock clients' setup calls; synthesize auto-attach |
238
242
 
239
243
  ### Reading the result
240
244
 
241
- Each row reports `control`, `guarded` and `live` as `pass`, `fail`, `skip`, `incomplete` (the test couldn't gather its own evidence) or `not-run` (vectors described only in words). `summary()` turns the rows into one line:
245
+ Each row reports `control`, `guarded` and `live` as `pass`, `fail`, `skip`, `incomplete` (evidence missing) or `not-run` (words only). `summary()` makes one line:
242
246
 
243
247
  ```text
244
248
  GUARD-PASS my-guard@1a2b3c4 (vectors @ai-ecoverse/slicc-browsers@1.18.5)
@@ -266,7 +270,7 @@ A new discovery behaviour gets a corpus case, and a new guard behaviour gets a g
266
270
 
267
271
  CI is [slicc-shared-web's pipeline](https://github.com/ai-ecoverse/slicc-shared-web#pipeline), called from `.github/workflows/ci.yml`. A pull request gets lint, then `npm test` split over shards by timing, then a Claude and a Codex review; the merge queue runs lint and the tests again. Tests run on Linux only.
268
272
 
269
- Every merge to `main` that warrants it is released by semantic-release in the same workflow, as one version for all three packages. The `vX.Y.Z` tag is the Go release, and npm gets the package with provenance through trusted publishing. Then `ci.yml` publishes the crate to crates.io, also with trusted publishing, and asks the Go module proxy and pkg.go.dev for the new tag. Pushed tags are never moved or deleted: a bad release is fixed by the next one.
273
+ Every merge to `main` that warrants it is released by semantic-release as one version for all three packages. Pushed tags are never moved or deleted: a bad release is fixed by the next one.
270
274
 
271
275
  ## License
272
276
 
@@ -103,6 +103,8 @@ function route(req, url, send, html, redirects) {
103
103
  },
104
104
  '/doc': () => document(url, html),
105
105
  '/csp': () => document(url, html),
106
+ '/sw.js': () =>
107
+ send(200, "self.addEventListener('install',()=>self.skipWaiting())", 'text/javascript'),
106
108
  };
107
109
  if (req.method === 'OPTIONS') return send(204, '');
108
110
  if (url.pathname.startsWith('/auth')) {
package/guard-runner.mjs CHANGED
@@ -60,6 +60,7 @@ const matches = (value, predicate) => {
60
60
  if (key === '$some') return Array.isArray(value) && value.some((item) => matches(item, want));
61
61
  if (key === '$every')
62
62
  return Array.isArray(value) && value.length > 0 && value.every((item) => matches(item, want));
63
+ if (key === '$absentOr') return value === undefined || value === want;
63
64
  return matches(value?.[key], want);
64
65
  });
65
66
  };
@@ -153,6 +154,7 @@ class Execution {
153
154
  this.answers = [];
154
155
  this.internalSessions = new Set();
155
156
  this.attachSettings = new Map();
157
+ this.tabSessions = new Set();
156
158
  this.childSteps = [];
157
159
  this.deferred = [];
158
160
  this.expectWhere = [...(vector.setup ?? []), ...(vector.trigger ?? [])].some(
@@ -346,28 +348,61 @@ class Execution {
346
348
  });
347
349
  }
348
350
 
349
- async blocked(spec, observer) {
350
- const urls = (spec.anyOf ?? [spec.url]).map(address);
351
+ clientBlocked(spec, urls) {
352
+ const session = spec.session === 'child' ? this.sessions.child : this.sessions.page;
353
+ if (!session) return false;
351
354
  const events = this.client.events.slice(this.eventStart);
352
- if (observer === 'client') {
353
- const requests = new Map(
354
- events
355
- .filter((event) => event.method === 'Network.requestWillBeSent')
356
- .map((event) => [event.params.requestId, event.params.request.url])
355
+ const requests = new Map();
356
+ const mainFrames = new Set();
357
+ const mainLoaders = new Set();
358
+ for (const event of events) {
359
+ if (event.sessionId !== session) continue;
360
+ if (event.method === 'Network.requestWillBeSent')
361
+ requests.set(event.params.requestId, {
362
+ url: event.params.request.url,
363
+ type: event.params.type,
364
+ frameId: event.params.frameId,
365
+ loaderId: event.params.loaderId,
366
+ });
367
+ if (event.method === 'Page.frameNavigated' && !event.params.frame.parentId) {
368
+ mainFrames.add(event.params.frame.id);
369
+ if (event.params.frame.loaderId) mainLoaders.add(event.params.frame.loaderId);
370
+ }
371
+ }
372
+ const navigated = (event) => {
373
+ const child = event.params.frame.parentId || event.sessionId === this.sessions.child;
374
+ const frameOk = spec.frame ? child : !event.params.frame.parentId;
375
+ return (
376
+ frameOk &&
377
+ urls.includes(event.params.frame.unreachableUrl) &&
378
+ event.params.frame.url.startsWith('chrome-error://')
357
379
  );
358
- return events.some(
359
- (event) =>
360
- (event.method === 'Page.frameNavigated' &&
361
- (!spec.frame ||
362
- event.params.frame.parentId ||
363
- event.sessionId === this.sessions.child) &&
364
- urls.includes(event.params.frame.unreachableUrl) &&
365
- event.params.frame.url.startsWith('chrome-error://')) ||
366
- (event.method === 'Network.loadingFailed' &&
367
- urls.includes(requests.get(event.params.requestId)) &&
368
- (event.params.blockedReason || event.params.errorText === 'net::ERR_BLOCKED_BY_CLIENT'))
380
+ };
381
+ const failed = (event) => {
382
+ const request = requests.get(event.params.requestId);
383
+ if (
384
+ request?.type !== 'Document' ||
385
+ !urls.includes(request.url) ||
386
+ !(event.params.blockedReason || event.params.errorText === 'net::ERR_BLOCKED_BY_CLIENT')
387
+ )
388
+ return false;
389
+ if (spec.frame) return Boolean(request.frameId && !mainFrames.has(request.frameId));
390
+ return (
391
+ (request.frameId && mainFrames.has(request.frameId)) ||
392
+ (request.loaderId && mainLoaders.has(request.loaderId))
369
393
  );
370
- }
394
+ };
395
+ return events.some(
396
+ (event) =>
397
+ event.sessionId === session &&
398
+ ((event.method === 'Page.frameNavigated' && navigated(event)) ||
399
+ (event.method === 'Network.loadingFailed' && failed(event)))
400
+ );
401
+ }
402
+
403
+ async blocked(spec, observer) {
404
+ const urls = (spec.anyOf ?? [spec.url]).map(address);
405
+ if (observer === 'client') return this.clientBlocked(spec, urls);
371
406
  const targetId = spec.session === 'child' ? this.targets.at(-1) : this.pageTarget;
372
407
  if (!targetId) return false;
373
408
  const attached = await outcome(
@@ -378,21 +413,18 @@ class Execution {
378
413
  const read = await outcome(
379
414
  this.reader.send('Page.getFrameTree', {}, attached.result.sessionId)
380
415
  );
381
- const visit = (tree) =>
416
+ const blockedFrame = (frame) =>
382
417
  Boolean(
383
- tree &&
384
- ((urls.includes(tree.frame.unreachableUrl) &&
385
- tree.frame.url.startsWith('chrome-error://')) ||
386
- (tree.childFrames ?? []).some(visit))
418
+ frame && urls.includes(frame.unreachableUrl) && frame.url.startsWith('chrome-error://')
387
419
  );
388
- if (
389
- read.ok &&
390
- (spec.frame
391
- ? (read.result.frameTree.childFrames ?? []).some(visit)
392
- : visit(read.result.frameTree))
393
- )
394
- return true;
395
- return spec.frame ? Boolean(await this.blockedFrameTarget(urls, targetId)) : false;
420
+ const visit = (tree) =>
421
+ Boolean(tree && (blockedFrame(tree.frame) || (tree.childFrames ?? []).some(visit)));
422
+ if (!read.ok) return false;
423
+ if (spec.frame) {
424
+ if ((read.result.frameTree.childFrames ?? []).some(visit)) return true;
425
+ return Boolean(await this.blockedFrameTarget(urls, targetId));
426
+ }
427
+ return blockedFrame(read.result.frameTree.frame);
396
428
  } finally {
397
429
  await outcome(
398
430
  this.reader.send('Target.detachFromTarget', { sessionId: attached.result.sessionId })
@@ -530,6 +562,7 @@ class Execution {
530
562
 
531
563
  async pump() {
532
564
  while (!this.stopped && !this.clients.flight?.abandoned) {
565
+ this.failWatched();
533
566
  for (const event of this.client.events.slice(this.eventStart)) {
534
567
  if (this.processed.has(event)) continue;
535
568
  this.processed.add(event);
@@ -557,27 +590,35 @@ class Execution {
557
590
  const info = event.params.targetInfo;
558
591
  const child = event.params.sessionId;
559
592
  if (info?.type === 'browser_ui') return;
593
+ const paused = event.params.waitingForDebugger || this.tabSessions.has(event.sessionId);
560
594
  if (this.expectWhere && !this.childWhere) {
561
595
  this.deferred.push(event);
562
596
  return;
563
597
  }
564
598
  if (info?.browserContextId && this.vars.context && info.browserContextId !== this.vars.context)
565
599
  return;
566
- if (this.childWhere && !this.childMatches(info)) {
567
- if (event.params.waitingForDebugger)
600
+ const matched = !this.childWhere || this.childMatches(info);
601
+ const isTab = info?.type === 'tab';
602
+ if (!matched && !isTab) {
603
+ if (paused)
568
604
  this.pending.push(outcome(this.client.send('Runtime.runIfWaitingForDebugger', {}, child)));
569
605
  return;
570
606
  }
571
- this.sessions.child = child;
572
- if (info?.targetId) this.targets.push(info.targetId);
607
+ if (isTab) this.tabSessions.add(child);
608
+ if (matched) {
609
+ this.sessions.child = child;
610
+ if (info?.targetId) this.targets.push(info.targetId);
611
+ }
612
+ const nested = matched
613
+ ? this.childSteps
614
+ : this.childSteps.filter((step) => step.cdp === 'Target.setAutoAttach');
573
615
  const started = (async () => {
574
- const reload = this.childSteps.findIndex((step) => step.cdp === 'Page.reload');
575
- if (reload < 0) await this.execute(this.childSteps, child);
616
+ const reload = nested.findIndex((step) => step.cdp === 'Page.reload');
617
+ if (reload < 0) await this.execute(nested, child);
576
618
  else {
577
- await this.execute(this.childSteps.slice(0, reload), child);
619
+ await this.execute(nested.slice(0, reload), child);
578
620
  const start = this.client.events.length;
579
- if (event.params.waitingForDebugger)
580
- await outcome(this.client.send('Runtime.runIfWaitingForDebugger', {}, child));
621
+ if (paused) await outcome(this.client.send('Runtime.runIfWaitingForDebugger', {}, child));
581
622
  await this.poll(
582
623
  () =>
583
624
  this.client.events
@@ -585,12 +626,11 @@ class Execution {
585
626
  .some((e) => e.method === 'Page.loadEventFired' && e.sessionId === child),
586
627
  1500
587
628
  );
588
- await this.execute(this.childSteps.slice(reload), child);
629
+ await this.execute(nested.slice(reload), child);
589
630
  }
590
631
  const resumed = this.client.events.length;
591
- if (event.params.waitingForDebugger)
592
- await outcome(this.client.send('Runtime.runIfWaitingForDebugger', {}, child));
593
- if (this.childSteps.some((step) => step.cdp === 'Page.enable'))
632
+ if (paused) await outcome(this.client.send('Runtime.runIfWaitingForDebugger', {}, child));
633
+ if (nested.some((step) => step.cdp === 'Page.enable'))
594
634
  await this.poll(() => this.firstLoad(child, resumed), 5000);
595
635
  })().catch((error) => this.errors.push(String(error.message ?? error)));
596
636
  this.childRuns.set(child, started);
@@ -657,8 +697,11 @@ class Execution {
657
697
  }
658
698
 
659
699
  childMatches(info) {
700
+ if (this.childWhere.type && info?.type !== this.childWhere.type) return false;
660
701
  if (this.childWhere.openerId === 'page') return info?.openerId === this.pageTarget;
661
- return String(info?.url ?? '').startsWith(this.childWhere.urlPrefix);
702
+ if (this.childWhere.urlPrefix !== undefined)
703
+ return String(info?.url ?? '').startsWith(this.childWhere.urlPrefix);
704
+ return true;
662
705
  }
663
706
 
664
707
  async childrenSettled() {
@@ -740,6 +783,10 @@ class Execution {
740
783
  phase,
741
784
  Boolean(childSession) && !out.ok && this.detached(childSession)
742
785
  );
786
+ if ((step.isolatedWorld || step.observer) && !out.ok)
787
+ this.notes.push(
788
+ `${step.isolatedWorld ? `isolatedWorld ${step.isolatedWorld}` : `observer ${step.observer}`}: ${out.error?.message ?? 'failed'}`
789
+ );
743
790
  this.options.debug?.({ step, out, side: this.side });
744
791
  }
745
792
  }
@@ -921,11 +968,136 @@ class Execution {
921
968
  })
922
969
  );
923
970
  if (step.guess) return this.guess(step.guess, session);
971
+ if (step.isolatedWorld) return outcome(this.isolatedWorld(step));
972
+ if (step.observer) return outcome(this.watchPage(step));
924
973
  if (step.cdp) return this.cdp(step, session, phase);
925
974
  if (step.page) return this.page(step, session, phase);
926
975
  throw new Error('Unknown step kind');
927
976
  }
928
977
 
978
+ referenced(value, session) {
979
+ if (Array.isArray(value)) return value.map((item) => this.referenced(item, session));
980
+ if (!value || typeof value !== 'object') return value;
981
+ if (Object.hasOwn(value, '$result') || Object.hasOwn(value, '$event')) {
982
+ const source = Object.hasOwn(value, '$result')
983
+ ? this.calls[value.$result]
984
+ : this.actor()
985
+ .events.slice(this.eventStart)
986
+ .filter(
987
+ (event) =>
988
+ event.method === value.$event &&
989
+ (session === undefined || event.sessionId === session) &&
990
+ matches(event, value.where ?? {})
991
+ )
992
+ .at(-1);
993
+ const found = at(source, value.path);
994
+ if (found === undefined)
995
+ throw new Error(`unresolved reference ${value.$result ?? value.$event} ${value.path}`);
996
+ return found;
997
+ }
998
+ return Object.fromEntries(
999
+ Object.entries(value).map(([key, item]) => [key, this.referenced(item, session)])
1000
+ );
1001
+ }
1002
+
1003
+ async isolatedWorld(step) {
1004
+ const targetId = step.session === 'child' ? this.targets.at(-1) : this.pageTarget;
1005
+ const { sessionId } = await this.reader.send('Target.attachToTarget', {
1006
+ targetId,
1007
+ flatten: true,
1008
+ });
1009
+ try {
1010
+ const { frameTree } = await this.reader.send('Page.getFrameTree', {}, sessionId);
1011
+ const { executionContextId } = await this.reader.send(
1012
+ 'Page.createIsolatedWorld',
1013
+ { frameId: frameTree.frame.id, worldName: step.isolatedWorld },
1014
+ sessionId
1015
+ );
1016
+ const read = await this.reader.send(
1017
+ 'Runtime.evaluate',
1018
+ {
1019
+ expression: step.expression,
1020
+ contextId: executionContextId,
1021
+ awaitPromise: true,
1022
+ returnByValue: true,
1023
+ },
1024
+ sessionId
1025
+ );
1026
+ return {
1027
+ contextId: executionContextId,
1028
+ value: read.result?.value,
1029
+ url: frameTree.frame.url,
1030
+ ...(read.exceptionDetails ? { exception: true } : {}),
1031
+ };
1032
+ } finally {
1033
+ await outcome(this.reader.send('Target.detachFromTarget', { sessionId }));
1034
+ }
1035
+ }
1036
+
1037
+ async watchPage(step) {
1038
+ if (!this.watch) {
1039
+ const start = this.reader.events.length;
1040
+ const { sessionId } = await this.reader.send('Target.attachToTarget', {
1041
+ targetId: this.pageTarget,
1042
+ flatten: true,
1043
+ });
1044
+ this.watch = { sessionId, start, failed: new Set() };
1045
+ await this.reader.send('Page.enable', {}, sessionId);
1046
+ await this.reader.send('Runtime.enable', {}, sessionId);
1047
+ await this.reader.send('Debugger.enable', {}, sessionId);
1048
+ } else if (step.observer === 'watch') {
1049
+ this.watch.cleared = this.reader.events.length;
1050
+ }
1051
+ if (step.observer === 'fail') {
1052
+ await this.reader.send(
1053
+ 'Fetch.enable',
1054
+ { patterns: [{ urlPattern: step.url, requestStage: 'Request' }] },
1055
+ this.watch.sessionId
1056
+ );
1057
+ this.watch.failing = true;
1058
+ }
1059
+ return { watching: true };
1060
+ }
1061
+
1062
+ failWatched() {
1063
+ if (!this.watch?.failing) return;
1064
+ for (const event of this.reader.events.slice(this.watch.start)) {
1065
+ if (
1066
+ event.method !== 'Fetch.requestPaused' ||
1067
+ event.sessionId !== this.watch.sessionId ||
1068
+ this.watch.failed.has(event.params.requestId)
1069
+ )
1070
+ continue;
1071
+ this.watch.failed.add(event.params.requestId);
1072
+ this.pending.push(
1073
+ outcome(
1074
+ this.reader.send(
1075
+ 'Fetch.failRequest',
1076
+ { requestId: event.params.requestId, errorReason: 'BlockedByClient' },
1077
+ this.watch.sessionId
1078
+ )
1079
+ )
1080
+ );
1081
+ }
1082
+ }
1083
+
1084
+ watched(c) {
1085
+ if (!this.watch) return 'unwatched';
1086
+ const from = c.afterClear ? (this.watch.cleared ?? this.watch.start) : this.watch.start;
1087
+ const until = c.afterClear ? undefined : this.watch.cleared;
1088
+ return this.reader.events
1089
+ .slice(from, until)
1090
+ .some(
1091
+ (event) =>
1092
+ event.sessionId === this.watch.sessionId &&
1093
+ (c.world !== undefined
1094
+ ? event.method === 'Runtime.executionContextCreated' &&
1095
+ event.params.context.name === c.world &&
1096
+ (c.origin === undefined || event.params.context.origin === c.origin)
1097
+ : event.method === 'Debugger.scriptParsed' && event.params.url === c.script)
1098
+ );
1099
+ }
1100
+
929
1101
  async settle(settle) {
930
1102
  const refused = () => (settle.dependsOn ?? []).some((name) => this.refused[name]);
931
1103
  const skipped = () => {
@@ -1000,7 +1172,12 @@ class Execution {
1000
1172
  if (step.cdp === 'Target.detachFromTarget') await sleep(this.delay);
1001
1173
  if (step.cdp === 'Target.setAutoAttach' && step.params?.autoAttach === false)
1002
1174
  this.cleanupEventStart = this.client.events.length;
1003
- const params = { ...step.params };
1175
+ let params;
1176
+ try {
1177
+ params = this.referenced({ ...step.params }, sessionId);
1178
+ } catch (error) {
1179
+ return { ok: false, error: { message: error.message } };
1180
+ }
1004
1181
  const session =
1005
1182
  step.session === 'page-or-browser' &&
1006
1183
  supported(['browserSession'], this.options.capabilities) &&
@@ -1136,10 +1313,36 @@ class Execution {
1136
1313
  return false;
1137
1314
  }
1138
1315
 
1316
+ forwardedCheck(name, check, want) {
1317
+ const ledger = this.actor().forwarded;
1318
+ if (!Array.isArray(ledger)) {
1319
+ if (want === undefined) return false;
1320
+ this.incomplete.push(`${name}: forwarding audit unavailable`);
1321
+ return 'incomplete';
1322
+ }
1323
+ const c = expand(check, this.vars);
1324
+ const call = [...ledger]
1325
+ .reverse()
1326
+ .find(
1327
+ (entry) => entry.method === c.forwarded.method && matches(entry, c.forwarded.where ?? {})
1328
+ );
1329
+ if (!call) return false;
1330
+ return matches(call.params ?? {}, c.forwarded.params ?? {});
1331
+ }
1332
+
1333
+ async absenceCheck(check) {
1334
+ const first = await this.check(check);
1335
+ if (first === 'unwatched' || first === 'incomplete') return first;
1336
+ if (first === true) return true;
1337
+ const late = await this.poll(async () => (await this.check(check)) === true, observeMs, false);
1338
+ return !!late;
1339
+ }
1340
+
1139
1341
  async observe(name, check) {
1140
1342
  const want = (this.side === 'control' ? this.vector.control.expected : this.vector.expected)?.[
1141
1343
  name
1142
1344
  ];
1345
+ if (check?.forwarded) return this.forwardedCheck(name, check, want);
1143
1346
  if (
1144
1347
  check?.result &&
1145
1348
  (at(this.calls[check.result], check.path) === 'incomplete' || this.unmeasured(check))
@@ -1157,13 +1360,17 @@ class Execution {
1157
1360
  supported(step.requires, this.options.capabilities)
1158
1361
  )
1159
1362
  : [];
1160
- if (want !== true || (check?.observer !== 'fixture' && !snapshots.length))
1161
- return this.check(check);
1363
+ const polled =
1364
+ check?.observer === 'fixture' || check?.world !== undefined || check?.script !== undefined;
1365
+ if (want === false && (check?.world !== undefined || check?.script !== undefined))
1366
+ return this.absenceCheck(check);
1367
+ if (want !== true || (!polled && !snapshots.length)) return this.check(check);
1162
1368
  let seen = false;
1163
1369
  const met = await this.poll(
1164
1370
  async () => {
1165
1371
  if (this.clients.flight?.abandoned) return true;
1166
1372
  seen = await this.check(check);
1373
+ if (seen === 'unwatched' || seen === 'incomplete') return true;
1167
1374
  if (seen !== true && snapshots.length) {
1168
1375
  await this.execute(snapshots, undefined, 'observation');
1169
1376
  if (this.clients.flight?.abandoned) return true;
@@ -1175,6 +1382,7 @@ class Execution {
1175
1382
  false,
1176
1383
  snapshots.length > 0
1177
1384
  );
1385
+ if (seen === 'unwatched' || seen === 'incomplete') return seen;
1178
1386
  if ((!met || this.clients.flight?.abandoned) && snapshots.length) {
1179
1387
  this.incomplete.push(
1180
1388
  `${name}: snapshot evidence not observed within ${this.bound(observeMs)}ms`
@@ -1189,6 +1397,24 @@ class Execution {
1189
1397
  return Boolean(out?.result?.incomplete) && at(out, check.path) === undefined;
1190
1398
  }
1191
1399
 
1400
+ async compoundCheck(c) {
1401
+ if (c.all) {
1402
+ const parts = await Promise.all(c.all.map((item) => this.check(item)));
1403
+ if (parts.includes('unwatched')) return 'unwatched';
1404
+ if (parts.includes('incomplete')) return 'incomplete';
1405
+ return parts.every(Boolean);
1406
+ }
1407
+ if (c.any) {
1408
+ const parts = await Promise.all(c.any.map((item) => this.check(item)));
1409
+ if (parts.includes('unwatched') && !parts.some((part) => part === true)) return 'unwatched';
1410
+ if (parts.includes('incomplete') && !parts.some((part) => part === true)) return 'incomplete';
1411
+ return parts.some((part) => part === true);
1412
+ }
1413
+ const inner = await this.check(c.not);
1414
+ if (inner === 'unwatched' || inner === 'incomplete') return inner;
1415
+ return !inner;
1416
+ }
1417
+
1192
1418
  async check(check) {
1193
1419
  const c = expand(check, this.vars);
1194
1420
  if (!(await this.ready(c))) return false;
@@ -1200,9 +1426,7 @@ class Execution {
1200
1426
  return this.trace.some(
1201
1427
  (entry) => entry.phase === c.executed && (entry.out.ok || entry.refused)
1202
1428
  );
1203
- if (c.all) return (await Promise.all(c.all.map((item) => this.check(item)))).every(Boolean);
1204
- if (c.any) return (await Promise.all(c.any.map((item) => this.check(item)))).some(Boolean);
1205
- if (c.not) return !(await this.check(c.not));
1429
+ if (c.all || c.any || c.not) return this.compoundCheck(c);
1206
1430
  if (c.event) return events.some((e) => e.method === c.event && matches(e, c.where ?? {}));
1207
1431
  if (c.hops || c.beacon || c.served || c.header) return this.requestCheck(c);
1208
1432
  if (c.title !== undefined || c.expression !== undefined) return this.readCheck(c);
@@ -1279,6 +1503,11 @@ class Execution {
1279
1503
  if (c.history) return this.history(c.history);
1280
1504
  if (c.frameBlocked) return this.frameBlocked(c.frameBlocked);
1281
1505
  if (c.pre || c.loaded) return this.attemptCheck(c);
1506
+ if (c.world !== undefined || c.script !== undefined) {
1507
+ const seen = this.watched(c);
1508
+ if (seen === 'unwatched') return 'unwatched';
1509
+ return seen;
1510
+ }
1282
1511
  }
1283
1512
 
1284
1513
  callCheck(c) {
@@ -1398,6 +1627,10 @@ class Execution {
1398
1627
  }
1399
1628
  if (dependencies.some((dep) => this.refused[dep] === true)) continue;
1400
1629
  observed[name] = await this.observe(name, observation.check);
1630
+ if (observed[name] === 'unwatched') {
1631
+ observed[name] = 'incomplete';
1632
+ this.incomplete.push(`${name}: absence watch not installed`);
1633
+ }
1401
1634
  if (exitsClient(this.vector) && dependencies.length && observed[name] === false) {
1402
1635
  observed[name] = 'incomplete';
1403
1636
  this.incomplete.push(`${name}: pre-exit evidence not observed`);
@@ -1476,6 +1709,10 @@ class Execution {
1476
1709
  } finally {
1477
1710
  this.stopped = true;
1478
1711
  await pumping;
1712
+ if (this.watch)
1713
+ await outcome(
1714
+ this.reader.send('Target.detachFromTarget', { sessionId: this.watch.sessionId })
1715
+ );
1479
1716
  for (const session of this.attachSettings.keys())
1480
1717
  await outcome(
1481
1718
  this.client.send(
@@ -1519,6 +1756,9 @@ const observerRequired = (check) => {
1519
1756
  };
1520
1757
  const needsObserver = (vector) =>
1521
1758
  vector.control.via === 'unguarded' ||
1759
+ allSteps([...(vector.setup ?? []), ...(vector.trigger ?? [])]).some(
1760
+ (step) => step.isolatedWorld || step.observer
1761
+ ) ||
1522
1762
  observerRequired(vector.liveCheck) ||
1523
1763
  Object.values(vector.observe ?? {}).some(
1524
1764
  (observation) => observerRequired(observation.check) || observerRequired(observation.eitherWhen)