@broberg/lens-daemon 0.1.11 → 0.1.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@broberg/lens-daemon",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "The Lens module of the cardmem daemon (flows, critic, coverage, capture sessions, the /lens/* routes and the cardmem-lens MCP) as a package. The daemon mounts createLensRouter(deps) + createLensMcpRouter(deps). Bun runtime.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -15,8 +15,8 @@
15
15
  "sharp": "^0.33.5",
16
16
  "turndown": "^7.2.4",
17
17
  "zod": "^3.23.8",
18
- "@broberg/lens-engine": "0.13.0",
19
- "@broberg/lens-svg": "0.1.0"
18
+ "@broberg/lens-svg": "0.1.0",
19
+ "@broberg/lens-engine": "0.13.0"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@happy-dom/global-registrator": "^20.10.6",
@@ -0,0 +1,82 @@
1
+ // F009.125 — what a flow needs around an extension build: which popup to open,
2
+ // and (only when asked) a temporary copy of the build that may read the page.
3
+ //
4
+ // MEASURED before this file existed (Jam Clipper's real dist/, M1, 15 Oct 2026):
5
+ // · a popup opened as its own tab sees the TARGET tab as active once the target
6
+ // is brought to the front (tabs.query → the target's id, not its own);
7
+ // · chrome.scripting.executeScript on that tab FAILS without the toolbar gesture:
8
+ // "Cannot access contents of the page. Extension manifest must request
9
+ // permission to access the respective host."
10
+ // · the same call SUCCEEDS on a copy whose manifest adds host access.
11
+ // A headless run cannot press the toolbar button, so `activeTab` can never be
12
+ // granted the way a person grants it. The copy is the honest substitute — and it
13
+ // is opt-in and reported, because a simulated permission must not read as the
14
+ // real gesture.
15
+
16
+ import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
17
+ import { tmpdir } from 'node:os';
18
+ import { join } from 'node:path';
19
+
20
+ interface Manifest {
21
+ manifest_version?: number;
22
+ action?: { default_popup?: string };
23
+ browser_action?: { default_popup?: string };
24
+ host_permissions?: string[];
25
+ permissions?: string[];
26
+ }
27
+
28
+ async function readManifest(dir: string): Promise<Manifest> {
29
+ let raw: string;
30
+ try {
31
+ raw = await readFile(join(dir, 'manifest.json'), 'utf8');
32
+ } catch {
33
+ throw new Error(
34
+ `extension.path ${dir} has no manifest.json — point it at the UNPACKED build directory (e.g. dist/), not the source or a .zip`,
35
+ );
36
+ }
37
+ return JSON.parse(raw) as Manifest;
38
+ }
39
+
40
+ /** The popup page an `openPopup` step opens when it names none: the one Chrome
41
+ * opens on the toolbar click (MV3 `action`, MV2 `browser_action`). */
42
+ export async function defaultPopupOf(dir: string): Promise<string> {
43
+ const m = await readManifest(dir);
44
+ const p = m.action?.default_popup ?? m.browser_action?.default_popup;
45
+ if (!p) {
46
+ throw new Error(
47
+ 'openPopup: the manifest declares no action.default_popup — name the page with openPopup {path: "…"}',
48
+ );
49
+ }
50
+ return p.replace(/^\/+/, '');
51
+ }
52
+
53
+ export interface PreparedExtension {
54
+ /** The directory Chromium loads — the original, or the temporary copy. */
55
+ dir: string;
56
+ /** True when host access was added to a copy rather than granted by a gesture. */
57
+ simulated: boolean;
58
+ cleanup: () => Promise<void>;
59
+ }
60
+
61
+ /** The build as-is, or — when `grantHostAccess` — a TEMPORARY COPY whose manifest
62
+ * may read every page, standing in for the toolbar gesture a headless run cannot
63
+ * make. The original directory is never written to. */
64
+ export async function prepareExtension(path: string, grantHostAccess: boolean): Promise<PreparedExtension> {
65
+ const manifest = await readManifest(path);
66
+ if (!grantHostAccess) return { dir: path, simulated: false, cleanup: async () => {} };
67
+ const dir = await mkdtemp(join(tmpdir(), 'lens-ext-copy-'));
68
+ await cp(path, dir, { recursive: true });
69
+ const hostPermissions = [...new Set([...(manifest.host_permissions ?? []), '<all_urls>'])];
70
+ // MV2 has no host_permissions key; host access lives in `permissions` there.
71
+ const next: Manifest =
72
+ manifest.manifest_version === 2
73
+ ? { ...manifest, permissions: [...new Set([...(manifest.permissions ?? []), '<all_urls>'])] }
74
+ : { ...manifest, host_permissions: hostPermissions };
75
+ await writeFile(join(dir, 'manifest.json'), JSON.stringify(next, null, 2));
76
+ return { dir, simulated: true, cleanup: () => rm(dir, { recursive: true, force: true }) };
77
+ }
78
+
79
+ /** The one line every report that used the copy carries. */
80
+ export const SIMULATED_ACCESS_NOTE =
81
+ 'host access SIMULATED: the extension ran from a temporary copy that may read every page, ' +
82
+ 'standing in for the toolbar click (activeTab) a headless run cannot make';
package/src/flow-store.ts CHANGED
@@ -19,6 +19,8 @@ export interface StoredFlowStep {
19
19
  label: string;
20
20
  /** F009.122 — the named browser the step ran in; absent = the default one. */
21
21
  browser?: string;
22
+ /** F009.125 — the step ran in the extension popup. */
23
+ in?: 'popup';
22
24
  status: 'pass' | 'fail' | 'skip';
23
25
  testid: string | null;
24
26
  detail: string | null;
@@ -86,6 +88,7 @@ export async function recordFlowRun(
86
88
  label: s.label,
87
89
  // F009.122 — third-place rule: a field only flow.ts knows reaches nobody.
88
90
  ...(s.browser ? { browser: s.browser } : {}),
91
+ ...(s.in ? { in: s.in } : {}),
89
92
  status: s.status,
90
93
  testid: s.testid,
91
94
  detail: s.detail,
@@ -148,6 +151,10 @@ export async function recordFlowRun(
148
151
  // findings were produced in flow.ts and written nowhere until fd-sundhed
149
152
  // measured it from a consumer's seat: correct instrument, nobody told.
150
153
  findings_json: JSON.stringify({
154
+ // F009.125 — which extension the run loaded, and whether its host access was
155
+ // simulated. Kept with the findings because a simulated permission is
156
+ // something a reader must see before trusting the green.
157
+ ...(result.extension ? { extension: result.extension } : {}),
151
158
  ...(result.password_in_url ? { password_in_url: result.password_in_url } : {}),
152
159
  ...(result.not_listening ? { not_listening: result.not_listening } : {}),
153
160
  }),
package/src/flow.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  import nodePath from 'node:path';
15
15
  import { mkdtemp, readFile, rm } from 'node:fs/promises';
16
16
  import { tmpdir } from 'node:os';
17
- import type { Locator, Page } from 'playwright';
17
+ import type { BrowserContext, Locator, Page } from 'playwright';
18
18
  import {
19
19
  resolveTarget,
20
20
  isEditableElement,
@@ -24,6 +24,7 @@ import {
24
24
  type Target,
25
25
  } from '@broberg/lens-engine';
26
26
  import { withPage, type LensEngine } from './browser.ts';
27
+ import { defaultPopupOf, prepareExtension, SIMULATED_ACCESS_NOTE } from './extension-flow.ts';
27
28
  import { authPrepare, type AuthConfig, type AuthEnv } from './auth/index.ts';
28
29
  import { takeShot, settleForShot } from './capture.ts';
29
30
  import { runCritic, topFindingMessage, type CriticMode, type CriticReport } from './critic.ts';
@@ -153,7 +154,11 @@ export type FlowStep =
153
154
  | { type: 'autocomplete'; testid: string; value: string; option: string; pickText?: string; pickIndex?: number; label?: string; timeout?: number }
154
155
  | { type: 'capture'; testid: string; as: string; from?: 'text' | 'value'; label?: string; timeout?: number }
155
156
  | { type: 'conditional'; testid: string; present: boolean; steps: FlowStep[]; label?: string; timeout?: number }
156
- | { type: 'loop'; data: Array<Record<string, unknown>>; steps: FlowStep[]; label?: string; parallel?: number };
157
+ | { type: 'loop'; data: Array<Record<string, unknown>>; steps: FlowStep[]; label?: string; parallel?: number }
158
+ // F009.125 — open the loaded extension's popup as its own page in the SAME browser,
159
+ // then put the page under test back in front so the extension sees it as the active
160
+ // tab (measured). `path` defaults to the manifest's action.default_popup.
161
+ | { type: 'openPopup'; path?: string; label?: string; timeout?: number };
157
162
 
158
163
  export interface Flow {
159
164
  name: string;
@@ -171,9 +176,19 @@ export interface Flow {
171
176
  * ejecting us. Default {@link DEFAULT_EJECT_GRACE_MS}; 0 opts out. Raise it
172
177
  * for an app whose auth check is slow (fd-sundhed's bounced at ~3.6s). */
173
178
  eject_grace_ms?: number;
179
+ /** F009.125 — run the whole flow in a Chromium with this UNPACKED extension
180
+ * loaded. `grant_host_access` loads a temporary copy that may read every page,
181
+ * standing in for the toolbar click (activeTab) a headless run cannot make;
182
+ * the original build is never written, and the report says it was simulated. */
183
+ extension?: FlowExtension;
174
184
  steps: FlowStep[];
175
185
  }
176
186
 
187
+ export interface FlowExtension {
188
+ path: string;
189
+ grant_host_access?: boolean;
190
+ }
191
+
177
192
  /**
178
193
  * MEASURED, not chosen. Against a fixture that bounces 800ms after load:
179
194
  *
@@ -242,6 +257,8 @@ export interface FlowStepResult {
242
257
  label: string;
243
258
  /** F009.122 — the named browser this step ran in. Absent = the default one. */
244
259
  browser?: string;
260
+ /** F009.125 — present when the step ran in the extension popup. */
261
+ in?: 'popup';
245
262
  status: 'pass' | 'fail' | 'skip';
246
263
  testid: string | null;
247
264
  detail: string | null;
@@ -336,6 +353,9 @@ export class InstrumentError extends Error {
336
353
  }
337
354
 
338
355
  export interface FlowResult {
356
+ /** F009.125 — the extension this run loaded: its id (read at load time), the
357
+ * build path the caller named, and whether host access was SIMULATED on a copy. */
358
+ extension?: { id: string; path: string; host_access: 'as-built' | 'simulated'; note?: string } | null;
339
359
  name: string;
340
360
  status: 'pass' | 'fail' | 'error';
341
361
  steps: FlowStepResult[];
@@ -403,6 +423,8 @@ export const STEP_TYPES = new Set([
403
423
  'waitForBuild',
404
424
  // F213.8 — readiness gate (the page is listening, not just painted)
405
425
  'waitForReady',
426
+ // F009.125 — drive a loaded extension's popup
427
+ 'openPopup',
406
428
  ]);
407
429
 
408
430
  /** Validate + normalise an untrusted flow (HTTP/MCP boundary). Throws on the
@@ -429,7 +451,25 @@ export function parseFlow(input: unknown): Flow {
429
451
  if (f.eject_grace_ms !== undefined && (typeof f.eject_grace_ms !== 'number' || !Number.isFinite(f.eject_grace_ms) || f.eject_grace_ms < 0)) {
430
452
  throw new Error('flow.eject_grace_ms must be a non-negative number of milliseconds');
431
453
  }
454
+ // F009.125 — refused when malformed rather than dropped: before this, an unknown
455
+ // flow-level key vanished in silence, so `extension` would have run the flow
456
+ // WITHOUT the extension and reported green about a page the extension never saw.
457
+ let extension: FlowExtension | undefined;
458
+ if (f.extension !== undefined) {
459
+ const e = f.extension as unknown as Record<string, unknown>;
460
+ const keys = e && typeof e === 'object' && !Array.isArray(e) ? Object.keys(e) : null;
461
+ if (!keys || typeof e.path !== 'string' || !e.path || keys.some((k) => k !== 'path' && k !== 'grant_host_access')) {
462
+ throw new Error('flow.extension must be {path: "<unpacked build dir>", grant_host_access?: boolean}');
463
+ }
464
+ if (e.grant_host_access !== undefined && typeof e.grant_host_access !== 'boolean') {
465
+ throw new Error('flow.extension.grant_host_access must be a boolean');
466
+ }
467
+ extension = { path: e.path, ...(e.grant_host_access === true ? { grant_host_access: true } : {}) };
468
+ }
432
469
  validateAndNormalizeSteps(f.steps);
470
+ if (!extension && usesPopup(f.steps as FlowStep[])) {
471
+ throw new Error('openPopup / in:"popup" need flow.extension — there is no extension loaded to open a popup from');
472
+ }
433
473
  return {
434
474
  name: f.name.trim(),
435
475
  base_url: f.base_url,
@@ -437,10 +477,20 @@ export function parseFlow(input: unknown): Flow {
437
477
  mutates: f.mutates === true,
438
478
  ...(f.wait_for_build !== undefined ? { wait_for_build: f.wait_for_build } : {}),
439
479
  ...(f.eject_grace_ms !== undefined ? { eject_grace_ms: f.eject_grace_ms } : {}),
480
+ ...(extension ? { extension } : {}),
440
481
  steps: f.steps as FlowStep[],
441
482
  };
442
483
  }
443
484
 
485
+ function usesPopup(steps: FlowStep[]): boolean {
486
+ return steps.some(
487
+ (st) =>
488
+ st.type === 'openPopup' ||
489
+ (st as { in?: string }).in === 'popup' ||
490
+ ((st.type === 'loop' || st.type === 'conditional') && usesPopup(st.steps)),
491
+ );
492
+ }
493
+
444
494
  // F074.23 — light structural check for a self-healing `target` at the HTTP/MCP
445
495
  // boundary (the daemon stays zod-free in this validator; the engine's
446
496
  // resolveTarget does the real resolution at run time). A bare string is the
@@ -458,7 +508,7 @@ function isValidTarget(t: unknown): boolean {
458
508
  * FlowStep union above; flow-step-fields.test.ts reads the union from source and
459
509
  * fails if the two drift. An unknown field is REFUSED: `inspect` with a guessed
460
510
  * `selector` used to run a whole-page dump in silence (cardmem, flow b62ffca3). */
461
- const SHARED_STEP_FIELDS = ['type', 'label', 'timeout', 'timeout_ms', 'browser'];
511
+ const SHARED_STEP_FIELDS = ['type', 'label', 'timeout', 'timeout_ms', 'browser', 'in'];
462
512
 
463
513
  /** F009.122 — a step may name the browser it runs in. Each name is its own browser
464
514
  * context (own cookies + storage), opened on first use and kept open for the rest
@@ -500,6 +550,7 @@ export const STEP_FIELDS: Record<string, string[]> = {
500
550
  capture: ['testid', 'as', 'from'],
501
551
  conditional: ['testid', 'present', 'steps'],
502
552
  loop: ['data', 'steps', 'parallel'],
553
+ openPopup: ['path'],
503
554
  };
504
555
 
505
556
  function validateAndNormalizeSteps(steps: unknown[], depth = 0, inParallel = false): void {
@@ -560,6 +611,28 @@ function validateAndNormalizeSteps(steps: unknown[], depth = 0, inParallel = fal
560
611
  throw new Error(`step ${index} (${s.type}): browser is not allowed inside a parallel loop — each iteration already runs in its own browser`);
561
612
  }
562
613
  }
614
+ // F009.125 — `in: "popup"` runs a step in the extension popup an earlier
615
+ // openPopup opened. Refused where it cannot mean anything, the same way
616
+ // `browser` is: the popup lives in the main browser only.
617
+ if ('in' in s) {
618
+ const where = (s as { in?: unknown }).in;
619
+ if (where !== 'popup') throw new Error(`step ${index} (${s.type}): in must be "popup", got ${JSON.stringify(where)}`);
620
+ if (s.type === 'loop' || s.type === 'conditional' || s.type === 'openPopup') {
621
+ throw new Error(`step ${index} (${s.type}): in:"popup" goes on the steps that act in the popup, not on a ${s.type}`);
622
+ }
623
+ const b = (s as { browser?: unknown }).browser;
624
+ if (b !== undefined && b !== 'main') {
625
+ throw new Error(`step ${index} (${s.type}): in:"popup" and browser:${JSON.stringify(b)} cannot be combined — the extension is loaded in the main browser only`);
626
+ }
627
+ if (inParallel) throw new Error(`step ${index} (${s.type}): in:"popup" is not allowed inside a parallel loop`);
628
+ }
629
+ if (s.type === 'openPopup') {
630
+ const pth = (s as { path?: unknown }).path;
631
+ if (pth !== undefined && (typeof pth !== 'string' || !pth || pth.includes('..') || /^[a-z]+:/i.test(pth))) {
632
+ throw new Error(`step ${index} (openPopup): path must be a page inside the extension, like "popup.html"`);
633
+ }
634
+ if ('browser' in s || inParallel) throw new Error(`step ${index} (openPopup): the popup opens in the main browser only`);
635
+ }
563
636
  if (s.type === 'autocomplete') {
564
637
  if (typeof (s as { value?: unknown }).value !== 'string') throw new Error('autocomplete step requires a string value');
565
638
  if (typeof (s as { option?: unknown }).option !== 'string') throw new Error('autocomplete step requires a string option testid');
@@ -1291,6 +1364,7 @@ function describe(step: FlowStep): string {
1291
1364
  case 'capture': return `capture ${step.testid} → {{${step.as}}}`;
1292
1365
  case 'conditional': return `${step.present ? 'if' : 'unless'} [${step.testid}]`;
1293
1366
  case 'loop': return 'loop';
1367
+ case 'openPopup': return `openPopup ${step.path ?? '(manifest popup)'}`;
1294
1368
  case 'waitForBuild': return `waitForBuild${step.sha ? ` ${step.sha.slice(0, 8)}` : ' (changed)'}`;
1295
1369
  // F255.4 — show the offset in the step label so a positioned click is
1296
1370
  // recognisable in the report (a plain "click canvas" hides where it aimed).
@@ -2253,6 +2327,8 @@ async function execStep(page: Page, step: FlowStep, base: string, vars: Record<s
2253
2327
  throw new Error('conditional steps are handled by runSeq, never execStep');
2254
2328
  case 'loop':
2255
2329
  throw new Error('loop steps are flattened before execution');
2330
+ case 'openPopup':
2331
+ throw new Error('openPopup steps are handled by runSeq, never execStep');
2256
2332
  }
2257
2333
  }
2258
2334
 
@@ -2585,7 +2661,11 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
2585
2661
  // F009.122 — which browser this step runs in. `sp` is the page every line
2586
2662
  // below acts on; the loop's own `page` is the default browser.
2587
2663
  const browserName = (step as { browser?: string }).browser ?? 'main';
2588
- const onBrowser = browserName !== 'main' ? { browser: browserName } : {};
2664
+ // F009.125 — a popup step says so in the report, the same way a named browser does.
2665
+ const onBrowser = {
2666
+ ...(browserName !== 'main' ? { browser: browserName } : {}),
2667
+ ...((step as { in?: string }).in === 'popup' ? { in: 'popup' as const } : {}),
2668
+ };
2589
2669
  let sp: Page = page;
2590
2670
  if (i < from) {
2591
2671
  record({ index: i, type: step.type, label, status: 'skip', testid: testidOf(step), detail: 'resumed past', screenshot: null });
@@ -2604,7 +2684,16 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
2604
2684
  try {
2605
2685
  switchBrowser(browserName);
2606
2686
  sp = await pageFor(browserName, page);
2607
- const out = await execStep(sp, step, base, vars, opts.critic ?? 'off', opts.localPath, sink, filledPasswords, notListening, opts.secretRoot);
2687
+ if ((step as { in?: string }).in === 'popup') {
2688
+ if (!extCtx?.popup) throw new Error('in:"popup" before any openPopup step — open the popup first');
2689
+ sp = extCtx.popup;
2690
+ // Keep the page under test the extension's active tab while the popup is driven.
2691
+ await extCtx.main.bringToFront();
2692
+ }
2693
+ const out =
2694
+ step.type === 'openPopup'
2695
+ ? await openPopup(step)
2696
+ : await execStep(sp, step, base, vars, opts.critic ?? 'off', opts.localPath, sink, filledPasswords, notListening, opts.secretRoot);
2608
2697
  const obj = out !== null && !Buffer.isBuffer(out) && typeof out === 'object' ? out : null;
2609
2698
  const inspect = obj && '__inspect' in obj ? (obj as { __inspect: string }).__inspect : null;
2610
2699
  const via = obj && '__via' in obj ? (obj as { __via: string }).__via : null;
@@ -2754,6 +2843,22 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
2754
2843
 
2755
2844
  const videoDir = opts.record ? await mkdtemp(nodePath.join(tmpdir(), 'lens-flow-video-')) : null;
2756
2845
  let videoPage: Page | null = null;
2846
+ // F009.125 — set once the extension's browser is up; read by openPopup / in:"popup".
2847
+ let extCtx: { context: BrowserContext; id: string; main: Page; popup: Page | null } | null = null;
2848
+ let extensionInfo: FlowResult['extension'] = null;
2849
+ const prepared = flow.extension ? await prepareExtension(flow.extension.path, flow.extension.grant_host_access === true) : null;
2850
+ const openPopup = async (step: Extract<FlowStep, { type: 'openPopup' }>): Promise<null> => {
2851
+ if (!extCtx || !flow.extension || !prepared) throw new Error('openPopup needs flow.extension');
2852
+ const path = step.path ?? (await defaultPopupOf(prepared.dir));
2853
+ const popup = await extCtx.context.newPage();
2854
+ collectFailures(popup);
2855
+ await popup.goto(`chrome-extension://${extCtx.id}/${path}`, { timeout: stepTimeout(step) });
2856
+ // MEASURED: with the page under test back in front, the popup's
2857
+ // tabs.query({active, currentWindow}) answers with THAT tab, as on a real click.
2858
+ await extCtx.main.bringToFront();
2859
+ extCtx.popup = popup;
2860
+ return null;
2861
+ };
2757
2862
  const readVideo = async (): Promise<Buffer | null> => {
2758
2863
  if (!videoDir) return null;
2759
2864
  try {
@@ -2778,6 +2883,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
2778
2883
  } catch (err) {
2779
2884
  const msg = err instanceof Error ? err.message : String(err);
2780
2885
  skipFill(flow.steps, 0, {}, 'skipped — the build under test never went live');
2886
+ await prepared?.cleanup();
2781
2887
  return {
2782
2888
  name: flow.name,
2783
2889
  status: 'error',
@@ -2801,10 +2907,20 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
2801
2907
  timezone: opts.timezone,
2802
2908
  reducedMotion: opts.reducedMotion,
2803
2909
  ...(videoDir ? { recordVideoDir: videoDir } : {}),
2910
+ ...(prepared ? { extension: { path: prepared.dir } } : {}),
2804
2911
  },
2805
- async (page, context) => {
2912
+ async (page, context, ext) => {
2806
2913
  videoPage = page;
2807
2914
  collectFailures(page);
2915
+ if (ext && flow.extension && prepared) {
2916
+ extCtx = { context, id: ext.id, main: page, popup: null };
2917
+ extensionInfo = {
2918
+ id: ext.id,
2919
+ path: flow.extension.path,
2920
+ host_access: prepared.simulated ? 'simulated' : 'as-built',
2921
+ ...(prepared.simulated ? { note: SIMULATED_ACCESS_NOTE } : {}),
2922
+ };
2923
+ }
2808
2924
  // F074.36 — measure the credential BEFORE driving, so a run that cannot
2809
2925
  // possibly be authenticated says so instead of producing a confident
2810
2926
  // verdict about a login screen. Only when auth was actually requested:
@@ -2902,10 +3018,13 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
2902
3018
  reason = reason ?? (err instanceof Error ? err.message : String(err));
2903
3019
  // Keep the recording even on a crash — watching HOW it broke is the whole point.
2904
3020
  const video = await readVideo();
2905
- return { name: flow.name, status: 'error', steps: results.sort((a, b) => a.index - b.index), failed_at: firstFailedAt, reason: leadWithRedirect(reason, firstRedirect), duration_ms: Date.now() - startedAt, video, build: observedBuild, final_url: finalUrl, redirected: firstRedirect, password_in_url: passwordInUrl(finalUrl, filledPasswords), not_listening: notListening[0] ?? null, reduced_motion: opts.reducedMotion ?? 'reduce' };
3021
+ await prepared?.cleanup();
3022
+ return { ...(extensionInfo ? { extension: extensionInfo } : {}), name: flow.name, status: 'error', steps: results.sort((a, b) => a.index - b.index), failed_at: firstFailedAt, reason: leadWithRedirect(reason, firstRedirect), duration_ms: Date.now() - startedAt, video, build: observedBuild, final_url: finalUrl, redirected: firstRedirect, password_in_url: passwordInUrl(finalUrl, filledPasswords), not_listening: notListening[0] ?? null, reduced_motion: opts.reducedMotion ?? 'reduce' };
2906
3023
  }
2907
3024
 
2908
3025
  const video = await readVideo();
3026
+ // F009.125 — the temporary copy (if any) is ours alone; the original is never touched.
3027
+ await prepared?.cleanup();
2909
3028
  results.sort((a, b) => a.index - b.index);
2910
3029
  const anyFail = results.some((s) => s.status === 'fail');
2911
3030
  const baseReason = reason ?? (aborted ? 'flow aborted on a top-level failure' : null);
@@ -2953,6 +3072,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
2953
3072
  // ASKED for, defaulted the same way the context does, so the report and the
2954
3073
  // context can never disagree about it.
2955
3074
  reduced_motion: opts.reducedMotion ?? 'reduce',
3075
+ ...(extensionInfo ? { extension: extensionInfo } : {}),
2956
3076
  };
2957
3077
  }
2958
3078
 
package/src/mcp.ts CHANGED
@@ -360,10 +360,20 @@ export const TOOLS = [
360
360
  'F255.7 — do not start until the app is serving the build under test. A commit sha waits for THAT build; true waits for any change from the build live at run start. Polls GET /api/health for a `build` field (the fleet contract), from the runner with no-store, requiring 3 consecutive matching reads so a fleet mid-rollout cannot end the wait early. Fails loudly if the app exposes no marker. Use it right after a deploy — otherwise a green run may be verifying the build you just replaced.',
361
361
  },
362
362
  auth: authSchema,
363
+ extension: {
364
+ type: 'object',
365
+ required: ['path'],
366
+ description:
367
+ 'F009.125 — run the flow in a Chromium with this UNPACKED extension loaded (the build dir holding manifest.json; relative = relative to local_path; must be inside the daemon sandbox). Then: {type:"openPopup", path?} opens the popup (default: the manifest action.default_popup) as its own page and keeps the page under test as the extension\'s ACTIVE tab (measured), and any step with in:"popup" runs in the popup. grant_host_access:true loads a TEMPORARY COPY with <all_urls> host access, standing in for the toolbar click (activeTab) a headless run cannot make — without it chrome.scripting on the page fails ("Cannot access contents of the page"). The original build is never written; the report carries extension {id, host_access:"simulated"|"as-built"}. CHROMIUM ONLY.',
368
+ properties: {
369
+ path: { type: 'string' },
370
+ grant_host_access: { type: 'boolean' },
371
+ },
372
+ },
363
373
  steps: {
364
374
  type: 'array',
365
375
  description: 'Ordered steps. Each has a "type"; testid-anchored steps carry a "testid". fillSelector/clickSelector carry a CSS "selector" + optional "frame" (one iframe selector or an outer→inner string[] chain) to reach a cross-origin iframe; fillSelector also carries "value". waitForUrl carries a "url" substring. A "loop" step has data[] + steps[] and substitutes {{field}}/{{__index}}.',
366
- items: { type: 'object', required: ['type'], properties: { type: { type: 'string' }, testid: { type: 'string' }, selector: { type: 'string' }, frame: { type: ['string', 'array'], items: { type: 'string' } }, url: { type: 'string' }, value: { type: 'string' }, text: { type: 'string' }, path: { type: 'string' }, js: { type: 'string' }, label: { type: 'string' }, timeout: { type: 'number' }, sha: { type: 'string', description: 'F255.7 — waitForBuild step: the commit sha to wait for. Omit to wait for any change from the build live when the step starts.' }, at: { type: 'string', description: 'F255.4 — click a POINT inside the element instead of its centre: "50%,28%" (fraction of the box) or "40,120" (css px). For canvas/3D surfaces where the centre hits nothing. Works on click + clickSelector.' }, activate: { type: 'string', enum: ['auto', 'click', 'enter'], description: 'F255.1 — how a button is fired. auto (default) clicks and falls back to focus+Enter only if nothing observable happened; enter forces focus+Enter (Angular/MDC forms); click keeps mouse-only.' } } },
376
+ items: { type: 'object', required: ['type'], properties: { type: { type: 'string' }, testid: { type: 'string' }, selector: { type: 'string' }, frame: { type: ['string', 'array'], items: { type: 'string' } }, url: { type: 'string' }, value: { type: 'string' }, text: { type: 'string' }, path: { type: 'string' }, js: { type: 'string' }, label: { type: 'string' }, timeout: { type: 'number' }, sha: { type: 'string', description: 'F255.7 — waitForBuild step: the commit sha to wait for. Omit to wait for any change from the build live when the step starts.' }, at: { type: 'string', description: 'F255.4 — click a POINT inside the element instead of its centre: "50%,28%" (fraction of the box) or "40,120" (css px). For canvas/3D surfaces where the centre hits nothing. Works on click + clickSelector.' }, activate: { type: 'string', enum: ['auto', 'click', 'enter'], description: 'F255.1 — how a button is fired. auto (default) clicks and falls back to focus+Enter only if nothing observable happened; enter forces focus+Enter (Angular/MDC forms); click keeps mouse-only.' }, in: { type: 'string', enum: ['popup'], description: 'F009.125 — run this step in the extension popup an earlier openPopup opened (needs flow.extension).' } } },
367
377
  },
368
378
  },
369
379
  },
package/src/service.ts CHANGED
@@ -684,6 +684,7 @@ export function flowRunView(row: LensFlowRunRow, port: number) {
684
684
  status: s.status,
685
685
  // F009.122 — which named browser ran the step. Absent = the default one.
686
686
  ...(s.browser ? { browser: s.browser } : {}),
687
+ ...(s.in ? { in: s.in } : {}),
687
688
  testid: s.testid,
688
689
  detail: s.detail,
689
690
  ...(s.fault ? { fault: s.fault } : {}),
@@ -929,6 +930,15 @@ export async function runFlatFlow(deps: LensDeps, input: FlatFlowInput) {
929
930
  export async function lensRunFlow(deps: LensDeps, input: RunFlowInput) {
930
931
  const project = requireProject(input.project);
931
932
  const flow = parseFlow(input.flow);
933
+ // F009.125 — an extension build is loaded into a real browser (and, with
934
+ // grant_host_access, may read every page), so its directory must be inside the
935
+ // daemon sandbox like any other repo path. Relative = relative to local_path.
936
+ if (flow.extension) {
937
+ const raw = flow.extension.path;
938
+ const abs = raw.startsWith('/') ? raw : input.local_path ? `${input.local_path.replace(/\/$/, '')}/${raw}` : null;
939
+ if (!abs) throw new Error('flow.extension.path is relative — pass local_path, or give an absolute path');
940
+ flow.extension = { ...flow.extension, path: resolveSandboxedPath(abs, deps.sandboxRoots) };
941
+ }
932
942
  // F082.6 — auth reaches flows: own auth (relative paths resolved) or inherit
933
943
  // the manifest's. Fixes the blocker where authed routes redirected to login.
934
944
  // F074.36 — and the run now REPORTS which of those two it used, or why neither.