scenescout 3.15.0 → 3.17.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/README.md +70 -18
  3. package/dist/browsers.js +28 -0
  4. package/dist/check-run.js +191 -14
  5. package/dist/ci-run.js +268 -52
  6. package/dist/cli.js +107 -47
  7. package/dist/commands.js +3 -2
  8. package/dist/engine/baseline.js +377 -0
  9. package/dist/engine/brief.js +16 -7
  10. package/dist/engine/browser.js +1147 -286
  11. package/dist/engine/calibration.js +61 -30
  12. package/dist/engine/capture.js +164 -0
  13. package/dist/engine/check.js +244 -42
  14. package/dist/engine/ci-lanes.js +215 -0
  15. package/dist/engine/ci.js +136 -18
  16. package/dist/engine/claims.js +159 -3
  17. package/dist/engine/collector.js +561 -30
  18. package/dist/engine/crawl.js +49 -0
  19. package/dist/engine/design.js +281 -38
  20. package/dist/engine/export.js +877 -0
  21. package/dist/engine/fingerprint.js +92 -4
  22. package/dist/engine/flow.js +18 -6
  23. package/dist/engine/forms.js +181 -18
  24. package/dist/engine/journey.js +29 -1
  25. package/dist/engine/lane.js +13 -3
  26. package/dist/engine/launch.js +45 -6
  27. package/dist/engine/limits.js +7 -0
  28. package/dist/engine/live-page.js +49 -2
  29. package/dist/engine/live.js +4 -1
  30. package/dist/engine/memory.js +501 -47
  31. package/dist/engine/open.js +118 -0
  32. package/dist/engine/oracles.js +41 -1
  33. package/dist/engine/plain.js +268 -0
  34. package/dist/engine/png.js +127 -0
  35. package/dist/engine/policy.js +379 -9
  36. package/dist/engine/probes.js +3 -2
  37. package/dist/engine/profiles.js +45 -9
  38. package/dist/engine/project-folder.js +191 -0
  39. package/dist/engine/refresh.js +68 -3
  40. package/dist/engine/replay.js +63 -10
  41. package/dist/engine/report.js +241 -40
  42. package/dist/engine/request.js +317 -23
  43. package/dist/engine/sarif.js +120 -0
  44. package/dist/engine/settle.js +67 -0
  45. package/dist/engine/signed-in.js +256 -0
  46. package/dist/engine/status-pane-page.js +441 -0
  47. package/dist/engine/status-pane.js +128 -0
  48. package/dist/engine/tickets.js +671 -0
  49. package/dist/engine/unload.js +3 -2
  50. package/dist/export-run.js +633 -0
  51. package/dist/first-run.js +5 -0
  52. package/dist/installer.js +378 -8
  53. package/dist/intake.js +104 -0
  54. package/dist/login-run.js +250 -36
  55. package/dist/mcp-server.js +660 -65
  56. package/dist/playbook.js +5 -0
  57. package/dist/prompts.js +106 -0
  58. package/package.json +8 -5
  59. package/skills/scenescout/SKILL.md +49 -16
@@ -5,7 +5,12 @@
5
5
  */
