@broberg/lens-daemon 0.1.0 → 0.1.5

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.5",
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
  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
  }
@@ -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, localPath, 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
  }
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,80 @@
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 { isAbsolute, join } 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
+ localPath: 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
+ 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);
33
+ }
34
+ let raw: string;
35
+ try {
36
+ raw = await readFile(p, 'utf-8');
37
+ } catch (err) {
38
+ const code = (err as NodeJS.ErrnoException)?.code ?? 'unreadable';
39
+ throw new Error(`fill valueFrom: could not read ${p} (${code})`);
40
+ }
41
+ // One trailing newline is an editor's, not the password's. vault-get.sh writes
42
+ // byte-exact, so this only ever removes something a human added.
43
+ const v = raw.replace(/\r?\n$/, '');
44
+ if (!v) throw new Error(`fill valueFrom: ${p} is empty`);
45
+ return v;
46
+ }
47
+
48
+ /** Type the secret into a PASSWORD field and prove it landed — reporting only
49
+ * the shape of a mismatch, never characters. */
50
+ export async function fillSecret(
51
+ loc: Locator,
52
+ vf: ValueFrom,
53
+ localPath: string | undefined,
54
+ timeout: number,
55
+ pwSink: FilledPassword[] | undefined,
56
+ anchor: string,
57
+ env: Record<string, string | undefined> = process.env,
58
+ ): Promise<void> {
59
+ // A password input masks itself in every screenshot. Any other field would
60
+ // print the secret into the next still, so it is refused rather than filled.
61
+ const isPassword = await loc
62
+ .evaluate((el) => (el as HTMLInputElement).type === 'password', undefined, { timeout })
63
+ .catch(() => false);
64
+ if (!isPassword) {
65
+ throw new Error('fill valueFrom only fills a password input (type="password") — any other field would show the secret in every later screenshot');
66
+ }
67
+ const secret = await readSecret(vf, localPath, env);
68
+ try {
69
+ await fillAndVerify(loc, secret, timeout);
70
+ } catch {
71
+ // fillAndVerify's message quotes expected and actual values. For a secret
72
+ // that IS the leak, so only the length is reported.
73
+ const got = await loc.inputValue({ timeout: 1000 }).catch(() => null);
74
+ throw new Error(
75
+ `fill did not land: expected ${secret.length} characters, the field holds ${got === null ? 'an unreadable value' : `${got.length}`} (value withheld — it is a secret)`,
76
+ );
77
+ }
78
+ // F074.99 — the password-in-URL check still sees it, in memory only.
79
+ pwSink?.push({ anchor, value: secret });
80
+ }
package/src/service.ts CHANGED
@@ -308,7 +308,17 @@ export function resolveVerifyAuth(
308
308
  const row = deps.db
309
309
  .query('SELECT local_path FROM registered_projects WHERE slug = ?')
310
310
  .get(project) as { local_path: string } | undefined;
311
- if (!row?.local_path) return auth;
311
+ if (!row?.local_path) {
312
+ // F009.22 — a RELATIVE credential path with no registered project would be
313
+ // resolved against the daemon's own cwd, i.e. read another repo's secret
314
+ // and send it to whatever url the caller named. Refuse instead.
315
+ if (resolveAuthPaths(auth, '/') !== auth) {
316
+ throw new Error(
317
+ `project "${project}" is not registered, so a relative ${auth.adapter === 'storageState' ? 'statePath' : 'secretPath'} cannot be resolved — register the project or pass an absolute path`,
318
+ );
319
+ }
320
+ return auth;
321
+ }
312
322
  return (resolveAuthPaths(auth, row.local_path) as AuthConfig | undefined) ?? auth;
313
323
  }
314
324