@beryl-so/cli 0.11.1 → 0.17.0

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.
@@ -1,10 +1,68 @@
1
1
  import fs from "node:fs";
2
- import { UsageError } from "../errors.js";
2
+ import { CliError, UsageError } from "../errors.js";
3
+ import { ApiError } from "../http.js";
3
4
  import { lintPlan } from "../lint.js";
4
- import { table } from "../output.js";
5
+ import { buildImportForm, executeLocalSpec, toRunEntry, } from "../local-exec.js";
6
+ import { PlaywrightMissingError } from "../local-run.js";
7
+ import { dim, green, red, table, yellow } from "../output.js";
8
+ import { confirmInstall, installPlaywright } from "../playwright-install.js";
5
9
  import { ACTION_PLAN_SCHEMA } from "../schema.generated.js";
6
10
  import { arg, argList, flagBool, flagNum, flagStr, projectPath, readJsonFlag } from "./util.js";
7
11
  const testPath = (ws, p, id) => `${projectPath(ws, p)}/tests/${id}`;
12
+ // A duplicate-title 409 after a green replay is idempotent when the existing row
13
+ // holds the same plan (a retry after a lost response) — success, not an error.
14
+ // A different plan under the same title is a real conflict.
15
+ function createConflictResult(err, planHash) {
16
+ if (!(err instanceof ApiError) || err.status !== 409)
17
+ return undefined;
18
+ const detail = err.detail;
19
+ if (typeof detail !== "object" || detail === null || detail.code !== "duplicate_title")
20
+ return undefined;
21
+ if (detail.existing_plan_hash && detail.existing_plan_hash === planHash) {
22
+ return {
23
+ data: { id: detail.existing_test_id, already_existed: true },
24
+ human: green(`✓ Already banked as ${detail.existing_test_id} (same plan) — nothing to do.`),
25
+ };
26
+ }
27
+ return {
28
+ human: (detail.message ?? "A test with this title already exists.") +
29
+ "\nIts plan differs from yours — use `tests set-plan` to update it, or pick a new title.",
30
+ exitCode: 1,
31
+ };
32
+ }
33
+ // The server transcodes a failure screenshot to WebP to fit it under the payload cap,
34
+ // so the format is not knowable up front — sniff it off the decoded magic bytes rather
35
+ // than asserting PNG.
36
+ export function sniffImageMime(b64) {
37
+ const head = Buffer.from(b64.slice(0, 24), "base64");
38
+ if (head.subarray(0, 4).toString("latin1") === "RIFF" && head.subarray(8, 12).toString("latin1") === "WEBP") {
39
+ return "image/webp";
40
+ }
41
+ if (head.subarray(0, 3).toString("hex") === "ffd8ff")
42
+ return "image/jpeg";
43
+ return "image/png";
44
+ }
45
+ // A verify-failure 422 carries evidence (a11y page state + failure screenshot).
46
+ // Returned as a CommandResult rather than thrown: a thrown error is text-only in
47
+ // the MCP adapter, and the screenshot only reaches the agent as image content.
48
+ function verifyFailureResult(err) {
49
+ if (!(err instanceof ApiError) || err.status !== 422)
50
+ return undefined;
51
+ const detail = err.detail;
52
+ if (typeof detail !== "object" || detail === null || !detail.message)
53
+ return undefined;
54
+ const parts = [detail.message];
55
+ if (detail.error_context) {
56
+ parts.push("", "----- Page state at failure (accessibility snapshot) -----", detail.error_context);
57
+ }
58
+ return {
59
+ human: parts.join("\n"),
60
+ images: detail.screenshot_b64
61
+ ? [{ data: detail.screenshot_b64, mimeType: sniffImageMime(detail.screenshot_b64) }]
62
+ : undefined,
63
+ exitCode: 1,
64
+ };
65
+ }
8
66
  // The concise human table for `tests list` — the full TestResponse is a 24-column
9
67
  // firehose of internal ids that wraps unreadably in a normal terminal. `--wide`
10
68
  // (and `--json`) still expose every field.
