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
package/dist/login-run.js CHANGED
@@ -1,13 +1,15 @@
1
1
  /**
2
- * Runs `scenescout login <url> --role <name>`: opens a visible browser at the
3
- * URL, lets the person sign in however the app asks (SSO, MFA, a password
4
- * manager), and when they press Enter in the terminal saves the browser's
5
- * storage state as that role's profile.
2
+ * Runs `scenescout login <url> --role <name>` and scout_login: opens a
3
+ * visible browser at the URL, lets the person sign in however the app asks
4
+ * (SSO, MFA, a password manager), and once they are signed in saves the
5
+ * browser's storage state as that role's profile.
6
6
  *
7
- * Enter is the one way to save. Closing the window, Ctrl+C, or the terminal's
8
- * input ending saves nothing: by the time a window is closed its state can no
9
- * longer be read, and a profile saved by accident half-way through a sign-in
10
- * would be attached later as if it worked.
7
+ * The window saves by itself once engine/signed-in.ts says the sign-in has
8
+ * finished; Enter in the terminal saves at once, and `--save enter` makes it
9
+ * the only way, as before. Closing the window or Ctrl+C saves nothing: by the
10
+ * time a window is closed its state can no longer be read, and a profile saved
11
+ * by accident half-way through a sign-in would be attached later as if it
12
+ * worked.
11
13
  *
12
14
  * The rules (role names, where the file goes, its mode, what may be printed)
13
15
  * are in engine/profiles.ts; this file only drives the browser and the terminal.
@@ -21,6 +23,7 @@ import { redactRoute } from "./engine/check.js";
21
23
  import { describeLifetime, readLifetime } from "./engine/expiry.js";
22
24
  import { describeSaved, mergeSessionStorage, withSessionStorage, writeProfile } from "./engine/profiles.js";
23
25
  import { afterTyping, chooseFields, describeStep, fieldIdentity, filledNames, nextStep, otpBoxes, quotable, secondsLeft, splitCode, totp as totpCode, TOTP_MIN_SECONDS_LEFT, urlMatches, } from "./engine/scripted-login.js";
26
+ import { DEFAULT_SAVE_MODE, judgeSignIn, LOOK_EVERY_MS, onApp, startWatch, } from "./engine/signed-in.js";
24
27
  /**
25
28
  * Read what the profile keeps from a signed-in context: cookies, localStorage
26
29
  * and IndexedDB through Playwright's storage state, and sessionStorage, which
@@ -62,8 +65,13 @@ export async function captureState(context) {
62
65
  }
63
66
  /** How long after the prompt input still counts as typed before it. */
64
67
  const EARLY_INPUT_MS = 300;
65
- /** Wait for Enter on the terminal, the window closing, the input ending or Ctrl+C, whichever comes first. */
66
- function waitForEnd(browserClosed) {
68
+ /**
69
+ * Wait for Enter on the terminal, the input ending or Ctrl+C. With `keepOnEnd`
70
+ * (the window saves by itself), input that ends is not an answer: a client
71
+ * that starts the command with no terminal closes it at once, and the window
72
+ * is still the way to finish.
73
+ */
74
+ function waitForEnter(keepOnEnd) {
67
75
  const rl = readline.createInterface({ input: process.stdin, terminal: false });
68
76
  // Lines typed before the prompt (while the browser launched or the page
69
77
  // loaded) arrive at once, buffered; they are not an answer to it.
@@ -74,10 +82,10 @@ function waitForEnd(browserClosed) {
74
82
  if (Date.now() >= listening)
75
83
  resolve("enter");
76
84
  });
77
- rl.once("close", () => resolve("input-ended"));
85
+ if (!keepOnEnd)
86
+ rl.once("close", () => resolve("input-ended"));
78
87
  onSigint = () => resolve("interrupted");
79
88
  process.once("SIGINT", onSigint);
80
- void browserClosed.then(() => resolve("window-closed"));
81
89
  });
82
90
  return {
83
91
  done,
@@ -90,28 +98,30 @@ function waitForEnd(browserClosed) {
90
98
  };
91
99
  }
