@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 +20 -16
- package/fixture-server.mjs +2 -0
- package/guard-runner.mjs +292 -52
- package/guard-vectors.json +3784 -2
- package/package.json +3 -3
- package/schema/guard-vectors.schema.json +249 -28
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
232
|
+
| `browserSession` | send commands on the browser session, not only a page |
|
|
229
233
|
| `browserContexts` | create isolated browser contexts |
|
|
230
|
-
| `discovery` | report target
|
|
231
|
-
| `chromeUrlsAttach` | attach to `chrome://` pages (
|
|
232
|
-
| `extensionWorld` | host a foreign extension
|
|
233
|
-
| `extensionFrame` | host the harness extension
|
|
234
|
-
| `clientExit` | close the guarded client
|
|
235
|
-
| `siteIsolationOff`, `inboundDelay`, `forwardAudit` |
|
|
236
|
-
|
|
237
|
-
|
|
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` (
|
|
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
|
|
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
|
|
package/fixture-server.mjs
CHANGED
|
@@ -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
|
-
|
|
350
|
-
const
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
|
416
|
+
const blockedFrame = (frame) =>
|
|
382
417
|
Boolean(
|
|
383
|
-
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
return
|
|
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
|
-
|
|
567
|
-
|
|
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.
|
|
572
|
-
if (
|
|
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 =
|
|
575
|
-
if (reload < 0) await this.execute(
|
|
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(
|
|
619
|
+
await this.execute(nested.slice(0, reload), child);
|
|
578
620
|
const start = this.client.events.length;
|
|
579
|
-
if (
|
|
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(
|
|
629
|
+
await this.execute(nested.slice(reload), child);
|
|
589
630
|
}
|
|
590
631
|
const resumed = this.client.events.length;
|
|
591
|
-
if (
|
|
592
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1161
|
-
|
|
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
|
|
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)
|