@broberg/lens-daemon 0.1.35 → 0.1.37

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.35",
3
+ "version": "0.1.37",
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",
package/src/browser.ts CHANGED
@@ -404,6 +404,9 @@ export function runBrowserLaunchOptions(b: RunBrowser) {
404
404
  return {
405
405
  channel: b.channel,
406
406
  headless: b.visible !== true,
407
+ // Playwright launches with --no-sandbox by default. This browser carries a real
408
+ // login (Google) and browses the open web, so Chrome's renderer sandbox stays ON.
409
+ chromiumSandbox: true,
407
410
  ignoreDefaultArgs: ['--enable-automation'],
408
411
  args: ['--disable-blink-features=AutomationControlled', '--disable-dev-shm-usage'],
409
412
  };
@@ -68,6 +68,9 @@ export function profileLaunchOptions(channel: ProfileChannel = EXTENSION_CHANNEL
68
68
  // F009.136 — 'chrome' when the runs that will use this profile run in Google
69
69
  // Chrome; the default stays the extension runs' build.
70
70
  channel,
71
+ // The login window holds the password + 2FA being typed: renderer sandbox ON
72
+ // (Playwright's default is --no-sandbox).
73
+ chromiumSandbox: true,
71
74
  ignoreDefaultArgs: ['--enable-automation'],
72
75
  args: ['--disable-blink-features=AutomationControlled'],
73
76
  };
package/src/flow.ts CHANGED
@@ -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,9 @@ 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
+ /** F009.137 — mails this run consumed for a code, and how many were removed
428
+ * after. Present only when a step read mail. Never a code or a body. */
429
+ mail?: { consumed: number; cleaned: number; reason?: string };
424
430
  /** F009.125 — the extension this run loaded: its id (read at load time), the
425
431
  * build path the caller named, and whether host access was SIMULATED on a copy. */
426
432
  extension?: {
@@ -855,8 +861,21 @@ function validateAndNormalizeSteps(steps: unknown[], depth = 0, inParallel = fal
855
861
  const hasValue = typeof (s as { value?: unknown }).value === 'string';
856
862
  const vf = (s as { valueFrom?: unknown }).valueFrom;
857
863
  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) {
864
+ if (!hasValue && vf === undefined) throw new Error('fill step requires a string value or valueFrom {path} | {env} | {mail}');
865
+ // F009.137 — a code from mail: only the test mailbox, checked here at parse time.
866
+ if (vf && typeof vf === 'object' && 'mail' in (vf as object)) {
867
+ const m = (vf as { mail?: unknown }).mail as Record<string, unknown> | null;
868
+ if (!m || typeof m !== 'object' || typeof m.to !== 'string' || Object.keys(m).some((k) => !['to', 'subject', 'match', 'timeout'].includes(k))) {
869
+ throw new Error('fill valueFrom.mail must be {to: "<address>", subject?, match?, timeout?}');
870
+ }
871
+ assertTestMailbox(m.to);
872
+ if (m.match !== undefined) {
873
+ try { new RegExp(String(m.match)); } catch { throw new Error(`fill from mail: match /${String(m.match)}/ is not a valid regex`); }
874
+ }
875
+ if (m.timeout !== undefined && !(typeof m.timeout === 'number' && m.timeout > 0 && m.timeout <= 300_000)) {
876
+ throw new Error('fill from mail: timeout must be 1..300000 ms');
877
+ }
878
+ } else if (vf !== undefined) {
860
879
  const keys = vf && typeof vf === 'object' && !Array.isArray(vf) ? Object.keys(vf) : [];
861
880
  const k = keys[0];
862
881
  if (keys.length !== 1 || (k !== 'path' && k !== 'env') || typeof (vf as Record<string, unknown>)[k] !== 'string' || !(vf as Record<string, string>)[k]) {
@@ -1242,6 +1261,18 @@ function parseManuscriptStep(s: string): FlowStep {
1242
1261
  case 'fill': {
1243
1262
  // F009.121 — "fill [pw] from file <path>" / "from env <NAME>": the secret
1244
1263
  // is read at run time, so the manuscript (and its label) never holds it.
1264
+ // F009.137 — "fill [code] from mail to <addr> [subject ~ <text>] [match /<re>/]".
1265
+ const mail = s.match(/\]\s+from\s+mail\s+to\s+(\S+)(.*)$/i);
1266
+ if (mail) {
1267
+ const rest = mail[2]!;
1268
+ const re = rest.match(/\bmatch\s+\/(.+)\/\s*$/i);
1269
+ const subj = rest.replace(/\bmatch\s+\/.+\/\s*$/i, '').match(/\bsubject\s*~\s*(.+?)\s*$/i);
1270
+ return {
1271
+ type: 'fill',
1272
+ testid: testid(),
1273
+ valueFrom: { mail: { to: mail[1]!, ...(subj ? { subject: subj[1]!.trim() } : {}), ...(re ? { match: re[1]! } : {}) } },
1274
+ };
1275
+ }
1245
1276
  const from = s.match(/\]\s+from\s+(file|env)\s+(\S.*)$/i);
1246
1277
  if (from) {
1247
1278
  const src = from[2]!.trim();
@@ -2043,7 +2074,7 @@ async function sampleMotion(
2043
2074
  const MOTION_SAMPLE_MS = 250;
2044
2075
 
2045
2076
 
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 }> {
2077
+ 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
2078
  switch (step.type) {
2048
2079
  case 'goto': {
2049
2080
  const p = subst(step.path, vars);
@@ -2076,11 +2107,17 @@ async function execStep(page: Page, step: FlowStep, base: string, vars: Record<s
2076
2107
  return via ? { __via: via } : null;
2077
2108
  }
2078
2109
  case 'fill': {
2110
+ const stepStart = Date.now(); // F009.137 — only mail received after this counts
2079
2111
  const { loc, via, remaining } = await locate(page, step);
2080
2112
  await consultReadiness(page, loc, step, readySink);
2081
2113
  // F213.7 — fill proves its own write; a silent no-op or a prepended value
2082
2114
  // used to pass as success, which is the half of the fleet save-rule this
2083
2115
  // instrument could not perform.
2116
+ if (step.valueFrom && 'mail' in step.valueFrom) {
2117
+ if (!mailCtx) throw new Error('fill from mail: this run has no mail source');
2118
+ await fillFromMail(loc, step.valueFrom.mail, stepStart, mailCtx.source, mailCtx.consumed, remaining);
2119
+ return via ? { __via: via } : null;
2120
+ }
2084
2121
  if (step.valueFrom) {
2085
2122
  await fillSecret(loc, step.valueFrom, secretRoot, remaining, pwSink, anchorOf(step));
2086
2123
  return via ? { __via: via } : null;
@@ -2621,6 +2658,9 @@ export interface RunFlowOptions {
2621
2658
  * page the caller named, so an unsandboxed root would let a flow post any file
2622
2659
  * the daemon can read to any URL. */
2623
2660
  secretRoot?: string;
2661
+ /** F009.137 — where `fill … from mail` reads; defaults to mail-mcp over HTTP
2662
+ * (LENS_MAIL_API/LENS_MAIL_KEY). Injected by tests. */
2663
+ mailSource?: MailSource;
2624
2664
  /** F255.5 — record the whole driven run to a .webm, returned as FlowResult.video.
2625
2665
  * Off by default: recording costs disk + a little time on every run, and most flows
2626
2666
  * only need the per-step stills. */
@@ -2954,7 +2994,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
2954
2994
  ? await openPopup(step)
2955
2995
  : step.type === 'expectRequest'
2956
2996
  ? await expectRequest(step, vars)
2957
- : await execStep(sp, step, base, vars, opts.critic ?? 'off', opts.localPath, sink, filledPasswords, notListening, opts.secretRoot);
2997
+ : await execStep(sp, step, base, vars, opts.critic ?? 'off', opts.localPath, sink, filledPasswords, notListening, opts.secretRoot, mailCtx);
2958
2998
  const obj = out !== null && !Buffer.isBuffer(out) && typeof out === 'object' ? out : null;
2959
2999
  const inspect = obj && '__inspect' in obj ? (obj as { __inspect: string }).__inspect : null;
2960
3000
  const via = obj && '__via' in obj ? (obj as { __via: string }).__via : null;
@@ -3127,6 +3167,12 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
3127
3167
  const shapesRef: { c: ReturnType<typeof collectNetworkShapes> | null } = { c: null };
3128
3168
  let networkShapesReport: NetworkShapesReport | undefined;
3129
3169
  const persistPath = persistTarget(flow.auth);
3170
+ // F009.137 — every mail a step consumes is removed when the run ends, pass or fail.
3171
+ const mailCtx = { source: opts.mailSource ?? httpMailSource(), consumed: [] as Array<{ to: string; id: string }> };
3172
+ let mailReport: FlowResult['mail'];
3173
+ const finishMail = async () => {
3174
+ if (mailCtx.consumed.length && !mailReport) mailReport = await cleanupMail(mailCtx.consumed, mailCtx.source);
3175
+ };
3130
3176
  let authPersisted: FlowResult['auth_persisted'] = persistPath ? { ok: false, reason: 'the run ended before the login could be saved' } : undefined;
3131
3177
  const pendingDownloads: Promise<void>[] = [];
3132
3178
  let currentStep: number | null = null;
@@ -3405,9 +3451,11 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
3405
3451
  // F009.133 — what was collected before the crash is still in memory; say it.
3406
3452
  if (shapesRef.c && !networkShapesReport) networkShapesReport = await shapesRef.c.finish();
3407
3453
  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' };
3454
+ await finishMail();
3455
+ 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
3456
  }
3410
3457
 
3458
+ await finishMail(); // F009.137 — consumed mail is removed, pass or fail
3411
3459
  const video = await readVideo();
3412
3460
  // F009.125 — the temporary copy (if any) is ours alone; the original is never touched.
3413
3461
  await prepared?.cleanup();
@@ -3459,6 +3507,7 @@ export async function runFlow(flow: Flow, env: AuthEnv, opts: RunFlowOptions = {
3459
3507
  // context can never disagree about it.
3460
3508
  reduced_motion: opts.reducedMotion ?? 'reduce',
3461
3509
  ...(authPersisted ? { auth_persisted: authPersisted } : {}),
3510
+ ...(mailReport ? { mail: mailReport } : {}),
3462
3511
  ...(extensionInfo ? { extension: extensionInfo } : {}),
3463
3512
  ...(networkShapesReport ? { network_shapes: networkShapesReport } : {}),
3464
3513
  downloads,
@@ -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,