6
6
  const DESTRUCTIVE_PATTERNS = [
7
7
  /\bdelete\b/i,
8
- /\bremove\b/i,
8
+ // "Remove" a filter chip drops a condition from the view: "Remove Status:
9
+ // Open filter" re-queries the list, the same sense the reset rule exempts.
10
+ // A label whose object, at its end, is a filter or a chip, or a test id with
11
+ // the chip before the verb ("filter-remove"), is exempt; "Remove member",
12
+ // and "Remove member. Use the filter to find others.", still count.
13
+ /(?<!\b(?:filters?|chips?)[\s-]{1,3})\bremove\b(?![^\n]{0,60}?\b(?:filters?|chips?)\W*$)/i,
9
14
  /\brevoke\b/i,
10
15
  /\bdestroy\b/i,
11
16
  /\bpurge\b/i,
@@ -20,13 +25,23 @@ const DESTRUCTIVE_PATTERNS = [
20
25
  // view/form senses; everything else ("Reset workspace", "Factory reset",
21
26
  // "Reset all data") still counts.
22
27
  /\breset\b(?!\s+(filters?|zoom|search|view|sort|order|form|password|layout|columns?|selection|preferences?|defaults?))/i,
23
- /\bdiscard\b/i,
28
+ // "Discard" names two different things. "Discard changes", "Discard your
29
+ // edits" and "Discard unsaved changes" drop what the tester typed and has not
30
+ // sent: no record exists to lose, and refusing it left a session stuck on a
31
+ // dirty form behind the app's own leave-confirmation. Those senses are
32
+ // exempt, written as words or as a test id ("discard-changes"); "Discard
33
+ // draft", "Discard record" and a bare "Discard" still count.
34
+ // If the confirm does send a write, the wire policy judges that request as it
35
+ // judges any other, and `discard` is a destructive verb in a path there.
36
+ /\bdiscard\b(?![\s_-]+(?:(?:your|my|all|the|any)[\s_-]+)?(?:unsaved[\s_-]+|pending[\s_-]+|local[\s_-]+|draft[\s_-]+)?(?:changes|edits)\b)/i,
24
37
  /\bcancel subscription\b/i,
25
- // The verb: a control that signs the user off, alone or after another verb
26
- // ("Save and sign off"). The noun is how approval apps label things, and it
27
- // is told by the word before it ("Needs sign-off", "Awaiting sign-off",
28
- // "Send for sign-off"); those destroy nothing. Same shape as the reset rule.
29
- /(?<!\b(?:needs?|awaiting|awaits|pending|requires?|required|for|before|after|without|of|the|a|an|its|their|your|my)\s)\bsign(?: |-)?off\b/i,
38
+ // The verb: a control that signs the user off, starting the label ("Sign
39
+ // off", "(Sign off)", a test id "sign-off-button") or joined to another verb
40
+ // ("Save and sign off", "Save & sign off"). Anywhere else it is the noun
41
+ // approval apps label things with ("Needs sign-off", "Manager sign-off",
42
+ // "Final sign-off recorded"), which destroys nothing. Told by position, not
43
+ // by a list of the words that may come before it: any modifier can.
44
+ /(?:^[^\p{L}\p{N}]*|\b(?:and|then)\s+|&\s*)sign(?: |-)?off\b/iu,
30
45
  // The tool is generic — destructive labels come in many languages.
31
46
  /\b(eliminar|borrar|suprimir)\b/i, // es
32
47
  /\b(excluir|apagar|remover)\b/i, // pt
@@ -55,7 +70,7 @@ const DESTRUCTIVE_PATTERNS = [
55
70
  * destructive. So the body is matched only for STRUCTURED destructive intent.
56
71
  */
57
72
  /** A destructive verb occupying a URL PATH segment. Bare keywords are meaningful here — a path is not prose. */
58
- const DESTRUCTIVE_URL_RE = /(\/|\b|_)(delete|remove|purge|destroy|archive|revoke|deactivate|wipe|bulk[-_]?delete|force[-_]?delete)(\/|\b|_)/i;
73
+ const DESTRUCTIVE_URL_RE = /(\/|\b|_)(delete|remove|purge|destroy|archive|revoke|deactivate|wipe|discard|bulk[-_]?delete|force[-_]?delete)(\/|\b|_)/i;
59
74
  /**
60
75
  * Structured destructive intent inside a body — never a bare keyword.
61
76
  * Two shapes: (a) a GraphQL destructive mutation (the 200-char window after
@@ -72,7 +87,7 @@ const DESTRUCTIVE_URL_RE = /(\/|\b|_)(delete|remove|purge|destroy|archive|revoke
72
87
  * and `verb` are not in the key list at all — they read as prose far more often
73
88
  * than as command fields.
74
89
  */
75
- const DESTRUCTIVE_BODY_RE = /\bmutation\b[\s\S]{0,200}?\b(?:delete|remove|archive|destroy|purge|revoke)[A-Za-z_]|(?:^|[{,[\s]*["']|[?&])\s*(?:action|operation|op|method|_method|command|cmd)["']?\s*[:=]\s*["']?(?:delete|remove|purge|destroy|archive|revoke|deactivate|wipe)\b/i;
90
+ const DESTRUCTIVE_BODY_RE = /\bmutation\b[\s\S]{0,200}?\b(?:delete|remove|archive|destroy|purge|revoke)[A-Za-z_]|(?:^|[{,[\s]*["']|[?&])\s*(?:action|operation|op|method|_method|command|cmd)["']?\s*[:=]\s*["']?(?:delete|remove|purge|destroy|archive|revoke|deactivate|wipe|discard)\b/i;
76
91
  export function isDestructiveWire(url, body) {
77
92
  return DESTRUCTIVE_URL_RE.test(url) || (!!body && DESTRUCTIVE_BODY_RE.test(body.slice(0, 2000)));
78
93
  }
@@ -128,11 +143,226 @@ export function isDestructive(...labels) {
128
143
  });
129
144
  });
130
145
  }
146
+ /** The most words a click target's own text may have and still be read as its command. */
147
+ export const COMMAND_TEXT_WORDS = 4;
148
+ /** A short verb phrase, or null: at most COMMAND_TEXT_WORDS words and no sentence. */
149
+ function commandText(text) {
150
+ const words = text.trim().split(/\s+/).filter(Boolean);
151
+ return words.length > 0 && words.length <= COMMAND_TEXT_WORDS && !SENTENCE_RE.test(text) ? text : null;
152
+ }
153
+ /** Roles whose accessible name is the label of the one thing a click does. */
154
+ const LABELLED_CONTROL_ROLES = new Set([
155
+ "button",
156
+ "link",
157
+ "menuitem",
158
+ "menuitemcheckbox",
159
+ "menuitemradio",
160
+ "tab",
161
+ "option",
162
+ "checkbox",
163
+ "radio",
164
+ "switch",
165
+ "treeitem",
166
+ "image",
167
+ ]);
168
+ /**
169
+ * The label that makes a listed element destructive, or null. A control is
170
+ * judged by its OWN name, never by what it merely contains:
171
+ *
172
+ * - A button, link, menu item, tab or option (by tag or by role) is named
173
+ * by what it does, and that name is judged whole, however long: a button
174
+ * "Delete all my saved data" is refused. The word cap below is only for
175
+ * containers, whose text is the record they show.
176
+ * - A dropdown (a `<select>`, a combobox, a listbox) is named by its options,
177
+ * all of them, so a filter offering "All, Create, Update, Delete" read as a
178
+ * delete control and choosing "Create" was refused. Choosing is judged where
179
+ * the value is known: scout_select vets the chosen option, and an option
180
+ * clicked in a custom list is a control of its own. Only its test id, and a
181
+ * control covering its centre, are judged here.
182
+ * - Anything else (a row, a card, a heading, a panel listed for its test id
183
+ * or a click handler) is judged by its test id and by the control covering
184
+ * its centre, where a click on it lands: a row whose middle IS a delete
185
+ * button is still refused, and the buttons inside it are listed, and
186
+ * refused, on their own. Its text is the record it shows ("Archive Test
187
+ * Widget", "Final sign-off recorded"), not a command, so the text counts
188
+ * only for a click target whose own text is a short verb phrase
189
+ * (COMMAND_TEXT_WORDS words, no sentence): a clickable div saying "Delete".
190
+ * A heading is never judged by its text.
191
+ *
192
+ * When the page could not say what the own text is, the whole name stands in
193
+ * and is judged whole, so a reading that failed can only refuse more.
194
+ */
195
+ export function destructiveLabelOf(c) {
196
+ const first = (...labels) => labels.find((l) => typeof l === "string" && l.length > 0 && isDestructive(l)) ?? null;
197
+ const centre = c.centre ?? [];
198
+ if (c.tag === "select" || c.role === "combobox" || c.role === "listbox")
199
+ return first(c.testid, ...centre);
200
+ if (LABELLED_CONTROL_ROLES.has(c.role) || c.tag === "button" || c.tag === "a")
201
+ return first(c.name, c.testid);
202
+ if (c.role === "heading" || /^h[1-6]$/i.test(c.tag))
203
+ return first(c.testid, ...centre);
204
+ const own = c.ownText == null ? c.name : c.interactive === false ? null : commandText(c.ownText);
205
+ return first(own, c.testid, ...centre);
206
+ }
207
+ /**
208
+ * A dropdown pick, judged by the value asked for, the dropdown's test id and
209
+ * the label of the option it matches. The dropdown's own name is not judged
210
+ * (destructiveLabelOf), so this is the check, and a label that could not be
211
+ * read (null) refuses: an unvetted pick could be "Delete".
212
+ */
213
+ export function pickIsDestructive(value, testid, optionLabel) {
214
+ return optionLabel === null || isDestructive(value, testid, optionLabel);
215
+ }
131
216
  export function destructiveRefusal(label, mode = "read-only") {
132
217
  return (`REFUSED by ${mode} policy: "${label}" matches a destructive-action pattern. ` +
133
218
  `This run is ${mode}; do not attempt this element again. If destructive flows must be tested, ` +
134
219
  `the user has to re-attach with mode="destructive" against a disposable/seeded environment.`);
135
220
  }
221
+ /** The `why` of a page move refused as a possible escape from a foreign frame's sandbox. */
222
+ export const ESCAPE_REFUSAL = "a possible frame escape";
223
+ /** New blocked endpoints listed by name in one notice; the rest are counted. */
224
+ export const BLOCK_NOTICE_LIST = 5;
225
+ /** What a blocked write is remembered by: its method and URL, without query or fragment. */
226
+ export function blockSignature(sig) {
227
+ return sig.replace(/[?#].*$/, "");
228
+ }
229
+ const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
230
+ /**
231
+ * The write-policy block notice, said once. A page that beacons to a
232
+ * monitoring endpoint on every load had every tool result open with the same
233
+ * blocked request five times over and the whole explanation after it, which
234
+ * buried the action's own result and the blocks that mattered.
235
+ *
236
+ * So a session remembers what it has been told. Each endpoint (by
237
+ * blockSignature, per write rule) is named in full the first time it is
238
+ * blocked; after that it is counted on one line. The explanation is given in
239
+ * full once per rule, and later notices for a new endpoint refer back to it.
240
+ * Nothing about the blocking changes: every request is still refused, and
241
+ * every refusal is still reported, if only as a count.
242
+ */
243
+ export class BlockNotices {
244
+ seen = new Set();
245
+ explained = new Set();
246
+ /** Forget everything said: a re-attached session starts a new conversation. */
247
+ reset() {
248
+ this.seen.clear();
249
+ this.explained.clear();
250
+ }
251
+ /**
252
+ * The notice for the writes refused since the last action, or "" when there
253
+ * were none. `createdCount` is how many records this run has created, which
254
+ * the safe-write advice names.
255
+ */
256
+ notice(rule, blocked, createdCount = 0) {
257
+ if (blocked.length === 0)
258
+ return "";
259
+ const groups = new Map();
260
+ for (const b of blocked) {
261
+ const key = blockSignature(b.sig);
262
+ const g = groups.get(key);
263
+ if (g) {
264
+ g.count += 1;
265
+ g.allLate &&= b.late;
266
+ g.answered ||= !!b.answered;
267
+ if (b.why)
268
+ g.whys.add(b.why);
269
+ }
270
+ else
271
+ groups.set(key, { first: b, count: 1, allLate: b.late, answered: !!b.answered, whys: new Set(b.why ? [b.why] : []) });
272
+ }
273
+ const fresh = [];
274
+ const repeats = [];
275
+ for (const [key, g] of groups) {
276
+ const memo = `${rule} ${key}`;
277
+ if (this.seen.has(memo))
278
+ repeats.push([key, g]);
279
+ else {
280
+ this.seen.add(memo);
281
+ fresh.push([key, g]);
282
+ }
283
+ }
284
+ const late = (g) => (g.allLate ? " (late — likely from a previous action or background traffic)" : "");
285
+ const times = (g) => (g.count > 1 ? ` ×${g.count}` : "");
286
+ const repeatCount = repeats.reduce((n, [, g]) => n + g.count, 0);
287
+ const repeatLine = repeats.length === 0
288
+ ? ""
289
+ : `${plural(repeatCount, "repeat block")} of ${plural(repeats.length, "known endpoint")} (` +
290
+ repeats
291
+ .slice(0, 3)
292
+ .map(([key, g]) => `${key} ×${g.count}${g.allLate ? ", background" : ""}`)
293
+ .join("; ") +
294
+ (repeats.length > 3 ? `; +${repeats.length - 3} more` : "") +
295
+ `)`;
296
+ const head = `\n🛡 WRITE-POLICY blocked (${rule}): `;
297
+ if (fresh.length === 0)
298
+ return `${head}${repeatLine}, refused as before. The tester's safety policy, not an app bug.`;
299
+ const list = fresh
300
+ .slice(0, BLOCK_NOTICE_LIST)
301
+ .map(([, g]) => `${g.first.sig}${times(g)}${late(g)}`)
302
+ .join("; ");
303
+ const more = fresh.length > BLOCK_NOTICE_LIST ? ` (+${fresh.length - BLOCK_NOTICE_LIST} more new)` : "";
304
+ const also = repeatLine ? `; also ${repeatLine}` : "";
305
+ const whys = new Set(fresh.flatMap(([, g]) => [...g.whys]));
306
+ const escaped = whys.delete(ESCAPE_REFUSAL);
307
+ const foreign = [...whys];
308
+ const answered = fresh.some(([, g]) => g.answered);
309
+ const reasons = (foreign.length > 0
310
+ ? `Refused because it was ${foreign.join("; ")}: it would reach a site embedded in the page rather than the app, which no mode but destructive allows. `
311
+ : "") +
312
+ (escaped
313
+ ? `A move of the whole page off the app, with no Referer, was refused: a frame that held another site now sits on a data: or blob: URL, where WebKit drops the frame's sandbox, so the move may be that frame's. No mode but destructive allows it. `
314
+ : "");
315
+ if (this.explained.has(rule)) {
316
+ return (`${head}${list}${more}${also}. The tester's safety policy, not an app bug (explained in full earlier in this session). ` +
317
+ reasons +
318
+ (answered ? `The page's request was answered with a 403 in the server's place, as before. ` : "")).trimEnd();
319
+ }
320
+ this.explained.add(rule);
321
+ return (`${head}${list}${more}${also}. ` +
322
+ `This is the tester's safety policy, NOT an app bug — do not file a finding for the resulting error UI. ` +
323
+ reasons +
324
+ (answered
325
+ ? `The page's own requests were answered with a 403 in the server's place, so the page's handling of a refusal is real: an error message is correct, and a success message is a false_success violation. `
326
+ : "") +
327
+ (rule === "observe"
328
+ ? `observe mode blocks every request that is not a GET, so no form submission reaches the server. Re-attach with mode="read-only" ONLY if the user confirms that ordinary form submissions are acceptable on this target. If a refused POST only reads (a search or query sent as POST), the user can name it in readPosts instead; never add one yourself.`
329
+ : rule === "read-only"
330
+ ? `Re-attach with mode="safe-write" to test create/edit flows, or "destructive" (user-approved disposable env only).`
331
+ : `In safe-write, updates/deletes are only allowed on resources this session created (${createdCount} so far).`));
332
+ }
333
+ }
334
+ /**
335
+ * The answer to a native dialog. alert, confirm and prompt are dismissed in
336
+ * the modes that protect data (observe, read-only) and accepted otherwise, as
337
+ * before: a confirm can stand between a click and a delete.
338
+ *
339
+ * `beforeunload` is the page asking whether to leave while it holds unsent
340
+ * input. Leaving sends no write of its own, and whatever the page sends as it
341
+ * goes is judged by the wire policy like any other request, but leaving does
342
+ * throw away what the tester typed. So it is the caller's choice: `leave: true`
343
+ * leaves, `leave: false` stays, and with no choice the mode decides, staying
344
+ * in observe and read-only. Either way the result says what happened (see
345
+ * dialogNote), because a dismissed one surfaced only as a bare ERR_ABORTED.
346
+ */
347
+ export function dialogResponse(type, readOnly, leave) {
348
+ if (type === "beforeunload")
349
+ return (leave ?? !readOnly) ? "accept" : "dismiss";
350
+ return readOnly ? "dismiss" : "accept";
351
+ }
352
+ /** The line an action's result carries for a native dialog the page opened during it. */
353
+ export function dialogNote(d) {
354
+ const message = d.message.trim().replace(/\s+/g, " ").slice(0, 120);
355
+ if (d.type === "beforeunload") {
356
+ return d.response === "dismiss"
357
+ ? `\n⚠ LEAVE CONFIRMATION: the page asked to confirm leaving (it holds input that was never sent), and the engine answered "stay"` +
358
+ `${d.leave === false ? " as asked (leave: false)" : ""}, so the navigation was cancelled and the page is unchanged. ` +
359
+ `This is not an app bug, and nothing was sent. To leave and discard that unsent input, repeat the action with leave: true.`
360
+ : `\nℹ LEAVE CONFIRMATION: the page asked to confirm leaving (it held input that was never sent), and the engine answered "leave"` +
361
+ `${d.leave === true ? " as asked (leave: true)" : ""}, so that unsent input is discarded. ` +
362
+ `Any request the page sends as it is left is judged by the write policy like any other.`;
363
+ }
364
+ return `\nℹ DIALOG (${d.type})${message ? `: "${message}"` : ""} — ${d.response === "accept" ? "accepted" : "dismissed"} by the engine.`;
365
+ }
136
366
  export const WRITE_MODES = ["observe", "read-only", "safe-write", "destructive"];
137
367
  /** The stricter of two modes: WRITE_MODES runs from the strictest to the loosest. */
138
368
  export function stricterMode(a, b) {
@@ -572,6 +802,146 @@ export const FOREIGN_FRAME_SANDBOX = "sandbox allow-scripts allow-forms allow-sa
572
802
  export function withForeignFrameSandbox(existing) {
573
803
  return existing && existing.trim() ? `${existing}, ${FOREIGN_FRAME_SANDBOX}` : FOREIGN_FRAME_SANDBOX;
574
804
  }
805
+ /** The most POST endpoints a session may name as reads. */
806
+ export const MAX_READ_POSTS = 20;
807
+ /** The environment variable naming POST endpoints that only read, for every surface that attaches. A `readPosts` option wins over it. */
808
+ export const READ_POSTS_ENV = "SCENESCOUT_READ_POSTS";
809
+ /**
810
+ * The POST endpoints a session was told only read, from the `readPosts`
811
+ * option, else the environment variable (entries separated by commas or new
812
+ * lines). Nothing by default: observe refuses every POST until the user names
813
+ * one, and the agent must never add one itself.
814
+ */
815
+ export function readPostsSetting(option, env) {
816
+ if (option !== undefined)
817
+ return [...option];
818
+ return (env ?? "")
819
+ .split(/[,\n]/)
820
+ .map((e) => e.trim())
821
+ .filter(Boolean);
822
+ }
823
+ /**
824
+ * The entries parsed, and what was given that is not one. An entry is
825
+ * `POST <path>` for the app's own origin, or `POST <http(s) URL>` for an API
826
+ * on another origin: the method must be POST, the path must start with `/`
827
+ * and carry no query or fragment, and `*` may stand only for one whole path
828
+ * segment, such as an id. Exact otherwise, so nothing is widened by accident;
829
+ * a trailing slash is ignored.
830
+ */
831
+ export function readPostEntries(list) {
832
+ const entries = [];
833
+ const rejected = [];
834
+ const overflow = [];
835
+ for (const raw of list) {
836
+ const m = /^\s*POST\s+(\S+)\s*$/i.exec(raw);
837
+ if (!m) {
838
+ rejected.push(raw);
839
+ continue;
840
+ }
841
+ let origin = null;
842
+ let pathname = m[1];
843
+ if (/^https?:\/\//i.test(pathname)) {
844
+ let u;
845
+ try {
846
+ u = new URL(pathname);
847
+ }
848
+ catch {
849
+ rejected.push(raw);
850
+ continue;
851
+ }
852
+ if (u.search || u.hash || u.username || u.password || /[?#]/.test(pathname)) {
853
+ rejected.push(raw);
854
+ continue;
855
+ }
856
+ origin = u.origin;
857
+ // The path as written: URL would percent-encode a `*`.
858
+ const slash = pathname.indexOf("/", pathname.indexOf("//") + 2);
859
+ pathname = slash === -1 ? "/" : pathname.slice(slash);
860
+ }
861
+ const segments = pathname.split("/").filter(Boolean);
862
+ if (!pathname.startsWith("/") || /[?#\s]/.test(pathname) || segments.some((seg) => seg.includes("*") && seg !== "*")) {
863
+ rejected.push(raw);
864
+ continue;
865
+ }
866
+ const entry = `POST ${origin ?? ""}${"/" + segments.join("/")}`;
867
+ if (entries.some((e) => e.entry === entry))
868
+ continue;
869
+ if (entries.length < MAX_READ_POSTS)
870
+ entries.push({ entry, origin, segments });
871
+ else
872
+ overflow.push(raw);
873
+ }
874
+ return { entries, rejected, overflow };
875
+ }
876
+ /** The entry a POST to `url` matches, or null. An entry with no origin matches the app's own origin only. */
877
+ export function matchReadPost(entries, appUrl, url) {
878
+ let target;
879
+ let app;
880
+ try {
881
+ target = new URL(url);
882
+ app = new URL(appUrl).origin;
883
+ }
884
+ catch {
885
+ return null;
886
+ }
887
+ const segments = target.pathname.split("/").filter(Boolean);
888
+ return (entries.find((e) => (e.origin ?? app) === target.origin && e.segments.length === segments.length && e.segments.every((seg, i) => seg === "*" || seg === segments[i])) ?? null);
889
+ }
890
+ /** The longest body read for a GraphQL operation; a longer one cannot be vetted and is refused. */
891
+ export const MAX_READ_POST_BODY = 100_000;
892
+ /** A GraphQL operation that writes: `mutation` or `subscription`, then an optional name, then its variables, directives or selection. */
893
+ const GRAPHQL_WRITE_OP_RE = /(?:^|[^A-Za-z0-9_$])(?:mutation|subscription)\s*(?:[A-Za-z_][A-Za-z0-9_]*\s*)?[({@]/;
894
+ /**
895
+ * Whether a body carries a GraphQL operation that is not a query, or one that
896
+ * cannot be read. Read from the body as sent, from every `query` string in a
897
+ * JSON body (or a batch of them), so an escaped newline cannot hide the
898
+ * keyword, and from a form-encoded `query` field. A persisted query (a hash
899
+ * and no text) counts, since its operation is unknown. A search box's text
900
+ * ("mutation testing") is not an operation and does not match.
901
+ */
902
+ export function graphqlWriteOperation(body) {
903
+ if (GRAPHQL_WRITE_OP_RE.test(body))
904
+ return true;
905
+ let parsed;
906
+ try {
907
+ parsed = JSON.parse(body);
908
+ }
909
+ catch {
910
+ // A form-encoded body: its `query` field, decoded.
911
+ const query = /(?:^|&)query=/.test(body) ? new URLSearchParams(body).get("query") : null;
912
+ return query !== null && GRAPHQL_WRITE_OP_RE.test(query);
913
+ }
914
+ const items = Array.isArray(parsed) ? parsed : [parsed];
915
+ return items.some((item) => {
916
+ if (typeof item !== "object" || item === null)
917
+ return false;
918
+ const { query, extensions } = item;
919
+ if (typeof query === "string")
920
+ return GRAPHQL_WRITE_OP_RE.test(query);
921
+ // A persisted query names its operation by hash alone, so what it runs cannot be read: refused.
922
+ return typeof extensions === "object" && extensions !== null && extensions.persistedQuery !== undefined;
923
+ });
924
+ }
925
+ /**
926
+ * The entry that lets this POST out of observe, or null. Only in observe:
927
+ * read-only and safe-write already let a POST out unless it looks
928
+ * destructive, and destructive lets everything out. Even a listed endpoint is
929
+ * refused when its path or body looks destructive (the whole body is read),
930
+ * when its body is a GraphQL mutation, subscription or persisted query, or
931
+ * when the body is too long to vet.
932
+ */
933
+ export function readPostAllowed(input) {
934
+ if (input.mode !== "observe" || input.method !== "POST" || input.destructiveWire || input.entries.length === 0)
935
+ return null;
936
+ const entry = matchReadPost(input.entries, input.appUrl, input.url);
937
+ if (!entry)
938
+ return null;
939
+ const body = input.body ?? "";
940
+ // The whole body, not the first 2000 characters isDestructiveWire reads: a command after a long filter still counts.
941
+ if (body.length > MAX_READ_POST_BODY || DESTRUCTIVE_BODY_RE.test(body) || graphqlWriteOperation(body))
942
+ return null;
943
+ return entry;
944
+ }
575
945
  export function allowsWrite(mode, method, destructiveWire, owned) {
576
946
  if (mode === "destructive")
577
947
  return true;
@@ -1,4 +1,4 @@
1
- import { DIALOG_LIKE_SEL } from "./collector.js";
1
+ import { ACCESSIBLE_NAME_SRC, DIALOG_LIKE_SEL } from "./collector.js";
2
2
  import { focusAdvanceKey, isBrowserEngine } from "../browsers.js";
3
3
  /**
4
4
  * Scroll ONE named region rather than the page. The page-level heuristic
@@ -267,7 +267,8 @@ export async function probeFocusIndicators(page) {
267
267
  el.setAttribute("data-scout-focus-probe", "${i}");
268
268
  const s = getComputedStyle(el);
269
269
  const tid = el.getAttribute("data-testid");
270
- const name = ((el.textContent || el.getAttribute("aria-label") || "").trim().replace(/\\s+/g, " ").slice(0, 30));
270
+ // Named as the snapshot names it, so an icon button reads by its aria-label here too.
271
+ const name = (${ACCESSIBLE_NAME_SRC})(el).slice(0, 30);
271
272
  return { label: tid ? "[" + tid + "]" : "<" + el.tagName.toLowerCase() + "> " + JSON.stringify(name), focused: ${styleSig} };
272
273
  })()`));
273
274
  if (info === null || info === "wrapped")
@@ -22,6 +22,7 @@ import path from "node:path";
22
22
  import { BROWSER_ENGINES } from "../browsers.js";
23
23
  import { MEMORY_DIRNAME, writeSelfIgnore } from "./memory.js";
24
24
  import { SCRIPT_FLAGS } from "./scripted-login.js";
25
+ import { SAVE_MODES } from "./signed-in.js";
25
26
  /** The directory under .scenescout/ that holds one file per role. */
26
27
  export const AUTH_DIRNAME = "auth";
27
28
  /** Owner read/write only: a profile is a live session. */
@@ -279,14 +280,36 @@ export function permissionNote(file, mode, platform = process.platform) {
279
280
  return null;
280
281
  return `The profile at ${file} can be read by other accounts on this machine (mode ${(mode & 0o777).toString(8)}); it holds a live session. Tighten it: chmod 600 "${file}"`;
281
282
  }
282
- /** The command that records a role's profile, with the URL when it is known. */
283
- export function loginCommand(role, url) {
284
- return `scenescout login ${url ?? "<url>"} --role ${role}`;
283
+ /** One argument quoted for the platform's usual shell: double quotes on Windows, single quotes elsewhere. */
284
+ export function shellQuote(value, platform = process.platform) {
285
+ if (platform === "win32")
286
+ return `"${value.replace(/"/g, '""')}"`;
287
+ return `'${value.replace(/'/g, "'\\''")}'`;
288
+ }
289
+ /**
290
+ * The command that records a role's profile, with the URL when it is known,
291
+ * and `--project` when the profile must go somewhere other than the folder
292
+ * the command is run from (a project folder SceneScout chose itself).
293
+ */
294
+ export function loginCommand(role, url, project, platform = process.platform) {
295
+ return `scenescout login ${url ?? "<url>"} --role ${role}${project ? ` --project ${shellQuote(project, platform)}` : ""}`;
296
+ }
297
+ /**
298
+ * The command that records a role's profile again for a session: with
299
+ * `--project` only when SceneScout chose the session's folder itself
300
+ * (`projectChosen`), since the command otherwise saves into the folder it is
301
+ * run from and the next attach would not find it. Used by a refusal for a
302
+ * missing role and by every hint for an expired one.
303
+ */
304
+ export function reloginCommand(opts) {
305
+ return loginCommand(opts.role, opts.url, opts.projectChosen ? opts.projectDir : undefined, opts.platform);
285
306
  }
286
307
  /**
287
308
  * Turn scout_attach's `role` and `storageStatePath` into the one storage state
288
309
  * the session loads. Both at once is refused rather than letting one win
289
- * silently. A missing profile names the command that records it.
310
+ * silently. A missing profile names the command that records it, with
311
+ * `--project` when SceneScout chose the folder (`projectChosen`), so the
312
+ * command saves where the next attach looks.
290
313
  */
291
314
  export function resolveAttachAuth(opts, exists = fs.existsSync, listRoles = listProfiles) {
292
315
  if (opts.role !== undefined && opts.storageStatePath !== undefined) {
@@ -305,7 +328,7 @@ export function resolveAttachAuth(opts, exists = fs.existsSync, listRoles = list
305
328
  catch {
306
329
  /* the list is a hint in the message; the refusal stands without it */
307
330
  }
308
- throw new Error(`no sign-in is saved for role "${checked.role}" in this project. Run \`${loginCommand(checked.role, opts.url)}\`, sign in in the window it opens, then attach again.` +
331
+ throw new Error(`no sign-in is saved for role "${checked.role}" in this project. Run \`${reloginCommand({ role: checked.role, url: opts.url, projectDir: opts.projectDir, projectChosen: opts.projectChosen, platform: opts.platform })}\` (or, from a conversation, scout_login { role: "${checked.role}" }), sign in in the window it opens, then attach again.` +
309
332
  (saved.length > 0 ? ` Saved roles: ${saved.join(", ")}.` : ""));
310
333
  }
311
334
  return { kind: "role", role: checked.role, storageStatePath: file };
@@ -322,11 +345,14 @@ export function roleLabel(auth) {
322
345
  return path.basename(auth.storageStatePath).replace(/\.json$/i, "");
323
346
  return "anonymous";
324
347
  }
325
- export const LOGIN_OPTION_NAMES = ["role", "project", "browser"];
348
+ export const LOGIN_OPTION_NAMES = ["role", "project", "browser", "save"];
349
+ /** Of the `--script` flags, those the window reads too. */
350
+ const WINDOW_SCRIPT_FLAGS = new Set(["success-url"]);
326
351
  /**
327
352
  * Parse `scenescout login <url> --role <name> [--project dir] [--browser engine]
328
- * [--script [--success-url …] [--timeout s] …]`. `--script` takes no value; the
329
- * flags only it reads are refused without it.
353
+ * [--save auto|enter] [--success-url …] [--script [--timeout s] …]`. `--script`
354
+ * takes no value; the flags only it reads are refused without it, and `--save`,
355
+ * which only the window reads, is refused with it.
330
356
  */
331
357
  export function parseLoginArgs(args, cwd) {
332
358
  const positional = [];
@@ -354,8 +380,10 @@ export function parseLoginArgs(args, cwd) {
354
380
  const known = new Set(LOGIN_OPTION_NAMES);
355
381
  const scriptOnly = new Set(SCRIPT_FLAGS);
356
382
  for (const name of flags.keys()) {
357
- if (scriptOnly.has(name) && !script)
383
+ if (scriptOnly.has(name) && !script && !WINDOW_SCRIPT_FLAGS.has(name))
358
384
  return { ok: false, error: `--${name} only applies with --script` };
385
+ if (name === "save" && script)
386
+ return { ok: false, error: "--save applies to the window, not to --script, which saves once its sign-in succeeds" };
359
387
  if (!known.has(name) && !scriptOnly.has(name))
360
388
  return { ok: false, error: `unknown option --${name}` };
361
389
  }
@@ -379,6 +407,12 @@ export function parseLoginArgs(args, cwd) {
379
407
  if (browser !== undefined && !BROWSER_ENGINES.includes(browser)) {
380
408
  return { ok: false, error: `--browser must be one of ${BROWSER_ENGINES.join(", ")}` };
381
409
  }
410
+ const save = flags.get("save");
411
+ if (save !== undefined && !SAVE_MODES.includes(save))
412
+ return { ok: false, error: `--save must be one of ${SAVE_MODES.join(", ")}` };
413
+ const successUrl = flags.get("success-url");
414
+ if (successUrl !== undefined && successUrl.trim() === "")
415
+ return { ok: false, error: "--success-url needs a value" };
382
416
  return {
383
417
  ok: true,
384
418
  options: {
@@ -387,6 +421,8 @@ export function parseLoginArgs(args, cwd) {
387
421
  projectDir: path.resolve(cwd, flags.get("project") ?? "."),
388
422
  ...(browser ? { browser: browser } : {}),
389
423
  ...(script ? { script: new Map([...flags].filter(([name]) => scriptOnly.has(name))) } : {}),
424
+ ...(save ? { save: save } : {}),
425
+ ...(!script && successUrl !== undefined ? { successUrl } : {}),
390
426
  },
391
427
  };
392
428
  }