@broberg/lens-daemon 0.1.36 → 0.1.38

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.36",
3
+ "version": "0.1.38",
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.35.5",
16
16
  "turndown": "^7.2.4",
17
17
  "zod": "^3.23.8",
18
- "@broberg/lens-engine": "0.14.1",
19
- "@broberg/lens-svg": "0.1.1"
18
+ "@broberg/lens-svg": "0.1.1",
19
+ "@broberg/lens-engine": "0.14.1"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@happy-dom/global-registrator": "^20.10.6",
package/src/browser.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  // context + page are always closed in a finally, even on capture error.
12
12
 
13
13
  import { bindProfileChannel, claimProfile, killOwnBrowsersOn, profileDir, type ProfileChannel } from './profiles.ts';
14
+ import { launchWithRepair, type BrowserRepair } from './ensure-chromium.ts';
14
15
  import {
15
16
  chromium,
16
17
  webkit,
@@ -80,7 +81,15 @@ const CHROMIUM_ARGS = [
80
81
 
81
82
  async function launch(engine: LensEngine): Promise<Browser> {
82
83
  const args = engine === 'chromium' ? CHROMIUM_ARGS : [];
83
- return ENGINES[engine].launch({ headless: true, args });
84
+ // F010.1 — a deleted browser is installed and the launch retried, not a fleet-wide outage.
85
+ return launchWithRepair(engine, () => ENGINES[engine].launch({ headless: true, args }), (r) => lastRepairs.push(r));
86
+ }
87
+
88
+ /** F010.1 — repairs made since the last read; a run reads (and clears) them into
89
+ * its report. Read-and-clear, so one repair is reported once. */
90
+ const lastRepairs: BrowserRepair[] = [];
91
+ export function takeBrowserRepairs(): BrowserRepair[] {
92
+ return lastRepairs.splice(0, lastRepairs.length);
84
93
  }
85
94
 
86
95
  /** Get the warm browser for an engine, launching it on first use. Re-launches
@@ -353,7 +362,7 @@ async function launchWithExtension(
353
362
  if (profile) bindProfileChannel(profile, EXTENSION_CHANNEL);
354
363
  userDataDir = profile ? profileDir(profile) : await mkdtemp(join(tmpdir(), 'lens-ext-'));
355
364
  try {
356
- context = await chromium.launchPersistentContext(userDataDir, {
365
+ context = await launchWithRepair('chromium', () => chromium.launchPersistentContext(userDataDir, {
357
366
  ...contextOptions(opts),
358
367
  headless: true,
359
368
  channel: EXTENSION_CHANNEL,
@@ -364,7 +373,7 @@ async function launchWithExtension(
364
373
  '--disable-dev-shm-usage',
365
374
  '--no-sandbox',
366
375
  ],
367
- });
376
+ }), (r) => lastRepairs.push(r));
368
377
  } catch (err) {
369
378
  lastLaunchError = err instanceof Error ? err.message.split('\n')[0]! : String(err);
370
379
  if (!profile) await rm(userDataDir, { recursive: true, force: true }).catch(() => {});
@@ -14,7 +14,7 @@ import { spawn } from 'node:child_process';
14
14
  import { existsSync } from 'node:fs';
15
15
  import { createRequire } from 'node:module';
16
16
  import { dirname, join } from 'node:path';
17
- import { chromium } from 'playwright';
17
+ import { chromium, firefox, webkit } from 'playwright';
18
18
 
19
19
  export interface ChromiumExpectation {
20
20
  installed: boolean;
@@ -116,3 +116,115 @@ export function ensureChromium(): Promise<{
116
116
 
117
117
  return inFlight;
118
118
  }
119
+
120
+ // ── F010.1 — repair at the moment of need, for every engine ────────────────────
121
+ //
122
+ // ensureChromium() runs once at daemon boot and installs the FULL Chromium only.
123
+ // On 25 Oct the browsers were deleted while the daemon was running, and the warm
124
+ // headless launch uses chromium-headless-shell, which boot never checked: every
125
+ // run fleet-wide failed until a hand reinstalled. Now a launch that fails because
126
+ // its binary is missing installs that engine's browsers (from the daemon's own
127
+ // pinned Playwright, same as boot) and is retried once.
128
+
129
+ export type BrowserEngine = 'chromium' | 'webkit' | 'firefox';
130
+
131
+ /** What `playwright install` must fetch for an engine to launch in every mode Lens uses. */
132
+ const INSTALL_NAMES: Record<BrowserEngine, string[]> = {
133
+ chromium: ['chromium', 'chromium-headless-shell', 'ffmpeg'],
134
+ webkit: ['webkit'],
135
+ firefox: ['firefox'],
136
+ };
137
+
138
+ export function isMissingBrowserError(err: unknown): boolean {
139
+ return /Executable doesn't exist/.test(err instanceof Error ? err.message : String(err));
140
+ }
141
+
142
+ type Installer = (names: string[]) => Promise<{ ok: boolean; error?: string }>;
143
+
144
+ const spawnInstaller: Installer = (names) =>
145
+ new Promise((resolve) => {
146
+ let cliPath: string;
147
+ try {
148
+ cliPath = resolvePlaywrightCli();
149
+ } catch (err) {
150
+ resolve({ ok: false, error: `cannot resolve playwright CLI: ${err instanceof Error ? err.message : String(err)}` });
151
+ return;
152
+ }
153
+ console.log(`[lens] browser missing — installing ${names.join(', ')} via ${cliPath} …`);
154
+ const child = spawn(process.execPath, [cliPath, 'install', ...names], { stdio: 'inherit', env: process.env });
155
+ child.on('error', (err) => resolve({ ok: false, error: err.message }));
156
+ child.on('exit', (code) => resolve(code === 0 ? { ok: true } : { ok: false, error: `playwright install exited ${code}` }));
157
+ });
158
+
159
+ let installer: Installer = spawnInstaller;
160
+ /** Test seam: replace the installer (null restores the real one). */
161
+ export function setBrowserInstaller(fn: Installer | null): void {
162
+ installer = fn ?? spawnInstaller;
163
+ }
164
+
165
+ // One install per engine at a time: two runs that hit the gap together share it.
166
+ const engineInFlight = new Map<BrowserEngine, Promise<{ ok: boolean; error?: string }>>();
167
+
168
+ export function installBrowsers(engine: BrowserEngine): Promise<{ ok: boolean; error?: string }> {
169
+ let p = engineInFlight.get(engine);
170
+ if (!p) {
171
+ p = installer(INSTALL_NAMES[engine]).finally(() => engineInFlight.delete(engine));
172
+ engineInFlight.set(engine, p);
173
+ }
174
+ return p;
175
+ }
176
+
177
+ export interface BrowserRepair {
178
+ engine: BrowserEngine;
179
+ ms: number;
180
+ }
181
+
182
+ /** Run a launch; if it fails ONLY because the browser binary is missing, install
183
+ * that engine's browsers and retry once. Any other error propagates untouched. */
184
+ export async function launchWithRepair<T>(
185
+ engine: BrowserEngine,
186
+ launch: () => Promise<T>,
187
+ onRepaired?: (r: BrowserRepair) => void,
188
+ ): Promise<T> {
189
+ try {
190
+ return await launch();
191
+ } catch (err) {
192
+ if (!isMissingBrowserError(err)) throw err;
193
+ const t0 = Date.now();
194
+ const inst = await installBrowsers(engine);
195
+ if (!inst.ok) {
196
+ throw new Error(`${engine} browser is missing and could not be installed: ${inst.error ?? 'unknown'} — original: ${(err as Error).message.split('\n')[0]}`);
197
+ }
198
+ const out = await launch();
199
+ onRepaired?.({ engine, ms: Date.now() - t0 });
200
+ return out;
201
+ }
202
+ }
203
+
204
+ /** For /health: does each engine's browser binary exist where this daemon looks?
205
+ * chromium is reported for both the full build and the headless shell, because
206
+ * the warm headless launch needs the shell (the 25 Oct gap boot never checked). */
207
+ export function browsersPresent(): Record<string, boolean> {
208
+ const has = (f: () => string) => {
209
+ try {
210
+ return existsSync(f());
211
+ } catch {
212
+ return false;
213
+ }
214
+ };
215
+ const full = (() => {
216
+ try {
217
+ return chromium.executablePath();
218
+ } catch {
219
+ return '';
220
+ }
221
+ })();
222
+ const m = /^(.*[\\/])chromium-(\d+)[\\/]/.exec(full);
223
+ const shellDir = m ? join(m[1]!, `chromium_headless_shell-${m[2]}`) : '';
224
+ return {
225
+ chromium: has(() => full),
226
+ 'chromium-headless-shell': shellDir ? existsSync(shellDir) : false,
227
+ webkit: has(() => webkit.executablePath()),
228
+ firefox: has(() => firefox.executablePath()),
229
+ };
230
+ }
package/src/flow.ts CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  type AssertOutcome,
24
24
  type Target,
25
25
  } from '@broberg/lens-engine';
26
- import { withPage, type LensEngine, type RunBrowser } from './browser.ts';
26
+ import { takeBrowserRepairs, withPage, type LensEngine, type RunBrowser } from './browser.ts';
27
27
  import { collectNetworkShapes, type NetworkShapesOptions, type NetworkShapesReport } from './network-shapes.ts';
28
28
  import { deadline, defaultPopupOf, prepareExtension, seedExtensionStorage, SIMULATED_ACCESS_NOTE } from './extension-flow.ts';
29
29
  import { authPrepare, fetchMintJson, persistStorageState, persistTarget, type AuthConfig, type AuthEnv } from './auth/index.ts';
@@ -34,6 +34,7 @@ import { fillAndVerify } from './fill.ts';
34
34
  import { fillSecret } from './secret-fill.ts';
35
35
  import { storagePayload, type StorageSeed } from './seed-storage.ts';
36
36
  import { passwordInUrl, type FilledPassword, type PasswordInUrlFinding } from './password-in-url.ts';
37
+ import { assertTestMailbox, cleanupMail, fillFromMail, httpMailSource, type MailFrom, type MailSource } from './mail-fill.ts';
37
38
  import {
38
39
  EDIT_EVENTS,
39
40
  waitForListening,
@@ -50,7 +51,9 @@ export type ActivateMode = 'auto' | 'click' | 'enter';
50
51
  /** F009.121 — where a `fill` reads a SECRET from instead of carrying it inline:
51
52
  * a gitignored file (absolute, or relative to the run's local_path) or a daemon
52
53
  * env var. The value is read at run time and never echoed. */
53
- export type ValueFrom = { path: string } | { env: string };
54
+ export type SecretFrom = { path: string } | { env: string };
55
+ /** F009.137 — or a code that arrives by mail (the test mailbox only). */
56
+ export type ValueFrom = SecretFrom | { mail: MailFrom };
54
57
 
55
58
  /** F009.123 — a drag by an offset, in css pixels from the source's centre. */
56
59
  export type DragOffset = { x: number; y: number };
@@ -421,6 +424,11 @@ export interface FlowResult {
421
424
  /** F009.131 — set when auth.persist was asked for: whether the renewed login was
422
425
  * written back to statePath, and why not when it was not. Never a value. */
423
426
  auth_persisted?: { ok: boolean; reason?: string };
427
+ /** F010.1 — browsers the daemon reinstalled because their binary was missing. */
428
+ browser_repaired?: Array<{ engine: string; ms: number }>;
429
+ /** F009.137 — mails this run consumed for a code, and how many were removed
430
+ * after. Present only when a step read mail. Never a code or a body. */
431
+ mail?: { consumed: number; cleaned: number; reason?: string };
424
432
  /** F009.125 — the extension this run loaded: its id (read at load time), the
425
433
  * build path the caller named, and whether host access was SIMULATED on a copy. */
426
434
  extension?: {
@@ -855,8 +863,21 @@ function validateAndNormalizeSteps(steps: unknown[], depth = 0, inParallel = fal
855
863
  const hasValue = typeof (s as { value?: unknown }).value === 'string';
856
864
  const vf = (s as { valueFrom?: unknown }).valueFrom;
857
865
  if (hasValue && vf !== undefined) throw new Error('fill step takes value OR valueFrom, not both');
858
- if (!hasValue && vf === undefined) throw new Error('fill step requires a string value or valueFrom {path} | {env}');
859
- if (vf !== undefined) {
866
+ if (!hasValue && vf === undefined) throw new Error('fill step requires a string value or valueFrom {path} | {env} | {mail}');
867
+ // F009.137 — a code from mail: only the test mailbox, checked here at parse time.
868
+ if (vf && typeof vf === 'object' && 'mail' in (vf as object)) {
869
+ const m = (vf as { mail?: unknown }).mail as Record<string, unknown> | null;
870
+ if (!m || typeof m !== 'object' || typeof m.to !== 'string' || Object.keys(m).some((k) => !['to', 'subject', 'match', 'timeout'].includes(k))) {
871
+ throw new Error('fill valueFrom.mail must be {to: "<address>", subject?, match?, timeout?}');
872
+ }
873
+ assertTestMailbox(m.to);
874
+ if (m.match !== undefined) {
875
+ try { new RegExp(String(m.match)); } catch { throw new Error(`fill from mail: match /${String(m.match)}/ is not a valid regex`); }
876
+ }
877
+ if (m.timeout !== undefined && !(typeof m.timeout === 'number' && m.timeout > 0 && m.timeout <= 300_000)) {
878
+ throw new Error('fill from mail: timeout must be 1..300000 ms');
879
+ }
880
+ } else if (vf !== undefined) {
860
881
  const keys = vf && typeof vf === 'object' && !Array.isArray(vf) ? Object.keys(vf) : [];
861
882
  const k = keys[0];
862
883
  if (keys.length !== 1 || (k !== 'path' && k !== 'env') || typeof (vf as Record<string, unknown>)[k] !== 'string' || !(vf as Record<string, string>)[k]) {
@@ -1242,6 +1263,18 @@ function parseManuscriptStep(s: string): FlowStep {
1242
1263
  case 'fill': {
1243
1264
  // F009.121 — "fill [pw] from file <path>" / "from env <NAME>": the secret
1244
1265
  // is read at run time, so the manuscript (and its label) never holds it.
1266
+ // F009.137 — "fill [code] from mail to <addr> [subject ~ <text>] [match /<re>/]".
1267
+ const mail = s.match(/\]\s+from\s+mail\s+to\s+(\S+)(.*)$/i);
1268
+ if (mail) {
1269
+ const rest = mail[2]!;
1270
+ const re = rest.match(/\bmatch\s+\/(.+)\/\s*$/i);
1271
+ const subj = rest.replace(/\bmatch\s+\/.+\/\s*$/i, '').match(/\bsubject\s*~\s*(.+?)\s*$/i);
1272
+ return {
1273
+ type: 'fill',
1274
+ testid: testid(),
1275
+ valueFrom: { mail: { to: mail[1]!, ...(subj ? { subject: subj[1]!.trim() } : {}), ...(re ? { match: re[1]! } : {}) } },
1276
+ };
1277
+ }
1245
1278
  const from = s.match(/\]\s+from\s+(file|env)\s+(\S.*)$/i);
1246
1279
  if (from) {
1247
1280
  const src = from[2]!.trim();
@@ -2043,7 +2076,7 @@ async function sampleMotion(
2043
2076
  const MOTION_SAMPLE_MS = 250;
2044
2077
 
2045
2078
 
2046
- 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 }> {
2079
+ 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, mailCtx?: { source: MailSource; consumed: Array<{ to: string; id: string }> }): Promise<Buffer | null | { __inspect: string } | { __via: string; __build?: string; __redirect?: string }> {
2047
2080
  switch (step.type) {
2048
2081
  case 'goto': {
2049
2082
  const p = subst(step.path, vars);
@@ -2076,11 +2109,17 @@ async function execStep(page: Page, step: FlowStep, base: string, vars: Record<s
2076
2109
  return via ? { __via: via } : null;
2077
2110
  }
2078
2111
  case 'fill': {
2112
+ const stepStart = Date.now(); // F009.137 — only mail received after this counts
2079
2113
  const { loc, via, remaining } = await locate(page, step);
2080
2114
  await consultReadiness(page, loc, step, readySink);
2081
2115
  // F213.7 — fill proves its own write; a silent no-op or a prepended value
2082
2116
  // used to pass as success, which is the half of the fleet save-rule this
2083
2117
  // instrument could not perform.
2118
+ if (step.valueFrom && 'mail' in step.valueFrom) {
2119
+ if (!mailCtx) throw new Error('fill from mail: this run has no mail source');
2120
+ await fillFromMail(loc, step.valueFrom.mail, stepStart, mailCtx.source, mailCtx.consumed, remaining);
2121
+ return via ? { __via: via } : null;
2122
+ }
2084
2123
  if (step.valueFrom) {
2085
2124
  await fillSecret(loc, step.valueFrom, secretRoot, remaining, pwSink, anchorOf(step));
2086
2125
  return via ? { __via: via } : null;
@@ -2621,6 +2660,9 @@ export interface RunFlowOptions {
2621
2660
  * page the caller named, so an unsandboxed root would let a flow post any file
2622
2661
  * the daemon can read to any URL. */
2623
2662
  secretRoot?: string;
2663
+ /** F009.137 — where `fill … from mail` reads; defaults to mail-mcp over HTTP
2664
+ * (LENS_MAIL_API/LENS_MAIL_KEY). Injected by tests. */
2665
+ mailSource?: MailSource;
2624
2666
  /** F255.5 — record the whole driven run to a .webm, returned as FlowResult.video.
2625
2667
  * Off by default: recording costs disk + a little time on every run, and most flows
2626
2668
  * only need the per-step stills. */
@@ -2954,7 +2996,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
2954
2996
  ? await openPopup(step)
2955
2997
  : step.type === 'expectRequest'
2956
2998
  ? await expectRequest(step, vars)
2957
- : await execStep(sp, step, base, vars, opts.critic ?? 'off', opts.localPath, sink, filledPasswords, notListening, opts.secretRoot);
2999
+ : await execStep(sp, step, base, vars, opts.critic ?? 'off', opts.localPath, sink, filledPasswords, notListening, opts.secretRoot, mailCtx);
2958
3000
  const obj = out !== null && !Buffer.isBuffer(out) && typeof out === 'object' ? out : null;
2959
3001
  const inspect = obj && '__inspect' in obj ? (obj as { __inspect: string }).__inspect : null;
2960
3002
  const via = obj && '__via' in obj ? (obj as { __via: string }).__via : null;
@@ -3127,6 +3169,12 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
3127
3169
  const shapesRef: { c: ReturnType<typeof collectNetworkShapes> | null } = { c: null };
3128
3170
  let networkShapesReport: NetworkShapesReport | undefined;
3129
3171
  const persistPath = persistTarget(flow.auth);
3172
+ // F009.137 — every mail a step consumes is removed when the run ends, pass or fail.
3173
+ const mailCtx = { source: opts.mailSource ?? httpMailSource(), consumed: [] as Array<{ to: string; id: string }> };
3174
+ let mailReport: FlowResult['mail'];
3175
+ const finishMail = async () => {
3176
+ if (mailCtx.consumed.length && !mailReport) mailReport = await cleanupMail(mailCtx.consumed, mailCtx.source);
3177
+ };
3130
3178
  let authPersisted: FlowResult['auth_persisted'] = persistPath ? { ok: false, reason: 'the run ended before the login could be saved' } : undefined;
3131
3179
  const pendingDownloads: Promise<void>[] = [];
3132
3180
  let currentStep: number | null = null;
@@ -3405,9 +3453,11 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
3405
3453
  // F009.133 — what was collected before the crash is still in memory; say it.
3406
3454
  if (shapesRef.c && !networkShapesReport) networkShapesReport = await shapesRef.c.finish();
3407
3455
  await prepared?.cleanup();
3408
- return { downloads, ...(networkShapesReport ? { network_shapes: networkShapesReport } : {}), ...(authPersisted ? { auth_persisted: authPersisted } : {}), ...(extensionInfo ? { extension: extensionInfo } : {}), name: flow.name, status: 'error', steps: results.sort((a, b) => a.index - b.index), failed_at: firstFailedAt, reason: leadWithRedirect(reason, firstRedirect), duration_ms: Date.now() - startedAt, video, build: observedBuild, final_url: finalUrl, redirected: firstRedirect, password_in_url: passwordInUrl(finalUrl, filledPasswords), not_listening: notListening[0] ?? null, reduced_motion: opts.reducedMotion ?? 'reduce' };
3456
+ await finishMail();
3457
+ return { downloads, ...(mailReport ? { mail: mailReport } : {}), ...(networkShapesReport ? { network_shapes: networkShapesReport } : {}), ...(authPersisted ? { auth_persisted: authPersisted } : {}), ...(extensionInfo ? { extension: extensionInfo } : {}), name: flow.name, status: 'error', steps: results.sort((a, b) => a.index - b.index), failed_at: firstFailedAt, reason: leadWithRedirect(reason, firstRedirect), duration_ms: Date.now() - startedAt, video, build: observedBuild, final_url: finalUrl, redirected: firstRedirect, password_in_url: passwordInUrl(finalUrl, filledPasswords), not_listening: notListening[0] ?? null, reduced_motion: opts.reducedMotion ?? 'reduce' };
3409
3458
  }
3410
3459
 
3460
+ await finishMail(); // F009.137 — consumed mail is removed, pass or fail
3411
3461
  const video = await readVideo();
3412
3462
  // F009.125 — the temporary copy (if any) is ours alone; the original is never touched.
3413
3463
  await prepared?.cleanup();
@@ -3459,6 +3509,9 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
3459
3509
  // context can never disagree about it.
3460
3510
  reduced_motion: opts.reducedMotion ?? 'reduce',
3461
3511
  ...(authPersisted ? { auth_persisted: authPersisted } : {}),
3512
+ ...(mailReport ? { mail: mailReport } : {}),
3513
+ // F010.1 — a missing browser this run had to reinstall, said out loud.
3514
+ ...((() => { const r = takeBrowserRepairs(); return r.length ? { browser_repaired: r } : {}; })()),
3462
3515
  ...(extensionInfo ? { extension: extensionInfo } : {}),
3463
3516
  ...(networkShapesReport ? { network_shapes: networkShapesReport } : {}),
3464
3517
  downloads,
package/src/http.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  // all hit these. Artifact bytes are served here too, so an <img> in the
6
6
  // dashboard points straight at the daemon.
7
7
 
8
+ import { browsersPresent } from './ensure-chromium.ts';
8
9
  import { Hono } from 'hono';
9
10
  import { setSandboxResolver } from './sandbox.ts';
10
11
  import { registerDaemonRoutes } from './daemon-routes.ts';
@@ -79,6 +80,8 @@ export function createLensRouter(deps: LensDeps): Hono {
79
80
 
80
81
  // F276.1/F276.4 — the upload queue: what it holds, and one drain pass.
81
82
  r.get('/uploads', (c) => c.json({ ok: true, ...uploadStats(deps.db) }));
83
+ // F010.1 — does each engine's browser binary exist where this daemon looks?
84
+ r.get('/browsers', (c) => c.json({ ok: true, browsers: browsersPresent() }));
82
85
  r.post('/uploads/drain', handle(async (body) => {
83
86
  const b = (body ?? {}) as { batch?: number };
84
87
  return drainUploads(deps.db, deps.storage, deps.cloud?.() ?? null, { batch: b.batch });
package/src/index.ts CHANGED
@@ -16,7 +16,7 @@ export { durabilityOf, type EvidenceDurability } from './durability.ts';
16
16
  export { setSandboxResolver, type SandboxResolver } from './sandbox.ts';
17
17
  // Lifecycle the daemon owns: provision Chromium at boot, drain the evidence
18
18
  // upload queue on its timer, close the warm browser on shutdown.
19
- export { ensureChromium } from './ensure-chromium.ts';
19
+ export { ensureChromium, browsersPresent } from './ensure-chromium.ts';
20
20
  export { drainUploads } from './artifact-uploads.ts';
21
21
  export { closeBrowser } from './browser.ts';
22
22
 
@@ -0,0 +1,176 @@
1
+ // F009.137 — fill a field with a code that arrives by mail, read through the
2
+ // fleet's mail-mcp (owner's choice, 25 Oct: no IMAP client of Lens' own).
3
+ //
4
+ // Two rules from the owner shape everything here:
5
+ // * only the TEST mailbox may be read — never his own inbox (a HelpDesk test
6
+ // burst landed there the night before this was built);
7
+ // * every mail a test consumes is cleaned up afterwards.
8
+ // The code, the mail body and the key never reach a report, a step detail or an
9
+ // error. A screenshot taken after the fill can show the field: a one-time code is
10
+ // spent by then, and hiding it would mean not photographing the form at all.
11
+ import type { Locator } from 'playwright';
12
+ import { fillAndVerify } from './fill.ts';
13
+
14
+ export interface MailFrom {
15
+ to: string;
16
+ /** Substring the subject must contain (case-insensitive). */
17
+ subject?: string;
18
+ /** Regex source for the code; the first capture group (or whole match) is used. */
19
+ match?: string;
20
+ /** How long to wait for the mail, ms. Default {@link MAIL_WAIT_MS}. */
21
+ timeout?: number;
22
+ }
23
+
24
+ export interface Mail {
25
+ id: string;
26
+ from: string;
27
+ /** The To header — carries the +tag the mailbox itself does not. */
28
+ to: string;
29
+ subject: string;
30
+ /** epoch ms */
31
+ receivedAt: number;
32
+ text: string;
33
+ }
34
+
35
+ /** The one door to the mailbox — mail-mcp's contract (mail-mcp F001.17): a
36
+ * long-poll `wait` that returns the newest matching mail (+ the code when a
37
+ * pattern is given), and `remove`. The HTTP implementation talks to mail-mcp;
38
+ * tests pass a fake. */
39
+ export interface MailSource {
40
+ wait(q: { to: string; since: number; subject?: string; codePattern: string; timeoutSeconds: number }): Promise<{ found: false } | { found: true; code: string | null; mail: Mail }>;
41
+ remove(q: { to: string; id: string }): Promise<void>;
42
+ }
43
+
44
+ export const MAIL_WAIT_MS = 60_000;
45
+ export const DEFAULT_CODE = '\\b\\d{6}\\b';
46
+
47
+ // The test mailbox and its agreed aliases — one place. Anything else is refused
48
+ // at parse time, so a flow can never read or clean the owner's inbox.
49
+ const TEST_MAILBOXES = ['webhouse.developer@gmail.com', 'jam@jams.party'];
50
+
51
+ export function assertTestMailbox(addr: string): void {
52
+ const m = /^([^@+\s]+)(\+[^@\s]*)?@([^@\s]+)$/.exec(addr.trim().toLowerCase());
53
+ const base = m ? `${m[1]}@${m[3]}` : '';
54
+ if (!TEST_MAILBOXES.includes(base)) {
55
+ throw new Error(`fill from mail: ${addr} is not the test mailbox — only ${TEST_MAILBOXES.join(', ')} (and their +tags) may be read`);
56
+ }
57
+ }
58
+
59
+ /** mail-mcp addresses the MAILBOX (jam@jams.party); a +tag is only visible in
60
+ * the mail's own To header. */
61
+ export function mailboxOf(addr: string): string {
62
+ return addr.trim().toLowerCase().replace(/\+[^@]*@/, '@');
63
+ }
64
+
65
+ export function codeIn(mail: Mail, match: string | undefined): string | null {
66
+ const re = new RegExp(match ?? DEFAULT_CODE);
67
+ const m = re.exec(`${mail.subject}\n${mail.text}`);
68
+ return m ? (m[1] ?? m[0]) : null;
69
+ }
70
+
71
+ /** Wait for a mail to `mf.to` received at/after `since`, return its code + id.
72
+ * mail-mcp holds each call open for at most 55 s, so a longer wait is several
73
+ * calls; the overall bound is ours. */
74
+ export async function waitForMailCode(
75
+ mf: MailFrom,
76
+ since: number,
77
+ source: MailSource,
78
+ now: () => number = Date.now,
79
+ ): Promise<{ code: string; id: string }> {
80
+ const timeout = mf.timeout ?? MAIL_WAIT_MS;
81
+ const until = now() + timeout;
82
+ const pattern = mf.match ?? DEFAULT_CODE;
83
+ for (;;) {
84
+ const left = Math.max(1, Math.min(55, Math.ceil((until - now()) / 1000)));
85
+ const r = await source.wait({ to: mailboxOf(mf.to), since, ...(mf.subject ? { subject: mf.subject } : {}), codePattern: pattern, timeoutSeconds: left });
86
+ // A +tag addresses ONE run: a mail to jam+other@ in the same mailbox is someone
87
+ // else's code and must not be typed here.
88
+ const forUs = (m: Mail) => mailboxOf(mf.to) === mf.to.trim().toLowerCase() || m.to.toLowerCase().includes(mf.to.trim().toLowerCase());
89
+ if (r.found && r.mail.receivedAt >= since && forUs(r.mail)) {
90
+ // Our own regex decides, so the code means the same thing whatever the
91
+ // server's pattern dialect; the server's code is only a fallback.
92
+ const code = codeIn(r.mail, pattern) ?? r.code;
93
+ if (code) return { code, id: r.mail.id };
94
+ }
95
+ if (now() >= until) {
96
+ throw new Error(
97
+ `fill from mail: no mail to ${mf.to}${mf.subject ? ` with subject ~ "${mf.subject}"` : ''} carrying a code arrived within ${Math.round(timeout / 1000)} s (counting mail received after ${new Date(since).toISOString()})`,
98
+ );
99
+ }
100
+ }
101
+ }
102
+
103
+ /** Fill the field with the code and record the mail for cleanup. Errors carry
104
+ * lengths, never the code. */
105
+ export async function fillFromMail(
106
+ loc: Locator,
107
+ mf: MailFrom,
108
+ since: number,
109
+ source: MailSource,
110
+ consumed: Array<{ to: string; id: string }>,
111
+ timeout: number,
112
+ ): Promise<void> {
113
+ const { code, id } = await waitForMailCode(mf, since, source);
114
+ consumed.push({ to: mailboxOf(mf.to), id });
115
+ try {
116
+ await fillAndVerify(loc, code, timeout);
117
+ } catch {
118
+ throw new Error(`fill from mail did not land: expected a ${code.length}-character code in the field (value withheld)`);
119
+ }
120
+ }
121
+
122
+ /** Remove every consumed mail. Never throws — the run's verdict is about the
123
+ * page; the cleanup result is reported beside it. */
124
+ export async function cleanupMail(consumed: Array<{ to: string; id: string }>, source: MailSource): Promise<{ consumed: number; cleaned: number; reason?: string }> {
125
+ let cleaned = 0;
126
+ let reason: string | undefined;
127
+ for (const { to, id } of consumed) {
128
+ try {
129
+ await source.remove({ to, id });
130
+ cleaned++;
131
+ } catch (err) {
132
+ reason = `could not remove mail ${id}: ${err instanceof Error ? err.message.split('\n')[0] : String(err)}`;
133
+ }
134
+ }
135
+ return { consumed: consumed.length, cleaned, ...(reason ? { reason } : {}) };
136
+ }
137
+
138
+ /** mail-mcp's read API over HTTP (contract: mail-mcp F001.17, recorded in
139
+ * docs/features/F009.137-fill-from-mail.md). Base URL and key come from the
140
+ * daemon's own env (LENS_MAIL_API, LENS_MAIL_KEY) — never from a caller. */
141
+ export function httpMailSource(env: Record<string, string | undefined> = process.env): MailSource {
142
+ const base = (env.LENS_MAIL_API ?? 'https://mailmcp.webhouse.net').replace(/\/$/, '');
143
+ const key = env.LENS_MAIL_KEY;
144
+ const post = async (path: string, body: unknown, ms: number) => {
145
+ if (!key) throw new Error('fill from mail: the daemon has no mail-mcp key (LENS_MAIL_KEY)');
146
+ const r = await fetch(`${base}${path}`, {
147
+ method: 'POST',
148
+ headers: { 'x-mailmcp-key': key, 'content-type': 'application/json' },
149
+ body: JSON.stringify(body),
150
+ signal: AbortSignal.timeout(ms),
151
+ });
152
+ if (!r.ok) throw new Error(`mail-mcp ${path} answered ${r.status}`);
153
+ return (await r.json()) as Record<string, unknown>;
154
+ };
155
+ return {
156
+ async wait(q) {
157
+ const j = await post('/api/mail/wait', {
158
+ to: q.to,
159
+ since: q.since,
160
+ code_pattern: q.codePattern,
161
+ timeout_seconds: q.timeoutSeconds,
162
+ ...(q.subject ? { subject_contains: q.subject } : {}),
163
+ }, (q.timeoutSeconds + 10) * 1000);
164
+ if (!j.found) return { found: false };
165
+ const m = j.message as { id: string; from: string; to?: string; subject: string; received_at: string | number; body?: string };
166
+ return {
167
+ found: true,
168
+ code: typeof j.code === 'string' ? j.code : null,
169
+ mail: { id: m.id, from: m.from, to: m.to ?? '', subject: m.subject, receivedAt: new Date(m.received_at).getTime(), text: m.body ?? '' },
170
+ };
171
+ },
172
+ async remove(q) {
173
+ await post('/api/mail/delete', { to: q.to, id: q.id }, 30_000);
174
+ },
175
+ };
176
+ }
package/src/mcp.ts CHANGED
@@ -405,7 +405,7 @@ export const TOOLS = [
405
405
  {
406
406
  name: 'lens_run_manuscript',
407
407
  description:
408
- `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]" or "- drag [node] by 120,-40" (a canvas offset; both move in 10 mousemoves so React Flow / d3-drag nodes really move — "steps N" to change, F009.123) · "- upload [input-testid] = <fixture>" · an input with NO testid, also inside an open shadow root: "- uploadSelector <css> = <fixture>[, <fixture>]" (F009.136; fixtures repo-relative to local_path or data:) · "- 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.`,
408
+ `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]" or "- drag [node] by 120,-40" (a canvas offset; both move in 10 mousemoves so React Flow / d3-drag nodes really move — "steps N" to change, F009.123) · "- upload [input-testid] = <fixture>" · an input with NO testid, also inside an open shadow root: "- uploadSelector <css> = <fixture>[, <fixture>]" (F009.136; fixtures repo-relative to local_path or data:) · a CODE FROM MAIL: "- fill [testid] from mail to jam@jams.party subject ~ Jam match /\\b\\d{6}\\b/" (F009.137 — waits ≤60 s for a mail received after the step started, read through mail-mcp; only the test mailbox webhouse.developer@gmail.com / jam@jams.party; the mail is deleted after the run, report carries mail {consumed, cleaned}; the code never appears in the report) · "- 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.`,
409
409
  inputSchema: {
410
410
  type: 'object',
411
411
  required: ['project', 'manuscript'],
@@ -8,11 +8,11 @@ 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';
11
- import type { ValueFrom } from './flow.ts';
11
+ import type { SecretFrom } from './flow.ts';
12
12
 
13
13
  /** Read the secret. Every error names WHERE it looked and never WHAT it found. */
14
14
  export async function readSecret(
15
- vf: ValueFrom,
15
+ vf: SecretFrom,
16
16
  secretRoot: string | undefined,
17
17
  env: Record<string, string | undefined> = process.env,
18
18
  ): Promise<string> {
@@ -51,7 +51,7 @@ export async function readSecret(
51
51
  * the shape of a mismatch, never characters. */
52
52
  export async function fillSecret(
53
53
  loc: Locator,
54
- vf: ValueFrom,
54
+ vf: SecretFrom,
55
55
  secretRoot: string | undefined,
56
56
  timeout: number,
57
57
  pwSink: FilledPassword[] | undefined,