scenescout 3.7.0 → 3.9.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/CHANGELOG.md +14 -0
- package/dist/engine/browser.js +562 -37
- package/dist/engine/collector.js +146 -0
- package/dist/engine/memory.js +16 -1
- package/dist/engine/oracles.js +23 -3
- package/dist/engine/policy.js +358 -2
- package/dist/engine/report.js +54 -5
- package/dist/mcp-server.js +11 -2
- package/package.json +1 -1
- package/skills/scenescout/SKILL.md +1 -1
package/dist/engine/browser.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { chromium, firefox, webkit } from "playwright";
|
|
1
|
+
import { chromium, firefox, webkit, } from "playwright";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { elementKey, fingerprintState, isNonPageRoute, normalizePath } from "./fingerprint.js";
|
|
@@ -7,7 +7,7 @@ import { normalizeTask } from "./task.js";
|
|
|
7
7
|
import { CLAIM_SCAN_SCRIPT, findContradictions } from "./claims.js";
|
|
8
8
|
import { describeInjection, newInjections, probeQueries, probeScript, probeShape, rememberProbe } from "./injection.js";
|
|
9
9
|
import { AuthLossTracker } from "./authloss.js";
|
|
10
|
-
import { COLLECT_INTERACTABLES_SCRIPT, VISIBLE_SRC, geometryIssues, BROKEN_IMAGES_SCRIPT, brokenImageIssues, displayName, missingName, } from "./collector.js";
|
|
10
|
+
import { COLLECT_INTERACTABLES_SCRIPT, VISIBLE_SRC, geometryIssues, BROKEN_IMAGES_SCRIPT, brokenImageIssues, frameLines, hasVisibleFrame, frameElementKey, frameLabel, masksForeignName, MASKED_NAME, capForeignName, stripForeignHref, frameToPageRect, displayName, missingName, } from "./collector.js";
|
|
11
11
|
import { OracleMonitor, formatViolations } from "./oracles.js";
|
|
12
12
|
import { extractCreatedIds, isOwnedResource, normalizeId } from "./ownership.js";
|
|
13
13
|
import { formatJourney, measureJourney } from "./journey.js";
|
|
@@ -20,7 +20,7 @@ import { explainLaunchFailure, isMissingBrowser } from "./launch.js";
|
|
|
20
20
|
import { ACTION_TIMEOUT_MS, performScroll, probeFocusIndicators, probeOverlays, scrollContainer } from "./probes.js";
|
|
21
21
|
import { BROWSER_MARKER, reapOrphanBrowsers } from "./reaper.js";
|
|
22
22
|
import { planUploadOptions, resolveDiskUpload } from "./uploads.js";
|
|
23
|
-
import { answersWithRefusal, destructiveRefusal, isDestructive, isDestructiveWire, allowsWrite, policyRefusal, isAuthExempt, } from "./policy.js";
|
|
23
|
+
import { answersWithRefusal, destructiveRefusal, isDestructive, isDestructiveWire, allowsWrite, policyRefusal, foreignFrameOrigin, foreignWrite, withForeignFrameSandbox, offAppPageWrite, embedOfRequest, hostileForEmbed, trustedEmbedOrigins, MAX_TRUSTED_EMBEDS, trustsForeignWrite, embedProbeRefusal, EmbedMoveTracker, sandboxedRedirectPage, allowsForeignWriteOnSignIn, isAuthExempt, } from "./policy.js";
|
|
24
24
|
import { scanProject } from "../scan.js";
|
|
25
25
|
import { analyzeDesign, DESIGN_COLLECT_SCRIPT } from "./design.js";
|
|
26
26
|
import { acceptMatches, generatedUpload } from "./fixtures.js";
|
|
@@ -52,6 +52,35 @@ const CHOOSER_GRACE_MS = 2000;
|
|
|
52
52
|
const HOVER_REVEAL_WINDOW_MS = 2500;
|
|
53
53
|
/** Non-GET traffic that is auth/telemetry plumbing, not tester-caused state mutation. */
|
|
54
54
|
const BENIGN_MUTATION_RE = /\/auth\/(refresh|token|session)|refresh[-_]?token|\/telemetry|\/analytics|\/heartbeat|\/sentry|\/collect\b|\/logs?\b|\/metrics\b/i;
|
|
55
|
+
/**
|
|
56
|
+
* What the policy needs to know about where a request came from: the URLs of
|
|
57
|
+
* the frame that sent it and of its parents, up to but not including the top
|
|
58
|
+
* document, and that frame's own URL (null when the browser attributes it to no
|
|
59
|
+
* frame, as for a new window or a service worker).
|
|
60
|
+
*/
|
|
61
|
+
function requestSource(req) {
|
|
62
|
+
const frameChain = [];
|
|
63
|
+
let frame;
|
|
64
|
+
try {
|
|
65
|
+
frame = req.frame();
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return { frameChain, frameUrl: null };
|
|
69
|
+
}
|
|
70
|
+
const frameUrl = frame.url();
|
|
71
|
+
const top = frame.page().mainFrame();
|
|
72
|
+
while (frame && frame !== top) {
|
|
73
|
+
frameChain.push(frame.url());
|
|
74
|
+
frame = frame.parentFrame();
|
|
75
|
+
}
|
|
76
|
+
return { frameChain, frameUrl };
|
|
77
|
+
}
|
|
78
|
+
/** The `why` of a top-window navigation refused as a possible frame escape; the notice words it on its own. */
|
|
79
|
+
const ESCAPE_REFUSAL = "a possible frame escape";
|
|
80
|
+
/** The most frames a snapshot reads controls from. */
|
|
81
|
+
const MAX_READ_FRAMES = 10;
|
|
82
|
+
/** How long a snapshot waits for its frames' elements to answer. */
|
|
83
|
+
const FRAME_READ_MS = 1500;
|
|
55
84
|
/** In-page XPath lookup fragment for string-expression evaluates. */
|
|
56
85
|
function xpathLookup(xpath) {
|
|
57
86
|
return `document.evaluate(${JSON.stringify(xpath)}, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue`;
|
|
@@ -153,6 +182,9 @@ export class BrowserEngine {
|
|
|
153
182
|
projectDirNote = "";
|
|
154
183
|
memory = null;
|
|
155
184
|
mode = "read-only";
|
|
185
|
+
/** Origins named as trusted embeds (policy.ts trustsEmbedWrite decides when that counts). */
|
|
186
|
+
trustedEmbeds = new Set();
|
|
187
|
+
trustNotice = "";
|
|
156
188
|
/** Human label for the auth identity driving this session (the server sets it from the storage-state filename). */
|
|
157
189
|
role = "anonymous";
|
|
158
190
|
/** Named-session id (the server sets it; every engine shares one MemoryStore, so
|
|
@@ -601,6 +633,18 @@ export class BrowserEngine {
|
|
|
601
633
|
throw new Error(`storageStatePath does not exist: ${opts.storageStatePath}`);
|
|
602
634
|
}
|
|
603
635
|
this.mode = opts.mode ?? "read-only";
|
|
636
|
+
const trust = trustedEmbedOrigins(opts.trustedEmbeds);
|
|
637
|
+
this.trustedEmbeds = new Set(trust.origins);
|
|
638
|
+
this.trustNotice =
|
|
639
|
+
(trust.origins.length > 0
|
|
640
|
+
? this.mode === "safe-write"
|
|
641
|
+
? ` Trusted embeds: ${trust.origins.join(", ")} — writes their frames send outside the app go out in this mode; hostile input, repeated-click probes and uploads stay refused.`
|
|
642
|
+
: this.mode === "destructive"
|
|
643
|
+
? ` Trusted embeds (${trust.origins.join(", ")}) are not needed in destructive mode, which lets every embed's writes out.`
|
|
644
|
+
: ` Trusted embeds (${trust.origins.join(", ")}) are ignored in ${this.mode} mode: they apply in safe-write only.`
|
|
645
|
+
: "") +
|
|
646
|
+
(trust.rejected.length > 0 ? ` Not a plain http(s) origin, so not trusted: ${trust.rejected.join(", ")}.` : "") +
|
|
647
|
+
(trust.overflow.length > 0 ? ` More than ${MAX_TRUSTED_EMBEDS} trusted embeds; not trusted: ${trust.overflow.join(", ")}.` : "");
|
|
604
648
|
this.sessionObjective = (opts.objective ?? "").trim().replace(/\s+/g, " ").slice(0, 300);
|
|
605
649
|
// An agent-supplied task counts as stated; the placeholder does not.
|
|
606
650
|
this.setTask(opts.task ?? "Attaching and taking stock", opts.task !== undefined);
|
|
@@ -616,6 +660,7 @@ export class BrowserEngine {
|
|
|
616
660
|
this.contradictionsReported = new Set();
|
|
617
661
|
this.pendingCreations = new Set();
|
|
618
662
|
this.baseUrl = opts.url.replace(/\/$/, "");
|
|
663
|
+
this.embedMoves = new EmbedMoveTracker(this.baseUrl);
|
|
619
664
|
// Ownership (ownedIds/createdResources) deliberately NOT reset here: it
|
|
620
665
|
// lives on the shared MemoryStore for the whole run, so re-attaching one
|
|
621
666
|
// role must not discard what another role already created — otherwise
|
|
@@ -637,6 +682,18 @@ export class BrowserEngine {
|
|
|
637
682
|
}
|
|
638
683
|
this.oracles = new OracleMonitor();
|
|
639
684
|
this.oracles.setPolicyRefusalCheck((req) => this.refusedByPolicy.has(req));
|
|
685
|
+
// A request an embed sends to the app is the app's to answer: only one headed outside the app is the embed's.
|
|
686
|
+
this.oracles.setEmbedAttribution((req) => {
|
|
687
|
+
let site = null;
|
|
688
|
+
try {
|
|
689
|
+
const frame = req.frame();
|
|
690
|
+
site = frame === frame.page().mainFrame() ? null : this.foreignOriginOf(frame);
|
|
691
|
+
}
|
|
692
|
+
catch {
|
|
693
|
+
/* no frame: a service worker's or a new window's first request */
|
|
694
|
+
}
|
|
695
|
+
return embedOfRequest(this.baseUrl, req.url(), site);
|
|
696
|
+
});
|
|
640
697
|
this.lastSnap = null;
|
|
641
698
|
this.designAuditCount = 0;
|
|
642
699
|
// The engine learns the route list itself so completion is an objective,
|
|
@@ -688,6 +745,17 @@ export class BrowserEngine {
|
|
|
688
745
|
this.watchResponse(res.request(), res.status());
|
|
689
746
|
});
|
|
690
747
|
this.context.on("request", (req) => {
|
|
748
|
+
// Who moved the driven page, decided on its navigation's first request
|
|
749
|
+
// (this event fires before the route handler judges that page's writes).
|
|
750
|
+
if (req.isNavigationRequest() && !req.redirectedFrom()) {
|
|
751
|
+
try {
|
|
752
|
+
if (this.page && req.frame() === this.page.mainFrame())
|
|
753
|
+
this.embedMoves.navigationStarted(req.url(), req.headers()["referer"], this.embeddedSites());
|
|
754
|
+
}
|
|
755
|
+
catch {
|
|
756
|
+
/* no frame: not the driven page */
|
|
757
|
+
}
|
|
758
|
+
}
|
|
691
759
|
this.inFlight += 1;
|
|
692
760
|
this.lastRequestStart = Date.now();
|
|
693
761
|
const type = req.resourceType();
|
|
@@ -716,6 +784,13 @@ export class BrowserEngine {
|
|
|
716
784
|
return;
|
|
717
785
|
if (this.readOnly && isDestructiveWire(pathnameOf(req.url()), req.postData()))
|
|
718
786
|
return;
|
|
787
|
+
// A foreign frame's write is refused in every mode the route handler runs in.
|
|
788
|
+
if (this.mode !== "destructive" && this.foreignWriteOf(req))
|
|
789
|
+
return;
|
|
790
|
+
if (this.mode !== "destructive" &&
|
|
791
|
+
offAppPageWrite(this.baseUrl, this.page?.url(), req.url(), this.embedMoves.movedTo) &&
|
|
792
|
+
!isAuthExempt(this.mode, method, pathnameOf(req.url()), isDestructiveWire(pathnameOf(req.url()), req.postData())))
|
|
793
|
+
return;
|
|
719
794
|
const pageUrl = this.page?.url();
|
|
720
795
|
if (pageUrl && this.memory) {
|
|
721
796
|
try {
|
|
@@ -731,12 +806,111 @@ export class BrowserEngine {
|
|
|
731
806
|
await this.context.route("**/*", async (route) => {
|
|
732
807
|
const req = route.request();
|
|
733
808
|
const method = req.method();
|
|
809
|
+
// A redirect is followed by the browser without asking, so its target
|
|
810
|
+
// would load unsandboxed: a frame's redirect is answered with a
|
|
811
|
+
// sandboxed page that navigates there itself, and the next hop comes
|
|
812
|
+
// back here. Built from scratch: a redirect's framing or script
|
|
813
|
+
// headers never applied to a redirect, and would block this page.
|
|
814
|
+
const standIn = async (location, setCookie) => {
|
|
815
|
+
const headers = {
|
|
816
|
+
"content-type": "text/html; charset=utf-8",
|
|
817
|
+
"content-security-policy": withForeignFrameSandbox(undefined),
|
|
818
|
+
"cache-control": "no-store",
|
|
819
|
+
};
|
|
820
|
+
if (setCookie)
|
|
821
|
+
headers["set-cookie"] = setCookie;
|
|
822
|
+
await route.fulfill({ status: 200, headers, body: sandboxedRedirectPage(new URL(location, req.url()).href) });
|
|
823
|
+
};
|
|
824
|
+
// WebKit drops the sandbox for a frame that loads a data: URL in its own
|
|
825
|
+
// place. A top-window navigation out of the app, with no Referer, while
|
|
826
|
+
// a frame that held another site sits on a non-web URL, is that frame's
|
|
827
|
+
// escape: refused, judged on the page as it is when the request arrives.
|
|
828
|
+
if (method === "GET" && req.resourceType() === "document" && this.embedEscapeNavigation(req)) {
|
|
829
|
+
const why = ESCAPE_REFUSAL;
|
|
830
|
+
// Reported like any refusal, so a click whose navigation this stopped does not read as a click that did nothing.
|
|
831
|
+
if (this.blockedRequests.length < 20)
|
|
832
|
+
this.blockedRequests.push({ at: Date.now(), sig: `navigation to ${req.url().slice(0, 140)}`, answered: false, why });
|
|
833
|
+
this.logAction({ action: "write-policy:blocked", target: `navigation to ${req.url().slice(0, 140)} (${why})`, url: this.page?.url() ?? "" });
|
|
834
|
+
this.refusedByPolicy.add(req);
|
|
835
|
+
this.oracles.notePolicyBlock();
|
|
836
|
+
await route.abort("blockedbyclient").catch(() => { });
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
const frameDoc = method === "GET" && req.resourceType() === "document" ? this.frameDocumentKind(req) : null;
|
|
840
|
+
// A document loading into a frame of another origin gets the sandbox
|
|
841
|
+
// that forbids popups and top-window navigation (policy.ts says why).
|
|
842
|
+
if (frameDoc === "foreign") {
|
|
843
|
+
try {
|
|
844
|
+
const res = await route.fetch({ maxRedirects: 0 });
|
|
845
|
+
const headers = res.headers();
|
|
846
|
+
const location = res.status() >= 300 && res.status() < 400 ? headers["location"] : undefined;
|
|
847
|
+
if (location) {
|
|
848
|
+
await standIn(location, headers["set-cookie"]);
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
headers["content-security-policy"] = withForeignFrameSandbox(headers["content-security-policy"]);
|
|
852
|
+
await route.fulfill({ response: res, headers });
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
catch {
|
|
856
|
+
// Fail closed: a frame that cannot be sandboxed is not loaded.
|
|
857
|
+
await route.abort("blockedbyclient").catch(() => { });
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
// The app's own frame document can redirect into another site, whose
|
|
862
|
+
// page would then load unsandboxed. Asked first, one hop at a time; a
|
|
863
|
+
// document that does not redirect loads as it would have, sent on
|
|
864
|
+
// rather than served from here — a served document counts as public in
|
|
865
|
+
// Chromium and could no longer reach an app on localhost. The price is
|
|
866
|
+
// a second GET of the app's frame documents that are not redirects.
|
|
867
|
+
if (frameDoc === "app") {
|
|
868
|
+
try {
|
|
869
|
+
const res = await route.fetch({ maxRedirects: 0 });
|
|
870
|
+
const location = res.status() >= 300 && res.status() < 400 ? res.headers()["location"] : undefined;
|
|
871
|
+
// Every hop, not only one out of the app: Playwright does not route the
|
|
872
|
+
// later hops of a redirect, so a chain through the app and then out
|
|
873
|
+
// would go unseen, and each hop is then fetched once, not twice.
|
|
874
|
+
if (location) {
|
|
875
|
+
await standIn(location, res.headers()["set-cookie"]);
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
catch {
|
|
880
|
+
/* could not ask: load it as it would have loaded */
|
|
881
|
+
}
|
|
882
|
+
await route.continue().catch(() => { });
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
734
885
|
if (method === "GET" || method === "HEAD" || method === "OPTIONS")
|
|
735
886
|
return route.continue();
|
|
736
887
|
const url = req.url();
|
|
737
888
|
const pathname = pathnameOf(url);
|
|
889
|
+
const refuse = (why) => {
|
|
890
|
+
const answered = answersWithRefusal(req.resourceType());
|
|
891
|
+
if (this.blockedRequests.length < 20)
|
|
892
|
+
this.blockedRequests.push({ at: Date.now(), sig: `${method} ${url.slice(0, 140)}`, answered, why });
|
|
893
|
+
this.logAction({ action: "write-policy:blocked", target: `${method} ${pathname}${why ? ` (${why})` : ""}`, url: this.page?.url() ?? "" });
|
|
894
|
+
this.refusedByPolicy.add(req);
|
|
895
|
+
this.oracles.notePolicyBlock();
|
|
896
|
+
// A script's request is answered with a refusal, so the page's handling
|
|
897
|
+
// of one actually runs; a navigation is dropped (policy.ts says why).
|
|
898
|
+
// Caught: a request the page has already cancelled rejects these, and an unhandled rejection ends the process.
|
|
899
|
+
if (answered)
|
|
900
|
+
return route.fulfill(policyRefusal(this.mode, method, pathname, req.headers()["origin"], why)).catch(() => { });
|
|
901
|
+
return route.abort("blockedbyclient").catch(() => { });
|
|
902
|
+
};
|
|
903
|
+
// An embedded widget from another site writes to that site, not to the
|
|
904
|
+
// app under test: refused before any other rule, login included.
|
|
905
|
+
const foreign = this.foreignWriteOf(req);
|
|
906
|
+
if (foreign)
|
|
907
|
+
return refuse(`sent from a frame of ${foreign}`);
|
|
738
908
|
this.rememberAuthHeader(req.headers());
|
|
739
909
|
const destructiveWire = isDestructiveWire(pathname, req.postData());
|
|
910
|
+
// An embed moved the session's page off the app: its writes out are not the app's, a sign-in excepted.
|
|
911
|
+
const offApp = offAppPageWrite(this.baseUrl, this.page?.url(), url, this.embedMoves.movedTo);
|
|
912
|
+
if (offApp && !isAuthExempt(this.mode, method, pathname, destructiveWire))
|
|
913
|
+
return refuse(`sent from a page of ${offApp}, outside the app`);
|
|
740
914
|
// Auth/session flows must work in every mode — but never a destructive
|
|
741
915
|
// one, and in observe only the requests a login itself needs.
|
|
742
916
|
if (isAuthExempt(this.mode, method, pathname, destructiveWire))
|
|
@@ -776,17 +950,7 @@ export class BrowserEngine {
|
|
|
776
950
|
}
|
|
777
951
|
return route.continue();
|
|
778
952
|
}
|
|
779
|
-
|
|
780
|
-
if (this.blockedRequests.length < 20)
|
|
781
|
-
this.blockedRequests.push({ at: Date.now(), sig: `${method} ${url.slice(0, 140)}`, answered });
|
|
782
|
-
this.logAction({ action: "write-policy:blocked", target: `${method} ${pathname}`, url: this.page?.url() ?? "" });
|
|
783
|
-
this.refusedByPolicy.add(req);
|
|
784
|
-
this.oracles.notePolicyBlock();
|
|
785
|
-
// A script's request is answered with a refusal, so the page's handling
|
|
786
|
-
// of one actually runs; a navigation is dropped (policy.ts says why).
|
|
787
|
-
if (answered)
|
|
788
|
-
return route.fulfill(policyRefusal(this.mode, method, pathname, req.headers()["origin"]));
|
|
789
|
-
return route.abort("blockedbyclient");
|
|
953
|
+
return refuse();
|
|
790
954
|
});
|
|
791
955
|
}
|
|
792
956
|
// Popups / target=_blank: adopt same-origin pages as the active page (with
|
|
@@ -807,6 +971,7 @@ export class BrowserEngine {
|
|
|
807
971
|
if (sameOrigin) {
|
|
808
972
|
this.oracles.attach(newPage);
|
|
809
973
|
this.wireDialogHandler(newPage);
|
|
974
|
+
this.wireEmbedMoves(newPage);
|
|
810
975
|
this.page = newPage;
|
|
811
976
|
this.refs.clear();
|
|
812
977
|
this.snapshotUrl = "";
|
|
@@ -819,6 +984,7 @@ export class BrowserEngine {
|
|
|
819
984
|
.catch(() => { });
|
|
820
985
|
});
|
|
821
986
|
this.wireDialogHandler(this.page);
|
|
987
|
+
this.wireEmbedMoves(this.page);
|
|
822
988
|
try {
|
|
823
989
|
await this.page.goto(opts.url, { waitUntil: "domcontentloaded", timeout: 20000 });
|
|
824
990
|
}
|
|
@@ -859,9 +1025,51 @@ export class BrowserEngine {
|
|
|
859
1025
|
`Memory: ${this.memory.dir}.${this.memory.loadWarning ? ` WARNING: ${this.memory.loadWarning}` : ""}` +
|
|
860
1026
|
(this.memory.prunedStates > 0 ? ` Trimmed ${this.memory.prunedStates} old page state(s) from the history; coverage is unchanged.` : "") +
|
|
861
1027
|
`${this.memory.legacyDirNote ? ` ${this.memory.legacyDirNote}` : ""}` +
|
|
862
|
-
`${this.memory.gitIgnoreNote ? ` ${this.memory.gitIgnoreNote}` : ""} Call scout_snapshot to see the current state.` +
|
|
1028
|
+
`${this.memory.gitIgnoreNote ? ` ${this.memory.gitIgnoreNote}` : ""}${this.trustNotice} Call scout_snapshot to see the current state.` +
|
|
863
1029
|
authWarning);
|
|
864
1030
|
}
|
|
1031
|
+
/** Whether the page was moved off the app by one of its embeds (policy.ts EmbedMoveTracker). */
|
|
1032
|
+
embedMoves = new EmbedMoveTracker("");
|
|
1033
|
+
/**
|
|
1034
|
+
* Frames that have held a document of another origin, with the first such
|
|
1035
|
+
* origin. A frame that has since moved itself to a data: URL is still that
|
|
1036
|
+
* site's; it leaves the record when it leaves the page, as every frame of a
|
|
1037
|
+
* replaced document does, so nothing has to be cleared at the right moment.
|
|
1038
|
+
* Sticky on purpose: a frame that went back to the app (a silent-renew frame
|
|
1039
|
+
* landing on the app's callback) keeps counting as an embed, which refuses
|
|
1040
|
+
* more, never less.
|
|
1041
|
+
*/
|
|
1042
|
+
foreignFrames = new WeakMap();
|
|
1043
|
+
/** The other sites the driven page embeds right now, by the frames still attached to it. */
|
|
1044
|
+
embeddedSites() {
|
|
1045
|
+
const out = new Set();
|
|
1046
|
+
const page = this.page;
|
|
1047
|
+
if (!page)
|
|
1048
|
+
return out;
|
|
1049
|
+
const top = page.mainFrame();
|
|
1050
|
+
for (const frame of page.frames()) {
|
|
1051
|
+
if (frame === top)
|
|
1052
|
+
continue;
|
|
1053
|
+
const origin = this.foreignFrames.get(frame) ?? foreignFrameOrigin(this.baseUrl, [frame.url()]);
|
|
1054
|
+
if (origin)
|
|
1055
|
+
out.add(origin);
|
|
1056
|
+
}
|
|
1057
|
+
return out;
|
|
1058
|
+
}
|
|
1059
|
+
/** Feed the page's navigations to the embed-move tracker and the foreign-frame record. Wired on every page we drive, like the dialog handler. */
|
|
1060
|
+
wireEmbedMoves(page) {
|
|
1061
|
+
page.on("framenavigated", (frame) => {
|
|
1062
|
+
if (page !== this.page)
|
|
1063
|
+
return;
|
|
1064
|
+
if (frame === page.mainFrame()) {
|
|
1065
|
+
this.embedMoves.pageLoaded(frame.url());
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
1068
|
+
const origin = foreignFrameOrigin(this.baseUrl, [frame.url()]);
|
|
1069
|
+
if (origin && !this.foreignFrames.has(frame))
|
|
1070
|
+
this.foreignFrames.set(frame, origin);
|
|
1071
|
+
});
|
|
1072
|
+
}
|
|
865
1073
|
/** Dialogs (confirm/alert): dismiss in read-only mode, accept otherwise. Must be wired on every page we drive, including adopted popups. */
|
|
866
1074
|
wireDialogHandler(page) {
|
|
867
1075
|
page.on("dialog", (dialog) => {
|
|
@@ -975,31 +1183,156 @@ export class BrowserEngine {
|
|
|
975
1183
|
if (stable)
|
|
976
1184
|
break;
|
|
977
1185
|
}
|
|
1186
|
+
const mainCount = rawElements.length;
|
|
1187
|
+
// Then each frame's own controls, read once, each within its own limit.
|
|
1188
|
+
const framed = await this.collectFrames(page);
|
|
978
1189
|
// Element identity is the coverage key (testid or role+name, ordinal-
|
|
979
1190
|
// disambiguated). When a key persists across snapshots of the same route,
|
|
980
1191
|
// its ref is REUSED — diffs stay meaningful and the agent's mental model
|
|
981
|
-
// (and previously issued refs) survive re-snapshots.
|
|
1192
|
+
// (and previously issued refs) survive re-snapshots. An element inside a
|
|
1193
|
+
// frame carries the frame in its key (collector.ts frameElementKey).
|
|
982
1194
|
const route = normalizePath(page.url());
|
|
983
1195
|
const prevByKey = this.lastSnap?.route === route ? this.lastSnap.byKey : null;
|
|
984
1196
|
this.refs.clear();
|
|
1197
|
+
this.refFrames.clear();
|
|
985
1198
|
const keyCounts = new Map();
|
|
986
|
-
const
|
|
987
|
-
|
|
1199
|
+
const all = [
|
|
1200
|
+
...rawElements.map((raw) => ({ raw })),
|
|
1201
|
+
...framed.flatMap((g) => g.raws.map((raw) => ({ raw: raw, frame: g.frame, tag: g.tag }))),
|
|
1202
|
+
];
|
|
1203
|
+
const elements = all.map(({ raw: el, frame, tag }) => {
|
|
1204
|
+
const baseKey = frameElementKey(elementKey(el), tag);
|
|
988
1205
|
const count = keyCounts.get(baseKey) ?? 0;
|
|
989
1206
|
keyCounts.set(baseKey, count + 1);
|
|
990
1207
|
const key = count === 0 ? baseKey : `${baseKey}~${count}`;
|
|
991
1208
|
const ref = prevByKey?.get(key)?.ref ?? `e${++this.refCounter}`;
|
|
992
1209
|
const full = {
|
|
993
1210
|
...el,
|
|
1211
|
+
...(tag ? { frame: tag } : {}),
|
|
1212
|
+
// Judged on the real label, then masked: the policy must see what a click would press.
|
|
1213
|
+
destructive: isDestructive(el.name, el.testid),
|
|
1214
|
+
...(tag?.foreign ? { name: masksForeignName(el.tag, el.role) ? MASKED_NAME : capForeignName(el.name) } : {}),
|
|
1215
|
+
...(tag?.foreign && el.href ? { href: stripForeignHref(el.href) } : {}),
|
|
994
1216
|
ref,
|
|
995
1217
|
key,
|
|
996
|
-
destructive: isDestructive(el.name, el.testid),
|
|
997
1218
|
};
|
|
998
1219
|
this.refs.set(ref, full);
|
|
1220
|
+
if (frame)
|
|
1221
|
+
this.refFrames.set(ref, frame);
|
|
999
1222
|
return full;
|
|
1000
1223
|
});
|
|
1001
|
-
this.harvestRoutes(elements);
|
|
1002
|
-
|
|
1224
|
+
this.harvestRoutes(elements.filter((el) => !el.frame?.foreign));
|
|
1225
|
+
this.framesRead = new Set(framed.map((g) => g.frame));
|
|
1226
|
+
return { elements, truncated: mainCount >= 150 };
|
|
1227
|
+
}
|
|
1228
|
+
/**
|
|
1229
|
+
* The controls inside the page's frames, up to MAX_READ_FRAMES visible ones,
|
|
1230
|
+
* each read within FRAME_READ_MS so one busy frame cannot stall a snapshot.
|
|
1231
|
+
* Positions are moved into the page's coordinates (the frame element's box
|
|
1232
|
+
* on screen, the frame's own scroll, the page's scroll), so geometry and
|
|
1233
|
+
* the design audit can place them.
|
|
1234
|
+
*/
|
|
1235
|
+
async collectFrames(page) {
|
|
1236
|
+
const top = page.mainFrame();
|
|
1237
|
+
const frames = page.frames().filter((f) => f !== top && !f.isDetached());
|
|
1238
|
+
if (frames.length === 0)
|
|
1239
|
+
return [];
|
|
1240
|
+
const pageScroll = (await page.evaluate("({ x: window.scrollX, y: window.scrollY })").catch(() => null)) ?? {
|
|
1241
|
+
x: 0,
|
|
1242
|
+
y: 0,
|
|
1243
|
+
};
|
|
1244
|
+
const read = async (frame) => {
|
|
1245
|
+
let timer;
|
|
1246
|
+
const work = (async () => {
|
|
1247
|
+
const el = await frame.frameElement();
|
|
1248
|
+
const box = await el.boundingBox();
|
|
1249
|
+
if (!box || box.width < 2 || box.height < 2)
|
|
1250
|
+
return null;
|
|
1251
|
+
const title = ((await el.getAttribute("title")) || (await el.getAttribute("name")) || "").trim();
|
|
1252
|
+
const got = (await frame.evaluate(`(() => ({ els: ${COLLECT_INTERACTABLES_SCRIPT}, sx: window.scrollX, sy: window.scrollY }))()`));
|
|
1253
|
+
const raws = got.els.map((r) => ({ ...r, rect: frameToPageRect(r.rect, box, { x: got.sx, y: got.sy }, pageScroll) }));
|
|
1254
|
+
let origin = "";
|
|
1255
|
+
try {
|
|
1256
|
+
const u = new URL(frame.url());
|
|
1257
|
+
if (u.protocol === "http:" || u.protocol === "https:")
|
|
1258
|
+
origin = u.origin;
|
|
1259
|
+
}
|
|
1260
|
+
catch {
|
|
1261
|
+
/* no web address */
|
|
1262
|
+
}
|
|
1263
|
+
const foreignOrigin = this.foreignOriginOf(frame);
|
|
1264
|
+
return { frame, tag: { url: frame.url(), origin: foreignOrigin ?? origin, title, foreign: foreignOrigin !== null }, raws };
|
|
1265
|
+
})().catch(() => null);
|
|
1266
|
+
const limit = new Promise((resolve) => {
|
|
1267
|
+
timer = setTimeout(() => resolve(null), FRAME_READ_MS);
|
|
1268
|
+
});
|
|
1269
|
+
return Promise.race([work, limit]).finally(() => clearTimeout(timer));
|
|
1270
|
+
};
|
|
1271
|
+
// Visible frames first: a page's hidden plumbing frames must not use up the budget.
|
|
1272
|
+
const boxes = await Promise.all(frames.map((f) => this.withinFrameLimit(f.frameElement().then((el) => el.boundingBox()))));
|
|
1273
|
+
const visible = frames.filter((_, i) => {
|
|
1274
|
+
const b = boxes[i];
|
|
1275
|
+
return !!b && b.width >= 2 && b.height >= 2;
|
|
1276
|
+
});
|
|
1277
|
+
const got = await Promise.all(visible.slice(0, MAX_READ_FRAMES).map(read));
|
|
1278
|
+
return got.filter((g) => g !== null);
|
|
1279
|
+
}
|
|
1280
|
+
/** The origin of another site's frame an element is in, or null for the page and the app's own frames. */
|
|
1281
|
+
foreignEmbedOf(el) {
|
|
1282
|
+
const frame = this.refFrames.get(el.ref);
|
|
1283
|
+
const now = frame ? this.foreignOriginOf(frame) : null;
|
|
1284
|
+
if (now)
|
|
1285
|
+
return now;
|
|
1286
|
+
return el.frame?.foreign ? el.frame.origin || el.frame.url : null;
|
|
1287
|
+
}
|
|
1288
|
+
/**
|
|
1289
|
+
* The other site a frame belongs to, judged on the whole chain up to the
|
|
1290
|
+
* page: a blank or srcdoc frame inside another site's frame is that site's,
|
|
1291
|
+
* and so is a frame that once held its document (the foreign-frame record).
|
|
1292
|
+
*/
|
|
1293
|
+
foreignOriginOf(frame) {
|
|
1294
|
+
const top = frame.page().mainFrame();
|
|
1295
|
+
const chain = [];
|
|
1296
|
+
for (let f = frame; f && f !== top; f = f.parentFrame()) {
|
|
1297
|
+
const recorded = this.foreignFrames.get(f);
|
|
1298
|
+
if (recorded)
|
|
1299
|
+
return recorded;
|
|
1300
|
+
chain.push(f.url());
|
|
1301
|
+
}
|
|
1302
|
+
return foreignFrameOrigin(this.baseUrl, chain);
|
|
1303
|
+
}
|
|
1304
|
+
/** A frame read that gives up after FRAME_READ_MS, answering null. */
|
|
1305
|
+
async withinFrameLimit(work) {
|
|
1306
|
+
let timer;
|
|
1307
|
+
const limit = new Promise((resolve) => {
|
|
1308
|
+
timer = setTimeout(() => resolve(null), FRAME_READ_MS);
|
|
1309
|
+
});
|
|
1310
|
+
return Promise.race([work.catch(() => null), limit]).finally(() => clearTimeout(timer));
|
|
1311
|
+
}
|
|
1312
|
+
/** Frames not directly under the page, less those whose controls were read anyway. */
|
|
1313
|
+
unreadNestedFrames(page, nested) {
|
|
1314
|
+
const top = page.mainFrame();
|
|
1315
|
+
const readNested = [...this.framesRead].filter((f) => !f.isDetached() && f.parentFrame() !== top).length;
|
|
1316
|
+
return Math.max(0, nested - readNested);
|
|
1317
|
+
}
|
|
1318
|
+
/** The frames whose controls the last snapshot listed. */
|
|
1319
|
+
framesRead = new Set();
|
|
1320
|
+
/** The frame each ref of the last snapshot lives in; refs of the page itself are absent. */
|
|
1321
|
+
refFrames = new Map();
|
|
1322
|
+
/**
|
|
1323
|
+
* Where to look for a ref's element: its frame, or the page. A frame that has
|
|
1324
|
+
* gone makes the ref stale, like a page that has navigated.
|
|
1325
|
+
*/
|
|
1326
|
+
scopeOf(el) {
|
|
1327
|
+
const frame = this.refFrames.get(el.ref);
|
|
1328
|
+
if (!frame)
|
|
1329
|
+
return this.requirePage();
|
|
1330
|
+
if (frame.isDetached())
|
|
1331
|
+
throw new Error(`The frame holding ${el.ref} has gone — take a new scout_snapshot.`);
|
|
1332
|
+
// A frame that navigated holds a new document: the ref, and what it knew of the frame's site, are stale.
|
|
1333
|
+
if (el.frame && frame.url() !== el.frame.url)
|
|
1334
|
+
throw new Error(`The frame holding ${el.ref} has navigated — take a new scout_snapshot.`);
|
|
1335
|
+
return frame;
|
|
1003
1336
|
}
|
|
1004
1337
|
/**
|
|
1005
1338
|
* Collect the current page and REGISTER it as a visited state, returning the
|
|
@@ -1106,7 +1439,7 @@ export class BrowserEngine {
|
|
|
1106
1439
|
memory.wasExercised(fp, el.key) ? "done" : null,
|
|
1107
1440
|
el.href ? `href=${el.href.slice(0, 60)}` : null,
|
|
1108
1441
|
].filter(Boolean);
|
|
1109
|
-
return `${el.ref} ${el.role} "${displayName(el)}"${flags.length ? ` [${flags.join(", ")}]` : ""}`;
|
|
1442
|
+
return `${el.ref} ${el.role} "${displayName(el)}"${flags.length ? ` [${flags.join(", ")}]` : ""}${el.frame ? ` ⟨in ${frameLabel(el.frame)}⟩` : ""}`;
|
|
1110
1443
|
};
|
|
1111
1444
|
// Diff mode: when re-snapshotting the same route, report only what
|
|
1112
1445
|
// changed — same idea as UI reconciliation, applied to agent context.
|
|
@@ -1155,10 +1488,19 @@ export class BrowserEngine {
|
|
|
1155
1488
|
`Interactables (${elements.length}${truncated ? "+ — TRUNCATED at 150, dense page" : ""}${missingTestids ? `, ${missingTestids} missing data-testid` : ""}):\n` +
|
|
1156
1489
|
elements.map(line).join("\n");
|
|
1157
1490
|
}
|
|
1158
|
-
|
|
1491
|
+
// Per document: the page's own controls together, and each frame's apart —
|
|
1492
|
+
// an embed's controls are not laid out against the page's.
|
|
1493
|
+
const viewport = page.viewportSize() ?? { width: 1280, height: 900 };
|
|
1494
|
+
const byDocument = new Map();
|
|
1495
|
+
for (const el of elements) {
|
|
1496
|
+
const doc = el.frame ? el.key.slice(0, el.key.indexOf("|") + 1) : "";
|
|
1497
|
+
byDocument.set(doc, [...(byDocument.get(doc) ?? []), el]);
|
|
1498
|
+
}
|
|
1499
|
+
const geometry = [...byDocument.values()].flatMap((group) => geometryIssues(group, viewport));
|
|
1159
1500
|
geometry.push(...(await probeOverlays(page)));
|
|
1160
1501
|
const hiddenFileInputs = await this.hiddenFileInputs(page);
|
|
1161
1502
|
const brokenImages = brokenImageIssues((await page.evaluate(BROKEN_IMAGES_SCRIPT).catch(() => null)) ?? { images: [], total: 0 }, url);
|
|
1503
|
+
const { frames, nested: nestedFrames } = await this.frameInventory(page);
|
|
1162
1504
|
const cov = memory.coverage();
|
|
1163
1505
|
const unvisited = this.unvisitedKnownRoutes();
|
|
1164
1506
|
const title = await page.title();
|
|
@@ -1169,13 +1511,26 @@ export class BrowserEngine {
|
|
|
1169
1511
|
body +
|
|
1170
1512
|
(geometry.length > 0 ? `\nGEOMETRY issues:\n` + geometry.map((g) => ` ⚠ ${g}`).join("\n") : "") +
|
|
1171
1513
|
(brokenImages.length > 0 ? `\nBROKEN IMAGES:\n` + brokenImages.map((b) => ` ⚠ ${b}`).join("\n") : "") +
|
|
1514
|
+
(frames.length > 0 || nestedFrames > 0
|
|
1515
|
+
? `\n` +
|
|
1516
|
+
frameLines(this.baseUrl, frames, {
|
|
1517
|
+
nested: this.unreadNestedFrames(page, nestedFrames),
|
|
1518
|
+
writesRefused: this.mode !== "destructive",
|
|
1519
|
+
trustedWrites: this.mode === "safe-write" ? this.trustedEmbeds : undefined,
|
|
1520
|
+
read: new Set([...this.framesRead].map((f) => f.url())),
|
|
1521
|
+
}).join("\n")
|
|
1522
|
+
: "") +
|
|
1172
1523
|
(hiddenFileInputs.length > 0
|
|
1173
1524
|
? `\nFILE INPUTS not listed above (hidden behind a styled control — a user never sees the input itself): ${hiddenFileInputs.join("; ")}. ` +
|
|
1174
1525
|
`scout_upload {ref} on the control that opens one, or scout_upload {} when it is the page's only file input.`
|
|
1175
1526
|
: "") +
|
|
1176
1527
|
this.socketNotice() +
|
|
1177
1528
|
formatViolations(this.oracles.drain()) +
|
|
1178
|
-
(elements.length === 0
|
|
1529
|
+
(elements.length === 0
|
|
1530
|
+
? hasVisibleFrame(frames)
|
|
1531
|
+
? "\n⚠ No interactable elements in the page itself: what it shows is inside the frames listed above, which were not explored."
|
|
1532
|
+
: "\n⚠ DEAD END: no interactable elements found on this page."
|
|
1533
|
+
: ""));
|
|
1179
1534
|
}
|
|
1180
1535
|
/**
|
|
1181
1536
|
* Resolve a ref and re-verify the live element at action time. Refs are
|
|
@@ -1197,7 +1552,7 @@ export class BrowserEngine {
|
|
|
1197
1552
|
}
|
|
1198
1553
|
// String EXPRESSION via page.evaluate (locator.evaluate treats a string as
|
|
1199
1554
|
// an expression, not a function — the element arg never binds).
|
|
1200
|
-
const live = (await
|
|
1555
|
+
const live = (await this.scopeOf(el)
|
|
1201
1556
|
.evaluate(`(() => { const node = ${xpathLookup(el.xpath)}; if (!node) return null; ` +
|
|
1202
1557
|
`return { testid: node.getAttribute('data-testid'), label: (node.getAttribute('aria-label') || node.innerText || node.textContent || node.getAttribute('placeholder') || '').trim().slice(0, 120) }; })()`)
|
|
1203
1558
|
.catch(() => null));
|
|
@@ -1314,6 +1669,120 @@ export class BrowserEngine {
|
|
|
1314
1669
|
lateMark(entry) {
|
|
1315
1670
|
return entry.at < this.actionStartedAt ? `${entry.sig} (late — likely from a previous action)` : entry.sig;
|
|
1316
1671
|
}
|
|
1672
|
+
/**
|
|
1673
|
+
* The frames directly under the page, read from their <iframe> elements in
|
|
1674
|
+
* one pass each, all at once. Each read gets FRAME_READ_MS: a frame that
|
|
1675
|
+
* cannot answer in time (a busy ad loop in its own process) is left out on
|
|
1676
|
+
* its own rather than stalling the snapshot or dropping the others. Frames
|
|
1677
|
+
* inside frames, and direct frames past the first 30, are only counted.
|
|
1678
|
+
*/
|
|
1679
|
+
async frameInventory(page) {
|
|
1680
|
+
const top = page.mainFrame();
|
|
1681
|
+
const all = page.frames().filter((f) => f !== top);
|
|
1682
|
+
const direct = all.filter((f) => f.parentFrame() === top);
|
|
1683
|
+
const read = async (frame) => {
|
|
1684
|
+
let timer;
|
|
1685
|
+
const box = await Promise.race([
|
|
1686
|
+
frame
|
|
1687
|
+
.frameElement()
|
|
1688
|
+
.then((el) => el.evaluate((node) => {
|
|
1689
|
+
const r = node.getBoundingClientRect();
|
|
1690
|
+
return {
|
|
1691
|
+
title: (node.getAttribute("title") || node.getAttribute("name") || "").trim(),
|
|
1692
|
+
width: Math.round(r.width),
|
|
1693
|
+
height: Math.round(r.height),
|
|
1694
|
+
};
|
|
1695
|
+
}))
|
|
1696
|
+
.catch(() => null),
|
|
1697
|
+
new Promise((resolve) => {
|
|
1698
|
+
timer = setTimeout(() => resolve(null), FRAME_READ_MS);
|
|
1699
|
+
}),
|
|
1700
|
+
]).finally(() => clearTimeout(timer));
|
|
1701
|
+
return box ? { url: frame.url(), ...box, foreign: foreignFrameOrigin(this.baseUrl, [frame.url()]) !== null } : null;
|
|
1702
|
+
};
|
|
1703
|
+
const frames = (await Promise.all(direct.slice(0, 30).map(read))).filter((f) => f !== null);
|
|
1704
|
+
return { frames, nested: all.length - Math.min(direct.length, 30) };
|
|
1705
|
+
}
|
|
1706
|
+
/**
|
|
1707
|
+
* Whether a document request is the top window leaving the app, with no
|
|
1708
|
+
* Referer, while a frame that held another site's document now sits on a
|
|
1709
|
+
* data: or blob: URL — where WebKit no longer applies its sandbox.
|
|
1710
|
+
*/
|
|
1711
|
+
embedEscapeNavigation(req) {
|
|
1712
|
+
const page = this.page;
|
|
1713
|
+
if (!page || !req.isNavigationRequest() || req.redirectedFrom())
|
|
1714
|
+
return false;
|
|
1715
|
+
try {
|
|
1716
|
+
if (req.frame() !== page.mainFrame())
|
|
1717
|
+
return false;
|
|
1718
|
+
}
|
|
1719
|
+
catch {
|
|
1720
|
+
return false;
|
|
1721
|
+
}
|
|
1722
|
+
if (req.headers()["referer"])
|
|
1723
|
+
return false;
|
|
1724
|
+
if (foreignFrameOrigin(this.baseUrl, [req.url()]) === null)
|
|
1725
|
+
return false;
|
|
1726
|
+
const top = page.mainFrame();
|
|
1727
|
+
// data: and blob: only: where WebKit drops the sandbox. A frame the app set back to about:blank is not an escape.
|
|
1728
|
+
return page.frames().some((f) => f !== top && this.foreignFrames.has(f) && /^(data|blob):/i.test(f.url()));
|
|
1729
|
+
}
|
|
1730
|
+
/**
|
|
1731
|
+
* What a document request loads into: a frame (not the top window) of
|
|
1732
|
+
* another origin than the app's, a frame of the app's own origin, or
|
|
1733
|
+
* neither (null). On the app's own sign-in pages frames are left alone: a
|
|
1734
|
+
* "sign in with" button is a foreign frame that has to open its popup.
|
|
1735
|
+
*/
|
|
1736
|
+
frameDocumentKind(req) {
|
|
1737
|
+
let frame;
|
|
1738
|
+
try {
|
|
1739
|
+
frame = req.frame();
|
|
1740
|
+
}
|
|
1741
|
+
catch {
|
|
1742
|
+
return null;
|
|
1743
|
+
}
|
|
1744
|
+
if (frame === frame.page().mainFrame())
|
|
1745
|
+
return null;
|
|
1746
|
+
if (allowsForeignWriteOnSignIn(this.mode, this.page?.url() ?? "", this.baseUrl))
|
|
1747
|
+
return null;
|
|
1748
|
+
let url;
|
|
1749
|
+
try {
|
|
1750
|
+
url = new URL(req.url());
|
|
1751
|
+
}
|
|
1752
|
+
catch {
|
|
1753
|
+
return null;
|
|
1754
|
+
}
|
|
1755
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
1756
|
+
return null;
|
|
1757
|
+
return foreignFrameOrigin(this.baseUrl, [req.url()]) ? "foreign" : "app";
|
|
1758
|
+
}
|
|
1759
|
+
/**
|
|
1760
|
+
* The foreign origin behind a write headed outside the app (policy.ts
|
|
1761
|
+
* foreignWrite), or null — also null on the app's own sign-in page, where a
|
|
1762
|
+
* captcha frame's writes must go out for a login to work.
|
|
1763
|
+
*/
|
|
1764
|
+
foreignWriteOf(req) {
|
|
1765
|
+
let unadoptedPageUrl = null;
|
|
1766
|
+
try {
|
|
1767
|
+
const from = req.frame().page();
|
|
1768
|
+
if (this.page && from !== this.page)
|
|
1769
|
+
unadoptedPageUrl = from.url();
|
|
1770
|
+
}
|
|
1771
|
+
catch {
|
|
1772
|
+
/* no frame: a new window's first request, or a service worker */
|
|
1773
|
+
}
|
|
1774
|
+
// Frames still attached that hold, or held, another site: one that moved itself to data: is no longer foreign by its URL.
|
|
1775
|
+
const pageHasForeignFrame = this.embeddedSites().size > 0;
|
|
1776
|
+
const source = requestSource(req);
|
|
1777
|
+
const originHeader = req.headers()["origin"];
|
|
1778
|
+
const foreign = foreignWrite(this.baseUrl, { url: req.url(), originHeader, unadoptedPageUrl, pageHasForeignFrame, ...source });
|
|
1779
|
+
if (foreign && allowsForeignWriteOnSignIn(this.mode, this.page?.url() ?? "", this.baseUrl))
|
|
1780
|
+
return null;
|
|
1781
|
+
// Frames of origins the user named as trusted, in safe-write, every one involved: the ordinary rules.
|
|
1782
|
+
if (foreign && trustsForeignWrite(this.mode, this.trustedEmbeds, this.baseUrl, { frameChain: source.frameChain, originHeader, unadoptedPageUrl }))
|
|
1783
|
+
return null;
|
|
1784
|
+
return foreign;
|
|
1785
|
+
}
|
|
1317
1786
|
/** Report (and clear) write-policy blocks since the last action. */
|
|
1318
1787
|
drainBlocked() {
|
|
1319
1788
|
this.lastActionBlocked = this.blockedRequests.length;
|
|
@@ -1325,9 +1794,18 @@ export class BrowserEngine {
|
|
|
1325
1794
|
.join("; ");
|
|
1326
1795
|
const extra = this.blockedRequests.length > 5 ? ` (+${this.blockedRequests.length - 5} more)` : "";
|
|
1327
1796
|
const answered = this.blockedRequests.some((e) => e.answered);
|
|
1797
|
+
const reasons = new Set(this.blockedRequests.map((e) => e.why).filter((w) => !!w));
|
|
1798
|
+
const escaped = reasons.delete(ESCAPE_REFUSAL);
|
|
1799
|
+
const foreign = [...reasons];
|
|
1328
1800
|
this.blockedRequests = [];
|
|
1329
1801
|
return (`\n🛡 WRITE-POLICY blocked (${this.mode}): ${list}${extra}. ` +
|
|
1330
1802
|
`This is the tester's safety policy, NOT an app bug — do not file a finding for the resulting error UI. ` +
|
|
1803
|
+
(foreign.length > 0
|
|
1804
|
+
? `Refused because it was ${foreign.join("; ")}: it would reach a site embedded in the page rather than the app, which no mode but destructive allows. `
|
|
1805
|
+
: "") +
|
|
1806
|
+
(escaped
|
|
1807
|
+
? `A move of the whole page off the app, with no Referer, was refused: a frame that held another site now sits on a data: or blob: URL, where WebKit drops the frame's sandbox, so the move may be that frame's. No mode but destructive allows it. `
|
|
1808
|
+
: "") +
|
|
1331
1809
|
(answered
|
|
1332
1810
|
? `The page's own requests were answered with a 403 in the server's place, so the page's handling of a refusal is real: an error message is correct, and a success message is a false_success violation. `
|
|
1333
1811
|
: "") +
|
|
@@ -1429,11 +1907,16 @@ export class BrowserEngine {
|
|
|
1429
1907
|
this.logAction({ action: "click:refused", target: liveLabel || el.name, url: page.url() });
|
|
1430
1908
|
return refusal;
|
|
1431
1909
|
}
|
|
1910
|
+
const embed = this.foreignEmbedOf(el);
|
|
1911
|
+
if (embed && clicks > 1) {
|
|
1912
|
+
this.logAction({ action: "click:refused", target: `${clicks} clicks in a frame of ${embed}`, url: page.url() });
|
|
1913
|
+
return embedProbeRefusal(`a ${clicks}-click probe`, embed);
|
|
1914
|
+
}
|
|
1432
1915
|
// Submit-shaped clicks that fire zero network requests are a smell
|
|
1433
1916
|
// (silent no-op forms): capture the count before to compare after.
|
|
1434
1917
|
const xhrBefore = this.xhrCount;
|
|
1435
1918
|
const submitLike = el.role === "button" && /submit|send|save|create|apply|subscribe|register|sign|post|add\b/i.test(el.name + " " + (el.testid ?? ""));
|
|
1436
|
-
const { forced } = await this.resilientClick(
|
|
1919
|
+
const { forced } = await this.resilientClick(this.scopeOf(el).locator(`xpath=${el.xpath}`), ACTION_TIMEOUT_MS, clicks);
|
|
1437
1920
|
this.memory.markExercised(this.currentFingerprint, el.key, clicks > 1 ? `click×${clicks}` : "click");
|
|
1438
1921
|
const result = await this.afterAction(clicks > 1 ? `click×${clicks}` : "click", `${el.role} "${el.name}"`);
|
|
1439
1922
|
// Impatient-user probe: a rapid multi-click that fires the SAME
|
|
@@ -1543,15 +2026,28 @@ export class BrowserEngine {
|
|
|
1543
2026
|
const refusal = this.actionPolicyCheck(el, liveLabel);
|
|
1544
2027
|
if (refusal)
|
|
1545
2028
|
return refusal;
|
|
1546
|
-
const
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
2029
|
+
const embed = this.foreignEmbedOf(el);
|
|
2030
|
+
if (embed) {
|
|
2031
|
+
const hostile = hostileForEmbed(text);
|
|
2032
|
+
if (hostile) {
|
|
2033
|
+
this.logAction({ action: "type:refused", target: `${el.role} in a frame of ${embed} (${hostile})`, url: page.url() });
|
|
2034
|
+
return embedProbeRefusal(`typing this value (${hostile})`, embed);
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
const locator = this.scopeOf(el).locator(`xpath=${el.xpath}`);
|
|
2038
|
+
// Before the fill: a page that reflects input as it is typed already holds
|
|
2039
|
+
// the element afterwards. Never for another site's frame: no probe is placed there.
|
|
2040
|
+
if (!embed)
|
|
2041
|
+
await this.noteProbe(text, `${el.role} "${el.name}"`);
|
|
2042
|
+
let fillNote = await this.fillOrAppend(locator, text, replace);
|
|
2043
|
+
// What another site's field already held is its business, not the report's.
|
|
2044
|
+
if (embed)
|
|
2045
|
+
fillNote = fillNote.replace(/existing content "(?:[^"\\]|\\.)*"/g, "existing content (masked: another site's frame)");
|
|
1550
2046
|
if (pressEnter) {
|
|
1551
2047
|
// Enter inside a form submits it — check the form's submit target, or
|
|
1552
2048
|
// pressEnter becomes a read-only bypass for destructive submits.
|
|
1553
2049
|
if (this.readOnly) {
|
|
1554
|
-
const submitLabel = (await
|
|
2050
|
+
const submitLabel = (await this.scopeOf(el)
|
|
1555
2051
|
.evaluate(`(() => { const node = ${xpathLookup(el.xpath)}; if (!node) return ''; ` +
|
|
1556
2052
|
`const f = node.form || node.closest('form'); if (!f) return ''; ` +
|
|
1557
2053
|
`const s = f.querySelector('[type="submit"], button:not([type="button"]):not([type="reset"])'); ` +
|
|
@@ -1586,7 +2082,10 @@ export class BrowserEngine {
|
|
|
1586
2082
|
const refusal = this.actionPolicyCheck(el, resolved.liveLabel);
|
|
1587
2083
|
if (refusal)
|
|
1588
2084
|
return refusal;
|
|
1589
|
-
|
|
2085
|
+
const embed = this.foreignEmbedOf(el);
|
|
2086
|
+
if (embed)
|
|
2087
|
+
return embedProbeRefusal("a file upload", embed);
|
|
2088
|
+
locator = this.scopeOf(el).locator(`xpath=${el.xpath}`);
|
|
1590
2089
|
}
|
|
1591
2090
|
const outcome = await this.performUpload(locator, opts);
|
|
1592
2091
|
if (outcome.refused)
|
|
@@ -1783,7 +2282,7 @@ export class BrowserEngine {
|
|
|
1783
2282
|
const page = this.requirePage();
|
|
1784
2283
|
const { el } = await this.resolveForAction(ref);
|
|
1785
2284
|
const { before, bodyBefore, churning } = await this.hoverBaselines();
|
|
1786
|
-
const locator =
|
|
2285
|
+
const locator = this.scopeOf(el).locator(`xpath=${el.xpath}`);
|
|
1787
2286
|
await locator.hover({ timeout: ACTION_TIMEOUT_MS });
|
|
1788
2287
|
// Wiggle inside the element: pointer-tracking libraries distinguish real
|
|
1789
2288
|
// movement from a single synthetic hover event.
|
|
@@ -1793,7 +2292,7 @@ export class BrowserEngine {
|
|
|
1793
2292
|
await page.mouse.move(box.x + box.width / 2 - 2, box.y + box.height / 2 - 1);
|
|
1794
2293
|
}
|
|
1795
2294
|
const { revealed, fallbackUsed } = await this.detectHoverReveal(before, bodyBefore, churning);
|
|
1796
|
-
const attrTexts = (await
|
|
2295
|
+
const attrTexts = (await this.scopeOf(el)
|
|
1797
2296
|
.evaluate(`(() => { const node = ${xpathLookup(el.xpath)}; if (!node) return []; const out = []; ` +
|
|
1798
2297
|
`const t = node.getAttribute('title'); if (t) out.push('title: ' + t.slice(0, 300)); ` +
|
|
1799
2298
|
`const d = node.getAttribute('aria-describedby'); if (d) { for (const id of d.split(/\\s+/)) { ` +
|
|
@@ -1825,7 +2324,7 @@ export class BrowserEngine {
|
|
|
1825
2324
|
return refusal;
|
|
1826
2325
|
if (this.readOnly) {
|
|
1827
2326
|
// Bulk-action dropdowns fire on change — vet the chosen option itself.
|
|
1828
|
-
const optionLabel = (await
|
|
2327
|
+
const optionLabel = (await this.scopeOf(el)
|
|
1829
2328
|
.evaluate(`(() => { const node = ${xpathLookup(el.xpath)}; if (!node) return ''; const v = ${JSON.stringify(value)}; ` +
|
|
1830
2329
|
`const opts = Array.from(node.options || []); ` +
|
|
1831
2330
|
`const o = opts.find(o => o.value === v || o.label === v || (o.textContent || '').trim() === v); ` +
|
|
@@ -1836,7 +2335,7 @@ export class BrowserEngine {
|
|
|
1836
2335
|
return destructiveRefusal(optionLabel || value, this.mode);
|
|
1837
2336
|
}
|
|
1838
2337
|
}
|
|
1839
|
-
const loc =
|
|
2338
|
+
const loc = this.scopeOf(el).locator(`xpath=${el.xpath}`);
|
|
1840
2339
|
const options = el.tag === "select" ? await readSelectOptions(loc) : null;
|
|
1841
2340
|
const picked = await loc.selectOption(value, { timeout: ACTION_TIMEOUT_MS });
|
|
1842
2341
|
this.memory.markExercised(this.currentFingerprint, el.key, "select");
|
|
@@ -1849,11 +2348,37 @@ export class BrowserEngine {
|
|
|
1849
2348
|
* policy as click, or the keyboard becomes a read-only bypass. Shared by
|
|
1850
2349
|
* scout_press and plan press steps; returns a refusal message or null.
|
|
1851
2350
|
*/
|
|
2351
|
+
/**
|
|
2352
|
+
* The frame that holds keyboard focus: the page, or — when the page's
|
|
2353
|
+
* focused element is a frame — that frame, followed down as far as focus goes.
|
|
2354
|
+
*/
|
|
2355
|
+
async focusedFrame(page) {
|
|
2356
|
+
let scope = page;
|
|
2357
|
+
let current = page.mainFrame();
|
|
2358
|
+
for (let depth = 0; depth < 5; depth++) {
|
|
2359
|
+
let next = null;
|
|
2360
|
+
for (const child of current.childFrames()) {
|
|
2361
|
+
// Within the frame limit: a frame that never answers must not hold up a key press.
|
|
2362
|
+
const holds = await this.withinFrameLimit(child.frameElement().then((el) => el.evaluate((node) => node === node.ownerDocument?.activeElement)));
|
|
2363
|
+
if (holds) {
|
|
2364
|
+
next = child;
|
|
2365
|
+
break;
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
if (!next)
|
|
2369
|
+
break;
|
|
2370
|
+
scope = next;
|
|
2371
|
+
current = next;
|
|
2372
|
+
}
|
|
2373
|
+
return scope;
|
|
2374
|
+
}
|
|
1852
2375
|
async vetFocusedActivation(key) {
|
|
1853
2376
|
if (!(this.readOnly && /^(Enter|NumpadEnter|Space| )$/i.test(key)))
|
|
1854
2377
|
return null;
|
|
1855
2378
|
const page = this.requirePage();
|
|
1856
|
-
|
|
2379
|
+
// Focus inside a frame reads, from the page, as the <iframe> itself: follow it down.
|
|
2380
|
+
const focused = await this.focusedFrame(page);
|
|
2381
|
+
const focusedLabel = await focused
|
|
1857
2382
|
.evaluate(`(() => { const el = document.activeElement; if (!el) return ""; ` +
|
|
1858
2383
|
`return (el.getAttribute("aria-label") || el.getAttribute("data-testid") || el.innerText || el.textContent || "").trim().slice(0, 120); })()`)
|
|
1859
2384
|
.catch(() => "");
|