scenescout 3.1.1 → 3.3.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 +89 -0
- package/README.md +3 -2
- package/dist/cli.js +39 -23
- package/dist/engine/brief.js +134 -0
- package/dist/engine/browser.js +262 -19
- package/dist/engine/claims.js +194 -0
- package/dist/engine/lane.js +169 -0
- package/dist/engine/live-page.js +101 -2
- package/dist/engine/live.js +60 -0
- package/dist/engine/memory.js +124 -1
- package/dist/engine/oracles.js +9 -0
- package/dist/engine/pace.js +112 -0
- package/dist/engine/policy.js +53 -2
- package/dist/engine/report.js +70 -6
- package/dist/engine/request.js +144 -0
- package/dist/engine/settle.js +97 -0
- package/dist/engine/verify.js +142 -0
- package/dist/mcp-server.js +171 -33
- package/dist/scan.js +47 -1
- package/package.json +10 -3
- package/skills/scenescout/SKILL.md +11 -2
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Log entries that are not actions: a stated task, an attach, the note that a
|
|
3
|
+
* resource was created. They carry no browser work and take no time, so
|
|
4
|
+
* counting them inflates the action count and drags the median gap toward
|
|
5
|
+
* zero. One real run logged 325 entries of which 107 were stated tasks.
|
|
6
|
+
*/
|
|
7
|
+
const MARKER_ACTIONS = new Set(["task", "attach", "created-resource", "journey:start", "journey:end", "record:full", "record:failed"]);
|
|
8
|
+
/** Whether a log entry represents work the browser actually did. */
|
|
9
|
+
export function isActing(action) {
|
|
10
|
+
return !MARKER_ACTIONS.has(action);
|
|
11
|
+
}
|
|
12
|
+
/** A gap longer than this is the agent thinking, not the browser working. */
|
|
13
|
+
export const IDLE_GAP_MS = 30_000;
|
|
14
|
+
/** A session with no action for this long is probably forgotten, and is holding a browser for nothing. */
|
|
15
|
+
export const STALE_SESSION_MS = 5 * 60_000;
|
|
16
|
+
function median(sorted) {
|
|
17
|
+
if (sorted.length === 0)
|
|
18
|
+
return 0;
|
|
19
|
+
const mid = Math.floor(sorted.length / 2);
|
|
20
|
+
return sorted.length % 2 === 1 ? sorted[mid] : Math.round((sorted[mid - 1] + sorted[mid]) / 2);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The pace of each session in the log, and of the run as a whole. `nowMs` is
|
|
24
|
+
* passed rather than read so the numbers are the same every time they are
|
|
25
|
+
* computed from the same log.
|
|
26
|
+
*/
|
|
27
|
+
export function measurePace(log, nowMs, attached = []) {
|
|
28
|
+
const stillOpen = new Set(attached);
|
|
29
|
+
const bySession = new Map();
|
|
30
|
+
for (const entry of log) {
|
|
31
|
+
if (!isActing(entry.action))
|
|
32
|
+
continue;
|
|
33
|
+
const name = entry.session ?? "default";
|
|
34
|
+
const list = bySession.get(name);
|
|
35
|
+
if (list)
|
|
36
|
+
list.push(entry);
|
|
37
|
+
else
|
|
38
|
+
bySession.set(name, [entry]);
|
|
39
|
+
}
|
|
40
|
+
const sessions = [];
|
|
41
|
+
let first = Number.POSITIVE_INFINITY;
|
|
42
|
+
let last = 0;
|
|
43
|
+
for (const [session, entries] of bySession) {
|
|
44
|
+
const times = entries.map((e) => Date.parse(e.at)).filter((t) => Number.isFinite(t));
|
|
45
|
+
if (times.length === 0)
|
|
46
|
+
continue;
|
|
47
|
+
times.sort((a, b) => a - b);
|
|
48
|
+
first = Math.min(first, times[0]);
|
|
49
|
+
last = Math.max(last, times[times.length - 1]);
|
|
50
|
+
const gaps = [];
|
|
51
|
+
for (let i = 1; i < times.length; i += 1)
|
|
52
|
+
gaps.push(times[i] - times[i - 1]);
|
|
53
|
+
const spanMs = times[times.length - 1] - times[0];
|
|
54
|
+
const idleMs = gaps.filter((g) => g > IDLE_GAP_MS).reduce((sum, g) => sum + g, 0);
|
|
55
|
+
sessions.push({
|
|
56
|
+
session,
|
|
57
|
+
actions: entries.length,
|
|
58
|
+
spanMs,
|
|
59
|
+
medianGapMs: median([...gaps].sort((a, b) => a - b)),
|
|
60
|
+
maxGapMs: gaps.length > 0 ? Math.max(...gaps) : 0,
|
|
61
|
+
idleShare: spanMs > 0 ? idleMs / spanMs : 0,
|
|
62
|
+
quietMs: Math.max(0, nowMs - times[times.length - 1]),
|
|
63
|
+
framed: entries.filter((e) => e.frame).length,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
sessions.sort((a, b) => b.actions - a.actions);
|
|
67
|
+
return {
|
|
68
|
+
sessions,
|
|
69
|
+
spanMs: sessions.length > 0 ? last - first : 0,
|
|
70
|
+
actions: sessions.reduce((sum, x) => sum + x.actions, 0),
|
|
71
|
+
// Only a session that is STILL ATTACHED can be holding a browser. A lane
|
|
72
|
+
// that finished and closed is quiet because it is gone, and warning about
|
|
73
|
+
// it told the reader to close something that no longer exists — which is
|
|
74
|
+
// what the first run of this report did for six of its eleven sessions.
|
|
75
|
+
quiet: sessions.filter((s) => s.quietMs > STALE_SESSION_MS && stillOpen.has(s.session)).map((s) => s.session),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/** Milliseconds as a person says them: 45s, 4m12s, 1h03m. */
|
|
79
|
+
export function sayDuration(ms) {
|
|
80
|
+
const total = Math.max(0, Math.round(ms / 1000));
|
|
81
|
+
if (total < 60)
|
|
82
|
+
return `${total}s`;
|
|
83
|
+
const minutes = Math.floor(total / 60);
|
|
84
|
+
if (minutes < 60)
|
|
85
|
+
return `${minutes}m${String(total % 60).padStart(2, "0")}s`;
|
|
86
|
+
return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, "0")}m`;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The run's pace as report lines. Says what the numbers mean, because "idle
|
|
90
|
+
* 84%" reads as a fault in the engine when it is a description of how the
|
|
91
|
+
* agent paced itself.
|
|
92
|
+
*/
|
|
93
|
+
export function formatPace(pace) {
|
|
94
|
+
if (pace.sessions.length === 0)
|
|
95
|
+
return [];
|
|
96
|
+
const lines = [
|
|
97
|
+
`## How the run was paced`,
|
|
98
|
+
``,
|
|
99
|
+
`${pace.actions} action(s) over ${sayDuration(pace.spanMs)}. Stated tasks and attaches are not counted: they take no time. Idle share is time the browser stood still waiting for the agent, not time the engine spent working.`,
|
|
100
|
+
``,
|
|
101
|
+
`| Session | Actions | Span | Median gap | Longest gap | Idle | Frames |`,
|
|
102
|
+
`|---|---:|---:|---:|---:|---:|---:|`,
|
|
103
|
+
];
|
|
104
|
+
for (const s of pace.sessions) {
|
|
105
|
+
lines.push(`| ${s.session} | ${s.actions} | ${sayDuration(s.spanMs)} | ${sayDuration(s.medianGapMs)} | ${sayDuration(s.maxGapMs)} | ${Math.round(s.idleShare * 100)}% | ${s.framed} |`);
|
|
106
|
+
}
|
|
107
|
+
lines.push(``);
|
|
108
|
+
if (pace.quiet.length > 0) {
|
|
109
|
+
lines.push(`⚠ Held a browser with nothing to do for over ${sayDuration(STALE_SESSION_MS)}: ${pace.quiet.join(", ")}. A session waiting for its turn should be closed and re-attached when it is needed.`, ``);
|
|
110
|
+
}
|
|
111
|
+
return lines;
|
|
112
|
+
}
|
package/dist/engine/policy.js
CHANGED
|
@@ -22,7 +22,11 @@ const DESTRUCTIVE_PATTERNS = [
|
|
|
22
22
|
/\breset\b(?!\s+(filters?|zoom|search|view|sort|order|form|password|layout|columns?|selection|preferences?|defaults?))/i,
|
|
23
23
|
/\bdiscard\b/i,
|
|
24
24
|
/\bcancel subscription\b/i,
|
|
25
|
-
|
|
25
|
+
// The verb: a control that signs the user off, alone or after another verb
|
|
26
|
+
// ("Save and sign off"). The noun is how approval apps label things, and it
|
|
27
|
+
// is told by the word before it ("Needs sign-off", "Awaiting sign-off",
|
|
28
|
+
// "Send for sign-off"); those destroy nothing. Same shape as the reset rule.
|
|
29
|
+
/(?<!\b(?:needs?|awaiting|awaits|pending|requires?|required|for|before|after|without|of|the|a|an|its|their|your|my)\s)\bsign(?: |-)?off\b/i,
|
|
26
30
|
// The tool is generic — destructive labels come in many languages.
|
|
27
31
|
/\b(eliminar|borrar|suprimir)\b/i, // es
|
|
28
32
|
/\b(excluir|apagar|remover)\b/i, // pt
|
|
@@ -74,8 +78,55 @@ export function isDestructiveWire(url, body) {
|
|
|
74
78
|
}
|
|
75
79
|
/** Auth/session flows must work even under strict write policies (login, token refresh, logout). */
|
|
76
80
|
export const AUTH_FLOW_RE = /\/(auth|login|logout|signin|sign-in|signup|sign-up|session|token|verify|oauth|sso|password)\b/i;
|
|
81
|
+
/**
|
|
82
|
+
* A control's label is a verb phrase: "Delete", "Remove user", "Archive the
|
|
83
|
+
* project". A card or tile that is a button carries prose in its accessible
|
|
84
|
+
* name, the title then a sentence about it ("Manager Approves or rejects
|
|
85
|
+
* orders that need sign-off."), and a destructive word in that sentence
|
|
86
|
+
* describes what the thing is for; it is not the command the click sends, the
|
|
87
|
+
* same distinction the wire policy draws for words inside a POST body.
|
|
88
|
+
*
|
|
89
|
+
* So a label that is prose, longer than this many words AND containing a
|
|
90
|
+
* sentence, is judged by its head, the first few words, where the imperative
|
|
91
|
+
* lives: a match counts only when it STARTS there. The pattern still sees the
|
|
92
|
+
* whole label, so "reset filters" straddling the boundary keeps the context
|
|
93
|
+
* its exemption looks ahead at. Everything else is judged whole: a short
|
|
94
|
+
* label, a long one with no sentence in it ("Yes, I am sure I want to delete
|
|
95
|
+
* this"), a script written without spaces.
|
|
96
|
+
*
|
|
97
|
+
* A confirm button whose long label ends in a full stop and puts the verb
|
|
98
|
+
* late is the residue this lets through. The wire policy below still blocks
|
|
99
|
+
* the DELETE, PUT, PATCH or verb-path POST it would send; a plain POST to a
|
|
100
|
+
* neutral path passes in read-only, as it does for any control this list does
|
|
101
|
+
* not name.
|
|
102
|
+
*/
|
|
103
|
+
export const LABEL_HEAD_WORDS = 6;
|
|
104
|
+
/** The head: up to LABEL_HEAD_WORDS whitespace-separated words from the start. */
|
|
105
|
+
const HEAD_RE = new RegExp(`^\\s*(?:\\S+\\s+){0,${LABEL_HEAD_WORDS - 1}}\\S+`);
|
|
106
|
+
/** A sentence ends in the label: a full stop, question or exclamation mark followed by space or the end. */
|
|
107
|
+
const SENTENCE_RE = /[.!?](?:\s|$)/;
|
|
108
|
+
/**
|
|
109
|
+
* Where a match must start to count: the end of the head for prose, the end
|
|
110
|
+
* of the label for everything else.
|
|
111
|
+
*/
|
|
112
|
+
function matchLimit(label) {
|
|
113
|
+
if (!SENTENCE_RE.test(label))
|
|
114
|
+
return label.length;
|
|
115
|
+
const head = HEAD_RE.exec(label);
|
|
116
|
+
if (!head || head[0].length === label.trimEnd().length)
|
|
117
|
+
return label.length;
|
|
118
|
+
return head[0].length;
|
|
119
|
+
}
|
|
77
120
|
export function isDestructive(...labels) {
|
|
78
|
-
return labels.some((label) =>
|
|
121
|
+
return labels.some((label) => {
|
|
122
|
+
if (typeof label !== "string" || label.length === 0)
|
|
123
|
+
return false;
|
|
124
|
+
const limit = matchLimit(label);
|
|
125
|
+
return DESTRUCTIVE_PATTERNS.some((re) => {
|
|
126
|
+
const m = re.exec(label);
|
|
127
|
+
return m !== null && m.index < limit;
|
|
128
|
+
});
|
|
129
|
+
});
|
|
79
130
|
}
|
|
80
131
|
export function destructiveRefusal(label, mode = "read-only") {
|
|
81
132
|
return (`REFUSED by ${mode} policy: "${label}" matches a destructive-action pattern. ` +
|
package/dist/engine/report.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { SHARED_CHROME_ROUTE } from "./memory.js";
|
|
4
|
+
import { sayVerification } from "./verify.js";
|
|
4
5
|
import { feedForSession } from "./live.js";
|
|
5
6
|
import { buildReplayHtml, evidenceFor } from "./replay.js";
|
|
7
|
+
import { formatPace, measurePace } from "./pace.js";
|
|
6
8
|
function playwrightSkeleton(f) {
|
|
7
9
|
const routeClass = f.state.split("#")[0].split("?")[0];
|
|
8
10
|
let gotoPath = routeClass;
|
|
@@ -68,6 +70,24 @@ function projectName(dir) {
|
|
|
68
70
|
const parent = path.dirname(dir);
|
|
69
71
|
return path.basename(parent) || path.basename(dir) || "project";
|
|
70
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* How long ago a finding was last seen, for the historical index. A finding
|
|
75
|
+
* nobody has re-confirmed in four months is a different thing from one seen
|
|
76
|
+
* last week, and the report should not make a reader open both to find out.
|
|
77
|
+
*/
|
|
78
|
+
export function describeAge(foundAt, nowMs) {
|
|
79
|
+
const seen = Date.parse(foundAt);
|
|
80
|
+
if (!Number.isFinite(seen))
|
|
81
|
+
return "?";
|
|
82
|
+
const days = Math.floor((nowMs - seen) / 86_400_000);
|
|
83
|
+
if (days <= 0)
|
|
84
|
+
return "today";
|
|
85
|
+
if (days === 1)
|
|
86
|
+
return "1 day";
|
|
87
|
+
if (days < 60)
|
|
88
|
+
return `${days} days`;
|
|
89
|
+
return `${Math.floor(days / 30)} months`;
|
|
90
|
+
}
|
|
71
91
|
/** Every session that did anything, with its steps in order — what the HTML replays. */
|
|
72
92
|
function replaySessions(memory) {
|
|
73
93
|
const names = [...new Set(memory.actionLog.map((e) => e.session ?? "default"))];
|
|
@@ -378,6 +398,7 @@ export function generateReport(memory, oracleLog, extras, opts = {}) {
|
|
|
378
398
|
const lines = [];
|
|
379
399
|
const resolved = findings.filter((f) => f.status === "resolved");
|
|
380
400
|
const open = findings.filter((f) => f.status !== "resolved");
|
|
401
|
+
const now = Date.now();
|
|
381
402
|
const current = open.filter((f) => f.foundAt >= memory.sessionStart);
|
|
382
403
|
const historical = open.filter((f) => f.foundAt < memory.sessionStart);
|
|
383
404
|
lines.push(`# SceneScout Report`);
|
|
@@ -496,6 +517,11 @@ export function generateReport(memory, oracleLog, extras, opts = {}) {
|
|
|
496
517
|
lines.push(`- **Evidence:** \`${f.evidence}\``);
|
|
497
518
|
lines.push(`- **Where:** \`${f.state}\` (${f.url})`);
|
|
498
519
|
lines.push(`- **Seen in runs:** ${f.runs}`);
|
|
520
|
+
// Only printed once somebody has re-tested it. A finding nobody has looked
|
|
521
|
+
// at again says nothing here, which is the honest thing for it to say.
|
|
522
|
+
if (f.verdict && f.verifiedAt) {
|
|
523
|
+
lines.push(`- **Re-tested:**${sayVerification(f).replace(/^ · /, " ")}${f.verifyNote ? ` — ${f.verifyNote}` : ""}`);
|
|
524
|
+
}
|
|
499
525
|
lines.push(``);
|
|
500
526
|
lines.push(f.detail);
|
|
501
527
|
lines.push(``);
|
|
@@ -521,18 +547,47 @@ export function generateReport(memory, oracleLog, extras, opts = {}) {
|
|
|
521
547
|
if (historical.length > 0) {
|
|
522
548
|
lines.push(`## Historical findings — not re-verified this session (${historical.length})`);
|
|
523
549
|
lines.push(``);
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
550
|
+
if (extras?.history === "full") {
|
|
551
|
+
lines.push(`Recorded in earlier runs and not re-confirmed. Re-test before acting; \`scout_verify\` hands them back in re-test order and records what each re-test found.`);
|
|
552
|
+
lines.push(``);
|
|
553
|
+
for (const f of historical)
|
|
554
|
+
renderFinding(f);
|
|
555
|
+
}
|
|
556
|
+
else {
|
|
557
|
+
// An index, not the findings themselves. One project reached 412
|
|
558
|
+
// historical findings and printing each in full made the report 1.75 MB
|
|
559
|
+
// — a document nobody opens, in which the eleven findings the run
|
|
560
|
+
// actually made were buried. Each row carries what decides whether to
|
|
561
|
+
// re-test it; the detail is one scout_report {history:"full"} away.
|
|
562
|
+
lines.push(`Recorded in earlier runs and NOT re-confirmed by this one, so none of it is evidence about the build under test. Listed as an index: ` +
|
|
563
|
+
`work it down with \`scout_verify\`, which hands back these findings in re-test order and records what each re-test found, ` +
|
|
564
|
+
`and pass \`history: "full"\` to scout_report for the full text.`, ``, `| Sev | Id | Age | Runs | Re-tested | Title |`, `|---|---|---:|---:|---|---|`);
|
|
565
|
+
for (const f of historical) {
|
|
566
|
+
const retested = f.verdict && f.verifiedAt ? `${f.verdict} ${f.verifiedAt.slice(0, 10)}` : "never";
|
|
567
|
+
lines.push(`| ${SEVERITY_ICON[f.severity]} | \`${f.id}\` | ${describeAge(f.foundAt, now)} | ${f.runs} | ${retested} | ${escapeTableCell(f.title)} |`);
|
|
568
|
+
}
|
|
569
|
+
lines.push(``);
|
|
570
|
+
}
|
|
528
571
|
}
|
|
529
572
|
if (resolved.length > 0) {
|
|
530
573
|
lines.push(`## ✅ Resolved (${resolved.length})`);
|
|
531
574
|
lines.push(``);
|
|
532
575
|
lines.push(`Fixed and verified (or confirmed no longer reproducing). A resolved finding that is re-found reopens automatically and is flagged as a regression above.`);
|
|
533
576
|
lines.push(``);
|
|
534
|
-
|
|
535
|
-
|
|
577
|
+
if (extras?.history === "full") {
|
|
578
|
+
for (const f of resolved)
|
|
579
|
+
renderFinding(f, true);
|
|
580
|
+
}
|
|
581
|
+
else {
|
|
582
|
+
// The least actionable content in the document: these are fixed. On one
|
|
583
|
+
// project they were 314 findings and 783 KB — nearly half the report,
|
|
584
|
+
// none of it anything to do. The index keeps the record without the bulk.
|
|
585
|
+
lines.push(`| Sev | Id | Fixed | Title |`, `|---|---|---:|---|`);
|
|
586
|
+
for (const f of resolved) {
|
|
587
|
+
lines.push(`| ${SEVERITY_ICON[f.severity]} | \`${f.id}\` | ${describeAge(f.foundAt, now)} ago | ${escapeTableCell(f.title)} |`);
|
|
588
|
+
}
|
|
589
|
+
lines.push(``);
|
|
590
|
+
}
|
|
536
591
|
}
|
|
537
592
|
if (extras?.createdResources && extras.createdResources.length > 0) {
|
|
538
593
|
lines.push(`## Data created by this session (cleanup list)`);
|
|
@@ -552,6 +607,15 @@ export function generateReport(memory, oracleLog, extras, opts = {}) {
|
|
|
552
607
|
}
|
|
553
608
|
lines.push(``);
|
|
554
609
|
}
|
|
610
|
+
// How the run was paced. A reader who sees a session that did four actions
|
|
611
|
+
// in an hour learns more from that than from another coverage percentage.
|
|
612
|
+
// Omitted where the report is compared byte for byte, since it is timing.
|
|
613
|
+
// The sessions still attached, so the stale-browser warning names only the
|
|
614
|
+
// ones that could act on it. A lane that finished and closed is quiet
|
|
615
|
+
// because it is gone.
|
|
616
|
+
if (extras?.pace !== false) {
|
|
617
|
+
lines.push(...formatPace(measurePace(memory.actionLog, Date.now(), extras?.attachedSessions ?? [])));
|
|
618
|
+
}
|
|
555
619
|
const markdown = lines.join("\n");
|
|
556
620
|
const outPath = path.join(memory.dir, "report.md");
|
|
557
621
|
const htmlPath = path.join(memory.dir, "report.html");
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calling the app's own API with the UI bypassed.
|
|
3
|
+
*
|
|
4
|
+
* A refusal shown by hiding or disabling a button is not a refusal. Confirming
|
|
5
|
+
* that the server refuses the same action is the single most valuable check a
|
|
6
|
+
* permission pass makes, and until now it could only be done outside the tool
|
|
7
|
+
* — in a shell, with curl and a hand-extracted token — so none of that
|
|
8
|
+
* evidence reached the report. A whole validation run's permission matrices
|
|
9
|
+
* lived in shell history and vanished with it.
|
|
10
|
+
*
|
|
11
|
+
* The request is made by the PAGE, not beside it. That matters twice over:
|
|
12
|
+
* it goes through the same interception the write policy is enforced on, so a
|
|
13
|
+
* safe-write session cannot delete a record it does not own by calling the
|
|
14
|
+
* endpoint instead of clicking; and it carries the session's own credentials,
|
|
15
|
+
* because it is the same origin with the same cookies.
|
|
16
|
+
*
|
|
17
|
+
* Bearer schemes are handled by replaying the Authorization header the app
|
|
18
|
+
* itself last sent, which the engine already sees on every intercepted
|
|
19
|
+
* request. Nothing here knows what a token looks like or where an app keeps
|
|
20
|
+
* one, so nothing here is tuned to any app.
|
|
21
|
+
*
|
|
22
|
+
* Everything in this file is pure so it can be table-tested; the one
|
|
23
|
+
* `page.evaluate` lives in browser.ts.
|
|
24
|
+
*/
|
|
25
|
+
/** Methods a session may replay. Anything else is refused before it reaches the page. */
|
|
26
|
+
export const REPLAYABLE_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
|
|
27
|
+
/** Most of a response body that reaches the agent. A JSON list can be megabytes; the signature is in the first lines. */
|
|
28
|
+
export const BODY_MAX = 2000;
|
|
29
|
+
/** Response headers worth reporting. "Identical response" means status, body AND headers, so the ones that commonly differ are kept. */
|
|
30
|
+
export const REPORTED_HEADERS = ["content-type", "content-length", "location", "www-authenticate", "retry-after", "x-request-id", "cache-control"];
|
|
31
|
+
/**
|
|
32
|
+
* The path to call, resolved against the attached origin and fenced to it.
|
|
33
|
+
* Navigation is fenced the same way: a run attached to one app must not be
|
|
34
|
+
* able to make its browser talk to another host just because a path was
|
|
35
|
+
* spelled as a full URL.
|
|
36
|
+
*/
|
|
37
|
+
export function resolveRequestUrl(baseUrl, path) {
|
|
38
|
+
const trimmed = path.trim();
|
|
39
|
+
if (!trimmed)
|
|
40
|
+
return { problem: "No path given. Pass a path such as /api/things, or a full URL on the attached origin." };
|
|
41
|
+
let target;
|
|
42
|
+
let base;
|
|
43
|
+
try {
|
|
44
|
+
base = new URL(baseUrl);
|
|
45
|
+
target = new URL(trimmed, base);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return { problem: `Could not read ${JSON.stringify(trimmed)} as a path or a URL.` };
|
|
49
|
+
}
|
|
50
|
+
if (target.protocol !== "http:" && target.protocol !== "https:") {
|
|
51
|
+
return { problem: `Only http and https can be requested; ${target.protocol} cannot.` };
|
|
52
|
+
}
|
|
53
|
+
if (target.origin !== base.origin) {
|
|
54
|
+
return {
|
|
55
|
+
problem: `${target.origin} is not the origin this session is attached to (${base.origin}). A session talks to its own app only; attach another session to test another host.`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return { url: target.toString() };
|
|
59
|
+
}
|
|
60
|
+
/** The method, upper-cased, or the reason it cannot be replayed. */
|
|
61
|
+
export function resolveMethod(method) {
|
|
62
|
+
const upper = (method ?? "GET").trim().toUpperCase();
|
|
63
|
+
if (!REPLAYABLE_METHODS.includes(upper)) {
|
|
64
|
+
return { problem: `${upper} is not a method this can replay. Use one of: ${REPLAYABLE_METHODS.join(", ")}.` };
|
|
65
|
+
}
|
|
66
|
+
return { method: upper };
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The script the page runs. Built as one expression so it can be evaluated
|
|
70
|
+
* directly, with every value passed through JSON rather than interpolated as
|
|
71
|
+
* code: a header value or a body is data from the agent, and a quote in it
|
|
72
|
+
* must not be able to end the string it sits in.
|
|
73
|
+
*
|
|
74
|
+
* `credentials: "include"` so the session's cookies go with it, and the
|
|
75
|
+
* app's own Authorization header is replayed when one has been seen.
|
|
76
|
+
*/
|
|
77
|
+
export function buildRequestScript(input) {
|
|
78
|
+
const { url, method, body, headers } = input;
|
|
79
|
+
const init = { method, credentials: "include", headers };
|
|
80
|
+
if (body !== undefined && method !== "GET" && method !== "HEAD")
|
|
81
|
+
init.body = body;
|
|
82
|
+
return (`(async () => { const started = Date.now();` +
|
|
83
|
+
` const res = await fetch(${JSON.stringify(url)}, ${JSON.stringify(init)});` +
|
|
84
|
+
` const text = await res.text();` +
|
|
85
|
+
` const headers = {}; res.headers.forEach((v, k) => { headers[k] = v; });` +
|
|
86
|
+
` return { status: res.status, statusText: res.statusText, headers, body: text.slice(0, ${BODY_MAX * 2}), full: text.length, ms: Date.now() - started, url: res.url }; })()`);
|
|
87
|
+
}
|
|
88
|
+
/** The headers to send: what the caller asked for, plus the app's own auth and a JSON content type when a body is present. */
|
|
89
|
+
export function requestHeaders(input) {
|
|
90
|
+
const out = {};
|
|
91
|
+
// The app's own header first, so an explicit one from the caller wins — that
|
|
92
|
+
// is how a session tests what happens with a different or absent credential.
|
|
93
|
+
if (input.auth)
|
|
94
|
+
out["authorization"] = input.auth;
|
|
95
|
+
if (input.body !== undefined)
|
|
96
|
+
out["content-type"] = "application/json";
|
|
97
|
+
for (const [name, value] of Object.entries(input.given ?? {}))
|
|
98
|
+
out[name.toLowerCase()] = value;
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
/** The raw result of the in-page fetch, reduced to what the agent and the report need. */
|
|
102
|
+
export function toReplayResult(raw) {
|
|
103
|
+
const kept = {};
|
|
104
|
+
for (const name of REPORTED_HEADERS) {
|
|
105
|
+
const value = raw.headers[name];
|
|
106
|
+
if (value !== undefined)
|
|
107
|
+
kept[name] = value;
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
status: raw.status,
|
|
111
|
+
statusText: raw.statusText,
|
|
112
|
+
headers: kept,
|
|
113
|
+
body: raw.body.slice(0, BODY_MAX),
|
|
114
|
+
truncated: raw.full > BODY_MAX,
|
|
115
|
+
ms: raw.ms,
|
|
116
|
+
url: raw.url,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/** The signature a finding carries for this call: the line that dedups the same refusal across runs. */
|
|
120
|
+
export function replaySignature(method, url, status) {
|
|
121
|
+
let path = url;
|
|
122
|
+
try {
|
|
123
|
+
const parsed = new URL(url);
|
|
124
|
+
path = parsed.pathname + parsed.search;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// Not a URL we can shorten; the whole string is the signature.
|
|
128
|
+
}
|
|
129
|
+
return `${method} ${path} ${status}`;
|
|
130
|
+
}
|
|
131
|
+
/** What the agent reads back. Leads with the signature, because that is what a finding quotes. */
|
|
132
|
+
export function formatReplay(method, result) {
|
|
133
|
+
const lines = [replaySignature(method, result.url, result.status) + (result.statusText ? ` ${result.statusText}` : ""), `took ${result.ms} ms`];
|
|
134
|
+
const headers = Object.entries(result.headers);
|
|
135
|
+
if (headers.length > 0)
|
|
136
|
+
lines.push(headers.map(([k, v]) => `${k}: ${v}`).join(" · "));
|
|
137
|
+
if (result.body.length > 0) {
|
|
138
|
+
lines.push("", result.body + (result.truncated ? `\n… truncated at ${BODY_MAX} characters` : ""));
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
lines.push("", "(empty body)");
|
|
142
|
+
}
|
|
143
|
+
return lines.join("\n");
|
|
144
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How long to wait after an action before reading the page.
|
|
3
|
+
*
|
|
4
|
+
* This used to be a flat 400 ms sleep on every action, for a good reason:
|
|
5
|
+
* `waitForLoadState("networkidle")` latches once reached and then resolves
|
|
6
|
+
* instantly forever after, so it cannot be used to wait out the request an
|
|
7
|
+
* action just fired, and draining the oracles before that request lands would
|
|
8
|
+
* misattribute its violations to the next step.
|
|
9
|
+
*
|
|
10
|
+
* The floor was still the wrong shape. Measured against a demo app, a snapshot
|
|
11
|
+
* cost 740 ms of which 400 ms was the sleep — 54% of the wall time — and a run
|
|
12
|
+
* of 203 actions spent 81 seconds asleep. Meanwhile a page slower than 400 ms
|
|
13
|
+
* got cut off exactly when it mattered.
|
|
14
|
+
*
|
|
15
|
+
* The engine already intercepts every request, so it knows what is in flight.
|
|
16
|
+
* Waiting on THAT, with a quiet window after the last one starts, is both
|
|
17
|
+
* faster on a page that answers quickly and more patient with one that does
|
|
18
|
+
* not. The old constant survives as a ceiling rather than a floor.
|
|
19
|
+
*
|
|
20
|
+
* The same rule carries the deliberate slow-down: a run being watched by a
|
|
21
|
+
* person taking notes wants a fixed pace, not the fastest one. `paceMs` is a
|
|
22
|
+
* floor a session asks for; unset, a session goes as fast as its page allows.
|
|
23
|
+
*/
|
|
24
|
+
/** No request has started for this long and none is in flight: the page has settled. */
|
|
25
|
+
export const QUIET_MS = 120;
|
|
26
|
+
/** Longest to wait for requests to drain, however busy the page is. A page that never goes quiet must not stall the run. */
|
|
27
|
+
export const SETTLE_CAP_MS = 2000;
|
|
28
|
+
/** How often the wait re-checks. */
|
|
29
|
+
export const SETTLE_TICK_MS = 20;
|
|
30
|
+
/**
|
|
31
|
+
* Whether to keep waiting. Split out from the browser so the rule can be
|
|
32
|
+
* table-tested: it is the difference between a run that is fast and one that
|
|
33
|
+
* reads a page before it has finished changing.
|
|
34
|
+
*/
|
|
35
|
+
export function shouldKeepWaiting(state) {
|
|
36
|
+
// A pace a session asked for is a floor on the whole wait, and it is the
|
|
37
|
+
// only reason to wait once everything else says the page is ready.
|
|
38
|
+
if (state.elapsedMs < state.paceMs)
|
|
39
|
+
return true;
|
|
40
|
+
// Never longer than the cap, whatever the page is doing.
|
|
41
|
+
if (state.elapsedMs >= SETTLE_CAP_MS)
|
|
42
|
+
return false;
|
|
43
|
+
// Something is still out: wait for it, up to the cap.
|
|
44
|
+
if (state.inFlight > 0)
|
|
45
|
+
return true;
|
|
46
|
+
// Nothing in flight and nothing recent. The action itself is the last event
|
|
47
|
+
// that could still produce a request, so the quiet window runs from whichever
|
|
48
|
+
// of the two is later. Without this, an action whose request comes back
|
|
49
|
+
// through a hop — a click that posts to a service worker, which then fetches —
|
|
50
|
+
// is read while the page is between the two, and the request lands during the
|
|
51
|
+
// NEXT action, which is then blamed for it.
|
|
52
|
+
return Math.min(state.sinceLastStartMs, state.elapsedMs) < QUIET_MS;
|
|
53
|
+
}
|
|
54
|
+
/** A pace a session asked for, clamped. Negative or absurd values are a typo, not an instruction. */
|
|
55
|
+
export const PACE_MAX_MS = 60_000;
|
|
56
|
+
export function normalizePace(paceMs) {
|
|
57
|
+
if (typeof paceMs !== "number" || !Number.isFinite(paceMs) || paceMs <= 0)
|
|
58
|
+
return 0;
|
|
59
|
+
return Math.min(Math.round(paceMs), PACE_MAX_MS);
|
|
60
|
+
}
|
|
61
|
+
/** What the attach result says about a deliberate pace, or nothing when the session runs at full speed. */
|
|
62
|
+
export function describePace(paceMs) {
|
|
63
|
+
if (paceMs <= 0)
|
|
64
|
+
return "";
|
|
65
|
+
return `\n⏱ PACE: at least ${paceMs} ms between actions, so a person can follow along. Unset it with paceMs: 0 to go as fast as the page allows.`;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Waiting for a client-side redirect that has not happened yet.
|
|
69
|
+
*
|
|
70
|
+
* A guard that redirects on a timer after hydration issues no request until it
|
|
71
|
+
* fires, so there is nothing for the request-based settle above to wait on:
|
|
72
|
+
* it goes quiet, the URL is read, and the page reports the route it was asked
|
|
73
|
+
* for rather than the login page it actually bounced to. The old flat 400 ms
|
|
74
|
+
* sleep covered this by accident, and removing it made the bounce invisible
|
|
75
|
+
* whenever a loaded machine delayed the timer past the quiet window.
|
|
76
|
+
*
|
|
77
|
+
* So where a BOUNCE VERDICT is about to be made — attach judging a storage
|
|
78
|
+
* state, navigate judging coverage — the URL is watched until it has held
|
|
79
|
+
* still, rather than read once. This is paid per navigation, not per action.
|
|
80
|
+
*/
|
|
81
|
+
/**
|
|
82
|
+
* How long the URL must hold still before a bounce verdict is believed.
|
|
83
|
+
*
|
|
84
|
+
* This is a window, not a guarantee: a guard slower than it still lands after
|
|
85
|
+
* the verdict. What it buys is the realistic case — a guard written to fire
|
|
86
|
+
* promptly, delayed by a loaded machine — which is the one that made this
|
|
87
|
+
* project's own CI fail intermittently on a 40 ms timer.
|
|
88
|
+
*/
|
|
89
|
+
export const URL_QUIET_MS = 400;
|
|
90
|
+
/** Longest to watch. A page redirecting in a loop must not hold up the run. */
|
|
91
|
+
export const URL_CAP_MS = 3000;
|
|
92
|
+
/** Whether to keep watching the URL before judging where the page landed. */
|
|
93
|
+
export function keepWatchingUrl(w) {
|
|
94
|
+
if (w.elapsedMs >= URL_CAP_MS)
|
|
95
|
+
return false;
|
|
96
|
+
return w.sinceChangeMs < URL_QUIET_MS;
|
|
97
|
+
}
|