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.
- package/CHANGELOG.md +87 -0
- package/README.md +70 -18
- package/dist/browsers.js +28 -0
- package/dist/check-run.js +191 -14
- package/dist/ci-run.js +268 -52
- package/dist/cli.js +107 -47
- package/dist/commands.js +3 -2
- package/dist/engine/baseline.js +377 -0
- package/dist/engine/brief.js +16 -7
- package/dist/engine/browser.js +1147 -286
- package/dist/engine/calibration.js +61 -30
- package/dist/engine/capture.js +164 -0
- package/dist/engine/check.js +244 -42
- package/dist/engine/ci-lanes.js +215 -0
- package/dist/engine/ci.js +136 -18
- package/dist/engine/claims.js +159 -3
- package/dist/engine/collector.js +561 -30
- package/dist/engine/crawl.js +49 -0
- package/dist/engine/design.js +281 -38
- package/dist/engine/export.js +877 -0
- package/dist/engine/fingerprint.js +92 -4
- package/dist/engine/flow.js +18 -6
- package/dist/engine/forms.js +181 -18
- package/dist/engine/journey.js +29 -1
- package/dist/engine/lane.js +13 -3
- package/dist/engine/launch.js +45 -6
- package/dist/engine/limits.js +7 -0
- package/dist/engine/live-page.js +49 -2
- package/dist/engine/live.js +4 -1
- package/dist/engine/memory.js +501 -47
- package/dist/engine/open.js +118 -0
- package/dist/engine/oracles.js +41 -1
- package/dist/engine/plain.js +268 -0
- package/dist/engine/png.js +127 -0
- package/dist/engine/policy.js +379 -9
- package/dist/engine/probes.js +3 -2
- package/dist/engine/profiles.js +45 -9
- package/dist/engine/project-folder.js +191 -0
- package/dist/engine/refresh.js +68 -3
- package/dist/engine/replay.js +63 -10
- package/dist/engine/report.js +241 -40
- package/dist/engine/request.js +317 -23
- package/dist/engine/sarif.js +120 -0
- package/dist/engine/settle.js +67 -0
- package/dist/engine/signed-in.js +256 -0
- package/dist/engine/status-pane-page.js +441 -0
- package/dist/engine/status-pane.js +128 -0
- package/dist/engine/tickets.js +671 -0
- package/dist/engine/unload.js +3 -2
- package/dist/export-run.js +633 -0
- package/dist/first-run.js +5 -0
- package/dist/installer.js +378 -8
- package/dist/intake.js +104 -0
- package/dist/login-run.js +250 -36
- package/dist/mcp-server.js +660 -65
- package/dist/playbook.js +5 -0
- package/dist/prompts.js +106 -0
- package/package.json +8 -5
- package/skills/scenescout/SKILL.md +49 -16
package/dist/engine/policy.js
CHANGED
|
@@ -5,7 +5,12 @@
|
|
|
5
5
|
*/
|
|
6
6
|
const DESTRUCTIVE_PATTERNS = [
|
|
7
7
|
/\bdelete\b/i,
|
|
8
|
-
|
|
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
|
-
|
|
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,
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
// "
|
|
29
|
-
|
|
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;
|
package/dist/engine/probes.js
CHANGED
|
@@ -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
|
-
|
|
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")
|
package/dist/engine/profiles.js
CHANGED
|
@@ -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
|
-
/**
|
|
283
|
-
export function
|
|
284
|
-
|
|
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 \`${
|
|
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
|
-
* [--
|
|
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
|
}
|