scenescout 3.2.0 → 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 +79 -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/live-page.js +101 -2
- package/dist/engine/live.js +60 -0
- package/dist/engine/memory.js +100 -1
- package/dist/engine/oracles.js +9 -0
- package/dist/engine/pace.js +112 -0
- 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 +144 -11
- package/dist/scan.js +47 -1
- package/package.json +9 -3
- package/skills/scenescout/SKILL.md +9 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-testing findings a previous run left open.
|
|
3
|
+
*
|
|
4
|
+
* The report has always carried two kinds of finding and been honest that they
|
|
5
|
+
* are not the same thing: what this run saw, and what some earlier run saw.
|
|
6
|
+
* The second kind is labelled historical and unverified, which is accurate and
|
|
7
|
+
* almost useless — a reader cannot tell a bug fixed three weeks ago from one
|
|
8
|
+
* that is still costing users money today, and neither can the next run.
|
|
9
|
+
*
|
|
10
|
+
* Closing that gap by hand is possible and nobody does it. It means opening
|
|
11
|
+
* the report, copying each finding's route and evidence, re-walking them one
|
|
12
|
+
* at a time, and calling `scout_resolve` on the ones that are gone — per
|
|
13
|
+
* finding, for as many as the history holds. One project's history held over
|
|
14
|
+
* three hundred.
|
|
15
|
+
*
|
|
16
|
+
* So the campaign is a tool. Asked for a worklist, it hands back the open
|
|
17
|
+
* findings in the order a run should re-test them: by severity, grouped so a
|
|
18
|
+
* route is walked once rather than once per finding, each with the evidence
|
|
19
|
+
* that identifies it and the steps that produced it. Asked to record a
|
|
20
|
+
* verdict, it stamps what was seen and when, so the next report can say
|
|
21
|
+
* "confirmed still present" with a date instead of "unverified".
|
|
22
|
+
*
|
|
23
|
+
* Pure, so the ordering and the wording can be table-tested.
|
|
24
|
+
*/
|
|
25
|
+
import { normalizePath } from "./fingerprint.js";
|
|
26
|
+
/** What a re-test found. */
|
|
27
|
+
export const VERDICTS = ["gone", "present", "changed"];
|
|
28
|
+
export const VERDICT_MEANING = {
|
|
29
|
+
gone: "re-tested and the evidence no longer reproduces — resolved",
|
|
30
|
+
present: "re-tested and still reproduces exactly as filed",
|
|
31
|
+
changed: "re-tested and something is different — still broken, but not as described",
|
|
32
|
+
};
|
|
33
|
+
/** Most findings a single campaign hands over at once. A worklist longer than this is a plan nobody follows. */
|
|
34
|
+
export const MAX_WORKLIST = 25;
|
|
35
|
+
const SEVERITY_ORDER = { high: 0, medium: 1, low: 2 };
|
|
36
|
+
function item(f) {
|
|
37
|
+
return {
|
|
38
|
+
id: f.id,
|
|
39
|
+
severity: f.severity,
|
|
40
|
+
category: f.category,
|
|
41
|
+
title: f.title,
|
|
42
|
+
route: normalizePath(f.url),
|
|
43
|
+
url: f.url,
|
|
44
|
+
evidence: f.evidence ?? "",
|
|
45
|
+
repro: f.repro ?? [],
|
|
46
|
+
foundAt: f.foundAt,
|
|
47
|
+
runs: f.runs ?? 1,
|
|
48
|
+
...(f.verdict ? { lastVerdict: f.verdict } : {}),
|
|
49
|
+
...(f.verifiedAt ? { lastVerifiedAt: f.verifiedAt } : {}),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The findings to re-test, in the order to do it.
|
|
54
|
+
*
|
|
55
|
+
* Severity decides what matters; route groups what is cheap, because walking
|
|
56
|
+
* one route and checking four findings on it costs a fraction of walking four
|
|
57
|
+
* routes. Within a route, a finding never verified comes before one verified
|
|
58
|
+
* before — re-checking the same finding twice while another has never been
|
|
59
|
+
* looked at is the failure mode this ordering exists to prevent.
|
|
60
|
+
*/
|
|
61
|
+
export function verifyWorklist(findings, ids) {
|
|
62
|
+
const wanted = ids && ids.length > 0 ? new Set(ids) : null;
|
|
63
|
+
const open = findings.filter((f) => (f.status ?? "open") !== "resolved").filter((f) => (wanted ? wanted.has(f.id) : true));
|
|
64
|
+
// The severity of a route is its worst finding: a route carrying a high is
|
|
65
|
+
// walked before one carrying three mediums.
|
|
66
|
+
const worst = new Map();
|
|
67
|
+
for (const f of open) {
|
|
68
|
+
const route = normalizePath(f.url);
|
|
69
|
+
const rank = SEVERITY_ORDER[f.severity] ?? 3;
|
|
70
|
+
worst.set(route, Math.min(worst.get(route) ?? 9, rank));
|
|
71
|
+
}
|
|
72
|
+
return open
|
|
73
|
+
.map(item)
|
|
74
|
+
.sort((a, b) => (worst.get(a.route) ?? 9) - (worst.get(b.route) ?? 9) ||
|
|
75
|
+
a.route.localeCompare(b.route) ||
|
|
76
|
+
(SEVERITY_ORDER[a.severity] ?? 3) - (SEVERITY_ORDER[b.severity] ?? 3) ||
|
|
77
|
+
Number(Boolean(a.lastVerifiedAt)) - Number(Boolean(b.lastVerifiedAt)) ||
|
|
78
|
+
(a.lastVerifiedAt ?? "").localeCompare(b.lastVerifiedAt ?? "") ||
|
|
79
|
+
a.id.localeCompare(b.id))
|
|
80
|
+
.slice(0, MAX_WORKLIST);
|
|
81
|
+
}
|
|
82
|
+
/** Ids the caller asked for that no open finding matches — a silently short worklist is worse than a named miss. */
|
|
83
|
+
export function unknownIds(findings, ids) {
|
|
84
|
+
const open = new Set(findings.filter((f) => (f.status ?? "open") !== "resolved").map((f) => f.id));
|
|
85
|
+
return ids.filter((id) => !open.has(id));
|
|
86
|
+
}
|
|
87
|
+
/** The campaign brief the agent works down. */
|
|
88
|
+
export function formatWorklist(items, missing = []) {
|
|
89
|
+
if (items.length === 0) {
|
|
90
|
+
return missing.length > 0
|
|
91
|
+
? `Nothing to verify. No OPEN finding matches: ${missing.join(", ")}. They may already be resolved, or belong to another project's memory.`
|
|
92
|
+
: "Nothing to verify — no open findings in this project's memory.";
|
|
93
|
+
}
|
|
94
|
+
const lines = [
|
|
95
|
+
`VERIFY CAMPAIGN — ${items.length} open finding(s) to re-test, worst route first.`,
|
|
96
|
+
`Re-test each one, then record what you saw: scout_verify { id, verdict: "gone" | "present" | "changed", note }.`,
|
|
97
|
+
`"gone" resolves it. "present" stamps it confirmed, so the report stops calling it unverified. "changed" keeps it open and says what differs.`,
|
|
98
|
+
``,
|
|
99
|
+
];
|
|
100
|
+
let route = "";
|
|
101
|
+
for (const it of items) {
|
|
102
|
+
if (it.route !== route) {
|
|
103
|
+
route = it.route;
|
|
104
|
+
lines.push(`── ${route} ──`);
|
|
105
|
+
}
|
|
106
|
+
const seen = it.lastVerifiedAt ? ` · last re-tested ${it.lastVerifiedAt.slice(0, 10)} (${it.lastVerdict})` : " · never re-tested";
|
|
107
|
+
lines.push(`[${it.severity}] ${it.id} ${it.title}`);
|
|
108
|
+
lines.push(` ${it.category} · found ${it.foundAt.slice(0, 10)} · ${it.runs} run(s)${seen}`);
|
|
109
|
+
if (it.evidence)
|
|
110
|
+
lines.push(` evidence: ${it.evidence}`);
|
|
111
|
+
if (it.repro.length > 0)
|
|
112
|
+
lines.push(` repro: ${it.repro.slice(0, 6).join(" → ")}`);
|
|
113
|
+
lines.push(` at: ${it.url}`);
|
|
114
|
+
}
|
|
115
|
+
if (missing.length > 0)
|
|
116
|
+
lines.push(``, `No OPEN finding matches: ${missing.join(", ")}.`);
|
|
117
|
+
return lines.join("\n");
|
|
118
|
+
}
|
|
119
|
+
/** Whether a verdict resolves the finding it is recorded against. */
|
|
120
|
+
export function resolvesFinding(verdict) {
|
|
121
|
+
return verdict === "gone";
|
|
122
|
+
}
|
|
123
|
+
/** What the agent reads back after recording one. */
|
|
124
|
+
export function describeVerdict(f, verdict, note) {
|
|
125
|
+
const tail = note ? `\n ${note}` : "";
|
|
126
|
+
if (verdict === "gone")
|
|
127
|
+
return `Resolved by re-test: [${f.severity}] ${f.title} (${f.id}).${tail}`;
|
|
128
|
+
if (verdict === "present")
|
|
129
|
+
return `Confirmed still present: [${f.severity}] ${f.title} (${f.id}). It stays open, and the report now dates the confirmation instead of calling it unverified.${tail}`;
|
|
130
|
+
return `Still open and changed: [${f.severity}] ${f.title} (${f.id}). Re-file the difference as its own finding if the behaviour is now a different bug.${tail}`;
|
|
131
|
+
}
|
|
132
|
+
/** How a verified finding reads in the report, or nothing when it was never re-tested. */
|
|
133
|
+
export function sayVerification(f) {
|
|
134
|
+
if (!f.verdict || !f.verifiedAt)
|
|
135
|
+
return "";
|
|
136
|
+
const on = f.verifiedAt.slice(0, 10);
|
|
137
|
+
if (f.verdict === "present")
|
|
138
|
+
return ` · confirmed still present on ${on}`;
|
|
139
|
+
if (f.verdict === "changed")
|
|
140
|
+
return ` · re-tested ${on}, behaviour has changed since it was filed`;
|
|
141
|
+
return ` · re-tested ${on}`;
|
|
142
|
+
}
|
package/dist/mcp-server.js
CHANGED
|
@@ -38,9 +38,12 @@ import { FINDING_CATEGORIES, MemoryStore, redactSecrets } from "./engine/memory.
|
|
|
38
38
|
import { LANE_NAME_MAX, laneReportInstruction, parseLaneReport, summarizeLaneReport } from "./engine/lane.js";
|
|
39
39
|
import { SessionQueue, withWatchdog } from "./engine/dispatch.js";
|
|
40
40
|
import { FIXTURE_KINDS } from "./engine/fixtures.js";
|
|
41
|
-
import { feedForSession, LIVE_ENV, writeStatusFile, LIVE_TOKEN_FILE, LiveServer, StatusBoard, } from "./engine/live.js";
|
|
41
|
+
import { feedForSession, LIVE_ENV, writeStatusFile, LIVE_TOKEN_FILE, liveEngines, liveTokenFileName, pidAlive, statusFileName, LiveServer, StatusBoard, } from "./engine/live.js";
|
|
42
|
+
import { formatBriefs, MAX_LANES, planLanes } from "./engine/brief.js";
|
|
42
43
|
import { computeGaps, formatRouteCoverage, generateReport, replayDocument, reportEvidence } from "./engine/report.js";
|
|
44
|
+
import { describeVerdict, formatWorklist, unknownIds, VERDICTS, verifyWorklist } from "./engine/verify.js";
|
|
43
45
|
import { RECORD_MAX_FRAMES, resolveFrame } from "./engine/replay.js";
|
|
46
|
+
import { describePace, normalizePace } from "./engine/settle.js";
|
|
44
47
|
import { needsTask, taskRefusal, TASK_MAX } from "./engine/task.js";
|
|
45
48
|
import { EXPLORE_PROMPT_ARGUMENTS, explorePrompt, loadPlaybook, PLAYBOOK_PROMPT, PLAYBOOK_TOOL, SERVER_INSTRUCTIONS } from "./playbook.js";
|
|
46
49
|
import { formatScan, scanProject } from "./scan.js";
|
|
@@ -241,15 +244,20 @@ function reportExtras(eng) {
|
|
|
241
244
|
mode: eng.mode,
|
|
242
245
|
policyAttributed: eng.oracleLog.policyAttributed,
|
|
243
246
|
version: PKG_VERSION,
|
|
247
|
+
attachedSessions: [...engines.keys()],
|
|
244
248
|
};
|
|
245
249
|
}
|
|
246
250
|
/** Hand the live view's token to `scenescout watch` through a file only the owner can read. */
|
|
247
251
|
function publishLiveToken(dir) {
|
|
248
252
|
if (!liveAddress || liveDirs.has(dir) || liveTokenWrites.has(dir))
|
|
249
253
|
return;
|
|
250
|
-
|
|
254
|
+
// Named for this process: two engines on one project would otherwise hand
|
|
255
|
+
// `watch` one token for two ports, and whichever wrote last would win.
|
|
256
|
+
const file = path.join(dir, liveTokenFileName(process.pid));
|
|
251
257
|
const write = fs.promises
|
|
252
258
|
.writeFile(file, liveAddress.token, { mode: 0o600 })
|
|
259
|
+
// The shared name stays too, for a `watch` from before per-pid files.
|
|
260
|
+
.then(() => fs.promises.writeFile(path.join(dir, LIVE_TOKEN_FILE), liveAddress.token, { mode: 0o600 }))
|
|
253
261
|
// `mode` applies only when the file is created; a leftover one keeps its old bits.
|
|
254
262
|
.then(() => fs.promises.chmod(file, 0o600))
|
|
255
263
|
.then(() => {
|
|
@@ -487,6 +495,27 @@ server.server.setRequestHandler(GetPromptRequestSchema, (request) => {
|
|
|
487
495
|
// The lane report: how a parallel agent hands its results back to the planner
|
|
488
496
|
// as typed decisions. One tool for both halves, so the instruction a lane is
|
|
489
497
|
// given and the parser its reply meets are the same code.
|
|
498
|
+
// Splitting the app between lanes: the other half of the parallel protocol.
|
|
499
|
+
// scout_lane_report is how a lane hands its answers back; this is what the
|
|
500
|
+
// planner hands it in the first place.
|
|
501
|
+
server.registerTool("scout_lane_brief", {
|
|
502
|
+
description: "For a run split across parallel agents (lanes). Divides the app's known routes between N lanes and returns each lane's session name, the `objective` to attach it with, and the routes it owns — whole modules per lane, balanced by route count, so no two lanes audit the same area and none is left unopened. Call it after the first crawl, when route knowledge is complete. Touches no browser; pass the briefs to your lane agents, then use scout_lane_report for what they hand back.",
|
|
503
|
+
inputSchema: {
|
|
504
|
+
lanes: z.number().int().min(1).max(MAX_LANES).describe(`How many lanes to split across (1–${MAX_LANES})`),
|
|
505
|
+
goal: z.string().max(200).optional().describe("What the whole run is for; each lane's objective is written against it"),
|
|
506
|
+
routes: z.array(z.string()).max(500).optional().describe("Routes to split. Omit to split every route this project knows about."),
|
|
507
|
+
session: sessionParam,
|
|
508
|
+
},
|
|
509
|
+
}, serializedPerSession("scout_lane_brief", async ({ lanes, goal, routes }, session) => {
|
|
510
|
+
try {
|
|
511
|
+
const eng = engineFor(session);
|
|
512
|
+
const all = routes && routes.length > 0 ? routes : eng.allKnownRoutes();
|
|
513
|
+
return text(formatBriefs(planLanes(all, lanes, { goal, mode: eng.mode, role: eng.role }), { goal, mode: eng.mode, role: eng.role }), session);
|
|
514
|
+
}
|
|
515
|
+
catch (err) {
|
|
516
|
+
return errorText(err);
|
|
517
|
+
}
|
|
518
|
+
}));
|
|
490
519
|
server.registerTool("scout_lane_report", {
|
|
491
520
|
description: "For a run split across parallel agents (lanes). Without `reply`: returns the paragraph to put in a lane's prompt, telling it to hand back ONE typed JSON object (verdicts, severities and categories from closed sets, a calibrated confidence per decision, routes covered, what blocked it). " +
|
|
492
521
|
"With `reply`: parses what the lane handed back and returns the one-line fold (defects, highs, unsure, mean confidence, routes) or the reason it was refused, to relay to the lane once. Touches no browser.",
|
|
@@ -543,7 +572,18 @@ server.registerTool("scout_attach", {
|
|
|
543
572
|
.describe('This session\'s objective: the whole remit you were given, in one sentence ("Admin lane: §2 registers, §7 plan gating", ' +
|
|
544
573
|
'"Approve and reject orders as a manager"). It sits above the task, which is what the session is doing at any moment. ' +
|
|
545
574
|
"Shown to whoever is watching the run; worth setting whenever more than one session is live."),
|
|
546
|
-
task: z
|
|
575
|
+
task: z
|
|
576
|
+
.string()
|
|
577
|
+
.max(300)
|
|
578
|
+
.optional()
|
|
579
|
+
.describe("What this session is doing right now, shown under its objective from the moment it appears, e.g. Signing in and taking stock. Passed alone it is read as the 2.0 spelling of `objective`. Defaults to a placeholder so a fresh card never reads as idle."),
|
|
580
|
+
paceMs: z
|
|
581
|
+
.number()
|
|
582
|
+
.int()
|
|
583
|
+
.min(0)
|
|
584
|
+
.max(60000)
|
|
585
|
+
.optional()
|
|
586
|
+
.describe("A floor between actions, in milliseconds, for when a person is watching and needs to keep up — following a flow, taking notes, demonstrating. Default 0: as fast as the page allows, which is what a run wants otherwise. Changeable mid-run with scout_session {paceMs}."),
|
|
547
587
|
record: z
|
|
548
588
|
.boolean()
|
|
549
589
|
.default(false)
|
|
@@ -555,7 +595,7 @@ server.registerTool("scout_attach", {
|
|
|
555
595
|
.optional()
|
|
556
596
|
.describe("Session name for multi-role runs (e.g. 'admin', 'qa'). Creates/replaces that session's browser and makes it the default. Default: 'default'."),
|
|
557
597
|
},
|
|
558
|
-
}, serializedControl(async ({ url, projectPath, storageStatePath, mode, headed, browser, viewportWidth, viewportHeight, objective, task, record, session, }) => {
|
|
598
|
+
}, serializedControl(async ({ url, projectPath, storageStatePath, mode, headed, browser, viewportWidth, viewportHeight, objective, task, record, paceMs, session, }) => {
|
|
559
599
|
try {
|
|
560
600
|
const target = session ?? activeName;
|
|
561
601
|
if (session) {
|
|
@@ -624,8 +664,13 @@ server.registerTool("scout_attach", {
|
|
|
624
664
|
headed,
|
|
625
665
|
browser,
|
|
626
666
|
viewport,
|
|
627
|
-
// `task` is what this was called in 2.0
|
|
667
|
+
// `task` is what this was called in 2.0, where it named the session's
|
|
668
|
+
// whole remit. Alone it still means that. Given BESIDE an objective it
|
|
669
|
+
// means what it means everywhere else — what this session is doing
|
|
670
|
+
// right now — so the card says something from the moment it appears.
|
|
628
671
|
objective: objective ?? task,
|
|
672
|
+
paceMs,
|
|
673
|
+
task: objective ? task : undefined,
|
|
629
674
|
record,
|
|
630
675
|
memoryStore: store,
|
|
631
676
|
});
|
|
@@ -643,7 +688,7 @@ server.registerTool("scout_attach", {
|
|
|
643
688
|
const recordNote = record && eng.memory?.dir
|
|
644
689
|
? `\n\n📸 RECORDING: a frame of the page after each action, under ${path.join(eng.memory.dir, "recordings", target)}/ (at most ${RECORD_MAX_FRAMES}). scout_report writes them into report.html beside report.md.`
|
|
645
690
|
: "";
|
|
646
|
-
return text(out + conflictNote + recordNote + (engines.size > 1 ? `\n${sessionLines()}` : "") + liveLine(), target);
|
|
691
|
+
return text(out + conflictNote + recordNote + describePace(eng.pace) + (engines.size > 1 ? `\n${sessionLines()}` : "") + liveLine(), target);
|
|
647
692
|
}
|
|
648
693
|
catch (err) {
|
|
649
694
|
return errorText(err);
|
|
@@ -664,10 +709,32 @@ server.registerTool("scout_session", {
|
|
|
664
709
|
// costs nothing and removes a guaranteed first-try rejection, since all
|
|
665
710
|
// schemas are additionalProperties:false and reject the near-miss hard.
|
|
666
711
|
session: z.string().max(40).optional().describe("Alias for `name`."),
|
|
712
|
+
paceMs: z
|
|
713
|
+
.number()
|
|
714
|
+
.int()
|
|
715
|
+
.min(0)
|
|
716
|
+
.max(60000)
|
|
717
|
+
.optional()
|
|
718
|
+
.describe("Change how fast this session acts, mid-run: a floor between actions in milliseconds, for when a person is watching and needs to keep up. 0 restores full speed. With `name`, applies to that session; without, to every live session — which is what 'slow everything down so I can follow' means."),
|
|
667
719
|
},
|
|
668
|
-
}, serializedControl(async ({ name, session }) => {
|
|
720
|
+
}, serializedControl(async ({ name, session, paceMs }) => {
|
|
669
721
|
try {
|
|
670
722
|
name = name ?? session;
|
|
723
|
+
// A pace with no session named is meant for the whole run: somebody is
|
|
724
|
+
// watching and wants to keep up with all of it, not one lane.
|
|
725
|
+
if (paceMs !== undefined && !name) {
|
|
726
|
+
const applied = [...engines.values()].map((e) => e.setPace(paceMs));
|
|
727
|
+
const at = applied[0] ?? normalizePace(paceMs);
|
|
728
|
+
return text((at > 0
|
|
729
|
+
? `Every live session now waits at least ${at} ms between actions, so a person can follow along.`
|
|
730
|
+
: `Every live session is back to full speed: as fast as its page allows.`) + `\n${sessionLines()}`, activeName);
|
|
731
|
+
}
|
|
732
|
+
if (paceMs !== undefined && name) {
|
|
733
|
+
if (!engines.has(name))
|
|
734
|
+
return text(`No session named "${name}" yet — create it with scout_attach { session: "${name}", … }.\n${sessionLines()}`, activeName);
|
|
735
|
+
const at = engines.get(name).setPace(paceMs);
|
|
736
|
+
return text((at > 0 ? `${name} now waits at least ${at} ms between actions.` : `${name} is back to full speed.`) + `\n${sessionLines()}`, activeName);
|
|
737
|
+
}
|
|
671
738
|
if (!name)
|
|
672
739
|
return text(sessionLines() + liveLine(), activeName);
|
|
673
740
|
if (!engines.has(name)) {
|
|
@@ -860,6 +927,28 @@ server.registerTool("scout_navigate", {
|
|
|
860
927
|
return errorText(err);
|
|
861
928
|
}
|
|
862
929
|
}));
|
|
930
|
+
server.registerTool("scout_request", {
|
|
931
|
+
description: "Call the app's own API as this session, with the UI bypassed — the check that turns a hidden or disabled control into a proven refusal. A button that is not shown proves nothing; the same action refused by the server does. The fetch runs IN the page, so it carries the session's cookies and replays the Authorization header the app itself last sent, and it passes through the same interception the write policy is enforced on: in safe-write a mutation on a record this session did not create is refused here exactly as it would be for a click, and that refusal is the engine's safety net, not a finding. Returns the status line, the timing, the headers that decide whether two responses are truly identical (content-type, location, www-authenticate, retry-after, cache-control), and the body. Unlike a shell call, every request is recorded in the run's trail and its signature is what a finding should quote. Paths are fenced to the attached origin: use another session to reach another host.",
|
|
932
|
+
inputSchema: {
|
|
933
|
+
path: z.string().min(1).max(2000).describe("Path on the attached origin, e.g. /api/things/12, or a full URL on that same origin"),
|
|
934
|
+
method: z.enum(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]).optional().describe("Default GET"),
|
|
935
|
+
body: z.string().max(20000).optional().describe("Request body, sent as application/json unless a content-type header is given"),
|
|
936
|
+
headers: z
|
|
937
|
+
.record(z.string().max(2000))
|
|
938
|
+
.optional()
|
|
939
|
+
.describe("Extra headers. One given here wins over the app's own, which is how a session tests a different or absent credential."),
|
|
940
|
+
task: taskParam,
|
|
941
|
+
objective: legacyObjectiveParam,
|
|
942
|
+
session: sessionParam,
|
|
943
|
+
},
|
|
944
|
+
}, serializedPerSession("scout_request", async (args, session) => {
|
|
945
|
+
try {
|
|
946
|
+
return text(await engineFor(session).apiRequest({ method: args.method, path: args.path, body: args.body, headers: args.headers }), session);
|
|
947
|
+
}
|
|
948
|
+
catch (err) {
|
|
949
|
+
return errorText(err);
|
|
950
|
+
}
|
|
951
|
+
}));
|
|
863
952
|
server.registerTool("scout_back", {
|
|
864
953
|
description: "Go back in browser history (tests back-button resilience).",
|
|
865
954
|
inputSchema: { task: taskParam, objective: legacyObjectiveParam, session: sessionParam },
|
|
@@ -936,11 +1025,11 @@ server.registerTool("scout_journey", {
|
|
|
936
1025
|
}
|
|
937
1026
|
}));
|
|
938
1027
|
server.registerTool("scout_note", {
|
|
939
|
-
description: "Cumulative WRITTEN knowledge about the tested app — .scenescout/ASSUMPTIONS.md, in prose a human can read and correct. memory.json stores coverage; this stores UNDERSTANDING, so every run starts smarter than the last. READ it at the start of every session ({action:'read'}). ADD durable learnings as you go ({action:'add', section, note}): what the app is for (app-model), who each role is and what they're FOR — infer the persona from what the role can see and do, e.g. 'qa-role = reviewer: approves orders, cannot administer' (roles), UI patterns the app follows (conventions), rules discovered the hard way like 'an order can only ship once approved' (constraints), fragile areas worth re-testing every run (risks), domain terms (glossary). Notes are dated, attributed to the acting role, and deduplicated. Do NOT record session-specific facts (ids, counts) — only durable knowledge.",
|
|
1028
|
+
description: "Cumulative WRITTEN knowledge about the tested app — .scenescout/ASSUMPTIONS.md, in prose a human can read and correct. memory.json stores coverage; this stores UNDERSTANDING, so every run starts smarter than the last. READ it at the start of every session ({action:'read'}). ADD durable learnings as you go ({action:'add', section, note}): what the app is for (app-model), who each role is and what they're FOR — infer the persona from what the role can see and do, e.g. 'qa-role = reviewer: approves orders, cannot administer' (roles), UI patterns the app follows (conventions), rules discovered the hard way like 'an order can only ship once approved' (constraints), fragile areas worth re-testing every run (risks), domain terms (glossary), and how to get the app testable at all — the command that regenerates an expired login state, what has to be running (setup), which the engine reads back to you the next time a storage state has expired. Notes are dated, attributed to the acting role, and deduplicated. Do NOT record session-specific facts (ids, counts) — only durable knowledge.",
|
|
940
1029
|
inputSchema: {
|
|
941
1030
|
action: z.enum(["read", "add"]).describe("'read' the accumulated knowledge, or 'add' one durable learning"),
|
|
942
1031
|
section: z
|
|
943
|
-
.enum(["app-model", "roles", "conventions", "constraints", "risks", "glossary"])
|
|
1032
|
+
.enum(["app-model", "roles", "conventions", "constraints", "risks", "glossary", "setup"])
|
|
944
1033
|
.optional()
|
|
945
1034
|
.describe("For add: which knowledge section this belongs to"),
|
|
946
1035
|
note: z.string().max(500).optional().describe("For add: the learning, one or two sentences, written for a future reader with no context"),
|
|
@@ -1049,9 +1138,13 @@ server.registerTool("scout_report", {
|
|
|
1049
1138
|
.enum(["minimal", "medium", "extensive"])
|
|
1050
1139
|
.default("medium")
|
|
1051
1140
|
.describe("Which completion contract to enforce — match the level the run was asked for"),
|
|
1141
|
+
history: z
|
|
1142
|
+
.enum(["index", "full"])
|
|
1143
|
+
.default("index")
|
|
1144
|
+
.describe("How much of the history to print. 'index' lists findings from earlier runs, and resolved ones, as a row each: id, severity, age, title. 'full' prints every one in full as before — on one project that was 1.75 MB against 113 KB, nearly half of it findings already fixed. Use 'full' when handing the document to someone who has no access to the memory."),
|
|
1052
1145
|
session: sessionParam,
|
|
1053
1146
|
},
|
|
1054
|
-
}, serializedPerSession("scout_report", async ({ force, level }, session) => {
|
|
1147
|
+
}, serializedPerSession("scout_report", async ({ force, level, history }, session) => {
|
|
1055
1148
|
try {
|
|
1056
1149
|
const eng = engineFor(session);
|
|
1057
1150
|
if (!eng.memory)
|
|
@@ -1100,6 +1193,7 @@ server.registerTool("scout_report", {
|
|
|
1100
1193
|
`Then call scout_report again. Pass force=true ONLY if the user explicitly capped the budget.`, session);
|
|
1101
1194
|
}
|
|
1102
1195
|
const { path: p, summary } = generateReport(eng.memory, eng.oracleLog.all, {
|
|
1196
|
+
history,
|
|
1103
1197
|
routesVisited: all.length - unvisited.length,
|
|
1104
1198
|
routesTotal: all.length,
|
|
1105
1199
|
designAudits: eng.designAuditCount,
|
|
@@ -1140,6 +1234,39 @@ server.registerTool("scout_resolve", {
|
|
|
1140
1234
|
return errorText(err);
|
|
1141
1235
|
}
|
|
1142
1236
|
}));
|
|
1237
|
+
server.registerTool("scout_verify", {
|
|
1238
|
+
description: 'Re-test findings earlier runs left open. With no arguments, returns the open findings in the order to re-test them — worst route first, grouped so a route is walked once — each with its evidence and repro steps. Pass ids to narrow it to specific findings. After re-testing one, call again with id and verdict to record what you saw: "gone" resolves it, "present" stamps it confirmed so the report stops calling it unverified, "changed" keeps it open and says the behaviour differs. Use after a fix wave, or at the start of a run against an app this project has tested before.',
|
|
1239
|
+
inputSchema: {
|
|
1240
|
+
id: z.string().optional().describe("The finding being verified. Omit to get the worklist."),
|
|
1241
|
+
verdict: z.enum(VERDICTS).optional().describe('What the re-test found: "gone", "present" or "changed". Requires id.'),
|
|
1242
|
+
note: z.string().max(500).optional().describe("What you saw, in a sentence. Shown in the report beside the verdict."),
|
|
1243
|
+
ids: z.array(z.string()).max(50).optional().describe("Narrow the worklist to these finding ids."),
|
|
1244
|
+
session: sessionParam,
|
|
1245
|
+
},
|
|
1246
|
+
}, serializedPerSession("scout_verify", async ({ id, verdict, note, ids }, session) => {
|
|
1247
|
+
try {
|
|
1248
|
+
const eng = engineFor(session);
|
|
1249
|
+
if (!eng.memory)
|
|
1250
|
+
throw new Error("Not attached.");
|
|
1251
|
+
if (verdict && !id)
|
|
1252
|
+
return text(`Pass the finding the verdict is about: scout_verify { id: "a1b2c3d4e5", verdict: "${verdict}" }.`, session);
|
|
1253
|
+
if (id && !verdict) {
|
|
1254
|
+
return text(`Pass what the re-test found: scout_verify { id: "${id}", verdict: "gone" | "present" | "changed" }.`, session);
|
|
1255
|
+
}
|
|
1256
|
+
if (id && verdict) {
|
|
1257
|
+
const f = eng.memory.verifyFinding(id, verdict, note);
|
|
1258
|
+
if (!f)
|
|
1259
|
+
return text(`No finding with id ${id}.`, session);
|
|
1260
|
+
return text(describeVerdict(f, verdict, note), session);
|
|
1261
|
+
}
|
|
1262
|
+
const findings = eng.memory.findings;
|
|
1263
|
+
const missing = ids && ids.length > 0 ? unknownIds(findings, ids) : [];
|
|
1264
|
+
return text(formatWorklist(verifyWorklist(findings, ids), missing), session);
|
|
1265
|
+
}
|
|
1266
|
+
catch (err) {
|
|
1267
|
+
return errorText(err);
|
|
1268
|
+
}
|
|
1269
|
+
}));
|
|
1143
1270
|
server.registerTool("scout_close", {
|
|
1144
1271
|
description: "Close a session's browser (memory persists on disk). Default: the DEFAULT session. Pass session to close a specific one, or all=true to close every live session at the end of a multi-role run.",
|
|
1145
1272
|
inputSchema: {
|
|
@@ -1218,7 +1345,13 @@ async function shutdown() {
|
|
|
1218
1345
|
await Promise.allSettled(liveTokenWrites.values());
|
|
1219
1346
|
for (const dir of liveDirs) {
|
|
1220
1347
|
try {
|
|
1221
|
-
fs.rmSync(path.join(dir,
|
|
1348
|
+
fs.rmSync(path.join(dir, liveTokenFileName(process.pid)), { force: true });
|
|
1349
|
+
fs.rmSync(path.join(dir, statusFileName(process.pid)), { force: true });
|
|
1350
|
+
// The shared names belong to whichever engine is still running, so they
|
|
1351
|
+
// are only removed when this process is the last one holding them.
|
|
1352
|
+
if (liveEngines(dir, pidAlive).filter((e) => e.pid !== process.pid).length === 0) {
|
|
1353
|
+
fs.rmSync(path.join(dir, LIVE_TOKEN_FILE), { force: true });
|
|
1354
|
+
}
|
|
1222
1355
|
}
|
|
1223
1356
|
catch (err) {
|
|
1224
1357
|
console.error(`[scenescout] could not remove the live view's token file in ${dir}: ${err instanceof Error ? err.message : String(err)}`);
|