@broberg/lens-daemon 0.1.0 → 0.1.6

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.0",
3
+ "version": "0.1.6",
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.10.2",
19
- "@broberg/lens-svg": "0.1.0"
18
+ "@broberg/lens-svg": "0.1.0",
19
+ "@broberg/lens-engine": "0.10.2"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@happy-dom/global-registrator": "^20.10.6",
@@ -15,7 +15,7 @@
15
15
  import { readdir, stat } from 'node:fs/promises';
16
16
  import { join, relative, sep } from 'node:path';
17
17
  import type { LensDeps } from './service.ts';
18
- import { enqueueArtifact, uploadStats } from './artifact-uploads.ts';
18
+ import { SKIP_DIRS, enqueueArtifact, uploadStats } from './artifact-uploads.ts';
19
19
 
20
20
  export interface SkippedDir {
21
21
  /** The directory as it exists on disk. */
@@ -60,28 +60,14 @@ export interface BackfillResult {
60
60
  */
61
61
  export const DIR_ALIASES: Record<string, string> = {
62
62
  xrt81: 'xrt81-com',
63
- 'broberg-ai': 'broberg-ai-site',
63
+ // Reversed 2026-10-13: cardmem's project is `broberg-ai` again and
64
+ // `broberg-ai-site` no longer exists, so rows written under the old alias
65
+ // are repointed back (4 uploads failed on 2026-09-28 against the dead name).
66
+ 'broberg-ai-site': 'broberg-ai',
64
67
  'torrent-search-api': 'moovyy',
65
68
  };
66
69
 
67
- /**
68
- * Directories that are deliberately NOT uploaded, each with the reason it is held back.
69
- *
70
- * These are held back BY DECISION. Today they also happen to fail the project lookup,
71
- * which means they are currently safe by accident — and an accident is not a policy.
72
- * Naming them here makes the hold-back survive someone later fixing the lookup.
73
- *
74
- * `capture-sessions` is Christian's call, given 2026-08-25 when asked directly whether
75
- * 329 screenshots of him signing in to App Store Connect and Google Play should go to
76
- * the cloud: "Nej." No credentials are visible in them (all 329 verified as PNG), and
77
- * they stay on the machine anyway.
78
- */
79
- export const SKIP_DIRS: Record<string, string> = {
80
- 'capture-sessions': "owner decision 2026-08-25: login recordings stay on the Mac",
81
- _external: 'screenshots of third-party sites — not this fleet\'s project history',
82
- 'lens-statepath-test': 'one-off engine test run, not project history',
83
- 'lens-mint-test': 'one-off engine test run, not project history',
84
- };
70
+ export { SKIP_DIRS } from './artifact-uploads.ts';
85
71
 
86
72
  export type Attribution =
87
73
  | { kind: 'project'; slug: string }
@@ -49,11 +49,13 @@ export const UPLOAD_QUEUE_DDL = `
49
49
  /** Create the queue table if it is not there. Idempotent. */
50
50
  export function ensureUploadQueue(db: Database): void {
51
51
  db.exec(UPLOAD_QUEUE_DDL);
52
- // Additive for daemons whose table predates F276.5.
53
- try {
52
+ // Additive for daemons whose table predates F276.5. Look first rather than
53
+ // swallow the ALTER's error: a catch-all would also hide a locked or
54
+ // read-only database, and the drain would then fail later on a column it
55
+ // believes exists.
56
+ const cols = db.query('PRAGMA table_info(lens_artifact_uploads)').all() as { name: string }[];
57
+ if (!cols.some((c) => c.name === 'claimed_at')) {
54
58
  db.exec('ALTER TABLE lens_artifact_uploads ADD COLUMN claimed_at INTEGER');
55
- } catch {
56
- // column already exists
57
59
  }
58
60
  }
59
61
 
@@ -86,12 +88,37 @@ export const MAX_ATTEMPTS = 5;
86
88
  * must not create 6588 duplicates on the second pass. Expressing it in the table
87
89
  * rather than in a guard means no caller can forget it.
88
90
  */
91
+ /**
92
+ * Directories that are deliberately NOT uploaded, each with the reason it is held back.
93
+ *
94
+ * Applied where a file is QUEUED (enqueueArtifact), not only during a backfill —
95
+ * otherwise a live flow under `_external` would be held back only by the project
96
+ * lookup failing, which is the accident this list exists to replace (F006.4).
97
+ *
98
+ * These are held back BY DECISION. Today they also happen to fail the project lookup,
99
+ * which means they are currently safe by accident — and an accident is not a policy.
100
+ * Naming them here makes the hold-back survive someone later fixing the lookup.
101
+ *
102
+ * `capture-sessions` is Christian's call, given 2026-08-25 when asked directly whether
103
+ * 329 screenshots of him signing in to App Store Connect and Google Play should go to
104
+ * the cloud: "Nej." No credentials are visible in them (all 329 verified as PNG), and
105
+ * they stay on the machine anyway.
106
+ */
107
+ export const SKIP_DIRS: Record<string, string> = {
108
+ 'capture-sessions': "owner decision 2026-08-25: login recordings stay on the Mac",
109
+ _external: 'screenshots of third-party sites — not this fleet\'s project history',
110
+ 'lens-statepath-test': 'one-off engine test run, not project history',
111
+ 'lens-mint-test': 'one-off engine test run, not project history',
112
+ };
113
+
114
+
89
115
  export function enqueueArtifact(db: Database, projectSlug: string, key: string | null): void {
90
116
  if (!key) return;
117
+ const skipReason = SKIP_DIRS[key.split('/')[0] ?? ''];
91
118
  db.prepare(
92
- `INSERT OR IGNORE INTO lens_artifact_uploads (artifact_key, project_slug, status, enqueued_at)
93
- VALUES (?, ?, 'pending', ?)`,
94
- ).run(key, projectSlug, Date.now());
119
+ `INSERT OR IGNORE INTO lens_artifact_uploads (artifact_key, project_slug, status, enqueued_at, last_error)
120
+ VALUES (?, ?, ?, ?, ?)`,
121
+ ).run(key, projectSlug, skipReason ? 'skipped' : 'pending', Date.now(), skipReason ?? null);
95
122
  }
96
123
 
97
124
  /** Queue several at once — the flow path has one per step. */
package/src/auth/index.ts CHANGED
@@ -19,6 +19,7 @@
19
19
  // - cookieJwt : a single named cookie holding a JWT. Covers cms.
20
20
 
21
21
  import { readFileSync } from 'node:fs';
22
+ import { isAbsolute } from 'node:path';
22
23
  import type { BrowserContext } from 'playwright';
23
24
 
24
25
  export type AuthEnv = Record<string, string | undefined>;
@@ -199,6 +200,12 @@ async function applyMintEndpoint(
199
200
  // wins over secretEnv. The secret is NOT in the manifest/call, only its source.
200
201
  let secret: string | undefined;
201
202
  if (cfg.secretPath) {
203
+ // F009.22 — a relative path here resolves against the DAEMON's cwd, i.e.
204
+ // some other repo's secret, which would then be sent to the caller's url.
205
+ // Callers resolve against their project root first (resolveAuthPaths).
206
+ if (!isAbsolute(cfg.secretPath)) {
207
+ throw new Error(`mintEndpoint auth: secretPath must be absolute by the time it is read (got "${cfg.secretPath}") — register the project or pass an absolute path`);
208
+ }
202
209
  try {
203
210
  secret = readFileSync(cfg.secretPath, 'utf-8').trim();
204
211
  } catch (err) {
package/src/flow.ts CHANGED
@@ -29,6 +29,7 @@ import { takeShot, settleForShot } from './capture.ts';
29
29
  import { runCritic, topFindingMessage, type CriticMode, type CriticReport } from './critic.ts';
30
30
  import { waitForBuild, observeBuild } from './build-marker.ts';
31
31
  import { fillAndVerify } from './fill.ts';
32
+ import { fillSecret } from './secret-fill.ts';
32
33
  import { storagePayload, type StorageSeed } from './seed-storage.ts';
33
34
  import { passwordInUrl, type FilledPassword, type PasswordInUrlFinding } from './password-in-url.ts';
34
35
  import {
@@ -44,6 +45,11 @@ import {
44
45
  /** F255.1 — how a click activates a button. See `clickAndActivate`. */
45
46
  export type ActivateMode = 'auto' | 'click' | 'enter';
46
47
 
48
+ /** F009.121 — where a `fill` reads a SECRET from instead of carrying it inline:
49
+ * a gitignored file (absolute, or relative to the run's local_path) or a daemon
50
+ * env var. The value is read at run time and never echoed. */
51
+ export type ValueFrom = { path: string } | { env: string };
52
+
47
53
  export type FlowStep =
48
54
  // F074.51 — `timeout` was absent here while every other verb carried it, so a
49
55
  // goto's 30s was unoverridable. Found by the typecheck of this card's own
@@ -62,7 +68,7 @@ export type FlowStep =
62
68
  // F255.4 — `at` clicks a POINT inside the element ("50%,28%" or "40,120" px)
63
69
  // instead of its centre, for canvas/3D surfaces where the centre hits nothing.
64
70
  | { type: 'click'; testid?: string; target?: Target; label?: string; timeout?: number; activate?: ActivateMode; at?: string }
65
- | { type: 'fill'; testid?: string; target?: Target; value: string; label?: string; timeout?: number }
71
+ | { type: 'fill'; testid?: string; target?: Target; value?: string; valueFrom?: ValueFrom; label?: string; timeout?: number }
66
72
  | { type: 'select'; testid?: string; target?: Target; value: string; label?: string; timeout?: number }
67
73
  | { type: 'check'; testid?: string; target?: Target; label?: string; timeout?: number }
68
74
  | { type: 'uncheck'; testid?: string; target?: Target; label?: string; timeout?: number }
@@ -439,9 +445,48 @@ function isValidTarget(t: unknown): boolean {
439
445
  return keys.length > 0 && keys.every((k) => TARGET_KEYS.includes(k)) && TARGET_LAYERS.some((k) => k in (t as object));
440
446
  }
441
447
 
448
+ /** F009.119 — the fields each step type uses, beyond the shared ones. Mirrors the
449
+ * FlowStep union above; flow-step-fields.test.ts reads the union from source and
450
+ * fails if the two drift. An unknown field is REFUSED: `inspect` with a guessed
451
+ * `selector` used to run a whole-page dump in silence (cardmem, flow b62ffca3). */
452
+ const SHARED_STEP_FIELDS = ['type', 'label', 'timeout', 'timeout_ms'];
453
+ const TARGETED = ['testid', 'target'];
454
+ export const STEP_FIELDS: Record<string, string[]> = {
455
+ goto: ['path'],
456
+ click: [...TARGETED, 'activate', 'at'],
457
+ fill: [...TARGETED, 'value', 'valueFrom'],
458
+ select: [...TARGETED, 'value'],
459
+ check: TARGETED,
460
+ uncheck: TARGETED,
461
+ waitFor: TARGETED,
462
+ expectText: [...TARGETED, 'text', 'match'],
463
+ expectVisible: TARGETED,
464
+ expectEditable: TARGETED,
465
+ expectAbsent: ['testid'],
466
+ expectAnimating: [...TARGETED, 'property'],
467
+ expectStill: [...TARGETED, 'property'],
468
+ waitForReady: [...TARGETED, 'events'],
469
+ expectTopmost: TARGETED,
470
+ fillSelector: ['selector', 'value', 'frame'],
471
+ clickSelector: ['selector', 'frame', 'at'],
472
+ waitForBuild: ['sha'],
473
+ waitForUrl: ['url'],
474
+ inspect: ['frame', 'html', 'testid'],
475
+ assert: ['js'],
476
+ screenshot: [],
477
+ clear: TARGETED,
478
+ press: ['key', 'testid'],
479
+ drag: ['testid', 'toTestid'],
480
+ uploadFile: ['testid', 'path'],
481
+ autocomplete: ['testid', 'value', 'option', 'pickText', 'pickIndex'],
482
+ capture: ['testid', 'as', 'from'],
483
+ conditional: ['testid', 'present', 'steps'],
484
+ loop: ['data', 'steps', 'parallel'],
485
+ };
486
+
442
487
  function validateAndNormalizeSteps(steps: unknown[], depth = 0): void {
443
488
  if (depth > 3) throw new Error('flow loops nested too deep (max 3)');
444
- for (const raw of steps) {
489
+ for (const [index, raw] of steps.entries()) {
445
490
  const s = raw as FlowStep & Record<string, unknown>;
446
491
  if (!s || typeof s !== 'object' || !STEP_TYPES.has(s.type)) {
447
492
  // F074.50 — name the ones that DO exist. The list is rendered from
@@ -493,6 +538,21 @@ function validateAndNormalizeSteps(steps: unknown[], depth = 0): void {
493
538
  if (s.type === 'capture' && typeof (s as { as?: unknown }).as !== 'string') {
494
539
  throw new Error('capture step requires a string "as" name');
495
540
  }
541
+ // F009.121 — a fill carries its value inline OR names where to read a secret
542
+ // from; never both (which one would win is a guess), never neither.
543
+ if (s.type === 'fill') {
544
+ const hasValue = typeof (s as { value?: unknown }).value === 'string';
545
+ const vf = (s as { valueFrom?: unknown }).valueFrom;
546
+ if (hasValue && vf !== undefined) throw new Error('fill step takes value OR valueFrom, not both');
547
+ if (!hasValue && vf === undefined) throw new Error('fill step requires a string value or valueFrom {path} | {env}');
548
+ if (vf !== undefined) {
549
+ const keys = vf && typeof vf === 'object' && !Array.isArray(vf) ? Object.keys(vf) : [];
550
+ const k = keys[0];
551
+ if (keys.length !== 1 || (k !== 'path' && k !== 'env') || typeof (vf as Record<string, unknown>)[k] !== 'string' || !(vf as Record<string, string>)[k]) {
552
+ throw new Error('fill valueFrom must be exactly one of {path: "<file>"} or {env: "<NAME>"}');
553
+ }
554
+ }
555
+ }
496
556
  // F074.23 — single-anchor steps accept a string testid OR a self-healing
497
557
  // `target` (LocateSpec); the rest still require a string testid.
498
558
  const targetable = ['click', 'fill', 'select', 'check', 'uncheck', 'waitFor', 'expectText', 'expectVisible', 'expectEditable', 'expectTopmost', 'clear', 'waitForReady', 'expectAnimating', 'expectStill'];
@@ -564,6 +624,15 @@ function validateAndNormalizeSteps(steps: unknown[], depth = 0): void {
564
624
  ) {
565
625
  throw new Error(`${s.type} step frame must be a string or string[]`);
566
626
  }
627
+ // Checked LAST, so a step missing its required field keeps the message that
628
+ // names that field — the more useful one when both are wrong.
629
+ const allowed = [...SHARED_STEP_FIELDS, ...(STEP_FIELDS[s.type] ?? [])];
630
+ const unknown = Object.keys(s).filter((k) => !allowed.includes(k));
631
+ if (unknown.length > 0) {
632
+ throw new Error(
633
+ `step ${index} (${s.type}${depth > 0 ? `, nested depth ${depth}` : ''}): unknown field ${unknown.map((k) => JSON.stringify(k)).join(', ')} — ${s.type} accepts: ${allowed.filter((k) => k !== 'type').join(', ')}`,
634
+ );
635
+ }
567
636
  }
568
637
  }
569
638
 
@@ -842,6 +911,13 @@ function parseManuscriptStep(s: string): FlowStep {
842
911
  case 'expectabsent': return { type: 'expectAbsent', testid: testid() };
843
912
  case 'expecttopmost': return { type: 'expectTopmost', testid: testid() };
844
913
  case 'fill': {
914
+ // F009.121 — "fill [pw] from file <path>" / "from env <NAME>": the secret
915
+ // is read at run time, so the manuscript (and its label) never holds it.
916
+ const from = s.match(/\]\s+from\s+(file|env)\s+(\S.*)$/i);
917
+ if (from) {
918
+ const src = from[2]!.trim();
919
+ return { type: 'fill', testid: testid(), valueFrom: from[1]!.toLowerCase() === 'file' ? { path: src } : { env: src } };
920
+ }
845
921
  const v = s.split('=').slice(1).join('=').trim();
846
922
  return { type: 'fill', testid: testid(), value: v };
847
923
  }
@@ -1591,7 +1667,7 @@ async function sampleMotion(
1591
1667
  const MOTION_SAMPLE_MS = 250;
1592
1668
 
1593
1669
 
1594
- 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 }> {
1670
+ 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 }> {
1595
1671
  switch (step.type) {
1596
1672
  case 'goto': {
1597
1673
  const p = subst(step.path, vars);
@@ -1629,7 +1705,11 @@ async function execStep(page: Page, step: FlowStep, base: string, vars: Record<s
1629
1705
  // F213.7 — fill proves its own write; a silent no-op or a prepended value
1630
1706
  // used to pass as success, which is the half of the fleet save-rule this
1631
1707
  // instrument could not perform.
1632
- const filledValue = subst(step.value, vars);
1708
+ if (step.valueFrom) {
1709
+ await fillSecret(loc, step.valueFrom, secretRoot, remaining, pwSink, anchorOf(step));
1710
+ return via ? { __via: via } : null;
1711
+ }
1712
+ const filledValue = subst(step.value ?? '', vars);
1633
1713
  await fillAndVerify(loc, filledValue, remaining);
1634
1714
  // F074.99 — remember a PASSWORD so the end of the run can notice it coming
1635
1715
  // back in the URL. Read from the element rather than guessed from the name:
@@ -2061,7 +2141,11 @@ async function execStep(page: Page, step: FlowStep, base: string, vars: Record<s
2061
2141
  )
2062
2142
  .slice(0, 60)
2063
2143
  .map(pick);
2064
- return JSON.stringify({ iframes, inputs, clickables });
2144
+ // F009.119 — name the full-HTML option where the truncation is seen: two
2145
+ // sessions in two days worked around the 400-char cut without knowing
2146
+ // `html:true` existed.
2147
+ const hint = "each element's html is cut at 400 chars; for one element's FULL outerHTML use {type:'inspect', html:true, testid}";
2148
+ return JSON.stringify({ hint, iframes, inputs, clickables });
2065
2149
  }, undefined, { timeout: stepTimeout(step) });
2066
2150
  return { __inspect: String(dump) };
2067
2151
  }
@@ -2123,6 +2207,12 @@ export interface RunFlowOptions {
2123
2207
  /** F213.6 — repo root for resolving an uploadFile step's fixture path. The
2124
2208
  * path is sandboxed to this dir (no `../` escape). Absent → only data: URIs. */
2125
2209
  localPath?: string;
2210
+ /** F009.121 — the repo root a `fill valueFrom {path}` may read from, ALREADY
2211
+ * checked against the daemon sandbox by the caller. Absent → only {env} works.
2212
+ * Kept apart from localPath on purpose: a secret file is typed into whatever
2213
+ * page the caller named, so an unsandboxed root would let a flow post any file
2214
+ * the daemon can read to any URL. */
2215
+ secretRoot?: string;
2126
2216
  /** F255.5 — record the whole driven run to a .webm, returned as FlowResult.video.
2127
2217
  * Off by default: recording costs disk + a little time on every run, and most flows
2128
2218
  * only need the per-step stills. */
@@ -2384,7 +2474,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
2384
2474
  // matter most, and a sink scoped to the try is unreachable from the catch.
2385
2475
  const sink: CriticSink = { critic: null };
2386
2476
  try {
2387
- const out = await execStep(page, step, base, vars, opts.critic ?? 'off', opts.localPath, sink, filledPasswords, notListening);
2477
+ const out = await execStep(page, step, base, vars, opts.critic ?? 'off', opts.localPath, sink, filledPasswords, notListening, opts.secretRoot);
2388
2478
  const obj = out !== null && !Buffer.isBuffer(out) && typeof out === 'object' ? out : null;
2389
2479
  const inspect = obj && '__inspect' in obj ? (obj as { __inspect: string }).__inspect : null;
2390
2480
  const via = obj && '__via' in obj ? (obj as { __via: string }).__via : null;
package/src/gate.ts CHANGED
@@ -11,10 +11,10 @@
11
11
  // selector instead of a testid is a CONVENTION WARNING (migrate it).
12
12
  //
13
13
  // 2. decideVerdict() — fold the catalogue result + the anchor report into a
14
- // single green/red verdict + an action. F073's review loop OWNS the
15
- // execution (auto-merge the PR on green; open a GitHub issue + ping
16
- // Discord on red) since it holds the PR + dispatch context; this module
17
- // produces the decision it acts on.
14
+ // single green/red verdict + an action. This module only DECIDES; nothing
15
+ // here executes it. Today POST /lens/gate returns it as JSON, and cardmem's
16
+ // F095 Full Auto Review is what moves a green card to Done. A notice on a
17
+ // red verdict is not built — that is F009.120.
18
18
 
19
19
  import type { LensManifest } from './manifest.ts';
20
20
 
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]; 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. 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" · "- 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>". 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'],
@@ -0,0 +1,82 @@
1
+ // F009.121 — fill a password field from a file or env var, without the secret
2
+ // ever appearing in the manuscript, the step label, the run report or the stored
3
+ // row. Asked for by scout (F001.4) and broberg-id (F084.97): both prove a real
4
+ // login form with a test account, and the only way before was the plaintext
5
+ // password in the manuscript.
6
+ import { readFile } from 'node:fs/promises';
7
+ import { resolve, sep } from 'node:path';
8
+ import type { Locator } from 'playwright';
9
+ import { fillAndVerify } from './fill.ts';
10
+ import type { FilledPassword } from './password-in-url.ts';
11
+ import type { ValueFrom } from './flow.ts';
12
+
13
+ /** Read the secret. Every error names WHERE it looked and never WHAT it found. */
14
+ export async function readSecret(
15
+ vf: ValueFrom,
16
+ secretRoot: string | undefined,
17
+ env: Record<string, string | undefined> = process.env,
18
+ ): Promise<string> {
19
+ if ('env' in vf) {
20
+ const v = env[vf.env];
21
+ if (!v) throw new Error(`fill valueFrom: env ${vf.env} is not set in the daemon`);
22
+ return v;
23
+ }
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}`);
35
+ }
36
+ let raw: string;
37
+ try {
38
+ raw = await readFile(p, 'utf-8');
39
+ } catch (err) {
40
+ const code = (err as NodeJS.ErrnoException)?.code ?? 'unreadable';
41
+ throw new Error(`fill valueFrom: could not read ${p} (${code})`);
42
+ }
43
+ // One trailing newline is an editor's, not the password's. vault-get.sh writes
44
+ // byte-exact, so this only ever removes something a human added.
45
+ const v = raw.replace(/\r?\n$/, '');
46
+ if (!v) throw new Error(`fill valueFrom: ${p} is empty`);
47
+ return v;
48
+ }
49
+
50
+ /** Type the secret into a PASSWORD field and prove it landed — reporting only
51
+ * the shape of a mismatch, never characters. */
52
+ export async function fillSecret(
53
+ loc: Locator,
54
+ vf: ValueFrom,
55
+ secretRoot: string | undefined,
56
+ timeout: number,
57
+ pwSink: FilledPassword[] | undefined,
58
+ anchor: string,
59
+ env: Record<string, string | undefined> = process.env,
60
+ ): Promise<void> {
61
+ // A password input masks itself in every screenshot. Any other field would
62
+ // print the secret into the next still, so it is refused rather than filled.
63
+ const isPassword = await loc
64
+ .evaluate((el) => (el as HTMLInputElement).type === 'password', undefined, { timeout })
65
+ .catch(() => false);
66
+ if (!isPassword) {
67
+ throw new Error('fill valueFrom only fills a password input (type="password") — any other field would show the secret in every later screenshot');
68
+ }
69
+ const secret = await readSecret(vf, secretRoot, env);
70
+ try {
71
+ await fillAndVerify(loc, secret, timeout);
72
+ } catch {
73
+ // fillAndVerify's message quotes expected and actual values. For a secret
74
+ // that IS the leak, so only the length is reported.
75
+ const got = await loc.inputValue({ timeout: 1000 }).catch(() => null);
76
+ throw new Error(
77
+ `fill did not land: expected ${secret.length} characters, the field holds ${got === null ? 'an unreadable value' : `${got.length}`} (value withheld — it is a secret)`,
78
+ );
79
+ }
80
+ // F074.99 — the password-in-URL check still sees it, in memory only.
81
+ pwSink?.push({ anchor, value: secret });
82
+ }
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,
@@ -308,7 +322,17 @@ export function resolveVerifyAuth(
308
322
  const row = deps.db
309
323
  .query('SELECT local_path FROM registered_projects WHERE slug = ?')
310
324
  .get(project) as { local_path: string } | undefined;
311
- if (!row?.local_path) return auth;
325
+ if (!row?.local_path) {
326
+ // F009.22 — a RELATIVE credential path with no registered project would be
327
+ // resolved against the daemon's own cwd, i.e. read another repo's secret
328
+ // and send it to whatever url the caller named. Refuse instead.
329
+ if (resolveAuthPaths(auth, '/') !== auth) {
330
+ throw new Error(
331
+ `project "${project}" is not registered, so a relative ${auth.adapter === 'storageState' ? 'statePath' : 'secretPath'} cannot be resolved — register the project or pass an absolute path`,
332
+ );
333
+ }
334
+ return auth;
335
+ }
312
336
  return (resolveAuthPaths(auth, row.local_path) as AuthConfig | undefined) ?? auth;
313
337
  }
