scenescout 3.3.0 → 3.5.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 +27 -0
- package/README.md +38 -12
- package/dist/engine/bench.js +485 -0
- package/dist/engine/brief.js +20 -5
- package/dist/engine/browser.js +84 -27
- package/dist/engine/calibration.js +274 -0
- package/dist/engine/claims.js +10 -3
- package/dist/engine/collector.js +17 -0
- package/dist/engine/lane.js +45 -16
- package/dist/engine/memory.js +117 -4
- package/dist/engine/oracles.js +15 -8
- package/dist/engine/pace.js +57 -11
- package/dist/engine/policy.js +40 -0
- package/dist/engine/report.js +5 -1
- package/dist/engine/request.js +14 -1
- package/dist/mcp-server.js +67 -9
- package/package.json +6 -3
- package/skills/scenescout/SKILL.md +6 -6
package/dist/engine/lane.js
CHANGED
|
@@ -90,12 +90,21 @@ export const LaneReport = z
|
|
|
90
90
|
seen.add(d.observation);
|
|
91
91
|
});
|
|
92
92
|
});
|
|
93
|
-
const FENCE_OPEN = /^```[a-z]*\s*\n/i;
|
|
94
|
-
const FENCE_CLOSE = /\n?```\s*$/;
|
|
93
|
+
const FENCE_OPEN = /^```[a-z]*\s*\r?\n/i;
|
|
94
|
+
const FENCE_CLOSE = /\r?\n?```\s*$/;
|
|
95
|
+
/** Every fenced block in a reply, with its contents. */
|
|
96
|
+
const FENCED_BLOCK = /```[a-z]*[ \t]*\r?\n([\s\S]*?)\r?\n?```/gi;
|
|
95
97
|
/**
|
|
96
98
|
* The reply is one JSON object and nothing else. A fenced block around it is
|
|
97
|
-
* tolerated, because every model has been trained to add one
|
|
98
|
-
*
|
|
99
|
+
* tolerated, because every model has been trained to add one.
|
|
100
|
+
*
|
|
101
|
+
* Prose around ONE fenced JSON block is tolerated too, and dropped unread. The
|
|
102
|
+
* rule used to refuse it, and in a measured run six of eight lanes did it
|
|
103
|
+
* anyway: each refusal cost the planner a round trip or a hand-unwrap, to
|
|
104
|
+
* recover an object that was already unambiguous. The point — nobody has to
|
|
105
|
+
* READ the reply — survives, because the prose is never looked at. Unfenced
|
|
106
|
+
* prose is still refused, because where the object starts and ends is then a
|
|
107
|
+
* guess, and so is more than one fenced object.
|
|
99
108
|
* The reason names what was wrong, since the planner relays it to the lane.
|
|
100
109
|
*
|
|
101
110
|
* With `expectedLane`, a report that names another lane is refused: the
|
|
@@ -104,13 +113,19 @@ const FENCE_CLOSE = /\n?```\s*$/;
|
|
|
104
113
|
*/
|
|
105
114
|
export function parseLaneReport(text, expectedLane) {
|
|
106
115
|
let body = text.trim();
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
return { ok: false, reason: "the closing ``` must end the reply, with no text after it" };
|
|
116
|
+
let aroundIgnored = false;
|
|
117
|
+
if (FENCE_OPEN.test(body) && FENCE_CLOSE.test(body) && [...body.matchAll(FENCED_BLOCK)].length === 1) {
|
|
110
118
|
body = body.replace(FENCE_OPEN, "").replace(FENCE_CLOSE, "").trim();
|
|
111
119
|
}
|
|
112
|
-
if (!body.startsWith("{"))
|
|
113
|
-
|
|
120
|
+
else if (!body.startsWith("{")) {
|
|
121
|
+
const objects = [...body.matchAll(FENCED_BLOCK)].map((m) => m[1].trim()).filter((b) => b.startsWith("{"));
|
|
122
|
+
if (objects.length > 1)
|
|
123
|
+
return { ok: false, reason: `the reply holds ${objects.length} fenced JSON blocks; hand back exactly one object` };
|
|
124
|
+
if (objects.length === 0)
|
|
125
|
+
return { ok: false, reason: "the reply must be one JSON object, with no text before it" };
|
|
126
|
+
body = objects[0];
|
|
127
|
+
aroundIgnored = true;
|
|
128
|
+
}
|
|
114
129
|
let raw;
|
|
115
130
|
try {
|
|
116
131
|
raw = JSON.parse(body);
|
|
@@ -129,7 +144,7 @@ export function parseLaneReport(text, expectedLane) {
|
|
|
129
144
|
if (expectedLane !== undefined && result.data.lane !== expectedLane) {
|
|
130
145
|
return { ok: false, reason: `the report names lane "${result.data.lane}", but this reply was asked of lane "${expectedLane}"` };
|
|
131
146
|
}
|
|
132
|
-
return { ok: true, report: result.data };
|
|
147
|
+
return { ok: true, report: result.data, aroundIgnored };
|
|
133
148
|
}
|
|
134
149
|
/**
|
|
135
150
|
* The paragraph the planner puts in every lane's prompt. It is generated from
|
|
@@ -138,14 +153,28 @@ export function parseLaneReport(text, expectedLane) {
|
|
|
138
153
|
*/
|
|
139
154
|
export function laneReportInstruction(lane) {
|
|
140
155
|
return [
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
156
|
+
...LANE_RUBRIC,
|
|
157
|
+
// The ONLY lane-specific sentence, and it comes last on purpose. Every
|
|
158
|
+
// lane in a wave is given the same rubric, so keeping it byte-identical up
|
|
159
|
+
// to here makes it one shared prompt prefix: the cache hits from the
|
|
160
|
+
// second lane onward instead of diverging at the first sentence, which is
|
|
161
|
+
// what putting the name in the shape line used to do.
|
|
162
|
+
`Your lane name is ${JSON.stringify(lane)}; put exactly that in "lane".`,
|
|
147
163
|
].join(" ");
|
|
148
164
|
}
|
|
165
|
+
/**
|
|
166
|
+
* Everything every lane is told, identical for all of them. Built once from
|
|
167
|
+
* the same constants the parser enforces, because a limit a lane is not told
|
|
168
|
+
* refuses good replies.
|
|
169
|
+
*/
|
|
170
|
+
const LANE_RUBRIC = [
|
|
171
|
+
"Reply with ONE JSON object and nothing else — no prose before or after it, no explanation, no headings. A fenced ```json block is fine; anything outside it is discarded unread, so put nothing there you want kept.",
|
|
172
|
+
`Shape: {"lane":<your lane name>,"status":<${quoteAll(LANE_STATUSES)}>,"decisions":[…],"routes":[…],"blocked_by":<string or null>}.`,
|
|
173
|
+
`Each decision: {"observation":<a short id for what was observed, unique in the report, at most ${LANE_OBSERVATION_MAX} characters>,"verdict":<${quoteAll(LANE_VERDICTS)}>,"severity":<${quoteAll(LANE_SEVERITIES)} or null>,"category":<${quoteAll(LANE_CATEGORIES)} or null>,"confidence":<0..1>,"evidence":<machine signature such as "GET /api/things 500", or null>}.`,
|
|
174
|
+
`A "defect" must carry a severity and a category. "evidence" is a signature, not a sentence: at most ${LANE_EVIDENCE_MAX} characters. "confidence" is how sure you are of the verdict, calibrated: 0.5 means a coin flip, 0.95 means you would bet on it.`,
|
|
175
|
+
`"routes" lists the routes you covered, each at most ${LANE_ROUTE_MAX} characters. "blocked_by" is one line of at most ${LANE_BLOCKED_BY_MAX} characters saying what stopped you: required when the status is "blocked", allowed with "partial", null with "complete"; the detail belongs in a finding. The lane name is at most ${LANE_NAME_MAX} characters. At most ${LANE_MAX_ITEMS} decisions and ${LANE_MAX_ITEMS} routes. Unknown keys are refused.`,
|
|
176
|
+
"The object IS your final report: whatever hands it back must hand back the object verbatim, not a summary of it.",
|
|
177
|
+
];
|
|
149
178
|
function quoteAll(values) {
|
|
150
179
|
return values.map((v) => `"${v}"`).join("|");
|
|
151
180
|
}
|
package/dist/engine/memory.js
CHANGED
|
@@ -250,8 +250,39 @@ export function mergeMemory(mine, theirs) {
|
|
|
250
250
|
}
|
|
251
251
|
if (Object.keys(out.roleAccess).length === 0)
|
|
252
252
|
delete out.roleAccess;
|
|
253
|
+
// Lanes commonly run in SEPARATE processes — that is the point of a lane —
|
|
254
|
+
// so without this the spread at the top would keep one process's decisions
|
|
255
|
+
// and drop every other lane's, which is the exact bug this merge exists to
|
|
256
|
+
// prevent for findings. Keyed so merging the same foreign document twice is
|
|
257
|
+
// idempotent.
|
|
258
|
+
const byDecision = new Map();
|
|
259
|
+
for (const d of theirs.laneDecisions ?? [])
|
|
260
|
+
byDecision.set(decisionKey(d), d);
|
|
261
|
+
for (const d of mine.laneDecisions ?? [])
|
|
262
|
+
byDecision.set(decisionKey(d), d);
|
|
263
|
+
out.laneDecisions = [...byDecision.values()].sort((a, b) => a.at.localeCompare(b.at)).slice(-MAX_LANE_DECISIONS);
|
|
264
|
+
if (out.laneDecisions.length === 0)
|
|
265
|
+
delete out.laneDecisions;
|
|
253
266
|
return out;
|
|
254
267
|
}
|
|
268
|
+
/**
|
|
269
|
+
* What makes two stored decisions the same judgement.
|
|
270
|
+
*
|
|
271
|
+
* Deliberately NOT the timestamp. `at` is stamped when the planner folds the
|
|
272
|
+
* reply, not when the lane judged, so relaying one reply twice — which the
|
|
273
|
+
* protocol invites, since a refused report is asked for again — wrote every
|
|
274
|
+
* decision a second time and doubled the lane's weight in the calibration.
|
|
275
|
+
* Identity is what was decided, so re-folding the same reply is a no-op.
|
|
276
|
+
*/
|
|
277
|
+
function decisionKey(d) {
|
|
278
|
+
return [d.lane, d.observation, d.verdict, d.severity ?? "", d.category ?? "", d.confidence, d.evidence ?? ""].join("|");
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Most lane decisions kept. Calibration wants a few dozen; a long-lived
|
|
282
|
+
* project would otherwise accumulate every decision ever made and re-serialise
|
|
283
|
+
* them on each save, which is what made an old history slow to open.
|
|
284
|
+
*/
|
|
285
|
+
export const MAX_LANE_DECISIONS = 1000;
|
|
255
286
|
const MAX_DISCOVERED_ROUTES = 300;
|
|
256
287
|
/** Shared finding-similarity helpers (used by live dedup and retro-merge). */
|
|
257
288
|
function findingTokens(s) {
|
|
@@ -295,7 +326,22 @@ function findingLiterals(...texts) {
|
|
|
295
326
|
* (`/api/users/7` and `/api/users/9` are one endpoint) so a per-record repro
|
|
296
327
|
* does not read as a per-record bug.
|
|
297
328
|
*/
|
|
298
|
-
|
|
329
|
+
/**
|
|
330
|
+
* The signatures that identify a BUG: only those carrying a failure status.
|
|
331
|
+
*
|
|
332
|
+
* A bare `POST /api/orders` says which endpoint was involved, not what went
|
|
333
|
+
* wrong — a double submit and an accepted negative quantity both name it, and
|
|
334
|
+
* are two bugs. Exported so calibration applies the store's own rule instead
|
|
335
|
+
* of a second copy of this regex, which is what it had.
|
|
336
|
+
*/
|
|
337
|
+
export function failingSignatures(evidence) {
|
|
338
|
+
return new Set([...endpointSignatures(evidence)].filter((sig) => /\s[45]\d{2}$/.test(sig)));
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* The endpoint signatures a piece of evidence names: method + normalised path,
|
|
342
|
+
* with the failure status when one follows.
|
|
343
|
+
*/
|
|
344
|
+
export function endpointSignatures(evidence) {
|
|
299
345
|
const out = new Set();
|
|
300
346
|
if (!evidence)
|
|
301
347
|
return out;
|
|
@@ -338,11 +384,10 @@ function sharesEndpointSignature(a, b) {
|
|
|
338
384
|
// `POST /api/orders` (the endpoint answered 2xx, or no status was named)
|
|
339
385
|
// says which endpoint was involved, not what went wrong: a double submit
|
|
340
386
|
// and an accepted negative quantity both name it, and are two bugs.
|
|
341
|
-
const
|
|
342
|
-
const aSigs = failing(a.evidence);
|
|
387
|
+
const aSigs = failingSignatures(a.evidence);
|
|
343
388
|
if (aSigs.size === 0)
|
|
344
389
|
return false;
|
|
345
|
-
for (const sig of
|
|
390
|
+
for (const sig of failingSignatures(b.evidence))
|
|
346
391
|
if (aSigs.has(sig))
|
|
347
392
|
return true;
|
|
348
393
|
return false;
|
|
@@ -522,6 +567,35 @@ export class MemoryStore {
|
|
|
522
567
|
ownedIds = new Map();
|
|
523
568
|
/** Human-readable creation log for this run — becomes the report's cleanup list. */
|
|
524
569
|
createdResources = [];
|
|
570
|
+
/**
|
|
571
|
+
* Markup-shaped values any session of this run typed, and the injections
|
|
572
|
+
* already reported. Shared for the same reason ownership is: a parallel run
|
|
573
|
+
* splits the app by route, so the lane that types a payload into a create
|
|
574
|
+
* form is rarely the lane that opens the list rendering it, and a probe kept
|
|
575
|
+
* per session could only ever catch a reflected injection. Not persisted: a
|
|
576
|
+
* payload typed in an earlier run is not this run's evidence.
|
|
577
|
+
*/
|
|
578
|
+
probes = [];
|
|
579
|
+
injectionsReported = new Set();
|
|
580
|
+
/**
|
|
581
|
+
* Design audits any session of this run performed. The report's gate asked
|
|
582
|
+
* the session that happened to call scout_report, so a run whose lanes
|
|
583
|
+
* audited every page was refused because the planner's own session had not.
|
|
584
|
+
* Not persisted: an audit from an earlier run does not satisfy this one.
|
|
585
|
+
*/
|
|
586
|
+
auditsThisRun = 0;
|
|
587
|
+
/**
|
|
588
|
+
* End the run: forget what only this run's sessions know. Called when the
|
|
589
|
+
* last session on this project closes. The store itself outlives it — the
|
|
590
|
+
* server keeps one per project for the life of the process — so without
|
|
591
|
+
* this, a second run in the same process passed the audit gate on the first
|
|
592
|
+
* run's audit and stayed silent about an injection the first run reported.
|
|
593
|
+
*/
|
|
594
|
+
endRun() {
|
|
595
|
+
this.probes = [];
|
|
596
|
+
this.injectionsReported.clear();
|
|
597
|
+
this.auditsThisRun = 0;
|
|
598
|
+
}
|
|
525
599
|
constructor(projectDir) {
|
|
526
600
|
this.dir = path.join(projectDir, MEMORY_DIRNAME);
|
|
527
601
|
this.legacyDirNote = adoptLegacyMemoryDir(projectDir);
|
|
@@ -782,6 +856,45 @@ export class MemoryStore {
|
|
|
782
856
|
fs.writeFileSync(this.assumptionsPath, content);
|
|
783
857
|
return true;
|
|
784
858
|
}
|
|
859
|
+
/** What the lanes decided, oldest first. Empty on a project that has never run one. */
|
|
860
|
+
get laneDecisions() {
|
|
861
|
+
return this.data.laneDecisions ?? [];
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Record what a lane decided. Called once per accepted lane report, so the
|
|
865
|
+
* confidence it stated can be checked later against what the run filed.
|
|
866
|
+
* Free text from the lane is redacted like every other stored string: an
|
|
867
|
+
* observation is written by a model reading the app under test.
|
|
868
|
+
*/
|
|
869
|
+
addLaneDecisions(lane, decisions) {
|
|
870
|
+
if (decisions.length === 0)
|
|
871
|
+
return 0;
|
|
872
|
+
const list = this.data.laneDecisions ?? [];
|
|
873
|
+
const seen = new Set(list.map(decisionKey));
|
|
874
|
+
let added = 0;
|
|
875
|
+
for (const d of decisions) {
|
|
876
|
+
const record = {
|
|
877
|
+
...d,
|
|
878
|
+
lane,
|
|
879
|
+
observation: redactSecrets(d.observation).slice(0, 200),
|
|
880
|
+
evidence: d.evidence === null ? null : redactSecrets(d.evidence).slice(0, 200),
|
|
881
|
+
};
|
|
882
|
+
if (seen.has(decisionKey(record)))
|
|
883
|
+
continue;
|
|
884
|
+
seen.add(decisionKey(record));
|
|
885
|
+
list.push(record);
|
|
886
|
+
added += 1;
|
|
887
|
+
}
|
|
888
|
+
this.data.laneDecisions = list.slice(-MAX_LANE_DECISIONS);
|
|
889
|
+
if (added > 0)
|
|
890
|
+
this.flush();
|
|
891
|
+
// What survives the cap. Appends go to the tail and the cap keeps the
|
|
892
|
+
// tail, so all of `added` survives unless the call itself exceeds the cap.
|
|
893
|
+
// Measuring it as growth instead looked right and was not: `list` aliases
|
|
894
|
+
// the stored array, so the "before" length was read after the appends and
|
|
895
|
+
// every call after the first reported nothing kept — while storing fine.
|
|
896
|
+
return Math.min(added, MAX_LANE_DECISIONS);
|
|
897
|
+
}
|
|
785
898
|
/** Mark a finding resolved; returns it or null. */
|
|
786
899
|
resolveFinding(id) {
|
|
787
900
|
const f = this.data.findings.find((x) => x.id === id);
|
package/dist/engine/oracles.js
CHANGED
|
@@ -17,7 +17,8 @@ export const POLICY_BLOCK_WINDOW_MS = 2000;
|
|
|
17
17
|
* block rather than something the app did?
|
|
18
18
|
*
|
|
19
19
|
* Aborting a request makes the browser print a console error, and — when the
|
|
20
|
-
* app does not catch the rejection — raise a page error.
|
|
20
|
+
* app does not catch the rejection — raise a page error. Answering one with the
|
|
21
|
+
* policy's stand-in 403 makes the browser print its "status of 403" line. Left in, a report
|
|
21
22
|
* lists the tool's own safety net as defects of the app under test. (The
|
|
22
23
|
* failed REQUEST itself is matched exactly, by request identity, in the
|
|
23
24
|
* monitor; these two carry no request to match on, so they are attributed by
|
|
@@ -32,7 +33,7 @@ export function isPolicyInduced(v, msSincePolicyBlock) {
|
|
|
32
33
|
return false;
|
|
33
34
|
if (v.kind !== "page_error" && v.kind !== "console_error")
|
|
34
35
|
return false;
|
|
35
|
-
return /ERR_BLOCKED_BY_CLIENT|Failed to fetch|NetworkError when attempting to fetch|Load failed/i.test(v.detail);
|
|
36
|
+
return /ERR_BLOCKED_BY_CLIENT|Failed to fetch|NetworkError when attempting to fetch|Load failed|the server responded with a status of 403\b/i.test(v.detail);
|
|
36
37
|
}
|
|
37
38
|
/**
|
|
38
39
|
* Invariant oracles: passive listeners that record violations regardless of
|
|
@@ -74,7 +75,7 @@ export class OracleMonitor {
|
|
|
74
75
|
return;
|
|
75
76
|
if (BENIGN_URL_RE.test(req.url()))
|
|
76
77
|
return;
|
|
77
|
-
if (this.
|
|
78
|
+
if (this.refusedByPolicy(req)) {
|
|
78
79
|
this.policyAttributed += 1;
|
|
79
80
|
return;
|
|
80
81
|
}
|
|
@@ -93,6 +94,12 @@ export class OracleMonitor {
|
|
|
93
94
|
// reported here on every page load teaches the driver to ignore http_error.
|
|
94
95
|
if (BENIGN_URL_RE.test(res.url()))
|
|
95
96
|
return;
|
|
97
|
+
// The write policy's own stand-in refusal, matched by request identity
|
|
98
|
+
// like a dropped one: the server never said this.
|
|
99
|
+
if (this.refusedByPolicy(res.request())) {
|
|
100
|
+
this.policyAttributed += 1;
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
96
103
|
// 401/403 are often expected (auth probes); still report, but as medium.
|
|
97
104
|
this.record({
|
|
98
105
|
kind: "http_error",
|
|
@@ -113,7 +120,7 @@ export class OracleMonitor {
|
|
|
113
120
|
*/
|
|
114
121
|
static MAX_REPORTED_SIGS = 5000;
|
|
115
122
|
lastPolicyBlockAt = null;
|
|
116
|
-
|
|
123
|
+
refusedByPolicy = () => false;
|
|
117
124
|
/**
|
|
118
125
|
* Errors attributed to the write policy's own blocks and therefore not
|
|
119
126
|
* recorded as violations. Counted, so a report can say how many there were:
|
|
@@ -121,11 +128,11 @@ export class OracleMonitor {
|
|
|
121
128
|
* errors look the same.
|
|
122
129
|
*/
|
|
123
130
|
policyAttributed = 0;
|
|
124
|
-
/** The engine knows exactly which requests
|
|
125
|
-
|
|
126
|
-
this.
|
|
131
|
+
/** The engine knows exactly which requests its policy stopped; failed requests and stand-in refusals are matched against that, not against wording. */
|
|
132
|
+
setPolicyRefusalCheck(check) {
|
|
133
|
+
this.refusedByPolicy = check;
|
|
127
134
|
}
|
|
128
|
-
/** Called by the engine when the write policy
|
|
135
|
+
/** Called by the engine when the write policy stops a request (dropped or answered), so the errors that causes are not held against the app. */
|
|
129
136
|
notePolicyBlock() {
|
|
130
137
|
this.lastPolicyBlockAt = Date.now();
|
|
131
138
|
}
|
package/dist/engine/pace.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* counting them inflates the action count and drags the median gap toward
|
|
5
5
|
* zero. One real run logged 325 entries of which 107 were stated tasks.
|
|
6
6
|
*/
|
|
7
|
-
const MARKER_ACTIONS = new Set(["task", "attach", "created-resource", "journey:start", "journey:end", "record:full", "record:failed"]);
|
|
7
|
+
const MARKER_ACTIONS = new Set(["task", "attach", "close", "created-resource", "journey:start", "journey:end", "record:full", "record:failed"]);
|
|
8
8
|
/** Whether a log entry represents work the browser actually did. */
|
|
9
9
|
export function isActing(action) {
|
|
10
10
|
return !MARKER_ACTIONS.has(action);
|
|
@@ -27,23 +27,37 @@ function median(sorted) {
|
|
|
27
27
|
export function measurePace(log, nowMs, attached = []) {
|
|
28
28
|
const stillOpen = new Set(attached);
|
|
29
29
|
const bySession = new Map();
|
|
30
|
+
const lifecycle = new Map();
|
|
30
31
|
for (const entry of log) {
|
|
31
|
-
if (!isActing(entry.action))
|
|
32
|
-
continue;
|
|
33
32
|
const name = entry.session ?? "default";
|
|
34
|
-
const
|
|
33
|
+
const into = isActing(entry.action) ? bySession : entry.action === "attach" || entry.action === "close" ? lifecycle : null;
|
|
34
|
+
if (!into)
|
|
35
|
+
continue;
|
|
36
|
+
const list = into.get(name);
|
|
35
37
|
if (list)
|
|
36
38
|
list.push(entry);
|
|
37
39
|
else
|
|
38
|
-
|
|
40
|
+
into.set(name, [entry]);
|
|
39
41
|
}
|
|
42
|
+
// A session that attached and never acted still held a browser.
|
|
43
|
+
for (const name of lifecycle.keys())
|
|
44
|
+
if (!bySession.has(name))
|
|
45
|
+
bySession.set(name, []);
|
|
40
46
|
const sessions = [];
|
|
41
47
|
let first = Number.POSITIVE_INFINITY;
|
|
42
48
|
let last = 0;
|
|
43
49
|
for (const [session, entries] of bySession) {
|
|
44
|
-
const times = entries
|
|
45
|
-
|
|
50
|
+
const times = entries
|
|
51
|
+
.map((e) => Date.parse(e.at))
|
|
52
|
+
.filter((t) => Number.isFinite(t))
|
|
53
|
+
.sort((a, b) => a - b);
|
|
54
|
+
const heldIdleMs = heldIdle(lifecycle.get(session) ?? [], times, nowMs, stillOpen.has(session));
|
|
55
|
+
if (times.length === 0) {
|
|
56
|
+
if (heldIdleMs > 0) {
|
|
57
|
+
sessions.push({ session, actions: 0, spanMs: 0, medianGapMs: 0, maxGapMs: 0, idleShare: 0, quietMs: 0, framed: 0, heldIdleMs });
|
|
58
|
+
}
|
|
46
59
|
continue;
|
|
60
|
+
}
|
|
47
61
|
times.sort((a, b) => a - b);
|
|
48
62
|
first = Math.min(first, times[0]);
|
|
49
63
|
last = Math.max(last, times[times.length - 1]);
|
|
@@ -61,6 +75,7 @@ export function measurePace(log, nowMs, attached = []) {
|
|
|
61
75
|
idleShare: spanMs > 0 ? idleMs / spanMs : 0,
|
|
62
76
|
quietMs: Math.max(0, nowMs - times[times.length - 1]),
|
|
63
77
|
framed: entries.filter((e) => e.frame).length,
|
|
78
|
+
heldIdleMs,
|
|
64
79
|
});
|
|
65
80
|
}
|
|
66
81
|
sessions.sort((a, b) => b.actions - a.actions);
|
|
@@ -75,6 +90,37 @@ export function measurePace(log, nowMs, attached = []) {
|
|
|
75
90
|
quiet: sessions.filter((s) => s.quietMs > STALE_SESSION_MS && stillOpen.has(s.session)).map((s) => s.session),
|
|
76
91
|
};
|
|
77
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* Idle time at the edges of each attach: attach to first action, last action
|
|
95
|
+
* to close. An attach with no close after it ends at the next attach, or at
|
|
96
|
+
* now while the session is still open. A log written before close was
|
|
97
|
+
* recorded has no close marker; its closed sessions count no trailing idle,
|
|
98
|
+
* because when they closed is unknown.
|
|
99
|
+
*/
|
|
100
|
+
function heldIdle(markers, actionTimes, nowMs, stillOpen) {
|
|
101
|
+
const events = markers
|
|
102
|
+
.map((m) => ({ kind: m.action, at: Date.parse(m.at) }))
|
|
103
|
+
.filter((e) => Number.isFinite(e.at))
|
|
104
|
+
.sort((a, b) => a.at - b.at);
|
|
105
|
+
let idle = 0;
|
|
106
|
+
for (let i = 0; i < events.length; i += 1) {
|
|
107
|
+
if (events[i].kind !== "attach")
|
|
108
|
+
continue;
|
|
109
|
+
const start = events[i].at;
|
|
110
|
+
const next = events.slice(i + 1).find((e) => e.kind === "attach" || e.kind === "close");
|
|
111
|
+
const end = next ? next.at : stillOpen ? nowMs : null;
|
|
112
|
+
const inside = actionTimes.filter((t) => t >= start && (end === null || t <= end));
|
|
113
|
+
if (inside.length === 0) {
|
|
114
|
+
if (end !== null)
|
|
115
|
+
idle += end - start;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
idle += inside[0] - start;
|
|
119
|
+
if (end !== null)
|
|
120
|
+
idle += end - inside[inside.length - 1];
|
|
121
|
+
}
|
|
122
|
+
return Math.max(0, idle);
|
|
123
|
+
}
|
|
78
124
|
/** Milliseconds as a person says them: 45s, 4m12s, 1h03m. */
|
|
79
125
|
export function sayDuration(ms) {
|
|
80
126
|
const total = Math.max(0, Math.round(ms / 1000));
|
|
@@ -96,13 +142,13 @@ export function formatPace(pace) {
|
|
|
96
142
|
const lines = [
|
|
97
143
|
`## How the run was paced`,
|
|
98
144
|
``,
|
|
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.`,
|
|
145
|
+
`${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. Held idle is time a browser was open before a session's first action or after its last one — a lane waiting to be folded, or attached and never used.`,
|
|
100
146
|
``,
|
|
101
|
-
`| Session | Actions | Span | Median gap | Longest gap | Idle | Frames |`,
|
|
102
|
-
|
|
147
|
+
`| Session | Actions | Span | Median gap | Longest gap | Idle | Held idle | Frames |`,
|
|
148
|
+
`|---|---:|---:|---:|---:|---:|---:|---:|`,
|
|
103
149
|
];
|
|
104
150
|
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} |`);
|
|
151
|
+
lines.push(`| ${s.session} | ${s.actions} | ${sayDuration(s.spanMs)} | ${sayDuration(s.medianGapMs)} | ${sayDuration(s.maxGapMs)} | ${Math.round(s.idleShare * 100)}% | ${sayDuration(s.heldIdleMs)} | ${s.framed} |`);
|
|
106
152
|
}
|
|
107
153
|
lines.push(``);
|
|
108
154
|
if (pace.quiet.length > 0) {
|
package/dist/engine/policy.js
CHANGED
|
@@ -185,3 +185,43 @@ export function allowsWrite(mode, method, destructiveWire, owned) {
|
|
|
185
185
|
// PUT/PATCH/DELETE: only in safe-write, only on this run's own records.
|
|
186
186
|
return mode === "safe-write" && owned;
|
|
187
187
|
}
|
|
188
|
+
/**
|
|
189
|
+
* Which blocked requests the write policy answers rather than drops.
|
|
190
|
+
*
|
|
191
|
+
* A dropped request tells the page nothing it would ever meet in production:
|
|
192
|
+
* `fetch` rejects with a network error, and the code that handles a refusal —
|
|
193
|
+
* the branch that should say "couldn't save" — never runs. Answering a script's
|
|
194
|
+
* request with a refusal keeps the server untouched and exercises that branch,
|
|
195
|
+
* so a page that reports a refused save as saved is caught. A navigation (a
|
|
196
|
+
* native form post) is still dropped: answering it would replace the page the
|
|
197
|
+
* user was on with the stand-in body.
|
|
198
|
+
*/
|
|
199
|
+
export function answersWithRefusal(resourceType) {
|
|
200
|
+
return resourceType === "fetch" || resourceType === "xhr";
|
|
201
|
+
}
|
|
202
|
+
/** Header on every refusal the policy writes, so the stand-in can be told from the server's own answer. */
|
|
203
|
+
export const POLICY_REFUSAL_HEADER = "x-scenescout-policy";
|
|
204
|
+
/**
|
|
205
|
+
* The response the write policy sends in the server's place. 403, because the
|
|
206
|
+
* request is forbidden, not failed: a 5xx invites retries, and a 401 is what
|
|
207
|
+
* many apps read as "signed out". The CORS headers let a cross-origin API
|
|
208
|
+
* call read the refusal instead of failing as a network error, which would
|
|
209
|
+
* drop it all over again. `origin` is the request's own Origin header, echoed
|
|
210
|
+
* only when there is one.
|
|
211
|
+
*/
|
|
212
|
+
export function policyRefusal(mode, method, pathname, origin) {
|
|
213
|
+
const headers = { "content-type": "application/json", [POLICY_REFUSAL_HEADER]: `refused; mode=${mode}` };
|
|
214
|
+
if (origin) {
|
|
215
|
+
headers["access-control-allow-origin"] = origin;
|
|
216
|
+
headers["access-control-allow-credentials"] = "true";
|
|
217
|
+
headers["access-control-expose-headers"] = POLICY_REFUSAL_HEADER;
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
status: 403,
|
|
221
|
+
headers,
|
|
222
|
+
body: JSON.stringify({
|
|
223
|
+
error: "Forbidden",
|
|
224
|
+
message: `${method} ${pathname} was refused by the tester's ${mode} write policy. The server never received it.`,
|
|
225
|
+
}),
|
|
226
|
+
};
|
|
227
|
+
}
|
package/dist/engine/report.js
CHANGED
|
@@ -4,6 +4,7 @@ import { SHARED_CHROME_ROUTE } from "./memory.js";
|
|
|
4
4
|
import { sayVerification } from "./verify.js";
|
|
5
5
|
import { feedForSession } from "./live.js";
|
|
6
6
|
import { buildReplayHtml, evidenceFor } from "./replay.js";
|
|
7
|
+
import { calibrate, formatCalibration } from "./calibration.js";
|
|
7
8
|
import { formatPace, measurePace } from "./pace.js";
|
|
8
9
|
function playwrightSkeleton(f) {
|
|
9
10
|
const routeClass = f.state.split("#")[0].split("?")[0];
|
|
@@ -414,7 +415,7 @@ export function generateReport(memory, oracleLog, extras, opts = {}) {
|
|
|
414
415
|
lines.push(`| Route coverage | ${extras.routesVisited}/${extras.routesTotal} |`);
|
|
415
416
|
lines.push(`| States explored | ${cov.states} |`);
|
|
416
417
|
if (extras)
|
|
417
|
-
lines.push(`| Design audits this
|
|
418
|
+
lines.push(`| Design audits this run (all sessions) | ${extras.designAudits} |`);
|
|
418
419
|
lines.push(`| Oracle violations this session | ${oracleLog.length} |`);
|
|
419
420
|
if (extras?.policyAttributed) {
|
|
420
421
|
lines.push(`| Errors caused by the tester's own write-policy blocks (not counted above) | ${extras.policyAttributed} |`);
|
|
@@ -616,6 +617,9 @@ export function generateReport(memory, oracleLog, extras, opts = {}) {
|
|
|
616
617
|
if (extras?.pace !== false) {
|
|
617
618
|
lines.push(...formatPace(measurePace(memory.actionLog, Date.now(), extras?.attachedSessions ?? [])));
|
|
618
619
|
}
|
|
620
|
+
// Only on a run that used lanes, and only once enough of them have been
|
|
621
|
+
// judged for the number to mean anything; formatCalibration decides both.
|
|
622
|
+
lines.push(...formatCalibration(calibrate(memory.laneDecisions, memory.findings)));
|
|
619
623
|
const markdown = lines.join("\n");
|
|
620
624
|
const outPath = path.join(memory.dir, "report.md");
|
|
621
625
|
const htmlPath = path.join(memory.dir, "report.html");
|
package/dist/engine/request.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* Everything in this file is pure so it can be table-tested; the one
|
|
23
23
|
* `page.evaluate` lives in browser.ts.
|
|
24
24
|
*/
|
|
25
|
+
import { POLICY_REFUSAL_HEADER } from "./policy.js";
|
|
25
26
|
/** Methods a session may replay. Anything else is refused before it reaches the page. */
|
|
26
27
|
export const REPLAYABLE_METHODS = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
|
|
27
28
|
/** Most of a response body that reaches the agent. A JSON list can be megabytes; the signature is in the first lines. */
|
|
@@ -114,6 +115,7 @@ export function toReplayResult(raw) {
|
|
|
114
115
|
truncated: raw.full > BODY_MAX,
|
|
115
116
|
ms: raw.ms,
|
|
116
117
|
url: raw.url,
|
|
118
|
+
refusedByPolicy: raw.headers[POLICY_REFUSAL_HEADER] ?? null,
|
|
117
119
|
};
|
|
118
120
|
}
|
|
119
121
|
/** The signature a finding carries for this call: the line that dedups the same refusal across runs. */
|
|
@@ -128,8 +130,19 @@ export function replaySignature(method, url, status) {
|
|
|
128
130
|
}
|
|
129
131
|
return `${method} ${path} ${status}`;
|
|
130
132
|
}
|
|
131
|
-
/**
|
|
133
|
+
/**
|
|
134
|
+
* What the agent reads back. Leads with the signature, because that is what a
|
|
135
|
+
* finding quotes — except when the write policy answered, where there is no
|
|
136
|
+
* server signature to quote. Printing the stand-in's 403 as one would read as
|
|
137
|
+
* the server enforcing a permission it was never asked about.
|
|
138
|
+
*/
|
|
132
139
|
export function formatReplay(method, result) {
|
|
140
|
+
if (result.refusedByPolicy) {
|
|
141
|
+
const path = replaySignature(method, result.url, result.status).replace(/ \d+$/, "");
|
|
142
|
+
return (`REFUSED by the write policy (${result.refusedByPolicy}): ${path} never reached the server.\n` +
|
|
143
|
+
`This is the engine's safety net, not the server's answer, so it says nothing about whether the server enforces this rule. ` +
|
|
144
|
+
`To test that, re-attach in a mode that allows the request on a record this session owns, or leave it out of the finding.`);
|
|
145
|
+
}
|
|
133
146
|
const lines = [replaySignature(method, result.url, result.status) + (result.statusText ? ` ${result.statusText}` : ""), `took ${result.ms} ms`];
|
|
134
147
|
const headers = Object.entries(result.headers);
|
|
135
148
|
if (headers.length > 0)
|