@broberg/lens-daemon 0.1.5 → 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.5",
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",
package/src/flow.ts CHANGED
@@ -1667,7 +1667,7 @@ async function sampleMotion(
1667
1667
  const MOTION_SAMPLE_MS = 250;
1668
1668
 
1669
1669
 
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 }> {
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 }> {
1671
1671
  switch (step.type) {
1672
1672
  case 'goto': {
1673
1673
  const p = subst(step.path, vars);
@@ -1706,7 +1706,7 @@ async function execStep(page: Page, step: FlowStep, base: string, vars: Record<s
1706
1706
  // used to pass as success, which is the half of the fleet save-rule this
1707
1707
  // instrument could not perform.
1708
1708
  if (step.valueFrom) {
1709
- await fillSecret(loc, step.valueFrom, localPath, remaining, pwSink, anchorOf(step));
1709
+ await fillSecret(loc, step.valueFrom, secretRoot, remaining, pwSink, anchorOf(step));
1710
1710
  return via ? { __via: via } : null;
1711
1711
  }
1712
1712
  const filledValue = subst(step.value ?? '', vars);
@@ -2207,6 +2207,12 @@ export interface RunFlowOptions {
2207
2207
  /** F213.6 — repo root for resolving an uploadFile step's fixture path. The
2208
2208
  * path is sandboxed to this dir (no `../` escape). Absent → only data: URIs. */
2209
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;
2210
2216
  /** F255.5 — record the whole driven run to a .webm, returned as FlowResult.video.
2211
2217
  * Off by default: recording costs disk + a little time on every run, and most flows
2212
2218
  * only need the per-step stills. */
@@ -2468,7 +2474,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
2468
2474
  // matter most, and a sink scoped to the try is unreachable from the catch.
2469
2475
  const sink: CriticSink = { critic: null };
2470
2476
  try {
2471
- 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);
2472
2478
  const obj = out !== null && !Buffer.isBuffer(out) && typeof out === 'object' ? out : null;
2473
2479
  const inspect = obj && '__inspect' in obj ? (obj as { __inspect: string }).__inspect : null;
2474
2480
  const via = obj && '__via' in obj ? (obj as { __via: string }).__via : null;
@@ -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
  });