@broberg/lens-daemon 0.1.5 → 0.1.7

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.5",
3
+ "version": "0.1.7",
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,7 +15,7 @@
15
15
  "sharp": "^0.33.5",
16
16
  "turndown": "^7.2.4",
17
17
  "zod": "^3.23.8",
18
- "@broberg/lens-engine": "0.10.2",
18
+ "@broberg/lens-engine": "0.11.0",
19
19
  "@broberg/lens-svg": "0.1.0"
20
20
  },
21
21
  "devDependencies": {
package/src/flow.ts CHANGED
@@ -233,6 +233,8 @@ export interface FlowStepResult {
233
233
  index: number;
234
234
  type: string;
235
235
  label: string;
236
+ /** F009.122 — the named browser this step ran in. Absent = the default one. */
237
+ browser?: string;
236
238
  status: 'pass' | 'fail' | 'skip';
237
239
  testid: string | null;
238
240
  detail: string | null;
@@ -449,7 +451,12 @@ function isValidTarget(t: unknown): boolean {
449
451
  * FlowStep union above; flow-step-fields.test.ts reads the union from source and
450
452
  * fails if the two drift. An unknown field is REFUSED: `inspect` with a guessed
451
453
  * `selector` used to run a whole-page dump in silence (cardmem, flow b62ffca3). */
452
- const SHARED_STEP_FIELDS = ['type', 'label', 'timeout', 'timeout_ms'];
454
+ const SHARED_STEP_FIELDS = ['type', 'label', 'timeout', 'timeout_ms', 'browser'];
455
+
456
+ /** F009.122 — a step may name the browser it runs in. Each name is its own browser
457
+ * context (own cookies + storage), opened on first use and kept open for the rest
458
+ * of the run, so two people can be driven at the same time. `main` is the default. */
459
+ const BROWSER_NAME = /^[a-z][a-z0-9_-]{0,23}$/;
453
460
  const TARGETED = ['testid', 'target'];
454
461
  export const STEP_FIELDS: Record<string, string[]> = {
455
462
  goto: ['path'],
@@ -484,7 +491,7 @@ export const STEP_FIELDS: Record<string, string[]> = {
484
491
  loop: ['data', 'steps', 'parallel'],
485
492
  };
486
493
 
487
- function validateAndNormalizeSteps(steps: unknown[], depth = 0): void {
494
+ function validateAndNormalizeSteps(steps: unknown[], depth = 0, inParallel = false): void {
488
495
  if (depth > 3) throw new Error('flow loops nested too deep (max 3)');
489
496
  for (const [index, raw] of steps.entries()) {
490
497
  const s = raw as FlowStep & Record<string, unknown>;
@@ -518,13 +525,29 @@ function validateAndNormalizeSteps(steps: unknown[], depth = 0): void {
518
525
  if (s.type === 'loop') {
519
526
  if (!Array.isArray((s as { data?: unknown }).data)) throw new Error('loop step requires data[]');
520
527
  if (!Array.isArray((s as { steps?: unknown }).steps)) throw new Error('loop step requires steps[]');
521
- validateAndNormalizeSteps((s as { steps: unknown[] }).steps, depth + 1);
528
+ const par = (s as { parallel?: unknown }).parallel;
529
+ validateAndNormalizeSteps((s as { steps: unknown[] }).steps, depth + 1, inParallel || (typeof par === 'number' && par > 1));
522
530
  }
523
531
  // F213.3 — control-flow + composite verbs.
524
532
  if (s.type === 'conditional') {
525
533
  if (typeof (s as { present?: unknown }).present !== 'boolean') throw new Error('conditional step requires a boolean present');
526
534
  if (!Array.isArray((s as { steps?: unknown }).steps)) throw new Error('conditional step requires steps[]');
527
- validateAndNormalizeSteps((s as { steps: unknown[] }).steps, depth + 1);
535
+ validateAndNormalizeSteps((s as { steps: unknown[] }).steps, depth + 1, inParallel);
536
+ }
537
+ // F009.122 — the browser a step runs in. Refused where it cannot mean anything:
538
+ // a loop/conditional is not run in a browser (its children are), and a parallel
539
+ // loop iteration already owns a context of its own.
540
+ if ('browser' in s) {
541
+ const b = (s as { browser?: unknown }).browser;
542
+ if (typeof b !== 'string' || !BROWSER_NAME.test(b)) {
543
+ throw new Error(`step ${index} (${s.type}): browser must be a lowercase name like "anna" or "b" (${BROWSER_NAME}), got ${JSON.stringify(b)}`);
544
+ }
545
+ if (s.type === 'loop' || s.type === 'conditional') {
546
+ throw new Error(`step ${index} (${s.type}): browser goes on the steps inside a ${s.type}, not on the ${s.type} itself`);
547
+ }
548
+ if (inParallel) {
549
+ throw new Error(`step ${index} (${s.type}): browser is not allowed inside a parallel loop — each iteration already runs in its own browser`);
550
+ }
528
551
  }
529
552
  if (s.type === 'autocomplete') {
530
553
  if (typeof (s as { value?: unknown }).value !== 'string') throw new Error('autocomplete step requires a string value');
@@ -815,7 +838,13 @@ function parseStepBlock(lines: IndentLine[], start: number, minIndent: number):
815
838
  i = nextJ;
816
839
  continue;
817
840
  }
818
- steps.push(parseManuscriptStep(body));
841
+ // F009.122 — `- in <name>: <step>` runs the step in that named browser.
842
+ const inM = body.match(/^in\s+([^\s:]+)\s*:\s*(.+)$/);
843
+ if (inM) {
844
+ steps.push(Object.assign(parseManuscriptStep(inM[2]!), { browser: inM[1]! }));
845
+ } else {
846
+ steps.push(parseManuscriptStep(body));
847
+ }
819
848
  i++;
820
849
  }
821
850
  return [steps, i];
@@ -1667,7 +1696,7 @@ async function sampleMotion(
1667
1696
  const MOTION_SAMPLE_MS = 250;
1668
1697
 
1669
1698
 
1670
- async function execStep(page: Page, step: FlowStep, base: string, vars: Record<string, unknown>, critic: CriticMode, localPath?: string, sink?: CriticSink, pwSink?: FilledPassword[], readySink?: NotListeningFinding[]): Promise<Buffer | null | { __inspect: string } | { __via: string; __build?: string; __redirect?: string }> {
1699
+ async function execStep(page: Page, step: FlowStep, base: string, vars: Record<string, unknown>, critic: CriticMode, localPath?: string, sink?: CriticSink, pwSink?: FilledPassword[], readySink?: NotListeningFinding[], secretRoot?: string): Promise<Buffer | null | { __inspect: string } | { __via: string; __build?: string; __redirect?: string }> {
1671
1700
  switch (step.type) {
1672
1701
  case 'goto': {
1673
1702
  const p = subst(step.path, vars);
@@ -1706,7 +1735,7 @@ async function execStep(page: Page, step: FlowStep, base: string, vars: Record<s
1706
1735
  // used to pass as success, which is the half of the fleet save-rule this
1707
1736
  // instrument could not perform.
1708
1737
  if (step.valueFrom) {
1709
- await fillSecret(loc, step.valueFrom, localPath, remaining, pwSink, anchorOf(step));
1738
+ await fillSecret(loc, step.valueFrom, secretRoot, remaining, pwSink, anchorOf(step));
1710
1739
  return via ? { __via: via } : null;
1711
1740
  }
1712
1741
  const filledValue = subst(step.value ?? '', vars);
@@ -2207,6 +2236,12 @@ export interface RunFlowOptions {
2207
2236
  /** F213.6 — repo root for resolving an uploadFile step's fixture path. The
2208
2237
  * path is sandboxed to this dir (no `../` escape). Absent → only data: URIs. */
2209
2238
  localPath?: string;
2239
+ /** F009.121 — the repo root a `fill valueFrom {path}` may read from, ALREADY
2240
+ * checked against the daemon sandbox by the caller. Absent → only {env} works.
2241
+ * Kept apart from localPath on purpose: a secret file is typed into whatever
2242
+ * page the caller named, so an unsandboxed root would let a flow post any file
2243
+ * the daemon can read to any URL. */
2244
+ secretRoot?: string;
2210
2245
  /** F255.5 — record the whole driven run to a .webm, returned as FlowResult.video.
2211
2246
  * Off by default: recording costs disk + a little time on every run, and most flows
2212
2247
  * only need the per-step stills. */
@@ -2373,12 +2408,60 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
2373
2408
  skipFill(s.steps, idx, vars, detail);
2374
2409
  idx += flattenCount(s.steps);
2375
2410
  } else {
2376
- record({ index: idx, type: s.type, label: s.label ?? describe(s), status: 'skip', testid: testidOf(s), detail, screenshot: null });
2411
+ const b = (s as { browser?: string }).browser;
2412
+ record({ index: idx, type: s.type, label: s.label ?? describe(s), ...(b ? { browser: b } : {}), status: 'skip', testid: testidOf(s), detail, screenshot: null });
2377
2413
  idx += 1;
2378
2414
  }
2379
2415
  }
2380
2416
  };
2381
2417
 
2418
+ // ── F009.122 · named browsers ────────────────────────────────────────────
2419
+ // Each name gets its own context through the SAME withPage the default browser
2420
+ // uses (same auth, storage seed, viewport, engine), held open until the run ends.
2421
+ // The steps stay sequential; the browsers are concurrent — that is what lets
2422
+ // «A types, then B sees it» be measured with B's own timeout.
2423
+ const browsers = new Map<string, { page: Promise<Page>; release: () => void; closed: Promise<unknown> }>();
2424
+ const pageFor = (name: string, main: Page): Promise<Page> => {
2425
+ if (name === 'main') return Promise.resolve(main);
2426
+ const open = browsers.get(name);
2427
+ if (open) return open.page;
2428
+ let release!: () => void;
2429
+ const held = new Promise<void>((r) => (release = r));
2430
+ let resolvePage!: (p: Page) => void;
2431
+ let rejectPage!: (e: unknown) => void;
2432
+ const pagePromise = new Promise<Page>((res, rej) => {
2433
+ resolvePage = res;
2434
+ rejectPage = rej;
2435
+ });
2436
+ const closed = withPage(
2437
+ { prepareContext: prepare, seedStorage, viewport: opts.viewport, engine: opts.engine, locale: opts.locale, timezone: opts.timezone, reducedMotion: opts.reducedMotion },
2438
+ async (p) => {
2439
+ collectFailures(p);
2440
+ resolvePage(p);
2441
+ await held;
2442
+ },
2443
+ ).catch((err) => rejectPage(err));
2444
+ browsers.set(name, { page: pagePromise, release, closed });
2445
+ return pagePromise;
2446
+ };
2447
+ const closeBrowsers = async () => {
2448
+ for (const b of browsers.values()) b.release();
2449
+ await Promise.all([...browsers.values()].map((b) => b.closed));
2450
+ browsers.clear();
2451
+ };
2452
+ // Where each browser's page landed is that browser's own fact. Swapped on every
2453
+ // change of browser, so a goto in B can neither move A's ejection baseline nor
2454
+ // reset A's navigation clock.
2455
+ let currentBrowser = 'main';
2456
+ const navStates = new Map<string, { urlAtLoad: string | null; navActionPending: boolean; onWrongPage: string | null; lastNavAt: number }>();
2457
+ const switchBrowser = (name: string) => {
2458
+ if (name === currentBrowser) return;
2459
+ navStates.set(currentBrowser, { urlAtLoad, navActionPending, onWrongPage, lastNavAt });
2460
+ const next = navStates.get(name) ?? { urlAtLoad: null, navActionPending: false, onWrongPage: null, lastNavAt: Date.now() };
2461
+ ({ urlAtLoad, navActionPending, onWrongPage, lastNavAt } = next);
2462
+ currentBrowser = name;
2463
+ };
2464
+
2382
2465
  // Run a step-list on `page` starting at `startIndex`. Returns whether a
2383
2466
  // non-loop step failed (so the caller can stop THIS sequence).
2384
2467
  async function runSeq(page: Page, steps: FlowStep[], vars: Record<string, unknown>, startIndex: number): Promise<{ failedHere: boolean }> {
@@ -2453,6 +2536,11 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
2453
2536
  const i = idx;
2454
2537
  idx += 1;
2455
2538
  const label = step.label ?? describe(step);
2539
+ // F009.122 — which browser this step runs in. `sp` is the page every line
2540
+ // below acts on; the loop's own `page` is the default browser.
2541
+ const browserName = (step as { browser?: string }).browser ?? 'main';
2542
+ const onBrowser = browserName !== 'main' ? { browser: browserName } : {};
2543
+ let sp: Page = page;
2456
2544
  if (i < from) {
2457
2545
  record({ index: i, type: step.type, label, status: 'skip', testid: testidOf(step), detail: 'resumed past', screenshot: null });
2458
2546
  continue;
@@ -2468,7 +2556,9 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
2468
2556
  // matter most, and a sink scoped to the try is unreachable from the catch.
2469
2557
  const sink: CriticSink = { critic: null };
2470
2558
  try {
2471
- const out = await execStep(page, step, base, vars, opts.critic ?? 'off', opts.localPath, sink, filledPasswords, notListening);
2559
+ switchBrowser(browserName);
2560
+ sp = await pageFor(browserName, page);
2561
+ const out = await execStep(sp, step, base, vars, opts.critic ?? 'off', opts.localPath, sink, filledPasswords, notListening, opts.secretRoot);
2472
2562
  const obj = out !== null && !Buffer.isBuffer(out) && typeof out === 'object' ? out : null;
2473
2563
  const inspect = obj && '__inspect' in obj ? (obj as { __inspect: string }).__inspect : null;
2474
2564
  const via = obj && '__via' in obj ? (obj as { __via: string }).__via : null;
@@ -2490,7 +2580,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
2490
2580
  // would call that an ejection on every single run.
2491
2581
  if (formsAVerdict(step.type)) formedAVerdict = true;
2492
2582
  if (NAVIGATES_BY_DESIGN.has(step.type)) {
2493
- urlAtLoad = safeUrl(page) ?? urlAtLoad;
2583
+ urlAtLoad = safeUrl(sp) ?? urlAtLoad;
2494
2584
  // A goto is already settled — Playwright waited for it. Any OTHER
2495
2585
  // navigating action may commit after it returns, so it leaves a note
2496
2586
  // saying "one URL change after this is mine". See classifyUrlChange.
@@ -2500,7 +2590,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
2500
2590
  // under it, that opinion is void — and `page.url()` is a synchronous
2501
2591
  // read of state Playwright already holds, so this costs nothing and
2502
2592
  // adds no polling (the tax AC4 forbids).
2503
- const verdict = classifyUrlChange({ loaded: urlAtLoad, current: safeUrl(page) ?? urlAtLoad, navActionPending });
2593
+ const verdict = classifyUrlChange({ loaded: urlAtLoad, current: safeUrl(sp) ?? urlAtLoad, navActionPending });
2504
2594
  if (verdict.kind === 'flow-navigation') {
2505
2595
  urlAtLoad = verdict.rebaseTo;
2506
2596
  navActionPending = false;
@@ -2519,6 +2609,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
2519
2609
  index: i,
2520
2610
  type: step.type,
2521
2611
  label,
2612
+ ...onBrowser,
2522
2613
  status: 'pass',
2523
2614
  ...navOffset,
2524
2615
  ms_step_duration: Date.now() - stepStartedAt,
@@ -2541,7 +2632,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
2541
2632
  const msg = err instanceof Error ? err.message : String(err);
2542
2633
  let shot: Buffer | null = null;
2543
2634
  try {
2544
- shot = (await takeShot(page, 'viewport', null, 5000)).screenshot;
2635
+ shot = (await takeShot(sp, 'viewport', null, 5000)).screenshot;
2545
2636
  } catch {
2546
2637
  // page may be in a bad state — a missing failure shot is acceptable
2547
2638
  }
@@ -2575,6 +2666,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
2575
2666
  index: i,
2576
2667
  type: step.type,
2577
2668
  label,
2669
+ ...onBrowser,
2578
2670
  // Still 'fail' — a broken probe is never less than a failure.
2579
2671
  status: 'fail',
2580
2672
  // F213.11 — on the FAILING step above all: a lost write is exactly where
@@ -2682,7 +2774,15 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
2682
2774
  // cookie read failed — leave authReach null rather than invent a verdict
2683
2775
  }
2684
2776
  }
2685
- const r = await runSeq(page, flow.steps, {}, 0);
2777
+ let r: { failedHere: boolean };
2778
+ try {
2779
+ r = await runSeq(page, flow.steps, {}, 0);
2780
+ } finally {
2781
+ // F009.122 — the named browsers close with the run, before the default one,
2782
+ // and the end-of-run checks below are about the DEFAULT browser's page.
2783
+ await closeBrowsers();
2784
+ switchBrowser('main');
2785
+ }
2686
2786
  if (r.failedHere) aborted = true; // a TOP-LEVEL step failed → flow aborts
2687
2787
  // ── F213.10 · the window, and why it has to exist at all ──────────────
2688
2788
  //
package/src/mcp.ts CHANGED
@@ -321,7 +321,7 @@ export const TOOLS = [
321
321
  // A hand-written list in a tool description is the only thing most callers
322
322
  // ever read, and ours had drifted to 9 of 26: three repos reported an
323
323
  // existing verb as missing because of it.
324
- `Run an E2E manuscript flow: drive a site step-by-step in one authed page. Steps (${[...STEP_TYPES].length}): ${[...STEP_TYPES].join(' / ')}. testid-anchored steps use [data-testid]; a fill can take valueFrom:{path}|{env} instead of value to type a password without it ever appearing in the run (F009.121, password inputs only); fillSelector/clickSelector use a raw CSS selector + optional cross-origin frame chain to reach a 3rd-party iframe a testid can\'t (e.g. Stripe PCI card fields), and waitForUrl asserts the redirect back to our origin. Stops + pins a screenshot on the first failing step. Returns a per-step report with screenshot_url per step. This is full End-to-End — it can create users, submit forms, complete a Stripe test payment, etc. (set mutates:true for write flows). Scoped to the project slug.`,
324
+ `Run an E2E manuscript flow: drive a site step-by-step in one authed page — or several: any step may carry browser:"<name>" to run in its own browser (own cookies + storage), opened on first use and kept open beside the default one, so two users can be driven at once (F009.122). Steps (${[...STEP_TYPES].length}): ${[...STEP_TYPES].join(' / ')}. testid-anchored steps use [data-testid]; a fill can take valueFrom:{path}|{env} instead of value to type a password without it ever appearing in the run (F009.121, password inputs only); fillSelector/clickSelector use a raw CSS selector + optional cross-origin frame chain to reach a 3rd-party iframe a testid can\'t (e.g. Stripe PCI card fields), and waitForUrl asserts the redirect back to our origin. Stops + pins a screenshot on the first failing step. Returns a per-step report with screenshot_url per step. This is full End-to-End — it can create users, submit forms, complete a Stripe test payment, etc. (set mutates:true for write flows). Scoped to the project slug.`,
325
325
  inputSchema: {
326
326
  type: 'object',
327
327
  required: ['project', 'flow'],
@@ -373,7 +373,7 @@ export const TOOLS = [
373
373
  {
374
374
  name: 'lens_run_manuscript',
375
375
  description:
376
- `Compile a human-readable markdown manuscript (fmc-automation style: "# flow: name", "base: <url>", then one "- <verb> …" per line) into a flow and run it E2E. VERBS (${MANUSCRIPT_VERBS.length}, this is the complete list): ${MANUSCRIPT_VERBS.join(' / ')}. Shapes: "- goto /x" · "- fill [testid] = value" · a PASSWORD without plaintext: "- fill [testid] from file <path>" (absolute, or relative to local_path) or "- fill [testid] from env <NAME>" (F009.121 — password inputs only; the value never appears in the run, the report or a screenshot) · "- click [testid]" · "- press Enter" (global) or "- press Enter [testid]" (on a focused element) · "- expectText [testid] ~ text" · "- drag [source] -> [target]" · "- upload [input-testid] = <fixture>" · "- assert: <js>" · "- screenshot label" · frame-aware "- fillSelector <css> = <value> @frame <iframe>" and "- clickSelector <css> @frame <iframe>" · "- waitForUrl <substring>". Click modifiers: "- click [testid] via enter" (F255.1, for Angular/MDC buttons a mouse click does not activate) and "- click [testid] @ 50%,28%" (F255.4, aim at a point inside a canvas/3D surface). A field that already HOLDS a value may no longer render an input — a combobox typically swaps to its selected state, so "fill" times out there while "press ArrowDown" / "press Enter" still drive it (fd-sundhed, 2026-08-21). The authoring shortcut — write prose, not JSON.`,
376
+ `Compile a human-readable markdown manuscript (fmc-automation style: "# flow: name", "base: <url>", then one "- <verb> …" per line) into a flow and run it E2E. VERBS (${MANUSCRIPT_VERBS.length}, this is the complete list): ${MANUSCRIPT_VERBS.join(' / ')}. Shapes: "- goto /x" · "- fill [testid] = value" · a PASSWORD without plaintext: "- fill [testid] from file <path>" (absolute, or relative to local_path) or "- fill [testid] from env <NAME>" (F009.121 — password inputs only; the value never appears in the run, the report or a screenshot) · "- click [testid]" · "- press Enter" (global) or "- press Enter [testid]" (on a focused element) · "- expectText [testid] ~ text" · "- drag [source] -> [target]" · "- upload [input-testid] = <fixture>" · "- assert: <js>" · "- screenshot label" · frame-aware "- fillSelector <css> = <value> @frame <iframe>" and "- clickSelector <css> @frame <iframe>" · "- waitForUrl <substring>". TWO BROWSERS AT ONCE (F009.122): prefix a step with "in <name>:" — "- in b: goto /" · "- in b: expectText [note] == Hej" — and it runs in its own browser (own cookies + storage), open alongside the default one for the rest of the run; steps without the prefix run in the default browser. Click modifiers: "- click [testid] via enter" (F255.1, for Angular/MDC buttons a mouse click does not activate) and "- click [testid] @ 50%,28%" (F255.4, aim at a point inside a canvas/3D surface). A field that already HOLDS a value may no longer render an input — a combobox typically swaps to its selected state, so "fill" times out there while "press ArrowDown" / "press Enter" still drive it (fd-sundhed, 2026-08-21). The authoring shortcut — write prose, not JSON.`,
377
377
  inputSchema: {
378
378
  type: 'object',
379
379
  required: ['project', 'manuscript'],
@@ -4,7 +4,7 @@
4
4
  // login form with a test account, and the only way before was the plaintext
5
5
  // password in the manuscript.
6
6
  import { readFile } from 'node:fs/promises';
7
- import { isAbsolute, join } from 'node:path';
7
+ import { resolve, sep } from 'node:path';
8
8
  import type { Locator } from 'playwright';
9
9
  import { fillAndVerify } from './fill.ts';
10
10
  import type { FilledPassword } from './password-in-url.ts';
@@ -13,7 +13,7 @@ import type { ValueFrom } from './flow.ts';
13
13
  /** Read the secret. Every error names WHERE it looked and never WHAT it found. */
14
14
  export async function readSecret(
15
15
  vf: ValueFrom,
16
- localPath: string | undefined,
16
+ secretRoot: string | undefined,
17
17
  env: Record<string, string | undefined> = process.env,
18
18
  ): Promise<string> {
19
19
  if ('env' in vf) {
@@ -21,15 +21,17 @@ export async function readSecret(
21
21
  if (!v) throw new Error(`fill valueFrom: env ${vf.env} is not set in the daemon`);
22
22
  return v;
23
23
  }
24
- let p = vf.path;
25
- if (!isAbsolute(p)) {
26
- // The F009.22 lesson: a relative path resolved against the daemon's own cwd
27
- // reads some other repo's file. Relative means "relative to local_path" or
28
- // nothing.
29
- if (!localPath) {
30
- throw new Error(`fill valueFrom: relative path "${p}" needs local_path — it is never resolved against the daemon's own directory`);
31
- }
32
- p = join(localPath, p);
24
+ // The file is typed into whatever page the caller named, so it must live
25
+ // inside a repo the daemon sandbox allows (secretRoot is checked by the
26
+ // caller) — never anywhere else the daemon user can read. Same containment
27
+ // rule as an uploadFile fixture.
28
+ if (!secretRoot) {
29
+ throw new Error('fill valueFrom {path} needs local_path inside the daemon sandbox — the secret file must live in that repo (use {env} otherwise)');
30
+ }
31
+ const root = resolve(secretRoot);
32
+ const p = resolve(root, vf.path);
33
+ if (p !== root && !p.startsWith(root + sep)) {
34
+ throw new Error(`fill valueFrom: path escapes the repo root: ${vf.path}`);
33
35
  }
34
36
  let raw: string;
35
37
  try {
@@ -50,7 +52,7 @@ export async function readSecret(
50
52
  export async function fillSecret(
51
53
  loc: Locator,
52
54
  vf: ValueFrom,
53
- localPath: string | undefined,
55
+ secretRoot: string | undefined,
54
56
  timeout: number,
55
57
  pwSink: FilledPassword[] | undefined,
56
58
  anchor: string,
@@ -64,7 +66,7 @@ export async function fillSecret(
64
66
  if (!isPassword) {
65
67
  throw new Error('fill valueFrom only fills a password input (type="password") — any other field would show the secret in every later screenshot');
66
68
  }
67
- const secret = await readSecret(vf, localPath, env);
69
+ const secret = await readSecret(vf, secretRoot, env);
68
70
  try {
69
71
  await fillAndVerify(loc, secret, timeout);
70
72
  } catch {
package/src/service.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  // dashboard + cc point <img>/links at the daemon, the same way the rest of
9
9
  // the SPA already talks to 127.0.0.1:7475.
10
10
 
11
+ import { resolveSandboxedPath } from './sandbox.ts';
11
12
  import { randomUUID } from 'node:crypto';
12
13
  import sharp from 'sharp';
13
14
  import { durabilityOf } from './durability.ts';
@@ -299,6 +300,19 @@ function projectLocale(deps: LensDeps, project: string): { locale?: string; time
299
300
  return row?.local_path ? manifestLocale(row.local_path, deps.sandboxRoots) : null;
300
301
  }
301
302
 
303
+ /** F009.121 — the root a `fill valueFrom {path}` may read under: the caller's
304
+ * local_path, but only once the daemon sandbox has accepted it. Outside the
305
+ * sandbox (or absent) there is no root, so only {env} works — a secret file is
306
+ * typed into a page the caller chose, and must not be any file the daemon can read. */
307
+ export function secretRootOf(localPath: string | undefined, sandboxRoots: string[]): string | undefined {
308
+ if (!localPath) return undefined;
309
+ try {
310
+ return resolveSandboxedPath(localPath, sandboxRoots);
311
+ } catch {
312
+ return undefined; // refused by the sandbox; fill reports "needs local_path inside the daemon sandbox"
313
+ }
314
+ }
315
+
302
316
  export function resolveVerifyAuth(
303
317
  deps: LensDeps,
304
318
  project: string,
@@ -926,6 +940,7 @@ export async function lensRunFlow(deps: LensDeps, input: RunFlowInput) {
926
940
  viewport: coerceViewport(input.viewport, input.device),
927
941
  critic: input.critic,
928
942
  localPath: input.local_path, // F213.6 — sandbox root for uploadFile fixture paths
943
+ secretRoot: secretRootOf(input.local_path, deps.sandboxRoots), // F009.121
929
944
  record: input.record === true, // F255.5 — opt-in .webm of the whole run
930
945
  storage: coerceStorageSeed(input.storage), // F074.97 — seed saved client state
931
946
  });
@@ -988,6 +1003,7 @@ export async function lensRunManuscript(deps: LensDeps, input: RunManuscriptInpu
988
1003
  reducedMotion: input.reduced_motion,
989
1004
  viewport: coerceViewport(input.viewport, input.device), // F252.2 — mobile-only UI E2E in manuscripts
990
1005
  localPath: input.local_path, // F213.6 — uploadFile fixture root
1006
+ secretRoot: secretRootOf(input.local_path, deps.sandboxRoots), // F009.121
991
1007
  record: input.record === true, // F255.5 — opt-in .webm of the whole run
992
1008
  storage: coerceStorageSeed(input.storage), // F074.97 — seed saved client state
993
1009
  });