314
338
 
@@ -916,6 +940,7 @@ export async function lensRunFlow(deps: LensDeps, input: RunFlowInput) {
916
940
  viewport: coerceViewport(input.viewport, input.device),
917
941
  critic: input.critic,
918
942
  localPath: input.local_path, // F213.6 — sandbox root for uploadFile fixture paths
943
+ secretRoot: secretRootOf(input.local_path, deps.sandboxRoots), // F009.121
919
944
  record: input.record === true, // F255.5 — opt-in .webm of the whole run
920
945
  storage: coerceStorageSeed(input.storage), // F074.97 — seed saved client state
921
946
  });
@@ -978,6 +1003,7 @@ export async function lensRunManuscript(deps: LensDeps, input: RunManuscriptInpu
978
1003
  reducedMotion: input.reduced_motion,
979
1004
  viewport: coerceViewport(input.viewport, input.device), // F252.2 — mobile-only UI E2E in manuscripts
980
1005
  localPath: input.local_path, // F213.6 — uploadFile fixture root
1006
+ secretRoot: secretRootOf(input.local_path, deps.sandboxRoots), // F009.121
981
1007
  record: input.record === true, // F255.5 — opt-in .webm of the whole run
982
1008
  storage: coerceStorageSeed(input.storage), // F074.97 — seed saved client state
983
1009
  });