scenescout 1.2.0 → 1.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 +13 -0
- package/README.md +38 -7
- package/dist/browsers.js +11 -0
- package/dist/cli.js +144 -34
- package/dist/engine/browser.js +128 -4
- package/dist/engine/live-page.js +644 -0
- package/dist/engine/live.js +549 -0
- package/dist/engine/memory.js +3 -0
- package/dist/engine/report.js +8 -2
- package/dist/installer.js +82 -4
- package/dist/mcp-server.js +216 -26
- package/package.json +3 -2
- package/skills/scenescout/SKILL.md +2 -1
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The live view: what every session is doing right now, and what it is
|
|
3
|
+
* looking at.
|
|
4
|
+
*
|
|
5
|
+
* status.json used to hold ONE line for the whole project, last write wins. With
|
|
6
|
+
* several sessions attached that line describes whichever session happened to
|
|
7
|
+
* finish a call most recently, which is the opposite of what someone watching a
|
|
8
|
+
* multi-role run needs. The StatusBoard keeps one entry per session instead.
|
|
9
|
+
*
|
|
10
|
+
* The LiveServer puts that board, a thumbnail and an optional live stream of
|
|
11
|
+
* each session's page behind a small HTTP server. It exists for a person
|
|
12
|
+
* watching a run, and it holds to the rules in ADR 7:
|
|
13
|
+
*
|
|
14
|
+
* - it binds 127.0.0.1 only and refuses a Host header that is not loopback,
|
|
15
|
+
* so neither the network nor a DNS-rebinding page can reach it;
|
|
16
|
+
* - every path starts with a random token, handed over in the scout_attach
|
|
17
|
+
* result and, for `scenescout watch`, through a file only the owner can read;
|
|
18
|
+
* - it answers GET and nothing else: a viewer can look, never act;
|
|
19
|
+
* - a frame is held in memory and sent to the viewer. None is written to disk,
|
|
20
|
+
* because the page may be showing somebody's real data.
|
|
21
|
+
*
|
|
22
|
+
* Nothing here imports Playwright. The engine is reached through LiveProvider,
|
|
23
|
+
* so the whole module is tested over real HTTP with a fake provider.
|
|
24
|
+
*/
|
|
25
|
+
import crypto from "node:crypto";
|
|
26
|
+
import fs from "node:fs";
|
|
27
|
+
import http from "node:http";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
import { LIVE_PAGE } from "./live-page.js";
|
|
30
|
+
import { JOURNEY_END, JOURNEY_START } from "./memory.js";
|
|
31
|
+
/** Holds the live view's token, next to status.json. Written owner-only; removed when the engine shuts down. */
|
|
32
|
+
export const LIVE_TOKEN_FILE = "live-token";
|
|
33
|
+
/** `SCENESCOUT_LIVE=off` keeps the engine from opening the live view's port at all. */
|
|
34
|
+
export const LIVE_ENV = "SCENESCOUT_LIVE";
|
|
35
|
+
/**
|
|
36
|
+
* One session's most recent actions, oldest first, each tagged with the
|
|
37
|
+
* journey it belonged to. The log is project-wide and interleaves every
|
|
38
|
+
* session, and a journey is delimited in it by its own `journey:start` and
|
|
39
|
+
* `journey:end` lines, so the walk goes back past the window until it finds
|
|
40
|
+
* the marker that says whether the window's first lines were inside one.
|
|
41
|
+
*/
|
|
42
|
+
export function feedForSession(log, session, limit, redact = (s) => s) {
|
|
43
|
+
const mine = [];
|
|
44
|
+
let i = log.length - 1;
|
|
45
|
+
for (; i >= 0 && mine.length < limit; i -= 1) {
|
|
46
|
+
const e = log[i];
|
|
47
|
+
if (e && (e.session ?? session) === session)
|
|
48
|
+
mine.push(e);
|
|
49
|
+
}
|
|
50
|
+
let objective;
|
|
51
|
+
for (; i >= 0; i -= 1) {
|
|
52
|
+
const e = log[i];
|
|
53
|
+
if (!e || (e.session ?? session) !== session)
|
|
54
|
+
continue;
|
|
55
|
+
if (e.action === JOURNEY_END)
|
|
56
|
+
break;
|
|
57
|
+
if (e.action === JOURNEY_START) {
|
|
58
|
+
objective = e.target;
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const lines = [];
|
|
63
|
+
for (const e of mine.reverse()) {
|
|
64
|
+
if (e.action === JOURNEY_START)
|
|
65
|
+
objective = e.target;
|
|
66
|
+
const line = { at: e.at, action: e.action, url: redact(e.url) };
|
|
67
|
+
if (e.target !== undefined)
|
|
68
|
+
line.target = e.target;
|
|
69
|
+
if (e.result !== undefined)
|
|
70
|
+
line.result = e.result;
|
|
71
|
+
if (objective !== undefined)
|
|
72
|
+
line.objective = objective;
|
|
73
|
+
lines.push(line);
|
|
74
|
+
if (e.action === JOURNEY_END)
|
|
75
|
+
objective = undefined;
|
|
76
|
+
}
|
|
77
|
+
return lines;
|
|
78
|
+
}
|
|
79
|
+
/** How many feed lines a status poll carries per session. Enough to read the last move at a glance; the close-up asks for more. */
|
|
80
|
+
export const FEED_LINES = 6;
|
|
81
|
+
/** The cap the close-up gets. A long run's log is thousands of lines and none of it needs to reach the page. */
|
|
82
|
+
export const FEED_LINES_MAX = 60;
|
|
83
|
+
/**
|
|
84
|
+
* How long a call may run before the session is judged wedged rather than
|
|
85
|
+
* busy, when the entry does not carry the tool's own watchdog budget (an
|
|
86
|
+
* engine from before budgets were recorded). Each tool's real budget is what
|
|
87
|
+
* `classify` uses when it is there: a healthy crawl runs for minutes.
|
|
88
|
+
*/
|
|
89
|
+
export const STUCK_AFTER_MS = 120_000;
|
|
90
|
+
export class StatusBoard {
|
|
91
|
+
now;
|
|
92
|
+
entries = new Map();
|
|
93
|
+
constructor(now = Date.now) {
|
|
94
|
+
this.now = now;
|
|
95
|
+
}
|
|
96
|
+
update(session, fields) {
|
|
97
|
+
const prev = this.entries.get(session);
|
|
98
|
+
const at = new Date(this.now()).toISOString();
|
|
99
|
+
// `since` survives repeated writes of the same phase of the same tool:
|
|
100
|
+
// that is what lets a reader tell a long-running call from a fresh one.
|
|
101
|
+
const since = prev && prev.phase === fields.phase && prev.tool === fields.tool ? prev.since : at;
|
|
102
|
+
const next = { session, ...fields, since, at };
|
|
103
|
+
this.entries.set(session, next);
|
|
104
|
+
return next;
|
|
105
|
+
}
|
|
106
|
+
remove(session) {
|
|
107
|
+
this.entries.delete(session);
|
|
108
|
+
}
|
|
109
|
+
clear() {
|
|
110
|
+
this.entries.clear();
|
|
111
|
+
}
|
|
112
|
+
has(session) {
|
|
113
|
+
return this.entries.has(session);
|
|
114
|
+
}
|
|
115
|
+
/** Sorted by name, so a poller sees a stable order rather than insertion order. */
|
|
116
|
+
list() {
|
|
117
|
+
return [...this.entries.values()].sort((a, b) => a.session.localeCompare(b.session));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
export function classify(status, nowMs, stuckAfterMs = STUCK_AFTER_MS) {
|
|
121
|
+
if (status.phase !== "running")
|
|
122
|
+
return "idle";
|
|
123
|
+
// Past its own budget the watchdog should have ended the call; a call still
|
|
124
|
+
// running there has outlived the thing that was meant to stop it.
|
|
125
|
+
return nowMs - new Date(status.since).getTime() > (status.budgetMs ?? stuckAfterMs) ? "stuck" : "running";
|
|
126
|
+
}
|
|
127
|
+
export function formatDuration(ms) {
|
|
128
|
+
const total = Math.max(0, Math.floor(ms / 1000));
|
|
129
|
+
if (total < 60)
|
|
130
|
+
return `${total}s`;
|
|
131
|
+
const minutes = Math.floor(total / 60);
|
|
132
|
+
if (minutes < 60)
|
|
133
|
+
return `${minutes}m${String(total % 60).padStart(2, "0")}s`;
|
|
134
|
+
return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, "0")}m`;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* A log timestamp on the reader's own clock, 24-hour. The log stores UTC, and
|
|
138
|
+
* a time sliced straight out of it reads an hour or more off the person's
|
|
139
|
+
* watch, which makes a live feed look stale.
|
|
140
|
+
*/
|
|
141
|
+
export function localClock(iso) {
|
|
142
|
+
const d = new Date(iso);
|
|
143
|
+
if (Number.isNaN(d.getTime()))
|
|
144
|
+
return "";
|
|
145
|
+
return [d.getHours(), d.getMinutes(), d.getSeconds()].map((n) => String(n).padStart(2, "0")).join(":");
|
|
146
|
+
}
|
|
147
|
+
/** One line per session for the terminal (`scenescout status`). */
|
|
148
|
+
export function formatSessionLine(status, nowMs) {
|
|
149
|
+
const state = classify(status, nowMs);
|
|
150
|
+
const held = formatDuration(nowMs - new Date(status.since).getTime());
|
|
151
|
+
const what = state === "idle" ? `· idle ${held} after ${status.tool}` : `${state === "stuck" ? "⚠ STUCK" : "⏳"} ${status.tool} for ${held}`;
|
|
152
|
+
return `${status.session} (${status.role}) ${what}${status.url ? ` — ${status.url}` : ""}`;
|
|
153
|
+
}
|
|
154
|
+
const statusWrites = new Map();
|
|
155
|
+
/**
|
|
156
|
+
* Write status.json so that a reader never sees a torn file. Writes to one
|
|
157
|
+
* directory are queued behind each other, and each lands by rename, so two
|
|
158
|
+
* calls in the same tick cannot leave a short document with the tail of a
|
|
159
|
+
* longer one after it (which is what overlapping `writeFile`s produced).
|
|
160
|
+
* Best-effort: a failed write is dropped and the next one lands.
|
|
161
|
+
*/
|
|
162
|
+
export function writeStatusFile(dir, body) {
|
|
163
|
+
const file = path.join(dir, "status.json");
|
|
164
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
165
|
+
const next = (statusWrites.get(dir) ?? Promise.resolve())
|
|
166
|
+
.then(() => fs.promises.writeFile(tmp, body))
|
|
167
|
+
.then(() => fs.promises.rename(tmp, file))
|
|
168
|
+
.catch(() => fs.promises.rm(tmp, { force: true }).catch(() => { }));
|
|
169
|
+
statusWrites.set(dir, next);
|
|
170
|
+
return next;
|
|
171
|
+
}
|
|
172
|
+
/** The entries of a status file that are whole enough to describe. A truncated write or an older engine can leave others. */
|
|
173
|
+
export function wholeSessions(detail) {
|
|
174
|
+
return (detail ?? []).filter((e) => typeof e.session === "string" &&
|
|
175
|
+
typeof e.tool === "string" &&
|
|
176
|
+
typeof e.role === "string" &&
|
|
177
|
+
typeof e.url === "string" &&
|
|
178
|
+
(e.phase === "running" || e.phase === "idle") &&
|
|
179
|
+
typeof e.since === "string" &&
|
|
180
|
+
Number.isFinite(Date.parse(e.since)));
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Where `scenescout watch` should send the browser, or why it cannot.
|
|
184
|
+
*
|
|
185
|
+
* status.json and the token file sit inside the project under test, so a
|
|
186
|
+
* repository can ship its own. Both values are therefore checked for shape
|
|
187
|
+
* before they are put into a URL, and the host is never read from the file.
|
|
188
|
+
*/
|
|
189
|
+
export function watchTarget(input) {
|
|
190
|
+
const { status, alive, token } = input;
|
|
191
|
+
if (!status)
|
|
192
|
+
return { problem: "No SceneScout engine has attached to this project yet. Start a run, then run this again." };
|
|
193
|
+
if (status === "unreadable")
|
|
194
|
+
return { problem: "The status file is unreadable or truncated: the engine was probably killed mid-write. Attach again to rewrite it." };
|
|
195
|
+
if (!alive)
|
|
196
|
+
return { problem: `The engine that last ran here (pid ${status.pid ?? "?"}) is not running. The live view exists only while a run is attached.` };
|
|
197
|
+
if (status.live?.error)
|
|
198
|
+
return { problem: `The engine could not open the live view: ${status.live.error}` };
|
|
199
|
+
const port = status.live?.port;
|
|
200
|
+
if (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535) {
|
|
201
|
+
return {
|
|
202
|
+
problem: `This engine is not serving a live view: it was started with ${LIVE_ENV}=off, or it is a version from before the live view existed. Restart the MCP server to pick it up.`,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
const clean = token?.trim() ?? "";
|
|
206
|
+
if (!/^[A-Za-z0-9_-]{16,128}$/.test(clean))
|
|
207
|
+
return {
|
|
208
|
+
problem: `The live view's token file (${LIVE_TOKEN_FILE}) is missing or unreadable. It is written when a session attaches; if a run is attached and it is still missing, the engine could not write into the project directory.`,
|
|
209
|
+
};
|
|
210
|
+
return { url: `http://127.0.0.1:${port}/${clean}/` };
|
|
211
|
+
}
|
|
212
|
+
const LIVE_CSP = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'none'";
|
|
213
|
+
const BOUNDARY = "scenescoutframe";
|
|
214
|
+
/** A thumbnail younger than this is served again instead of taking a new one. */
|
|
215
|
+
export const SHOT_MAX_AGE_MS = 1500;
|
|
216
|
+
/** A viewer this far behind is skipped for a frame rather than buffered without bound. */
|
|
217
|
+
const MAX_VIEWER_BACKLOG_BYTES = 4_000_000;
|
|
218
|
+
export class LiveServer {
|
|
219
|
+
provider;
|
|
220
|
+
now;
|
|
221
|
+
server = null;
|
|
222
|
+
port = 0;
|
|
223
|
+
token = "";
|
|
224
|
+
streams = new Map();
|
|
225
|
+
shots = new Map();
|
|
226
|
+
shotsInFlight = new Map();
|
|
227
|
+
constructor(provider, now = Date.now) {
|
|
228
|
+
this.provider = provider;
|
|
229
|
+
this.now = now;
|
|
230
|
+
}
|
|
231
|
+
async start() {
|
|
232
|
+
if (this.server)
|
|
233
|
+
return { port: this.port, token: this.token };
|
|
234
|
+
this.token = crypto.randomBytes(24).toString("base64url");
|
|
235
|
+
const server = http.createServer((req, res) => {
|
|
236
|
+
this.handle(req, res).catch((err) => {
|
|
237
|
+
console.error(`[scenescout] live view ${(req.url ?? "").split("/").slice(2).join("/")} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
238
|
+
if (!res.headersSent)
|
|
239
|
+
res.writeHead(500);
|
|
240
|
+
res.end();
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
await new Promise((resolve, reject) => {
|
|
244
|
+
// The listener stays: an 'error' with nobody listening (an accept that
|
|
245
|
+
// fails under EMFILE, say) would throw and take the whole engine down.
|
|
246
|
+
server.on("error", (err) => {
|
|
247
|
+
if (this.server === server)
|
|
248
|
+
console.error(`[scenescout] live view server error: ${err.message}`);
|
|
249
|
+
else
|
|
250
|
+
reject(err);
|
|
251
|
+
});
|
|
252
|
+
// Loopback, never 0.0.0.0: the page being shown may be signed in to a real account.
|
|
253
|
+
server.listen(0, "127.0.0.1", () => resolve());
|
|
254
|
+
});
|
|
255
|
+
this.server = server;
|
|
256
|
+
this.port = server.address().port;
|
|
257
|
+
return { port: this.port, token: this.token };
|
|
258
|
+
}
|
|
259
|
+
async stop() {
|
|
260
|
+
const server = this.server;
|
|
261
|
+
this.server = null;
|
|
262
|
+
const stops = [];
|
|
263
|
+
for (const entry of this.streams.values()) {
|
|
264
|
+
for (const viewer of entry.viewers.keys())
|
|
265
|
+
viewer.end();
|
|
266
|
+
if (entry.screencast.state === "live")
|
|
267
|
+
stops.push(entry.screencast.stop().catch(() => { }));
|
|
268
|
+
}
|
|
269
|
+
this.streams.clear();
|
|
270
|
+
this.shots.clear();
|
|
271
|
+
await Promise.all(stops);
|
|
272
|
+
if (server) {
|
|
273
|
+
server.closeAllConnections();
|
|
274
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
get address() {
|
|
278
|
+
const addr = this.server?.address();
|
|
279
|
+
return addr ? { host: addr.address, port: addr.port } : null;
|
|
280
|
+
}
|
|
281
|
+
send(res, code, body = "", type = "text/plain; charset=utf-8", extra = {}) {
|
|
282
|
+
res.writeHead(code, {
|
|
283
|
+
"Content-Type": type,
|
|
284
|
+
"Cache-Control": "no-store",
|
|
285
|
+
"X-Content-Type-Options": "nosniff",
|
|
286
|
+
"Referrer-Policy": "no-referrer",
|
|
287
|
+
...extra,
|
|
288
|
+
});
|
|
289
|
+
res.end(body);
|
|
290
|
+
}
|
|
291
|
+
tokenMatches(candidate) {
|
|
292
|
+
const a = Buffer.from(candidate);
|
|
293
|
+
const b = Buffer.from(this.token);
|
|
294
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
295
|
+
}
|
|
296
|
+
async handle(req, res) {
|
|
297
|
+
// A page on another origin can point a hostname at 127.0.0.1 (DNS
|
|
298
|
+
// rebinding); it cannot make the browser send a loopback Host header.
|
|
299
|
+
const host = req.headers.host ?? "";
|
|
300
|
+
if (host !== `127.0.0.1:${this.port}` && host !== `localhost:${this.port}`)
|
|
301
|
+
return this.send(res, 403, "forbidden");
|
|
302
|
+
if (req.method !== "GET")
|
|
303
|
+
return this.send(res, 405, "the live view is read-only");
|
|
304
|
+
const parts = (req.url ?? "/").split("?")[0].split("/").slice(1);
|
|
305
|
+
// A wrong token gets the same answer as a wrong path.
|
|
306
|
+
if (!this.tokenMatches(parts[0] ?? ""))
|
|
307
|
+
return this.send(res, 404, "not found");
|
|
308
|
+
const [, route, name] = parts;
|
|
309
|
+
// The page's own requests are relative, so it has to be served from a
|
|
310
|
+
// directory. The target is this server's own token, which the request has
|
|
311
|
+
// just matched, never the request's text.
|
|
312
|
+
if (parts.length === 1) {
|
|
313
|
+
res.writeHead(302, { Location: `/${this.token}/`, "Cache-Control": "no-store", "Referrer-Policy": "no-referrer" });
|
|
314
|
+
res.end();
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (!route)
|
|
318
|
+
return this.send(res, 200, LIVE_PAGE, "text/html; charset=utf-8", { "Content-Security-Policy": LIVE_CSP });
|
|
319
|
+
if (route === "api" && name === "status") {
|
|
320
|
+
const snap = this.provider.snapshot();
|
|
321
|
+
const nowMs = this.now();
|
|
322
|
+
const body = {
|
|
323
|
+
...snap,
|
|
324
|
+
sessions: snap.sessions.map((s) => ({ ...s, state: classify(s, nowMs), feed: this.provider.activity(s.session, FEED_LINES) })),
|
|
325
|
+
};
|
|
326
|
+
return this.send(res, 200, JSON.stringify(body), "application/json; charset=utf-8");
|
|
327
|
+
}
|
|
328
|
+
if (route === "api" && name === "activity") {
|
|
329
|
+
const session = this.param(req, "session");
|
|
330
|
+
if (session === null)
|
|
331
|
+
return this.send(res, 404, "not found");
|
|
332
|
+
if (!this.hasSession(session))
|
|
333
|
+
return this.send(res, 404, "no such session");
|
|
334
|
+
return this.send(res, 200, JSON.stringify({ session, feed: this.provider.activity(session, FEED_LINES_MAX) }), "application/json; charset=utf-8");
|
|
335
|
+
}
|
|
336
|
+
if (route === "api" && name === "report") {
|
|
337
|
+
const report = this.provider.report();
|
|
338
|
+
if (!report)
|
|
339
|
+
return this.send(res, 404, "no run attached");
|
|
340
|
+
return this.send(res, 200, JSON.stringify(report), "application/json; charset=utf-8");
|
|
341
|
+
}
|
|
342
|
+
if (route === "events" && !name) {
|
|
343
|
+
const wanted = this.param(req, "sessions");
|
|
344
|
+
if (wanted === null)
|
|
345
|
+
return this.send(res, 404, "not found");
|
|
346
|
+
const sessions = [...new Set(wanted.split(","))].filter((s) => s.length > 0 && this.hasSession(s));
|
|
347
|
+
if (sessions.length === 0)
|
|
348
|
+
return this.send(res, 404, "no such session");
|
|
349
|
+
return this.serveEvents(sessions, req, res);
|
|
350
|
+
}
|
|
351
|
+
if ((route === "shot" || route === "stream") && name) {
|
|
352
|
+
const ext = route === "shot" ? ".jpg" : ".mjpg";
|
|
353
|
+
if (!name.endsWith(ext))
|
|
354
|
+
return this.send(res, 404, "not found");
|
|
355
|
+
let session;
|
|
356
|
+
try {
|
|
357
|
+
session = decodeURIComponent(name.slice(0, -ext.length));
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
return this.send(res, 404, "not found");
|
|
361
|
+
}
|
|
362
|
+
if (!this.hasSession(session))
|
|
363
|
+
return this.send(res, 404, "no such session");
|
|
364
|
+
return route === "shot" ? this.serveShot(session, res) : this.serveStream(session, req, res);
|
|
365
|
+
}
|
|
366
|
+
return this.send(res, 404, "not found");
|
|
367
|
+
}
|
|
368
|
+
/** The value of this server's single query parameter, or null when its escaping is malformed. */
|
|
369
|
+
param(req, key) {
|
|
370
|
+
const raw = (req.url ?? "").split("?")[1] ?? "";
|
|
371
|
+
try {
|
|
372
|
+
return decodeURIComponent(raw.startsWith(`${key}=`) ? raw.slice(key.length + 1) : raw);
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
/** A viewer may only address a session the board knows. */
|
|
379
|
+
hasSession(name) {
|
|
380
|
+
return this.provider.snapshot().sessions.some((s) => s.session === name);
|
|
381
|
+
}
|
|
382
|
+
/** One screenshot serves every poller for SHOT_MAX_AGE_MS, and concurrent requests share one capture. */
|
|
383
|
+
async takeShot(session) {
|
|
384
|
+
const cached = this.shots.get(session);
|
|
385
|
+
if (cached && this.now() - cached.at < SHOT_MAX_AGE_MS)
|
|
386
|
+
return cached.jpeg;
|
|
387
|
+
const pending = this.shotsInFlight.get(session);
|
|
388
|
+
if (pending)
|
|
389
|
+
return pending;
|
|
390
|
+
const capture = this.provider
|
|
391
|
+
.screenshot(session)
|
|
392
|
+
.catch(() => null)
|
|
393
|
+
.then((jpeg) => {
|
|
394
|
+
if (jpeg)
|
|
395
|
+
this.shots.set(session, { at: this.now(), jpeg });
|
|
396
|
+
return jpeg;
|
|
397
|
+
})
|
|
398
|
+
.finally(() => this.shotsInFlight.delete(session));
|
|
399
|
+
this.shotsInFlight.set(session, capture);
|
|
400
|
+
return capture;
|
|
401
|
+
}
|
|
402
|
+
async serveShot(session, res) {
|
|
403
|
+
const jpeg = await this.takeShot(session);
|
|
404
|
+
if (!jpeg)
|
|
405
|
+
return this.send(res, 503, "no frame available");
|
|
406
|
+
return this.send(res, 200, jpeg, "image/jpeg", { "Content-Length": jpeg.length });
|
|
407
|
+
}
|
|
408
|
+
writeFrame(res, jpeg) {
|
|
409
|
+
if (res.destroyed || res.writableLength > MAX_VIEWER_BACKLOG_BYTES)
|
|
410
|
+
return;
|
|
411
|
+
// One write per part: a part split across writes can reach the viewer half-drawn.
|
|
412
|
+
res.write(Buffer.concat([Buffer.from(`--${BOUNDARY}\r\nContent-Type: image/jpeg\r\nContent-Length: ${jpeg.length}\r\n\r\n`), jpeg, Buffer.from("\r\n")]));
|
|
413
|
+
}
|
|
414
|
+
writeEvent(res, event, data) {
|
|
415
|
+
if (res.destroyed || res.writableLength > MAX_VIEWER_BACKLOG_BYTES)
|
|
416
|
+
return;
|
|
417
|
+
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* The session is gone (closed by the agent, or its page died): every viewer
|
|
421
|
+
* is told, and the screencast is stopped. A stream nobody ends would show
|
|
422
|
+
* the last frame under a LIVE badge for as long as the page stayed open.
|
|
423
|
+
*/
|
|
424
|
+
dropSession(session) {
|
|
425
|
+
const stream = this.streams.get(session);
|
|
426
|
+
this.shots.delete(session);
|
|
427
|
+
if (!stream)
|
|
428
|
+
return;
|
|
429
|
+
this.streams.delete(session);
|
|
430
|
+
for (const viewer of stream.viewers.values())
|
|
431
|
+
viewer.gone();
|
|
432
|
+
stream.viewers.clear();
|
|
433
|
+
if (stream.screencast.state === "live")
|
|
434
|
+
void stream.screencast.stop().catch(() => { });
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Add a viewer to a session's screencast, starting it for the first viewer.
|
|
438
|
+
* Resolves to the function that removes the viewer again, and to null when
|
|
439
|
+
* the session cannot stream. The last viewer to leave stops the screencast,
|
|
440
|
+
* so an unwatched session pays nothing.
|
|
441
|
+
*/
|
|
442
|
+
async join(session, res, viewer) {
|
|
443
|
+
let stream = this.streams.get(session);
|
|
444
|
+
if (!stream) {
|
|
445
|
+
const entry = { viewers: new Map(), lastFrame: null, screencast: { state: "starting", ready: Promise.resolve(false) } };
|
|
446
|
+
entry.screencast = {
|
|
447
|
+
state: "starting",
|
|
448
|
+
ready: this.provider
|
|
449
|
+
.startStream(session, (jpeg) => {
|
|
450
|
+
entry.lastFrame = jpeg;
|
|
451
|
+
for (const v of entry.viewers.values())
|
|
452
|
+
v.frame(jpeg);
|
|
453
|
+
}, () => this.dropSession(session))
|
|
454
|
+
.catch(() => null)
|
|
455
|
+
.then((stop) => {
|
|
456
|
+
if (!stop)
|
|
457
|
+
return false;
|
|
458
|
+
// Stopped or dropped while it was starting: nobody is left to watch, so end it now.
|
|
459
|
+
if (this.streams.get(session) !== entry) {
|
|
460
|
+
void stop().catch(() => { });
|
|
461
|
+
return false;
|
|
462
|
+
}
|
|
463
|
+
entry.screencast = { state: "live", stop };
|
|
464
|
+
return true;
|
|
465
|
+
}),
|
|
466
|
+
};
|
|
467
|
+
this.streams.set(session, entry);
|
|
468
|
+
stream = entry;
|
|
469
|
+
}
|
|
470
|
+
const started = stream.screencast.state === "live" ? true : await stream.screencast.ready;
|
|
471
|
+
if (!started) {
|
|
472
|
+
if (this.streams.get(session) === stream && stream.viewers.size === 0)
|
|
473
|
+
this.streams.delete(session);
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
const entry = stream;
|
|
477
|
+
entry.viewers.set(res, viewer);
|
|
478
|
+
return () => {
|
|
479
|
+
entry.viewers.delete(res);
|
|
480
|
+
if (entry.viewers.size > 0 || this.streams.get(session) !== entry)
|
|
481
|
+
return;
|
|
482
|
+
this.streams.delete(session);
|
|
483
|
+
if (entry.screencast.state === "live")
|
|
484
|
+
void entry.screencast.stop().catch(() => { });
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* MJPEG: an <img> renders it natively, so anything that shows an image can
|
|
489
|
+
* show one session live. The page itself does not use this: see serveEvents.
|
|
490
|
+
*/
|
|
491
|
+
async serveStream(session, req, res) {
|
|
492
|
+
const leave = await this.join(session, res, { frame: (jpeg) => this.writeFrame(res, jpeg), gone: () => res.end() });
|
|
493
|
+
if (!leave)
|
|
494
|
+
return this.send(res, 503, "this session cannot stream");
|
|
495
|
+
if (res.destroyed || req.destroyed)
|
|
496
|
+
return leave();
|
|
497
|
+
res.writeHead(200, {
|
|
498
|
+
"Content-Type": `multipart/x-mixed-replace; boundary=${BOUNDARY}`,
|
|
499
|
+
"Cache-Control": "no-store",
|
|
500
|
+
"X-Content-Type-Options": "nosniff",
|
|
501
|
+
Connection: "close",
|
|
502
|
+
});
|
|
503
|
+
res.on("close", leave);
|
|
504
|
+
// A screencast emits on repaint, so a page that is sitting still would
|
|
505
|
+
// show a late viewer nothing at all. Give them the current picture first.
|
|
506
|
+
const first = this.streams.get(session)?.lastFrame ?? (await this.takeShot(session));
|
|
507
|
+
if (first)
|
|
508
|
+
this.writeFrame(res, first);
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Every session the page is watching, over ONE connection, as server-sent
|
|
512
|
+
* events: `frame` carries a session's name and a base64 JPEG, `unavailable`
|
|
513
|
+
* names a session that cannot stream, or that has gone away since. A browser
|
|
514
|
+
* allows about six open connections to one host; with a stream per <img>,
|
|
515
|
+
* "Stream all" on six sessions used them all up and the status poll queued
|
|
516
|
+
* behind them forever, so the page froze at the moment it had the most to show.
|
|
517
|
+
*/
|
|
518
|
+
async serveEvents(sessions, req, res) {
|
|
519
|
+
if (res.destroyed || req.destroyed)
|
|
520
|
+
return;
|
|
521
|
+
res.writeHead(200, {
|
|
522
|
+
"Content-Type": "text/event-stream",
|
|
523
|
+
"Cache-Control": "no-store",
|
|
524
|
+
"X-Content-Type-Options": "nosniff",
|
|
525
|
+
});
|
|
526
|
+
res.write("retry: 1000\n\n");
|
|
527
|
+
const leaves = [];
|
|
528
|
+
res.on("close", () => {
|
|
529
|
+
for (const leave of leaves)
|
|
530
|
+
leave();
|
|
531
|
+
});
|
|
532
|
+
for (const session of sessions) {
|
|
533
|
+
const leave = await this.join(session, res, {
|
|
534
|
+
frame: (jpeg) => this.writeEvent(res, "frame", { session, jpeg: jpeg.toString("base64") }),
|
|
535
|
+
gone: () => this.writeEvent(res, "unavailable", { session }),
|
|
536
|
+
});
|
|
537
|
+
if (!leave) {
|
|
538
|
+
this.writeEvent(res, "unavailable", { session });
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
if (res.destroyed)
|
|
542
|
+
return leave();
|
|
543
|
+
leaves.push(leave);
|
|
544
|
+
const first = this.streams.get(session)?.lastFrame ?? (await this.takeShot(session));
|
|
545
|
+
if (first)
|
|
546
|
+
this.writeEvent(res, "frame", { session, jpeg: first.toString("base64") });
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
package/dist/engine/memory.js
CHANGED
|
@@ -89,6 +89,9 @@ export function redactSecrets(text) {
|
|
|
89
89
|
}
|
|
90
90
|
return hits > 0 ? `${out} [${hits} secret${hits === 1 ? "" : "s"} redacted]` : out;
|
|
91
91
|
}
|
|
92
|
+
/** The action-log lines that open and close a journey (scout_journey). The feed reads them to tell which goal an action served. */
|
|
93
|
+
export const JOURNEY_START = "journey:start";
|
|
94
|
+
export const JOURNEY_END = "journey:end";
|
|
92
95
|
const EMPTY = { version: 1, states: {}, findings: [] };
|
|
93
96
|
/**
|
|
94
97
|
* Fold another process's memory into ours, losing nothing from either side.
|
package/dist/engine/report.js
CHANGED
|
@@ -305,7 +305,12 @@ export function computeGaps(memory, extras) {
|
|
|
305
305
|
gaps.push(`single-role run (${roles.join(", ") || "no role recorded"}) — permission boundaries and role capability gaps are untested`);
|
|
306
306
|
return gaps;
|
|
307
307
|
}
|
|
308
|
-
|
|
308
|
+
/**
|
|
309
|
+
* The report as the run stands now. `write` is what scout_report does at the
|
|
310
|
+
* end; the live view renders the same document on request without touching
|
|
311
|
+
* the disk, so someone can read it while the run is still going.
|
|
312
|
+
*/
|
|
313
|
+
export function generateReport(memory, oracleLog, extras, opts = {}) {
|
|
309
314
|
const cov = memory.coverage();
|
|
310
315
|
const findings = [...memory.findings].sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
|
|
311
316
|
const lines = [];
|
|
@@ -487,7 +492,8 @@ export function generateReport(memory, oracleLog, extras) {
|
|
|
487
492
|
}
|
|
488
493
|
const markdown = lines.join("\n");
|
|
489
494
|
const outPath = path.join(memory.dir, "report.md");
|
|
490
|
-
|
|
495
|
+
if (opts.write !== false)
|
|
496
|
+
fs.writeFileSync(outPath, markdown);
|
|
491
497
|
// Bounded summary for the tool result: full reports have exceeded client
|
|
492
498
|
// token limits in real runs (66–72KB observed) — the wire gets the digest,
|
|
493
499
|
// the disk gets the document.
|