@@ -94,10 +152,21 @@ export const testCommands = [
94
152
  name: "tests create",
95
153
  summary: "Create a test case from a JSON action plan — for tests authored locally, e.g. by your coding agent",
96
154
  description: "The plan is a JSON object whose steps are {action, selector, url, value, ...}: the first " +
97
- "EXECUTED step must be a goto, and at least one step must be an expect. By default the plan " +
98
- "is verified in a real browser before the test is accepted. Optional `before` and `after` " +
99
- "arrays hold setup and teardown steps: `after` runs even when a main step fails, which is " +
100
- "how a create/update/delete test cleans up the record it made on the runs that go red.",
155
+ "EXECUTED step must be a goto, and at least one step must be an expect. Before anything is " +
156
+ "banked, the plan is proven by replaying it in a browser ON YOUR MACHINE with your local " +
157
+ "@playwright/test: the server renders the spec (`tests/compile`), the CLI runs it minting " +
158
+ "a run inbox for await_email steps and resolving the saved login exactly as a cloud run " +
159
+ "would — and only a green replay creates the test (bound to the replayed plan by its hash). " +
160
+ "This holds over MCP too: the replay runs on the machine hosting the MCP server, never on " +
161
+ "Beryl's; if @playwright/test is missing there the tool returns the install commands (on a " +
162
+ "terminal the CLI offers to install it). " +
163
+ "A red replay banks NOTHING: the failure evidence comes back (over MCP the screenshot is " +
164
+ "image content), you fix the plan file and re-run. The proving run is imported as the " +
165
+ "test's first run (--no-sync to skip). A plan that signs in with a captured session cannot " +
166
+ "replay locally (the session never leaves Beryl's cloud) — it falls back to server-side " +
167
+ "verification automatically. Optional `before` and `after` arrays hold setup and teardown " +
168
+ "steps: `after` runs even when a main step fails, which is how a create/update/delete test " +
169
+ "cleans up the record it made on the runs that go red.",
101
170
  scope: "project",
102
171
  flags: [
103
172
  { name: "title", type: "string", required: true, description: "Title for the new test" },
@@ -112,25 +181,214 @@ export const testCommands = [
112
181
  {
113
182
  name: "no-verify",
114
183
  type: "boolean",
115
- description: "Skip the compile-time browser/AI verification trust the authored plan as-is",
184
+ description: "Skip verification entirelybank the authored plan as-is, unproven",
185
+ },
186
+ {
187
+ name: "url-override",
188
+ type: "string",
189
+ description: "Replay against this base URL instead of the environment's (e.g. http://localhost:3000). " +
190
+ "The banked test is then unproven against its real environment — the CLI says so.",
191
+ },
192
+ { name: "env", type: "string", description: "Environment id to compile and prove against" },
193
+ {
194
+ name: "sync",
195
+ type: "boolean",
196
+ default: true,
197
+ description: "Import the green proving replay as the test's first run (--no-sync: bank only, " +
198
+ "no run recorded)",
199
+ },
200
+ {
201
+ name: "dir",
202
+ type: "string",
203
+ description: "Keep the rendered spec, artifacts, and JSON report under this directory",
116
204
  },
117
205
  ],
118
206
  examples: [
119
207
  'beryl tests create --title "Checkout happy path" --file plan.json',
120
208
  'beryl tests create --title "Checkout happy path" --file plan.json --description "Proves a shopper can buy a product: after paying, an order-confirmation page with an order number appears."',
209
+ 'beryl tests create --title "Checkout happy path" --file plan.json --url-override http://localhost:3000',
121
210
  ],
122
211
  async run(ctx, input) {
123
212
  const title = flagStr(input, "title");
124
213
  if (!title)
125
214
  throw new UsageError("--title is required");
126
215
  const { workspaceId, projectId } = await ctx.requireProject(input);
216
+ const plan = readJsonFlag(input, "file");
217
+ const description = flagStr(input, "description");
218
+ const urlOverride = flagStr(input, "url-override");
219
+ const env = flagStr(input, "env");
220
+ const sync = input.flags.sync !== false;
221
+ const bank = (extra) => ctx.client.post(`${projectPath(workspaceId, projectId)}/tests`, {
222
+ title,
223
+ plan,
224
+ description,
225
+ ...extra,
226
+ });
227
+ if (flagBool(input, "no-verify")) {
228
+ const created = await bank({ verify: false });
229
+ return {
230
+ data: created,
231
+ human: yellow(`Banked UNPROVEN (--no-verify): ${created.id}`),
232
+ };
233
+ }
234
+ const compile = () => ctx.client.post(`${projectPath(workspaceId, projectId)}/tests/compile`, {
235
+ plan,
236
+ title,
237
+ base_url: urlOverride,
238
+ environment_id: env,
239
+ // The proof replay becomes the test's first run, so capture the filmstrip
240
+ // frames its replay view needs.
241
+ frames: sync ? true : undefined,
242
+ });
243
+ let compiled = await compile();
244
+ // A captured session lives encrypted in Beryl's cloud and is never handed to
245
+ // this machine — the cloud is the only place this plan can be proven.
246
+ if (compiled.requires_auth) {
247
+ ctx.err(yellow("! This plan signs in with a captured session, so it can only be verified " +
248
+ "in Beryl's cloud — verifying server-side instead."));
249
+ try {
250
+ return { data: await bank({ verify: true, plan_hash: compiled.plan_hash }) };
251
+ }
252
+ catch (err) {
253
+ const failure = verifyFailureResult(err);
254
+ if (failure)
255
+ return failure;
256
+ const conflict = createConflictResult(err, compiled.plan_hash);
257
+ if (conflict)
258
+ return conflict;
259
+ throw err;
260
+ }
261
+ }
262
+ if (compiled.login_config_error)
263
+ throw new CliError(compiled.login_config_error);
264
+ let loginPassword;
265
+ if (compiled.uses_login_password) {
266
+ try {
267
+ const secret = (await ctx.client.get(`${projectPath(workspaceId, projectId)}/config/secrets/LOGIN_PASSWORD/value`, { environment_id: env }));
268
+ loginPassword = secret.value;
269
+ }
270
+ catch (err) {
271
+ throw new CliError("Could not reveal the LOGIN_PASSWORD secret for the saved login: " +
272
+ (err instanceof Error ? err.message : String(err)));
273
+ }
274
+ }
275
+ const specOf = (content) => ({
276
+ id: "local-verify",
277
+ title,
278
+ content,
279
+ requiresAuth: false,
280
+ usesEmail: /__vmInbox\(|__vmAwaitEmail\(/.test(content),
281
+ usesLoginPassword: compiled.uses_login_password,
282
+ });
283
+ const startedAt = new Date().toISOString();
284
+ const attempts = 1 + Math.max(0, compiled.policy.error_retries);
285
+ let outcome;
286
+ let runError;
287
+ let installOffered = false;
288
+ for (let attempt = 1; attempt <= attempts; attempt++) {
289
+ if (attempt > 1) {
290
+ // Fresh render per attempt: the errored attempt may already have consumed
291
+ // the {{unique}}/inbox handles its spec minted — replaying the same spec
292
+ // would collide on exactly the uniqueness those handles exist to dodge.
293
+ ctx.err(dim(`Replay errored — recompiling and retrying (${attempt}/${attempts})…`));
294
+ compiled = await compile();
295
+ }
296
+ ctx.err(dim(`Replaying "${title}" locally before banking…`));
297
+ try {
298
+ ({ outcome, runError } = await executeLocalSpec({ client: ctx.client, workspaceId, projectId }, {
299
+ spec: specOf(compiled.content),
300
+ dir: flagStr(input, "dir"),
301
+ harvest: true,
302
+ loginPassword,
303
+ onEvent: (line) => ctx.err(dim(line)),
304
+ }));
305
+ }
306
+ catch (err) {
307
+ if (err instanceof PlaywrightMissingError) {
308
+ // On a TTY offer the install once and redo this attempt; over MCP or a
309
+ // pipe the hint is the answer.
310
+ if (ctx.interactive && !installOffered) {
311
+ installOffered = true;
312
+ if (await confirmInstall(ctx.prompt)) {
313
+ await installPlaywright(process.cwd(), (line) => ctx.err(dim(line)));
314
+ attempt -= 1;
315
+ continue;
316
+ }
317
+ }
318
+ throw new CliError(err.message);
319
+ }
320
+ throw err;
321
+ }
322
+ // A completed replay — green or red — is a verdict; only an errored one
323
+ // (the spec never ran) earns a retry.
324
+ if (outcome)
325
+ break;
326
+ }
327
+ if (!outcome) {
328
+ return {
329
+ data: { banked: false, verified: false, error: runError },
330
+ human: red(`Replay errored after ${attempts} attempt(s) — nothing was banked.`) +
331
+ `\n${runError ?? "the spec did not run"}` +
332
+ `\nFix the environment (or retry), then re-run \`beryl tests create\`.`,
333
+ exitCode: 1,
334
+ };
335
+ }
336
+ const result = outcome.results[0];
337
+ if (outcome.failed > 0 || outcome.passed === 0) {
338
+ const shot = outcome.harvested?.screenshot;
339
+ const parts = [
340
+ `Plan failed its local replay — nothing was banked.`,
341
+ ...(result?.error ? ["", result.error] : []),
342
+ "",
343
+ "Fix the plan file and re-run `beryl tests create`. The replay artifacts " +
344
+ (flagStr(input, "dir")
345
+ ? `are under ${outcome.directory}.`
346
+ : "can be kept with --dir."),
347
+ ];
348
+ return {
349
+ data: { banked: false, verified: false, error: result?.error },
350
+ human: parts.join("\n"),
351
+ images: shot
352
+ ? [{ data: shot.toString("base64"), mimeType: "image/png" }]
353
+ : undefined,
354
+ exitCode: 1,
355
+ };
356
+ }
357
+ let created;
358
+ try {
359
+ created = await bank({ verify: false, plan_hash: compiled.plan_hash });
360
+ }
361
+ catch (err) {
362
+ const conflict = createConflictResult(err, compiled.plan_hash);
363
+ if (conflict)
364
+ return conflict;
365
+ throw err;
366
+ }
367
+ let runId;
368
+ if (sync) {
369
+ const entry = toRunEntry({ ...specOf(compiled.content), id: String(created.id) }, outcome, undefined, startedAt, 0, loginPassword);
370
+ const imported = (await ctx.client.request("POST", `${projectPath(workspaceId, projectId)}/runs/import`, {
371
+ form: buildImportForm([entry], {
372
+ environmentId: env,
373
+ targetUrlOverride: urlOverride,
374
+ // The proof replay is authoring machinery, not a run the customer
375
+ // asked for — it must never email/Slack "your report is ready".
376
+ notifications: false,
377
+ startedAt,
378
+ completedAt: new Date().toISOString(),
379
+ onNote: (line) => ctx.err(yellow(`! ${line}`)),
380
+ }),
381
+ }));
382
+ runId = imported.id;
383
+ }
384
+ if (urlOverride) {
385
+ ctx.err(yellow(`! The proof ran against ${urlOverride}, not the environment's root URL — ` +
386
+ "the banked test is unproven against its real target until a run there passes."));
387
+ }
127
388
  return {
128
- data: await ctx.client.post(`${projectPath(workspaceId, projectId)}/tests`, {
129
- title,
130
- plan: readJsonFlag(input, "file"),
131
- verify: !flagBool(input, "no-verify"),
132
- description: flagStr(input, "description"),
133
- }),
389
+ data: { ...created, ...(runId ? { proof_run_id: runId } : {}) },
390
+ human: green(`✓ Replay passed locally — banked as ${created.id}`) +
391
+ (runId ? `\nProving run imported: \`beryl runs get ${runId}\`` : ""),
134
392
  };
135
393
  },
136
394
  },
@@ -227,8 +485,14 @@ export const testCommands = [
227
485
  ],
228
486
  async run(ctx, input) {
229
487
  const { workspaceId, projectId } = await ctx.requireProject(input);
488
+ const res = (await ctx.client.post(`${testPath(workspaceId, projectId, arg(input, "test-id"))}/plan/recompile`, { json_plan: readJsonFlag(input, "file") }));
489
+ // Base64 in the JSON output would flood the caller's context — hand the
490
+ // failure screenshot over as image content instead.
491
+ const shot = typeof res.screenshot_b64 === "string" ? res.screenshot_b64 : undefined;
492
+ delete res.screenshot_b64;
230
493
  return {
231
- data: await ctx.client.post(`${testPath(workspaceId, projectId, arg(input, "test-id"))}/plan/recompile`, { json_plan: readJsonFlag(input, "file") }),
494
+ data: res,
495
+ images: shot ? [{ data: shot, mimeType: sniffImageMime(shot) }] : undefined,
232
496
  };
233
497
  },
234
498
  },
@@ -342,11 +606,36 @@ export const testCommands = [
342
606
  },
343
607
  {
344
608
  name: "tests script",
345
- summary: "Print the rendered Playwright spec for a test",
609
+ summary: "Print the rendered Playwright spec for a test (or an unbanked plan file)",
610
+ description: "With a test id, fetches the banked test's rendered .spec.ts. With --file, compiles a " +
611
+ "plan JSON that has NOT been banked yet — the same render `tests create` proves locally — " +
612
+ "so you can inspect exactly what would run before creating anything.",
346
613
  scope: "project",
347
- args: [{ name: "test-id", description: "Test id", required: true }],
614
+ args: [{ name: "test-id", description: "Test id (omit when passing --file)" }],
615
+ flags: [
616
+ { name: "file", type: "string", description: "Compile this plan JSON file instead of a banked test" },
617
+ {
618
+ name: "url-override",
619
+ type: "string",
620
+ description: "With --file: resolve relative gotos against this base URL",
621
+ },
622
+ { name: "env", type: "string", description: "With --file: environment id to render against" },
623
+ ],
624
+ examples: ["beryl tests script 4f…", "beryl tests script --file plan.json"],
348
625
  async run(ctx, input) {
349
626
  const { workspaceId, projectId } = await ctx.requireProject(input);
627
+ const id = input.args["test-id"];
628
+ const file = flagStr(input, "file");
629
+ if (Boolean(id) === Boolean(file))
630
+ throw new UsageError("Pass exactly one of <test-id> or --file");
631
+ if (file) {
632
+ const data = (await ctx.client.post(`${projectPath(workspaceId, projectId)}/tests/compile`, {
633
+ plan: readJsonFlag(input, "file"),
634
+ base_url: flagStr(input, "url-override"),
635
+ environment_id: flagStr(input, "env"),
636
+ }));
637
+ return { data: data, human: data.content };
638
+ }
350
639
  const data = (await ctx.client.get(`${testPath(workspaceId, projectId, arg(input, "test-id"))}/script`));
351
640
  const script = typeof data === "string" ? data : (data.script ?? data.content ?? data);
352
641
  return { data, human: typeof script === "string" ? script : JSON.stringify(data, null, 2) };
package/dist/context.js CHANGED
@@ -1,6 +1,23 @@
1
1
  import readline from "node:readline/promises";
2
2
  import { CliError, UsageError } from "./errors.js";
3
3
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
4
+ // On a terminal, an ambiguous workspace/project becomes a numbered pick instead of an
5
+ // error the user answers by retyping the same command with a flag. Non-interactive
6
+ // callers (CI, MCP) still get the actionable UsageError — a prompt there would hang.
7
+ async function pickOne(ctx, candidates, kind, usageMessage) {
8
+ if (!ctx.interactive)
9
+ throw new UsageError(usageMessage);
10
+ ctx.err(`Multiple ${kind}s:`);
11
+ candidates.forEach((c, i) => {
12
+ ctx.err(` ${i + 1}. ${c.name ?? c.root_url ?? c.id} (${c.id})`);
13
+ });
14
+ const answer = await ctx.prompt(`Which ${kind}? [1-${candidates.length}] `);
15
+ const n = Number.parseInt(answer, 10);
16
+ if (!Number.isInteger(n) || n < 1 || n > candidates.length) {
17
+ throw new UsageError(usageMessage);
18
+ }
19
+ return candidates[n - 1].id;
20
+ }
4
21
  function matchByName(candidates, value, kind) {
5
22
  const lower = value.toLowerCase();
6
23
  const matches = candidates.filter((c) => c.name?.toLowerCase() === lower ||
@@ -65,13 +82,27 @@ export function createContext(options) {
65
82
  throw new CliError("You have no workspaces yet — create one with `beryl workspaces create`");
66
83
  }
67
84
  else {
68
- throw new UsageError("Multiple workspaces — pass --workspace <id|name> or set BERYL_WORKSPACE:\n" +
85
+ workspaceCache = await pickOne(ctx, workspaces, "workspace", "Multiple workspaces — pass --workspace <id|name> or set BERYL_WORKSPACE:\n" +
69
86
  workspaces.map((w) => ` ${w.id} ${w.name ?? ""}`).join("\n"));
70
87
  }
71
88
  return workspaceCache;
72
89
  },
73
90
  async requireProject(input) {
74
91
  const requested = input.flags.project ?? config.project;
92
+ // A project id is globally unique, so it alone must be enough: when no workspace
93
+ // is flagged/configured, find the owner instead of demanding --workspace.
94
+ const requestedWorkspace = input.flags.workspace ?? config.workspace;
95
+ if (requested && UUID_RE.test(requested) && !requestedWorkspace && !workspaceCache) {
96
+ const workspaces = (await client.get("/workspaces/"));
97
+ for (const w of workspaces) {
98
+ const projects = (await client.get(`/workspaces/${w.id}/projects`));
99
+ if (projects.some((p) => p.id === requested)) {
100
+ workspaceCache = w.id;
101
+ return { workspaceId: w.id, projectId: requested };
102
+ }
103
+ }
104
+ throw new CliError(`No project ${requested} found in any of your workspaces`);
105
+ }
75
106
  const workspaceId = await ctx.requireWorkspace(input);
76
107
  if (requested && UUID_RE.test(requested))
77
108
  return { workspaceId, projectId: requested };
@@ -90,7 +121,7 @@ export function createContext(options) {
90
121
  "`beryl projects create <url>` (add --no-explore to author tests yourself).");
91
122
  }
92
123
  else {
93
- throw new UsageError("Multiple projects — pass --project <id|name|url> or set BERYL_PROJECT:\n" +
124
+ projectId = await pickOne(ctx, projects, "project", "Multiple projects — pass --project <id|name|url> or set BERYL_PROJECT:\n" +
94
125
  projects.map((p) => ` ${p.id} ${p.name ?? p.root_url ?? ""}`).join("\n"));
95
126
  }
96
127
  projectCache = { workspaceId, projectId };
@@ -0,0 +1,99 @@
1
+ import vm from "node:vm";
2
+ // A labelled digit run ("your code is 654321") beats the bare fenced pattern, because a
3
+ // real sign-in mail is full of innocent 4-8 digit runs — "© 2026", a support number —
4
+ // and the bare fence would happily return the first of them.
5
+ const CODE_PATTERN = /(?<!\d)(\d{4,8})(?!\d)/;
6
+ const LABELLED_CODE_PATTERN = /(?:verification|security|one[\s-]?time|login|sign[\s-]?in|access|confirmation)?\s*(?:code|otp|passcode|pin)\b[^0-9]{0,20}(?<!\d)(\d{4,8})(?!\d)/i;
7
+ const LINK_PATTERN = /https:\/\/[^\s"'<>)\]]+/;
8
+ // An anchor's href is the click target in an HTML mail; a bare URL scan over raw markup
9
+ // would return a CSS background or tracking pixel instead.
10
+ const HREF_PATTERN = /<a\b[^>]*\bhref\s*=\s*["']?(https:\/\/[^\s"'>]+)/gi;
11
+ // Footer furniture a sign-in mail carries but nobody means.
12
+ const LINK_NOISE = [
13
+ "unsubscribe",
14
+ "/privacy",
15
+ "/terms",
16
+ "list-manage",
17
+ "mailchimp",
18
+ "sendgrid.net",
19
+ "/track/",
20
+ "/wf/open",
21
+ "twitter.com",
22
+ "facebook.com",
23
+ "linkedin.com",
24
+ ];
25
+ // Matches the server's EMAIL_PATTERN_MAX_BODY_CHARS bound: backtracking cost scales with
26
+ // haystack length, and no real sign-in code lives past the first few KB of an email.
27
+ const PATTERN_MAX_BODY_CHARS = 20_000;
28
+ const PATTERN_TIMEOUT_MS = 2_000;
29
+ // An author-supplied regex against a sender-supplied body is the textbook ReDoS setup,
30
+ // and native RegExp cannot be interrupted once it starts. Running the match inside a vm
31
+ // context gives it a real wall-clock deadline — V8 services the timeout interrupt while
32
+ // backtracking — matching the server's regex-engine timeout, whatever the pattern is.
33
+ function matchWithDeadline(haystack, pattern) {
34
+ try {
35
+ return vm.runInNewContext("haystack.match(new RegExp(pattern))", { haystack, pattern }, { timeout: PATTERN_TIMEOUT_MS });
36
+ }
37
+ catch (err) {
38
+ const code = err?.code;
39
+ if (code === "ERR_SCRIPT_EXECUTION_TIMEOUT" || /timed out/i.test(String(err))) {
40
+ throw new Error("the extract_pattern took too long to match this email — it backtracks " +
41
+ "explosively (an ambiguous quantifier like (a+)+ or (a|a)+ does this). " +
42
+ "Rewrite it to match unambiguously.");
43
+ }
44
+ throw err;
45
+ }
46
+ }
47
+ export function visibleText(html) {
48
+ return html
49
+ .replace(/<(style|script|head)\b[\s\S]*?<\/\1>/gi, " ")
50
+ .replace(/<[^>]+>/g, " ");
51
+ }
52
+ function bodyOf(email) {
53
+ if (email.body_text)
54
+ return email.body_text;
55
+ if (email.body_html)
56
+ return visibleText(email.body_html);
57
+ return "";
58
+ }
59
+ export function extractCode(email) {
60
+ for (const pattern of [LABELLED_CODE_PATTERN, CODE_PATTERN]) {
61
+ for (const text of [bodyOf(email), email.subject ?? ""]) {
62
+ const match = text.match(pattern);
63
+ if (match)
64
+ return match[1];
65
+ }
66
+ }
67
+ return null;
68
+ }
69
+ export function extractLink(email) {
70
+ const html = email.body_html ?? "";
71
+ const hrefs = [...html.matchAll(HREF_PATTERN)].map((m) => m[1]);
72
+ const candidates = hrefs.length > 0 ? hrefs : (bodyOf(email).match(LINK_PATTERN) ?? []);
73
+ for (const url of candidates) {
74
+ if (!LINK_NOISE.some((noise) => url.toLowerCase().includes(noise)))
75
+ return url;
76
+ }
77
+ if (candidates.length > 0) {
78
+ throw new Error("the email's only links look like footer/unsubscribe links, not a sign-in link");
79
+ }
80
+ throw new Error("the email arrived but carried no https link to follow");
81
+ }
82
+ export function extractValue(email, extract, pattern) {
83
+ if (extract === "link")
84
+ return extractLink(email);
85
+ const body = bodyOf(email);
86
+ if (extract === "pattern") {
87
+ if (!pattern)
88
+ throw new Error("extract=pattern needs an extract_pattern");
89
+ const match = matchWithDeadline(body.slice(0, PATTERN_MAX_BODY_CHARS), pattern);
90
+ if (!match) {
91
+ throw new Error("the email arrived but nothing in it matched the extract_pattern");
92
+ }
93
+ return match[1] ?? match[0];
94
+ }
95
+ const code = extractCode(email);
96
+ if (!code)
97
+ throw new Error("the email arrived but carried no 4-8 digit sign-in code");
98
+ return code;
99
+ }
@@ -0,0 +1,102 @@
1
+ import fs from "node:fs";
2
+ import { extractValue } from "./email-extract.js";
3
+ import { ApiError } from "./http.js";
4
+ const SIDECAR_POLL_MS = 500;
5
+ // The server caps one blocking wait at 50s; stay under it and loop.
6
+ const SERVER_WAIT_MAX_S = 45;
7
+ const DEFAULT_WAIT_S = 30;
8
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
9
+ export function startEmailPump(opts) {
10
+ let stopped = false;
11
+ const served = new Set();
12
+ const consumed = new Set();
13
+ let since = opts.since;
14
+ const fetchLatest = async (req, timeoutS) => {
15
+ try {
16
+ return (await opts.client.get(`/workspaces/${opts.workspaceId}/inboxes/${opts.inboxId}/emails/latest`, {
17
+ timeout_s: timeoutS,
18
+ since,
19
+ from_contains: req.from_contains,
20
+ subject_contains: req.subject_contains,
21
+ }));
22
+ }
23
+ catch (err) {
24
+ if (err instanceof ApiError && err.status === 404)
25
+ return null;
26
+ throw err;
27
+ }
28
+ };
29
+ const resolveRequest = async (req) => {
30
+ // Wait exactly wait_s, like the cloud resolver — the spec's own grace window
31
+ // covers this side's poll tick + round trip.
32
+ const waitS = Math.max(1, Number(req.wait_s) || DEFAULT_WAIT_S);
33
+ const deadline = Date.now() + waitS * 1000;
34
+ while (!stopped && Date.now() < deadline) {
35
+ const remainingS = Math.ceil((deadline - Date.now()) / 1000);
36
+ const email = await fetchLatest(req, Math.min(SERVER_WAIT_MAX_S, remainingS));
37
+ if (email === null)
38
+ continue;
39
+ if (consumed.has(email.id)) {
40
+ // The fence rides received_at, which comes from the sender's Date header — a
41
+ // lagging mailer clock can re-serve a consumed mail. Nudge past it instead.
42
+ since = new Date(new Date(email.received_at).getTime() + 1000).toISOString();
43
+ continue;
44
+ }
45
+ consumed.add(email.id);
46
+ since = email.received_at;
47
+ opts.onEvent?.(`await_email: matched "${email.subject ?? "(no subject)"}"`);
48
+ return extractValue(email, String(req.extract ?? "code"), req.pattern);
49
+ }
50
+ throw new Error(`no matching email arrived in the run inbox within ${waitS}s`);
51
+ };
52
+ const writeResponse = (response) => {
53
+ let box = {};
54
+ try {
55
+ box = JSON.parse(fs.readFileSync(opts.sidecarPath, "utf8"));
56
+ }
57
+ catch {
58
+ // keep at least the response readable
59
+ }
60
+ box.response = response;
61
+ // Write-then-rename so the spec never reads a half-written response.
62
+ const tmp = `${opts.sidecarPath}.tmp`;
63
+ fs.writeFileSync(tmp, JSON.stringify(box), "utf8");
64
+ fs.renameSync(tmp, opts.sidecarPath);
65
+ };
66
+ void (async () => {
67
+ while (!stopped) {
68
+ await sleep(SIDECAR_POLL_MS);
69
+ let box;
70
+ try {
71
+ box = JSON.parse(fs.readFileSync(opts.sidecarPath, "utf8"));
72
+ }
73
+ catch {
74
+ continue; // torn read or not yet written — the spec re-polls
75
+ }
76
+ const req = box.request;
77
+ if (!req || typeof req.id !== "string" || served.has(req.id))
78
+ continue;
79
+ served.add(req.id);
80
+ const response = { id: req.id };
81
+ try {
82
+ response.value = await resolveRequest(req);
83
+ }
84
+ catch (err) {
85
+ response.error = err instanceof Error ? err.message : String(err);
86
+ }
87
+ if (stopped)
88
+ return;
89
+ try {
90
+ writeResponse(response);
91
+ }
92
+ catch {
93
+ // run dir vanished mid-write — the run is over anyway
94
+ }
95
+ }
96
+ })();
97
+ return {
98
+ stop: () => {
99
+ stopped = true;
100
+ },
101
+ };
102
+ }