92
100
  const NOT_SAVED = {
93
- "window-closed": "The browser was closed before Enter was pressed, so nothing was saved. Run the command again and press Enter here once you are signed in.",
94
- "input-ended": "The terminal's input ended before Enter was pressed, so nothing was saved. Run this command in an interactive terminal.",
101
+ "window-closed": "The browser was closed before the sign-in finished, so nothing was saved. Run the command again and sign in in the window it opens.",
102
+ "input-ended": "The terminal's input ended before Enter was pressed, so nothing was saved. Run this command in an interactive terminal, or leave out --save enter so the window saves by itself once you are signed in.",
95
103
  interrupted: "Interrupted, so nothing was saved.",
104
+ "timed-out": "The sign-in did not finish in time, so the window was closed and nothing was saved.",
96
105
  };
97
- export async function runLogin(options, log) {
106
+ /** Open the window a person signs in in. Throws a sentence when the browser cannot start or the page cannot load. */
107
+ export async function openLoginWindow(options, opts = {}) {
98
108
  const engine = options.browser ?? defaultEngine(process.env);
99
109
  const types = { chromium, firefox, webkit };
100
110
  // Checked before a window opens: a limit out of bounds is a sentence, not a browser left behind.
101
111
  const navMs = explicitLimits({}, process.env).navMs ?? LOGIN_NAV_MS;
112
+ const headless = opts.headless ?? false;
102
113
  let browser;
103
114
  try {
104
- browser = await types[engine].launch({ headless: false });
115
+ browser = await types[engine].launch({ headless });
105
116
  }
106
117
  catch (err) {
107
- throw new Error(explainLaunchFailure(err instanceof Error ? err.message : String(err), 0, { engine, headed: true }));
118
+ throw new Error(explainLaunchFailure(err instanceof Error ? err.message : String(err), 0, { engine, headed: !headless }));
108
119
  }
109
- const browserClosed = new Promise((resolve) => browser.once("disconnected", () => resolve()));
110
- let end;
120
+ const closed = new Promise((resolve) => browser.once("disconnected", () => resolve()));
111
121
  try {
112
122
  const context = await browser.newContext();
113
123
  const page = await context.newPage();
114
- // Closing the only tab is how most people close the window; treat it the
124
+ // Closing the sign-in tab is how most people close the window; treat it the
115
125
  // same. The browser may already be closing, and either way it ends up closed.
116
126
  page.once("close", () => void browser.close().catch(() => { }));
117
127
  try {
@@ -121,23 +131,223 @@ export async function runLogin(options, log) {
121
131
  const explained = loginTimeout(err, "nav", navMs);
122
132
  throw new Error(`could not open ${options.url}: ${explained instanceof Error ? explained.message.split("\n")[0] : String(explained)}`);
123
133
  }
124
- log(`A ${engine} window is open at ${options.url}.`);
125
- log(`Sign in as "${options.role}" there — SSO, MFA, whatever the app asks — then come back here and press Enter to save.`);
126
- log(`(Closing the window or pressing Ctrl+C saves nothing.)`);
134
+ return { browser, context, page, engine, closed };
135
+ }
136
+ catch (err) {
137
+ // The error being thrown is the one to report; a browser that fails to close as well adds nothing to it.
138
+ await browser.close().catch(() => { });
139
+ throw err;
140
+ }
141
+ }
142
+ /** Longest storage value read for a look; a longer one is compared by its start. */
143
+ const MAX_HELD_VALUE = 64 * 1024;
144
+ /** The cookies the browser would send to `url` and the top document's storage: what a look compares with the baseline. */
145
+ async function readHeld(context, page, url) {
146
+ const held = (await context.cookies([url])).map((c) => ({
147
+ kind: "cookie",
148
+ key: `cookie|${c.domain}|${c.path}|${c.name}`,
149
+ name: c.name,
150
+ value: c.value,
151
+ }));
152
+ const storage = await page.evaluate((max) => {
153
+ const out = [];
154
+ for (const [kind, store] of [
155
+ ["local", localStorage],
156
+ ["session", sessionStorage],
157
+ ]) {
158
+ for (let i = 0; i < store.length; i++) {
159
+ const name = store.key(i);
160
+ if (name !== null)
161
+ out.push({ kind, name, value: (store.getItem(name) ?? "").slice(0, max) });
162
+ }
163
+ }
164
+ return { origin: location.origin, out };
165
+ }, MAX_HELD_VALUE);
166
+ for (const e of storage.out)
167
+ held.push({ kind: e.kind, key: `${e.kind}|${storage.origin}|${e.name}`, name: e.name, value: e.value });
168
+ return held;
169
+ }
170
+ /** A read the page's own navigation interrupted: the look is skipped and taken again. */
171
+ const betweenPages = (err) => /context was destroyed|navigat|detached|target closed|has been closed/i.test(err.message);
172
+ /** One look at the window, or null when the page was between documents and the look should be taken again. */
173
+ async function lookAt(win, watch) {
174
+ const url = win.page.url();
175
+ const popupAway = win.context.pages().some((p) => p !== win.page && !p.isClosed() && /^https?:/i.test(p.url()) && !onApp(watch, p.url()));
176
+ // Off the app nothing is read, except at an absolute success URL, where a sign-in field still says it is not done.
177
+ const atSuccess = watch.successUrl !== undefined && /^https?:\/\//i.test(watch.successUrl) && urlMatches(url, watch.successUrl);
178
+ if (!onApp(watch, url) && !atSuccess)
179
+ return { url, signInField: false, held: [], popupAway };
180
+ try {
181
+ const fields = await win.page.evaluate(collectFields, { selectors: {}, tag: false });
182
+ if (!Array.isArray(fields))
183
+ return null;
184
+ const chosen = chooseFields(fields);
185
+ const held = await readHeld(win.context, win.page, url);
186
+ // The page moved while it was read: what was read belongs to two pages.
187
+ if (win.page.url() !== url)
188
+ return null;
189
+ return { url, signInField: Boolean(chosen.password || chosen.otp), held, popupAway };
190
+ }
191
+ catch (err) {
192
+ if (err instanceof Error && betweenPages(err))
193
+ return null;
194
+ throw err;
195
+ }
196
+ }
197
+ /**
198
+ * Watch the window until the person is signed in (engine/signed-in.ts says
199
+ * when), the window closes, or `signal` aborts. Resolves with how it ended;
200
+ * the caller saves and closes.
201
+ */
202
+ export async function watchForSignIn(win, options, opts = {}) {
203
+ let closed = false;
204
+ void win.closed.then(() => {
205
+ closed = true;
206
+ });
207
+ // The baseline is what the window holds once the first page is there: whatever the app set before anyone signed in.
208
+ const firstUrl = win.page.url();
209
+ let watch = startWatch(options.url, [], options.successUrl, firstUrl);
210
+ const baseline = [];
211
+ try {
212
+ for (const c of await win.context.cookies())
213
+ baseline.push({ kind: "cookie", key: `cookie|${c.domain}|${c.path}|${c.name}`, name: c.name, value: c.value });
214
+ if (onApp(watch, firstUrl)) {
215
+ const first = await readHeld(win.context, win.page, firstUrl).catch((err) => {
216
+ if (betweenPages(err))
217
+ return [];
218
+ throw err;
219
+ });
220
+ baseline.push(...first.filter((h) => h.kind !== "cookie"));
221
+ }
222
+ }
223
+ catch (err) {
224
+ if (opts.signal?.aborted)
225
+ return { kind: "aborted" };
226
+ if (closed || win.page.isClosed())
227
+ return { kind: "closed" };
228
+ throw err;
229
+ }
230
+ watch = startWatch(options.url, baseline, options.successUrl, firstUrl);
231
+ opts.progress?.({ reason: "starting", url: firstUrl });
232
+ for (;;) {
233
+ if (closed || win.page.isClosed())
234
+ return { kind: "closed" };
235
+ if (opts.signal?.aborted)
236
+ return { kind: "aborted" };
237
+ let look;
238
+ try {
239
+ look = await lookAt(win, watch);
240
+ }
241
+ catch (err) {
242
+ // The window closing under a look is the window closing, not an error; nor is a look the caller already stopped
243
+ // (Enter was pressed and the window is being closed to save).
244
+ if (opts.signal?.aborted)
245
+ return { kind: "aborted" };
246
+ if (closed || win.page.isClosed())
247
+ return { kind: "closed" };
248
+ throw err;
249
+ }
250
+ if (look !== null) {
251
+ const judged = judgeSignIn(watch, look);
252
+ watch = judged.watch;
253
+ if (judged.verdict.kind === "signed-in")
254
+ return { kind: "signed-in", verdict: judged.verdict, url: look.url };
255
+ opts.progress?.({ reason: judged.verdict.reason, url: look.url });
256
+ }
257
+ await new Promise((resolve) => setTimeout(resolve, LOOK_EVERY_MS));
258
+ }
259
+ }
260
+ /** What the window says once the sign-in is seen: where, and by what. */
261
+ function sayDetected(verdict, url) {
262
+ const how = verdict.via === "success-url" ? "reached the success URL" : `the app holds a new session (${verdict.credential})`;
263
+ return `Signed in: back on ${redactRoute(url)} and ${how}. Saving and closing the window.`;
264
+ }
265
+ export async function runLogin(options, log) {
266
+ const mode = options.save ?? DEFAULT_SAVE_MODE;
267
+ const win = await openLoginWindow(options);
268
+ let enter;
269
+ const stop = new AbortController();
270
+ try {
271
+ log(`A ${win.engine} window is open at ${options.url}.`);
272
+ if (mode === "auto") {
273
+ log(`Sign in as "${options.role}" there — SSO, MFA, whatever the app asks. The window saves and closes by itself once you are signed in.`);
274
+ log(`(To save sooner, press Enter here. Closing the window or pressing Ctrl+C saves nothing.)`);
275
+ }
276
+ else {
277
+ log(`Sign in as "${options.role}" there — SSO, MFA, whatever the app asks — then come back here and press Enter to save.`);
278
+ log(`(Closing the window or pressing Ctrl+C saves nothing.)`);
279
+ }
127
280
  // Listening starts only now, after the prompt: an Enter pressed while the
128
281
  // page was still loading must not save a signed-out profile.
129
- end = waitForEnd(browserClosed);
130
- const how = await end.done;
131
- if (how !== "enter")
282
+ enter = waitForEnter(mode === "auto");
283
+ const racers = [enter.done, win.closed.then(() => "window-closed")];
284
+ if (mode === "auto") {
285
+ racers.push(watchForSignIn(win, options, { signal: stop.signal }).then((r) => {
286
+ if (r.kind === "signed-in") {
287
+ log(sayDetected(r.verdict, r.url));
288
+ return "signed-in";
289
+ }
290
+ return r.kind === "closed" ? "window-closed" : "interrupted";
291
+ }));
292
+ }
293
+ const how = await Promise.race(racers);
294
+ stop.abort();
295
+ if (how !== "enter" && how !== "signed-in")
132
296
  throw new Error(NOT_SAVED[how]);
133
- return await saveLogin(context, options);
297
+ return await saveLogin(win.context, options);
134
298
  }
135
299
  finally {
136
- end?.dispose();
300
+ stop.abort();
301
+ enter?.dispose();
137
302
  // Closing a browser the person already closed fails; nothing is left to clean up then.
138
- await browser.close().catch(() => { });
303
+ await win.browser.close().catch(() => { });
139
304
  }
140
305
  }
306
+ /** How long a sign-in window opened from the conversation stays open, at most, before it closes saving nothing. */
307
+ export const LOGIN_WINDOW_MAX_MS = 15 * 60_000;
308
+ /**
309
+ * Open a window for a person to sign in, watch it, and save the profile once
310
+ * they are signed in: what scout_login does. Never waits for Enter; the
311
+ * window closing, `cancel`, or LOGIN_WINDOW_MAX_MS passing saves nothing.
312
+ */
313
+ export async function startLoginWindow(options, opts = {}) {
314
+ const win = await openLoginWindow(options, opts);
315
+ const stop = new AbortController();
316
+ let last = { reason: "starting", url: options.url };
317
+ const timer = setTimeout(() => stop.abort(), opts.maxMs ?? LOGIN_WINDOW_MAX_MS);
318
+ const done = (async () => {
319
+ try {
320
+ const r = await watchForSignIn(win, options, { signal: stop.signal, progress: (p) => (last = p) });
321
+ if (r.kind === "closed")
322
+ return { ok: false, error: NOT_SAVED["window-closed"] };
323
+ if (r.kind === "aborted")
324
+ return { ok: false, error: NOT_SAVED["timed-out"] };
325
+ const saved = await saveLogin(win.context, options);
326
+ return { ok: true, saved, detected: sayDetected(r.verdict, r.url).replace(/ Saving and closing the window\.$/, "") };
327
+ }
328
+ catch (err) {
329
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
330
+ }
331
+ finally {
332
+ clearTimeout(timer);
333
+ // The outcome is decided; a window the person already closed fails to close again, and nothing is left then.
334
+ await win.browser.close().catch(() => { });
335
+ }
336
+ })();
337
+ return {
338
+ role: options.role,
339
+ url: options.url,
340
+ window: win,
341
+ done,
342
+ progress: () => last,
343
+ cancel: async () => {
344
+ stop.abort();
345
+ // Closed either way: a window already gone fails to close again. `done` settles once the watch sees it.
346
+ await win.browser.close().catch(() => { });
347
+ await done;
348
+ },
349
+ };
350
+ }
141
351
  /**
142
352
  * Save a signed-in context as the role's profile and read how long it lasts:
143
353
  * what `scenescout login` does once Enter is pressed, apart so it can be run
@@ -179,10 +389,12 @@ const LOGIN_ACTION_MS = 10_000;
179
389
  const MAX_SUBMITS = 5;
180
390
  /**
181
391
  * Read every visible form control on the page (the top document only), tag
182
- * each with its index, and mark those matching a configured selector. Runs in
183
- * the page, so it may use only what the page has.
392
+ * each with its index when asked (a scripted sign-in acts on them; watching a
393
+ * person sign in only reads them, so leaves the page as it is), and mark those
394
+ * matching a configured selector. Runs in the page, so it may use only what
395
+ * the page has.
184
396
  */
185
- function collectFields(selectors) {
397
+ function collectFields({ selectors, tag: tagging }) {
186
398
  const visible = (el) => {
187
399
  const r = el.getBoundingClientRect();
188
400
  const st = getComputedStyle(el);
@@ -220,8 +432,9 @@ function collectFields(selectors) {
220
432
  return parts.join(" ").replace(/\s+/g, " ").trim();
221
433
  };
222
434
  // Tags from an earlier read name other elements now: one index, one element.
223
- for (const old of Array.from(document.querySelectorAll("[data-scenescout-login]")))
224
- old.removeAttribute("data-scenescout-login");
435
+ if (tagging)
436
+ for (const old of Array.from(document.querySelectorAll("[data-scenescout-login]")))
437
+ old.removeAttribute("data-scenescout-login");
225
438
  const out = [];
226
439
  const all = Array.from(document.querySelectorAll("input, textarea, select, button"));
227
440
  for (const el of all) {
@@ -229,7 +442,8 @@ function collectFields(selectors) {
229
442
  if (type === "hidden" || !visible(el))
230
443
  continue;
231
444
  const index = out.length;
232
- el.setAttribute("data-scenescout-login", String(index));
445
+ if (tagging)
446
+ el.setAttribute("data-scenescout-login", String(index));
233
447
  const tag = el.tagName.toLowerCase();
234
448
  const input = el;
235
449
  out.push({
@@ -342,7 +556,7 @@ export async function runScriptedLogin(options, config, redactor, log, now = ()
342
556
  return true;
343
557
  };
344
558
  const read = async () => {
345
- const got = await page.evaluate(collectFields, config.selectors).catch((err) => {
559
+ const got = await page.evaluate(collectFields, { selectors: config.selectors, tag: true }).catch((err) => {
346
560
  if (midNavigation(err))
347
561
  return null;
348
562
  throw fail(err.message);