diff --git a/README.md b/README.md
index 1370870..c99a8f2 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,7 @@ packages/
openontology OpenOntology reference engine (entities, claims, queries, change sets)
openprd OpenPRD reference implementation (numbered PRDs, lifecycle, task bridge)
openfleet OpenFleet reference implementation (record, ledger, sysop verbs, Claude Code hooks)
+ openerrand OpenErrand reference runner (errand files in headless Chrome, human gates kept human)
logicsrc-mcp @profullstack/logicsrc-mcp standards MCP server
sdk SDK contract types and helpers
tui terminal UI
@@ -167,6 +168,7 @@ logicsrc prd … # OpenPRD
logicsrc ontology … # OpenOntology
logicsrc context … # OpenContext (also `opencontext`)
logicsrc fleet … # OpenFleet: open, cap, tree, stop, log, hooks install
+logicsrc errand … # OpenErrand: run, validate, status (an errand on a site with no API, gates kept human)
logicsrc openmcp … # OpenMCP: relays, find, call, add, probe, serve (also `openmcp`)
logicsrc openspec … # import, export, change; any other word is OpenSpec.dev's own CLI (init, list, validate, archive, show)
logicsrc mcp # the LogicSRC MCP server over stdio (also `logicsrc-mcp`)
diff --git a/apps/logicsrc-web/src/app/openerrand/page.tsx b/apps/logicsrc-web/src/app/openerrand/page.tsx
index 7a51782..c6cc7ee 100644
--- a/apps/logicsrc-web/src/app/openerrand/page.tsx
+++ b/apps/logicsrc-web/src/app/openerrand/page.tsx
@@ -55,8 +55,9 @@ export default function OpenErrandPage(): ReactNode {
values that are secret, the steps that belong to a person, and the point where it stops.
- Status: 0.1. A generic runner, logicsrc errand run from{" "}
- @logicsrc/openerrand, is in progress. The worked example
+ Status: 0.1. The reference runner is logicsrc errand run, from{" "}
+ @logicsrc/openerrand: it reads an errand file and drives
+ headless Chrome through it under the rules below. The worked example
transcribes the rule table of ftb in cli-tools, which registers
and activates MyFTB accounts at the California Franchise Tax Board. An excerpt:
diff --git a/bun.lock b/bun.lock
index c964981..6bf5367 100644
--- a/bun.lock
+++ b/bun.lock
@@ -144,7 +144,7 @@
},
"packages/cli": {
"name": "@logicsrc/cli",
- "version": "0.6.0",
+ "version": "0.7.0",
"bin": {
"logicsrc": "dist/index.js",
},
@@ -153,6 +153,7 @@
"@logicsrc/account-core": "file:../account-core",
"@logicsrc/opencontext": "file:../opencontext",
"@logicsrc/opencreds": "file:../opencreds",
+ "@logicsrc/openerrand": "file:../openerrand",
"@logicsrc/openfleet": "file:../openfleet",
"@logicsrc/openmcp": "^0.3.1",
"@logicsrc/openontology": "file:../openontology",
@@ -222,6 +223,17 @@
"vitest": "^4.0.8",
},
},
+ "packages/openerrand": {
+ "name": "@logicsrc/openerrand",
+ "version": "0.1.0",
+ "dependencies": {
+ "@logicsrc/validators": "^0.4.0",
+ "commander": "^14.0.2",
+ },
+ "devDependencies": {
+ "vitest": "^4.0.8",
+ },
+ },
"packages/openfleet": {
"name": "@logicsrc/openfleet",
"version": "0.1.0",
@@ -267,7 +279,7 @@
},
"packages/schemas": {
"name": "@logicsrc/schemas",
- "version": "0.3.0",
+ "version": "0.4.0",
},
"packages/sdk": {
"name": "@logicsrc/sdk",
@@ -290,12 +302,12 @@
},
"packages/validators": {
"name": "@logicsrc/validators",
- "version": "0.3.0",
+ "version": "0.4.0",
"bin": {
"logicsrc-validate": "dist/cli.js",
},
"dependencies": {
- "@logicsrc/schemas": "^0.3.0",
+ "@logicsrc/schemas": "^0.4.0",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"yaml": "^2.8.1",
@@ -675,6 +687,8 @@
"@logicsrc/opencreds": ["@logicsrc/opencreds@workspace:packages/opencreds"],
+ "@logicsrc/openerrand": ["@logicsrc/openerrand@workspace:packages/openerrand"],
+
"@logicsrc/openfleet": ["@logicsrc/openfleet@workspace:packages/openfleet"],
"@logicsrc/openmcp": ["@logicsrc/openmcp@0.3.1", "", { "dependencies": { "@hono/node-server": "^2.1.1", "hono": "^4.13.7" }, "bin": { "openmcp": "bin/openmcp.mjs" } }, "sha512-uqfEFyA+XNquADqtmhhl71cobuRck8+IUlhBpsCBGdlOMhCxjoLx+VagjsNES3tmmO+sEjnvW7zEHA7DtZ/oHA=="],
diff --git a/docs/cli.md b/docs/cli.md
index 7b021e7..020ee20 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -36,6 +36,7 @@ social
email
credentials
fleet
+errand
openspec
plugins
tui
diff --git a/docs/openerrand.md b/docs/openerrand.md
index cc9530d..ef430ce 100644
--- a/docs/openerrand.md
+++ b/docs/openerrand.md
@@ -641,7 +641,23 @@ npx @logicsrc/validators openerrand ftb-register-business.json
## Reference runner
-In progress: a generic runner, the `@logicsrc/openerrand` package in the LogicSRC repository, run as `logicsrc errand run `, reads an errand file and drives headless Chrome through it under the rules above. Until it ships, `ftb` in cli-tools is the runner the worked example was taken from; it has the same rule table compiled in rather than reading the file.
+`logicsrc errand run `, from the [`@logicsrc/openerrand`](https://github.com/profullstack/logicsrc/tree/master/packages/openerrand) package, reads an errand file, validates it with `@logicsrc/validators`, and drives headless Chrome through it under the rules above. `logicsrc errand validate ` shows what a file will ask of you; `logicsrc errand status` shows the last run of each errand, its hand-off card and any lockout.
+
+```
+logicsrc errand run ftb-register-business.json --extractor "python3 extract.py ~/taxes" --dry-run
+logicsrc errand run ftb-register-business.json --extractor "python3 extract.py ~/taxes" --declare --vault teams:profullstack/ftb/prod
+```
+
+What the runner adds that the file does not say:
+
+- **Documents** come from an extractor the principal names with `--extractor`: a local command that reads the requested forms and fields as JSON on stdin and prints records (`form`, `field`, `value`, `year`, `label`, `file`, `page`). The runner ships no extractor of its own.
+- **The vault** is `--vault`, or a string in the file's `metadata.vault`: `teams://` (read with `logicsrc teams pull`, written by pull, merge, push), `opencreds` (read-only), or `file:`. With none, credentials go to a 0600 file under `~/.local/share/logicsrc/errand/credentials/` and the runner says so.
+- **A code** is typed at the prompt, or written to `~/.local/share/logicsrc/errand/codes/.code` by whoever holds the phone. A wrong code waits for the next one.
+- **The throttle**: 2 runs of an errand per account in 30 minutes, 4 a day, 2 minutes between any two runs on one site, and nothing at all during a recorded lockout. A lockout is read from `metadata.lockout.text` (a pattern) and lasts `metadata.lockout.duration`, or a default pattern and 35 minutes. `--force` lifts the caps and never a lockout.
+- **One Chrome profile per site** is kept between runs, so a bot check the browser has passed stays passed. The user agent drops `HeadlessChrome` and nothing more.
+- **A captcha solver** is an interface a program embedding the runner may pass; none is bundled, and the runner calls one only where the [captcha rules](#captcha) permit it.
+
+`ftb` in [cli-tools](https://github.com/profullstack/cli-tools/pull/125) is the runner the worked example was taken from; it has the same rule table compiled in rather than reading the file.
## Not
diff --git a/package.json b/package.json
index cfb1ad7..e38b656 100644
--- a/package.json
+++ b/package.json
@@ -12,14 +12,14 @@
"apps/*"
],
"scripts": {
- "build": "bun run --cwd packages/schemas build && bun run --cwd packages/validators build && bun run --cwd packages/sdk build && bun run --cwd packages/agentad build && bun run --cwd packages/ans build && bun run --cwd packages/plugin-core build && bun run --cwd packages/agentstack build && bun run --cwd packages/agentswarm build && bun run --cwd packages/account-core build && bun run --cwd plugins/coinpay build && bun run --cwd plugins/ugig build && bun run --cwd plugins/sh1pt build && bun run --cwd plugins/c0mpute build && bun run --cwd plugins/feed-discovery build && bun run --cwd plugins/social-accounts build && bun run --cwd plugins/email-accounts build && bun run --cwd plugins/agentbbs build && bun run --cwd plugins/agentgit build && bun run --cwd plugins/agentmail build && bun run --cwd plugins/credential-sharing build && bun run --cwd packages/openontology build && bun run --cwd packages/openprd build && bun run --cwd packages/opencontext build && bun run --cwd packages/opencreds build && bun run --cwd packages/tui build && bun run --cwd packages/openfleet build && bun run --cwd packages/cli build && bun run --cwd packages/logicsrc-mcp build && bun run --cwd apps/commandboard-api build && bun run --cwd apps/commandboard-web build && bun run --cwd apps/logicsrc-web build",
+ "build": "bun run --cwd packages/schemas build && bun run --cwd packages/validators build && bun run --cwd packages/sdk build && bun run --cwd packages/agentad build && bun run --cwd packages/ans build && bun run --cwd packages/plugin-core build && bun run --cwd packages/agentstack build && bun run --cwd packages/agentswarm build && bun run --cwd packages/account-core build && bun run --cwd plugins/coinpay build && bun run --cwd plugins/ugig build && bun run --cwd plugins/sh1pt build && bun run --cwd plugins/c0mpute build && bun run --cwd plugins/feed-discovery build && bun run --cwd plugins/social-accounts build && bun run --cwd plugins/email-accounts build && bun run --cwd plugins/agentbbs build && bun run --cwd plugins/agentgit build && bun run --cwd plugins/agentmail build && bun run --cwd plugins/credential-sharing build && bun run --cwd packages/openontology build && bun run --cwd packages/openprd build && bun run --cwd packages/opencontext build && bun run --cwd packages/opencreds build && bun run --cwd packages/tui build && bun run --cwd packages/openfleet build && bun run --cwd packages/openerrand build && bun run --cwd packages/cli build && bun run --cwd packages/logicsrc-mcp build && bun run --cwd apps/commandboard-api build && bun run --cwd apps/commandboard-web build && bun run --cwd apps/logicsrc-web build",
"start": "bun run --cwd apps/logicsrc-web start",
"test": "bun run --filter '*' test",
"check": "bun run build && bun run test",
"schemas:validate": "bun run --cwd packages/validators validate:fixtures",
"test:contract": "bun run --cwd apps/commandboard-api test:contract && bun run --cwd apps/logicsrc-web test:contract",
"test:e2e": "bun run --cwd apps/commandboard-web test:e2e && bun run --cwd apps/logicsrc-web test:e2e",
- "build:cli": "bun run --cwd packages/schemas build && bun run --cwd packages/validators build && bun run --cwd packages/plugin-core build && bun run --cwd packages/account-core build && bun run --cwd plugins/coinpay build && bun run --cwd plugins/ugig build && bun run --cwd plugins/feed-discovery build && bun run --cwd plugins/social-accounts build && bun run --cwd plugins/email-accounts build && bun run --cwd plugins/agentbbs build && bun run --cwd plugins/credential-sharing build && bun run --cwd packages/openontology build && bun run --cwd packages/openprd build && bun run --cwd packages/opencontext build && bun run --cwd packages/opencreds build && bun run --cwd packages/tui build && bun run --cwd packages/logicsrc-mcp build && bun run --cwd packages/openfleet build && bun run --cwd packages/cli build"
+ "build:cli": "bun run --cwd packages/schemas build && bun run --cwd packages/validators build && bun run --cwd packages/plugin-core build && bun run --cwd packages/account-core build && bun run --cwd plugins/coinpay build && bun run --cwd plugins/ugig build && bun run --cwd plugins/feed-discovery build && bun run --cwd plugins/social-accounts build && bun run --cwd plugins/email-accounts build && bun run --cwd plugins/agentbbs build && bun run --cwd plugins/credential-sharing build && bun run --cwd packages/openontology build && bun run --cwd packages/openprd build && bun run --cwd packages/opencontext build && bun run --cwd packages/opencreds build && bun run --cwd packages/tui build && bun run --cwd packages/logicsrc-mcp build && bun run --cwd packages/openfleet build && bun run --cwd packages/openerrand build && bun run --cwd packages/cli build"
},
"devDependencies": {
"@types/node": "^24.10.1",
diff --git a/packages/cli/package.json b/packages/cli/package.json
index a0c9050..801fb5f 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@logicsrc/cli",
- "version": "0.6.0",
+ "version": "0.7.0",
"description": "LogicSRC CLI: every LogicSRC standard and tool as one command.",
"type": "module",
"main": "./dist/index.js",
@@ -18,6 +18,7 @@
"@logicsrc/account-core": "file:../account-core",
"@logicsrc/opencontext": "file:../opencontext",
"@logicsrc/opencreds": "file:../opencreds",
+ "@logicsrc/openerrand": "file:../openerrand",
"@logicsrc/openfleet": "file:../openfleet",
"@logicsrc/openmcp": "^0.3.1",
"@logicsrc/openontology": "file:../openontology",
diff --git a/packages/cli/src/errand.test.ts b/packages/cli/src/errand.test.ts
new file mode 100644
index 0000000..a276b68
--- /dev/null
+++ b/packages/cli/src/errand.test.ts
@@ -0,0 +1,42 @@
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { Command } from "commander";
+import { afterEach, describe, expect, it } from "vitest";
+import { registerErrandCommands } from "./errand.js";
+
+/** A program shaped like the real one: positional options on, no process.exit. */
+function program(): Command {
+ const p = new Command();
+ p.name("logicsrc").enablePositionalOptions().exitOverride();
+ return p;
+}
+
+const example = fileURLToPath(new URL("../../schemas/fixtures/openerrand/ftb-register-business.json", import.meta.url));
+
+describe("logicsrc errand", () => {
+ const homes: string[] = [];
+ afterEach(() => {
+ for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true });
+ process.exitCode = 0;
+ });
+
+ it("mounts run, validate and status", () => {
+ const p = program();
+ registerErrandCommands(p);
+ const errand = p.commands.find((c) => c.name() === "errand")!;
+ expect(errand.commands.map((c) => c.name()).sort()).toEqual(["run", "status", "validate"]);
+ });
+
+ it("validates the spec's worked example through the umbrella", async () => {
+ const out: string[] = [];
+ const home = mkdtempSync(join(tmpdir(), "logicsrc-errand-cli-"));
+ homes.push(home);
+ const p = program();
+ registerErrandCommands(p, { out: (l) => out.push(l), say: () => undefined, env: { LOGICSRC_ERRAND_HOME: home } });
+ await p.parseAsync(["node", "logicsrc", "errand", "validate", example]);
+ expect(process.exitCode).toBe(0);
+ expect(out.at(-1)).toMatch(/^valid OpenErrand 0\.1/);
+ });
+});
diff --git a/packages/cli/src/errand.ts b/packages/cli/src/errand.ts
new file mode 100644
index 0000000..4f76b74
--- /dev/null
+++ b/packages/cli/src/errand.ts
@@ -0,0 +1,35 @@
+import { spawnSync } from "node:child_process";
+import type { Command } from "commander";
+import { registerErrandCommands as registerRunner, type Deps, type LogicsrcExec } from "@logicsrc/openerrand/commands";
+
+/**
+ * `logicsrc errand …`
+ *
+ * The runner lives in `@logicsrc/openerrand`, the reference implementation of
+ * OpenErrand; this file only mounts it. A team vault named by an errand
+ * (`teams://`) is read and written through this same CLI's
+ * `teams pull` / `teams push`, re-entered as a child process so the runner
+ * never needs the CLI's session code and the plaintext lives for one call.
+ *
+ * `deps` is injectable so the umbrella test drives the group without a
+ * terminal, a browser or a vault.
+ */
+export function registerErrandCommands(program: Command, deps: Partial = {}): void {
+ const errand = program
+ .command("errand")
+ .description(
+ "OpenErrand: run an errand file on a website with no API, in headless Chrome, stopping at every step " +
+ "that belongs to a person (declarations, identity proofing, codes, letters, captchas).",
+ );
+
+ const self: LogicsrcExec = (args, options) => {
+ const entry = process.argv[1];
+ const result = entry
+ ? spawnSync(process.execPath, [entry, ...args], { encoding: "utf8", stdio: [options?.inheritStdin ? "inherit" : "ignore", "pipe", "pipe"] })
+ : spawnSync("logicsrc", args, { encoding: "utf8", stdio: [options?.inheritStdin ? "inherit" : "ignore", "pipe", "pipe"] });
+ if (result.error) return { status: 1, stdout: "", stderr: result.error.message };
+ return { status: result.status ?? 1, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
+ };
+
+ registerRunner(errand, { logicsrc: self, ...deps });
+}
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index 7dd5bea..2fb4853 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -41,6 +41,7 @@ import { exportOpenSpecSummary, importOpenSpec, writeOpenSpecChange } from "./op
import { registerOpenContextCommands } from "./context.js";
import { registerOpenCredsCommands } from "./creds.js";
import { registerFleetCommands } from "./fleet.js";
+import { registerErrandCommands } from "./errand.js";
import { registerOntologyCommands } from "./ontology.js";
import { registerPrdCommands } from "./prd.js";
import { registerOpenMcpCommands } from "./openmcp.js";
@@ -1176,6 +1177,7 @@ registerOpenCredsCommands(program);
registerOntologyCommands(program);
registerPrdCommands(program);
registerFleetCommands(program);
+registerErrandCommands(program);
registerOpenMcpCommands(program);
registerMcpCommands(program);
// Every other word under `logicsrc openspec` is OpenSpec.dev's own CLI.
diff --git a/packages/logicsrc-mcp/src/standards.test.ts b/packages/logicsrc-mcp/src/standards.test.ts
index e58ab1d..a493cea 100644
--- a/packages/logicsrc-mcp/src/standards.test.ts
+++ b/packages/logicsrc-mcp/src/standards.test.ts
@@ -203,7 +203,7 @@ describe("MCP: OpenPRD", () => {
it("reports the next free id and the allowed lifecycle moves", async () => {
const client = await connect();
// Asserted against the live prd/ directory, so this advances with every PRD added.
- expect(toolText(await client.callTool({ name: "prd_next_id", arguments: {} }))).toBe("0009");
+ expect(toolText(await client.callTool({ name: "prd_next_id", arguments: {} }))).toBe("0010");
const moves = await client.callTool({ name: "prd_next_statuses", arguments: { ref: "0001" } });
const payload = JSON.parse(toolText(moves)) as { status: string; allowedNext: string[] };
diff --git a/packages/openerrand/README.md b/packages/openerrand/README.md
new file mode 100644
index 0000000..62d42ef
--- /dev/null
+++ b/packages/openerrand/README.md
@@ -0,0 +1,117 @@
+# @logicsrc/openerrand
+
+The reference runner for [OpenErrand](https://logicsrc.com/docs/openerrand):
+one JSON file that describes an errand on a website with no API, run in
+headless Chrome, stopping at every step that belongs to a person.
+
+It reads an errand file, validates it with `@logicsrc/validators`, resolves
+every input before the browser opens, then for each page finds its step,
+tests the outcomes, fills the form from the rules (id first, label second,
+choices before text), hands every gate to a person, and presses the forward
+button, until the site answers or the runner has to stop.
+
+## Install
+
+```bash
+curl -fsSL https://logicsrc.com/install.sh | sh
+logicsrc errand --help
+```
+
+The commands live in this package and the umbrella CLI mounts them as
+`logicsrc errand`. To embed the runner:
+
+```bash
+npm install @logicsrc/openerrand
+```
+
+## Commands
+
+```bash
+logicsrc errand validate ftb-register-business.json
+logicsrc errand run ftb-register-business.json --extractor "python3 extract.py ~/taxes" --dry-run
+logicsrc errand run ftb-register-business.json --extractor "python3 extract.py ~/taxes" --declare
+logicsrc errand status
+```
+
+| Flag | What |
+| --- | --- |
+| `--input name=value` | Give an input's value (repeatable). Wins over every source. |
+| `--dry-run` | Fill every page up to the first that holds a declaration or a shared secret, show it with secrets masked and the declaration word for word, and stop before its forward button. Not counted by the throttle. |
+| `--declare` | Your consent, for this run, to tick the declarations the errand names. Never read from a file, the environment or a saved default. |
+| `--headful` | A visible window, so you can take identity proofing or a captcha yourself. |
+| `--chrome PATH` | The Chrome or Chromium binary (default: `CHROME_PATH`, then PATH, then the Puppeteer and Playwright caches). |
+| `--vault TARGET` | `teams://`, `opencreds[:]` or `file:`. Default: the file's `metadata.vault`, else a 0600 file. |
+| `--extractor CMD` | The local command that reads your documents (see below). |
+| `--candidate N` | Which shared-secret candidate to submit; a rejection lists the others. |
+| `--account NAME` | Which account at the site, for the throttle. |
+| `--force` | Lift the per-window, per-day and spacing caps. Never a lockout. |
+| `--yes` | Run a file whose SHA-256 changed since its last run without asking. |
+| `--json` | Print the run record on stdout. |
+
+Exit codes: 0 success, waiting or dry run; 1 rejected; 2 invalid file or
+usage; 3 stopped (a gate, an unmatched field, a loop, off-site, a timeout);
+4 refused by the throttle.
+
+## Inputs
+
+| Source | Served by |
+| --- | --- |
+| `document` | `--extractor`: a command on this machine. It gets `{ "errand", "documents": [{ "input", "form", "field", "match" }] }` on stdin and prints `[{ "form", "field", "value", "year", "label", "file", "page" }]`. The runner applies `match`, `years`, `pick` and `transform`, and keeps the values in memory only. No extractor is bundled. |
+| `vault` | The vault target, read-only here. |
+| `prompt` | The terminal; a secret does not echo. With no terminal the next source is tried. |
+| `generate` | `crypto.randomInt`, at least one character of each class, a letter first when letters are allowed. |
+| `derive`, `candidate`, `literal` | The file and the other inputs. |
+
+A shared-secret input's document sources become a ranked list of candidates
+(sources in order, newest year first within each). A run submits one; a
+rejection ends the run and lists the others for you to choose with
+`--candidate`. Nothing is ever retried on its own.
+
+## Gates
+
+| Gate | What the runner does |
+| --- | --- |
+| `declare` | Stops on the page and prints the statement word for word, unless you passed `--declare`; then it shows the values on the page (secrets masked) and ticks the box. |
+| `identity-proofing` | Never touches the provider's pages (it reads only the URL). Headless, it stops and tells you; with `--headful` at a terminal it waits until the page is back on the site. |
+| `code` | The terminal prompt, or the code file `~/.local/share/logicsrc/errand/codes/.code` written by whoever holds the phone. Used once, never logged. A wrong code waits for the next one. |
+| `mail` | Ends the run waiting, with the deadline and the hand-off card in the run record. |
+| `captcha` | Yours: a visible window with `--headful`, or the run stops. A program may pass a `CaptchaSolver`, and it is called only when the step says `solver: allowed` and the errand is outside the forbidden set (a stated sector that is not government, tax, financial, healthcare or identity-provider; no declaration or identity proofing; no secret input). Each use is logged with the time, URL and service. No solver ships with this package. |
+| `wait` | Polled until its match no longer fits, then the run goes on. Never solved or bypassed. |
+
+## What it keeps
+
+Under `$LOGICSRC_ERRAND_HOME`, else `~/.local/share/logicsrc/errand` (files
+0600, directories 0700):
+
+| Path | What |
+| --- | --- |
+| `throttle.json` | Attempts and lockouts. 2 runs of an errand per account in 30 minutes, 4 a day; 2 minutes between runs on one site; nothing during a lockout. A lockout is the file's `metadata.lockout.text` pattern (lasting `metadata.lockout.duration`) or a default pattern (35 minutes), and it holds every errand on that site and account. |
+| `approvals.json` | The SHA-256 of each errand file last run. A changed file is shown and not run without `--yes` or a yes at the terminal. |
+| `runs/` | One run record per run: outcome, page, candidate (no value), card, deadline. Never an input value. |
+| `pages/.jsonl` | The page log: each page's fields (selector, type, label, required, options) and a result page's text. Never a value. |
+| `profiles//` | One Chrome profile per site, kept so a passed bot check stays passed. |
+| `codes/.code` | Where a code may be written. |
+| `credentials/.env` | Credentials when no writable vault is named. |
+
+Hand-off cards stay in the run record and `errand status`. They are never
+posted anywhere, and a card that names a personal or secret input is refused.
+
+## Browser
+
+Chrome over the DevTools protocol, no dependency. The user agent is Chrome's
+own with `HeadlessChrome` replaced by `Chrome`, given at launch so it reaches
+every request; nothing else about the browser is changed.
+
+## Library
+
+```ts
+import { loadErrand, resolveInputs, runErrand, openCdpDriver, Store, fileVault } from "@logicsrc/openerrand";
+```
+
+`runErrand` takes a `Driver` factory, so an embedder can bring its own
+browser; the tests drive it with a scripted fake and one integration test runs
+the FTB example in real headless Chrome against a local fake site.
+
+## License
+
+MIT
diff --git a/packages/openerrand/package.json b/packages/openerrand/package.json
new file mode 100644
index 0000000..87e03ab
--- /dev/null
+++ b/packages/openerrand/package.json
@@ -0,0 +1,49 @@
+{
+ "name": "@logicsrc/openerrand",
+ "version": "0.1.0",
+ "description": "Reference runner for the OpenErrand standard: reads an errand file and drives headless Chrome through a website that has no API, stopping at every step that belongs to a person.",
+ "license": "MIT",
+ "type": "module",
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": "./dist/index.js",
+ "./commands": "./dist/commands.js"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/profullstack/logicsrc.git",
+ "directory": "packages/openerrand"
+ },
+ "homepage": "https://logicsrc.com/docs/openerrand",
+ "keywords": [
+ "logicsrc",
+ "openerrand",
+ "errand",
+ "browser-automation",
+ "chrome",
+ "cdp",
+ "standards",
+ "cli"
+ ],
+ "publishConfig": {
+ "access": "public"
+ },
+ "files": [
+ "dist"
+ ],
+ "engines": {
+ "node": ">=22"
+ },
+ "scripts": {
+ "build": "tsc -p tsconfig.json",
+ "test": "vitest run src"
+ },
+ "dependencies": {
+ "@logicsrc/validators": "^0.4.0",
+ "commander": "^14.0.2"
+ },
+ "devDependencies": {
+ "vitest": "^4.0.8"
+ }
+}
diff --git a/packages/openerrand/src/browser.ts b/packages/openerrand/src/browser.ts
new file mode 100644
index 0000000..7b2c58a
--- /dev/null
+++ b/packages/openerrand/src/browser.ts
@@ -0,0 +1,277 @@
+/**
+ * Chrome over the DevTools protocol, with no dependency: find a binary, start
+ * it, and talk to it over one WebSocket in flat session mode.
+ *
+ * Ported from cli-tools' wcag.ts (findChrome, Cdp, launchBrowser), which `ftb`
+ * drives MyFTB with.
+ */
+
+import { type ChildProcess, spawn } from "node:child_process";
+import { accessSync, constants, mkdirSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
+import { homedir, tmpdir } from "node:os";
+import { join } from "node:path";
+import { ErrandError } from "./util.js";
+
+const isExecutable = (path: string): boolean => {
+ try {
+ accessSync(path, constants.X_OK);
+ return true;
+ } catch {
+ return false;
+ }
+};
+
+const listDir = (path: string): string[] => {
+ try {
+ return readdirSync(path);
+ } catch {
+ return [];
+ }
+};
+
+/** `linux-152.0.7977.42` before `linux-131.0.6778.204`: newest first by version. */
+const byVersionDesc = (a: string, b: string): number => {
+ const parse = (name: string): number[] => (/(\d+(?:\.\d+)*)/.exec(name)?.[1] ?? "0").split(".").map(Number);
+ const left = parse(a);
+ const right = parse(b);
+ for (let i = 0; i < Math.max(left.length, right.length); i += 1) {
+ const d = (right[i] ?? 0) - (left[i] ?? 0);
+ if (d !== 0) return d;
+ }
+ return 0;
+};
+
+/** Every place a Chrome might be: CHROME_PATH, PATH names, /opt, Puppeteer and Playwright caches, macOS bundles. */
+export function chromeCandidates(env: NodeJS.ProcessEnv = process.env, home: string = homedir()): string[] {
+ const out: string[] = [];
+ if (env.CHROME_PATH) out.push(env.CHROME_PATH);
+ for (const name of ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome"]) {
+ for (const dir of (env.PATH ?? "").split(":").filter(Boolean)) out.push(join(dir, name));
+ }
+ out.push("/opt/google/chrome/chrome");
+ // Full Chrome before the headless shell: a person may need the window (--headful).
+ const puppeteer = join(home, ".cache", "puppeteer");
+ for (const flavour of ["chrome", "chrome-headless-shell"]) {
+ for (const build of listDir(join(puppeteer, flavour)).sort(byVersionDesc)) {
+ const dir = join(puppeteer, flavour, build);
+ const inner = listDir(dir).find((entry) => entry.startsWith(flavour));
+ if (inner) out.push(join(dir, inner, flavour));
+ }
+ }
+ const playwright = join(home, ".cache", "ms-playwright");
+ for (const build of listDir(playwright).sort(byVersionDesc)) {
+ if (build.startsWith("chromium-")) out.push(join(playwright, build, "chrome-linux", "chrome"));
+ }
+ out.push("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome");
+ out.push("/Applications/Chromium.app/Contents/MacOS/Chromium");
+ return out;
+}
+
+export function findChrome(env: NodeJS.ProcessEnv = process.env, home: string = homedir(), executable: (path: string) => boolean = isExecutable): string | null {
+ return chromeCandidates(env, home).find(executable) ?? null;
+}
+
+export const NO_CHROME = `no Chrome found. Pass --chrome PATH or set CHROME_PATH, install one
+(apt install chromium, brew install --cask google-chrome), or let Puppeteer fetch one
+(npx puppeteer browsers install chrome).`;
+
+interface CdpMessage {
+ id?: number;
+ method?: string;
+ params?: Record;
+ sessionId?: string;
+ result?: Record;
+ error?: { message: string };
+}
+
+export interface CdpEvent {
+ method: string;
+ params: Record;
+ sessionId?: string;
+}
+
+/** Requests with ids and events by name, over one socket for the browser and every page. */
+export class Cdp {
+ private nextId = 1;
+ private readonly pending = new Map) => void; reject: (error: Error) => void }>();
+ private readonly listeners = new Set<(message: CdpMessage) => void>();
+ private readonly socket: WebSocket;
+
+ constructor(socket: WebSocket) {
+ this.socket = socket;
+ socket.addEventListener("message", (event) => {
+ const message = JSON.parse(String(event.data)) as CdpMessage;
+ if (message.id !== undefined && this.pending.has(message.id)) {
+ const { resolve, reject } = this.pending.get(message.id)!;
+ this.pending.delete(message.id);
+ if (message.error) reject(new ErrandError(message.error.message));
+ else resolve(message.result ?? {});
+ return;
+ }
+ for (const listener of this.listeners) listener(message);
+ });
+ socket.addEventListener("close", () => {
+ for (const { reject } of this.pending.values()) reject(new ErrandError("browser closed"));
+ this.pending.clear();
+ });
+ }
+
+ static async connect(url: string): Promise {
+ const socket = new WebSocket(url);
+ await new Promise((resolve, reject) => {
+ socket.addEventListener("open", () => resolve(), { once: true });
+ socket.addEventListener("error", () => reject(new ErrandError(`could not connect to ${url}`)), { once: true });
+ });
+ return new Cdp(socket);
+ }
+
+ send(method: string, params: Record = {}, sessionId?: string): Promise> {
+ const id = this.nextId;
+ this.nextId += 1;
+ return new Promise((resolve, reject) => {
+ this.pending.set(id, { resolve, reject });
+ this.socket.send(JSON.stringify(sessionId ? { id, method, params, sessionId } : { id, method, params }));
+ });
+ }
+
+ on(listener: (event: CdpEvent) => void): () => void {
+ const wrapped = (message: CdpMessage): void => {
+ if (message.method) listener({ method: message.method, params: message.params ?? {}, ...(message.sessionId ? { sessionId: message.sessionId } : {}) });
+ };
+ this.listeners.add(wrapped);
+ return () => this.listeners.delete(wrapped);
+ }
+
+ waitFor(method: string, sessionId: string, timeoutMs: number): Promise {
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ this.listeners.delete(listener);
+ reject(new ErrandError(`timed out after ${Math.round(timeoutMs / 1000)}s waiting for ${method}`));
+ }, timeoutMs);
+ const listener = (message: CdpMessage): void => {
+ if (message.method === method && message.sessionId === sessionId) {
+ clearTimeout(timer);
+ this.listeners.delete(listener);
+ resolve();
+ }
+ };
+ this.listeners.add(listener);
+ });
+ }
+
+ close(): void {
+ this.socket.close();
+ }
+}
+
+export interface Browser {
+ cdp: Cdp;
+ path: string;
+ /** Settles when Chrome ends, including a person closing its window. */
+ exited: Promise;
+ close(): Promise;
+}
+
+export interface LaunchOptions {
+ chrome?: string;
+ env?: NodeJS.ProcessEnv;
+ sandbox?: boolean;
+ timeoutMs?: number;
+ /** A profile directory to keep between runs; without one a throwaway profile is removed on close. */
+ profile?: string;
+ /** false opens a window a person can use; the default is headless. */
+ headless?: boolean;
+ /** Extra Chrome switches. The tests use this to point a hostname at a local fake site. */
+ args?: string[];
+}
+
+export async function launchBrowser(options: LaunchOptions = {}): Promise {
+ const env = options.env ?? process.env;
+ const path = options.chrome ?? findChrome(env);
+ if (!path) throw new ErrandError(NO_CHROME);
+ if (!isExecutable(path)) throw new ErrandError(`${path} is not an executable`);
+
+ const keep = options.profile !== undefined;
+ if (keep) mkdirSync(options.profile!, { recursive: true, mode: 0o700 });
+ const profile = options.profile ?? mkdtempSync(join(tmpdir(), "logicsrc-errand-chrome-"));
+ const sandbox = options.sandbox ?? !(env.CHROME_NO_SANDBOX || process.getuid?.() === 0);
+ const args = [
+ ...(options.headless === false ? [] : ["--headless=new"]),
+ "--remote-debugging-port=0",
+ `--user-data-dir=${profile}`,
+ "--no-first-run",
+ "--no-default-browser-check",
+ "--disable-gpu",
+ "--disable-extensions",
+ "--disable-background-networking",
+ "--window-size=1280,900",
+ ...(sandbox ? [] : ["--no-sandbox"]),
+ ...(options.args ?? []),
+ "about:blank",
+ ];
+
+ const child: ChildProcess = spawn(path, args, { env, stdio: ["ignore", "ignore", "pipe"] });
+ const exited = new Promise((resolve) => child.once("exit", () => resolve()));
+ const cleanup = (): void => {
+ if (keep) return;
+ try {
+ rmSync(profile, { recursive: true, force: true });
+ } catch {
+ // Still held open; the next run's tmpdir sweep takes it.
+ }
+ };
+
+ const url = await new Promise((resolve, reject) => {
+ let stderr = "";
+ const timeoutMs = options.timeoutMs ?? 20_000;
+ const timer = setTimeout(() => reject(new ErrandError(`${path} did not start within ${timeoutMs / 1000}s\n${stderr}`)), timeoutMs);
+ child.stderr?.on("data", (chunk: Buffer) => {
+ stderr += chunk.toString();
+ const match = /DevTools listening on (ws:\/\/\S+)/.exec(stderr);
+ if (match?.[1]) {
+ clearTimeout(timer);
+ resolve(match[1]);
+ }
+ });
+ child.on("exit", (code) => {
+ clearTimeout(timer);
+ reject(new ErrandError(`${path} exited with ${code ?? "a signal"} before it was ready\n${stderr.trim()}`));
+ });
+ child.on("error", (error) => {
+ clearTimeout(timer);
+ reject(new ErrandError(`${path}: ${error.message}`));
+ });
+ }).catch((error: Error) => {
+ child.kill();
+ cleanup();
+ throw error;
+ });
+
+ const cdp = await Cdp.connect(url);
+ return {
+ cdp,
+ path,
+ exited,
+ async close() {
+ try {
+ await Promise.race([cdp.send("Browser.close"), new Promise((resolve) => setTimeout(resolve, 2000))]);
+ } catch {
+ // Already gone.
+ }
+ // Cookies reach a kept profile on a clean exit: give Chrome one before the kill.
+ if (keep) await Promise.race([exited, new Promise((resolve) => setTimeout(resolve, 10_000))]);
+ cdp.close();
+ child.kill();
+ cleanup();
+ },
+ };
+}
+
+/**
+ * Rule 11: a runner may present a normal desktop user agent by dropping
+ * `HeadlessChrome` from the string. That is all this does: no other header,
+ * no navigator patching, no fingerprint change.
+ */
+export function plainUserAgent(userAgent: string): string | null {
+ return userAgent.includes("HeadlessChrome") ? userAgent.replace("HeadlessChrome", "Chrome") : null;
+}
diff --git a/packages/openerrand/src/captcha.ts b/packages/openerrand/src/captcha.ts
new file mode 100644
index 0000000..388039b
--- /dev/null
+++ b/packages/openerrand/src/captcha.ts
@@ -0,0 +1,27 @@
+/**
+ * The captcha solver hook. The runner ships no solver and never will: by
+ * default a captcha is handed to the person in a visible window, or the run
+ * stops. A program embedding the runner may pass a {@link CaptchaSolver}, and
+ * the runner calls it only when {@link solverPermitted} says the errand allows
+ * one (the step says `solver: allowed`, the site's stated sector is outside
+ * government, tax, financial, healthcare and identity-provider, and the errand
+ * has no declare or identity-proofing step and no secret input). Every use is
+ * logged with the time, the page URL and the service, never the image or the
+ * answer.
+ */
+
+export { solverPermitted } from "./pages.js";
+
+export interface CaptchaContext {
+ /** The page the captcha is on. */
+ url: string;
+ /** Evaluate script in the page, for a solver that must place its token. */
+ evaluate(expression: string): Promise;
+}
+
+export interface CaptchaSolver {
+ /** The service's name, written to the log on every use. */
+ readonly service: string;
+ /** Resolve true when the captcha is cleared. */
+ solve(context: CaptchaContext): Promise;
+}
diff --git a/packages/openerrand/src/commands.test.ts b/packages/openerrand/src/commands.test.ts
new file mode 100644
index 0000000..69d45b8
--- /dev/null
+++ b/packages/openerrand/src/commands.test.ts
@@ -0,0 +1,94 @@
+import { readFileSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+import { Command } from "commander";
+import { afterEach, describe, expect, it } from "vitest";
+import { type Deps, registerErrandCommands } from "./commands.js";
+import { FakeDriver, ftbExamplePath, tempDir } from "./testing.js";
+
+function setup(over: Partial = {}) {
+ const home = tempDir();
+ const out: string[] = [];
+ const say: string[] = [];
+ const p = new Command();
+ p.name("logicsrc").enablePositionalOptions().exitOverride();
+ registerErrandCommands(p.command("errand"), {
+ env: { LOGICSRC_ERRAND_HOME: home, HOME: home },
+ out: (l) => out.push(l),
+ say: (l) => say.push(l),
+ interactive: false,
+ now: () => new Date("2026-10-04T12:00:00Z"),
+ ...over,
+ });
+ return { p, home, out, say };
+}
+
+afterEach(() => {
+ process.exitCode = 0;
+});
+
+describe("logicsrc errand validate", () => {
+ it("accepts the spec's worked example and shows every gate and input", async () => {
+ const { p, out } = setup();
+ await p.parseAsync(["node", "logicsrc", "errand", "validate", ftbExamplePath()]);
+ expect(process.exitCode).toBe(0);
+ const text = out.join("\n");
+ expect(text).toContain("declare (declaration): Ticking this box is the representative stating");
+ expect(text).toContain("net_income [secret, shared-secret]");
+ expect(text).toMatch(/valid OpenErrand 0\.1 {2}sha256 [0-9a-f]{64}/);
+ });
+
+ it("rejects a file the validator rejects, with the reason", async () => {
+ const { p, home, say } = setup();
+ const bad = JSON.parse(readFileSync(ftbExamplePath(), "utf8"));
+ bad.steps.push({ id: "cap", kind: "captcha", match: { selector: ".g-recaptcha" }, solver: "allowed" });
+ writeFileSync(join(home, "bad.json"), JSON.stringify(bad));
+ await p.parseAsync(["node", "logicsrc", "errand", "validate", join(home, "bad.json")]);
+ expect(process.exitCode).toBe(2);
+ expect(say.join("\n")).toMatch(/captcha solver is never allowed on a tax site/);
+ });
+});
+
+describe("logicsrc errand run", () => {
+ it("refuses an --input the errand does not have, before any browser", async () => {
+ let opened = false;
+ const { p, say } = setup({ openDriver: async () => ((opened = true), new FakeDriver({}, () => "")) });
+ await p.parseAsync(["node", "logicsrc", "errand", "run", ftbExamplePath(), "--input", "nope=1"]);
+ expect(process.exitCode).toBe(2);
+ expect(say.join("\n")).toMatch(/no input named nope/);
+ expect(opened).toBe(false);
+ });
+
+ it("stops before the browser when a required input has no value and nobody is at a terminal", async () => {
+ let opened = false;
+ const { p, say } = setup({ openDriver: async () => ((opened = true), new FakeDriver({}, () => "")) });
+ await p.parseAsync(["node", "logicsrc", "errand", "run", ftbExamplePath()]);
+ expect(process.exitCode).toBe(3);
+ expect(say.join("\n")).toMatch(/no value for Email address/);
+ expect(opened).toBe(false);
+ });
+
+ it("shows a changed file and does not run it without --yes", async () => {
+ const { p, home, say } = setup({ openDriver: async () => new FakeDriver({}, () => "") });
+ const copy = join(home, "errand.json");
+ writeFileSync(copy, readFileSync(ftbExamplePath(), "utf8"));
+ const args = ["node", "logicsrc", "errand", "run", copy, "--dry-run", "--input", "email=jane@example.com", "--input", "phone=5555550100"];
+ for (const name of ["first_name=Jane", "last_name=Doe", "street=1234 Maple St", "zip=95814", "corp_id=1234567", "net_income=48210", "tax_year=2025"]) args.push("--input", name);
+ await p.parseAsync(args);
+ const edited = JSON.parse(readFileSync(copy, "utf8"));
+ edited.title = "Register a MyFTB business account (edited)";
+ writeFileSync(copy, JSON.stringify(edited));
+ say.length = 0;
+ await p.parseAsync(args);
+ expect(say.join("\n")).toMatch(/this file changed since its last run/);
+ expect(say.join("\n")).toContain("Not run.");
+ expect(process.exitCode).toBe(3);
+ });
+});
+
+describe("logicsrc errand status", () => {
+ it("says when nothing has run", async () => {
+ const { p, out } = setup();
+ await p.parseAsync(["node", "logicsrc", "errand", "status"]);
+ expect(out).toEqual(["No errands run yet."]);
+ });
+});
diff --git a/packages/openerrand/src/commands.ts b/packages/openerrand/src/commands.ts
new file mode 100644
index 0000000..a58d510
--- /dev/null
+++ b/packages/openerrand/src/commands.ts
@@ -0,0 +1,270 @@
+/**
+ * `logicsrc errand run|validate|status`.
+ *
+ * Everything that touches the world (environment, terminal, the logicsrc CLI
+ * for team vaults, the browser) is injectable, so the commands are tested
+ * without a terminal and the integration test can point Chrome at a fake site.
+ * Exit codes go on `process.exitCode`, never `process.exit`.
+ */
+
+import { randomInt } from "node:crypto";
+import type { Command } from "commander";
+import type { CaptchaSolver } from "./captcha.js";
+import { type Driver, openCdpDriver } from "./driver.js";
+import { commandExtractor } from "./extract.js";
+import { type Prompt, resolveInputs } from "./inputs.js";
+import { loadErrand, summarize } from "./load.js";
+import { confirm, terminalPrompt } from "./prompt.js";
+import { runErrand } from "./run.js";
+import { type Env, Store } from "./store.js";
+import { durationMs, ErrandError } from "./util.js";
+import { describeTarget, fileVault, type LogicsrcExec, openVault, parseTarget, pathLogicsrc, type Vault } from "./vault.js";
+
+/** 0 done (success, waiting or a dry run), 1 rejected, 2 invalid file or usage, 3 stopped, 4 refused by the throttle. */
+export const EXIT = { OK: 0, REJECTED: 1, INVALID: 2, STOPPED: 3, THROTTLED: 4 } as const;
+
+export interface Deps {
+ env: Env;
+ /** stdout: machine output (--json) and the final line. */
+ out: (line: string) => void;
+ /** stderr: everything a person reads while it runs. */
+ say: (line: string) => void;
+ prompt: Prompt;
+ confirm: (question: string) => Promise;
+ interactive: boolean;
+ logicsrc: LogicsrcExec;
+ now: () => Date;
+ random: (max: number) => number;
+ /** Extra Chrome switches (tests map the site's hostname to a local server). */
+ chromeArgs?: string[];
+ /** Replace the browser entirely (unit tests). */
+ openDriver?: () => Promise;
+ solver?: CaptchaSolver;
+ pollMs?: number;
+ rereadMs?: number;
+ settleMs?: number;
+}
+
+export function defaultDeps(): Deps {
+ return {
+ env: process.env,
+ out: (line) => process.stdout.write(`${line}\n`),
+ say: (line) => process.stderr.write(`${line}\n`),
+ prompt: terminalPrompt,
+ confirm,
+ interactive: process.stdin.isTTY === true,
+ logicsrc: pathLogicsrc,
+ now: () => new Date(),
+ random: randomInt,
+ };
+}
+
+function collect(value: string, previous: string[]): string[] {
+ return [...previous, value];
+}
+
+function parseOverrides(pairs: string[]): Record {
+ const out: Record = {};
+ for (const pair of pairs) {
+ const at = pair.indexOf("=");
+ if (at <= 0) throw new ErrandError(`--input ${pair}: expected name=value`);
+ out[pair.slice(0, at)] = pair.slice(at + 1);
+ }
+ return out;
+}
+
+interface RunFlags {
+ input: string[];
+ dryRun?: boolean;
+ declare?: boolean;
+ headful?: boolean;
+ chrome?: string;
+ vault?: string;
+ extractor?: string;
+ candidate?: string;
+ account?: string;
+ force?: boolean;
+ yes?: boolean;
+ json?: boolean;
+}
+
+export function registerErrandCommands(parent: Command, partial: Partial = {}): void {
+ const deps: Deps = { ...defaultDeps(), ...partial };
+
+ parent
+ .command("run")
+ .argument("", "an OpenErrand 0.1 JSON file")
+ .description("Run an errand in headless Chrome, stopping at every step that belongs to a person.")
+ .option("--input ", "give an input's value (repeatable); wins over every source", collect, [])
+ .option("--dry-run", "fill every page up to the first that holds a declaration or a shared secret, show it, and stop before its forward button")
+ .option("--declare", "your consent, for this run, to tick the declarations the errand names after the values are shown")
+ .option("--headful", "open a visible window, so you can take identity proofing or a captcha yourself")
+ .option("--chrome ", "the Chrome or Chromium binary (default: CHROME_PATH, then the usual places)")
+ .option("--vault ", "teams://, opencreds[:] or file: (default: the file's metadata.vault)")
+ .option("--extractor ", "a command that reads the errand's document requests as JSON on stdin and prints records")
+ .option("--candidate ", "which shared-secret candidate to submit (1 is the best); a rejection lists the others")
+ .option("--account ", "which account at the site, for the throttle ledger", "default")
+ .option("--force", "lift the per-window, per-day and spacing caps (never a lockout)")
+ .option("--yes", "run a file whose SHA-256 changed since its last run without asking")
+ .option("--json", "print the run record as JSON on stdout")
+ .addHelpText(
+ "after",
+ `
+Examples:
+ logicsrc errand validate ftb-register-business.json
+ logicsrc errand run ftb-register-business.json --extractor "python3 extract.py ~/taxes" --dry-run
+ logicsrc errand run ftb-register-business.json --extractor "python3 extract.py ~/taxes" --declare
+ logicsrc errand status
+
+A one-time code is typed at the prompt, or written to the code file the run
+names (~/.local/share/logicsrc/errand/codes/.code) by whoever holds the
+phone. Credentials go to the vault the file or --vault names, else a 0600
+file under ~/.local/share/logicsrc/errand/credentials/.`,
+ )
+ .action(async (file: string, flags: RunFlags) => {
+ process.exitCode = await runCommand(file, flags, deps);
+ });
+
+ parent
+ .command("validate")
+ .argument("", "an OpenErrand 0.1 JSON file")
+ .description("Check an errand file against the schema and the spec's rules, and show what it will ask of you.")
+ .action((file: string) => {
+ try {
+ const loaded = loadErrand(file);
+ for (const line of summarize(loaded.errand, "unverified: a local file")) deps.out(line);
+ deps.out(`valid OpenErrand 0.1 sha256 ${loaded.sha256}`);
+ process.exitCode = EXIT.OK;
+ } catch (error) {
+ deps.say((error as Error).message);
+ process.exitCode = EXIT.INVALID;
+ }
+ });
+
+ parent
+ .command("status")
+ .description("The last run of each errand, the cards waiting on you, and any lockout.")
+ .option("--json", "machine-readable")
+ .action((flags: { json?: boolean }) => {
+ const store = Store.fromEnv(deps.env);
+ const runs = store.runs();
+ const latest = new Map();
+ for (const run of runs) latest.set(run.name, run);
+ const ledger = store.loadLedger();
+ const now = deps.now().getTime();
+ const locks = Object.entries(ledger.lockedUntil).filter(([, until]) => Date.parse(until) > now);
+ if (flags.json) {
+ deps.out(JSON.stringify({ runs: [...latest.values()], lockouts: Object.fromEntries(locks) }, null, 2));
+ process.exitCode = EXIT.OK;
+ return;
+ }
+ if (!latest.size) deps.out("No errands run yet.");
+ for (const run of latest.values()) {
+ deps.out(`${run.name} ${run.kind}${run.outcome !== run.kind ? ` (${run.outcome})` : ""} ${run.at.slice(0, 16).replace("T", " ")}${run.reason ? ` ${run.reason}` : ""}`);
+ if (run.card && !run.card.done) {
+ deps.out(` card ${run.card.id}: ${run.card.title}${run.card.expires_on ? ` (before ${run.card.expires_on})` : ""}`);
+ for (const [i, s] of run.card.steps.entries()) deps.out(` ${i + 1}. ${s}`);
+ if (run.card.open) deps.out(` open ${run.card.open}`);
+ if (run.card.command) deps.out(` ${run.card.command}`);
+ }
+ }
+ for (const [key, until] of locks) deps.out(`locked: ${key.replace("|", " account ")} until ${until}`);
+ process.exitCode = EXIT.OK;
+ });
+}
+
+export async function runCommand(file: string, flags: RunFlags, deps: Deps): Promise {
+ const store = Store.fromEnv(deps.env);
+ try {
+ const { errand, sha256 } = loadErrand(file);
+
+ // Rule 1: show it before running it, and show a change to a file run before.
+ for (const line of summarize(errand, "unverified: a local file, not fetched from a publisher's index")) deps.say(line);
+ const approved = store.approvals()[errand.name];
+ if (approved && approved.sha256 !== sha256) {
+ deps.say(` this file changed since its last run (${approved.sha256.slice(0, 12)} on ${approved.at.slice(0, 10)}, now ${sha256.slice(0, 12)})`);
+ if (!flags.yes && !(deps.interactive && (await deps.confirm("Run the changed file?")))) {
+ deps.say("Not run. Read the change, then rerun with --yes.");
+ return EXIT.STOPPED;
+ }
+ }
+
+ const metaVault = (errand.metadata as { vault?: unknown } | undefined)?.vault;
+ const targetText = flags.vault ?? (typeof metaVault === "string" ? metaVault : undefined);
+ const vault: Vault | null = targetText ? openVault(parseTarget(targetText), deps.logicsrc) : null;
+ const fallbackVault = fileVault(store.credentialsFile(errand.name));
+ const reader = vault ?? fallbackVault;
+
+ const candidate = flags.candidate !== undefined ? Number(flags.candidate) : undefined;
+ if (candidate !== undefined && (!Number.isInteger(candidate) || candidate < 1)) throw new ErrandError("--candidate is 1, 2, 3, ...");
+ const inputs = await resolveInputs(errand, {
+ overrides: parseOverrides(flags.input ?? []),
+ vault: reader,
+ extractor: flags.extractor ? commandExtractor(flags.extractor, { errand: errand.name }) : null,
+ prompt: deps.interactive ? deps.prompt : async () => null,
+ random: deps.random,
+ now: deps.now(),
+ ...(candidate !== undefined ? { candidate } : {}),
+ });
+
+ deps.say("Values for this run (secrets masked):");
+ for (const name of Object.keys(errand.inputs ?? {})) {
+ const resolved = inputs.values.get(name);
+ const qa = inputs.qa.get(name);
+ const from = qa ? (Object.keys(qa.answers).length ? "vault" : "generated as questions are chosen") : resolved ? `${resolved.from}${resolved.origin ? ` ${resolved.origin}` : ""}` : "none";
+ deps.say(` ${name} = ${qa ? "••••" : inputs.display(name)} (${from})`);
+ }
+ if (inputs.chosen && inputs.candidates.length > 1) deps.say(` shared secret: candidate ${inputs.candidates.indexOf(inputs.chosen) + 1} of ${inputs.candidates.length} (${inputs.chosen.year ?? ""} ${inputs.chosen.form} ${inputs.chosen.field}, ${inputs.chosen.source})`);
+ deps.say(` credentials go to ${vault?.write ? vault.describe() : `${fallbackVault.describe()} (0600)`}`);
+
+ store.approve(errand.name, sha256, file, deps.now());
+ const origin = errand.site.origins[0]!;
+ const runId = `${deps.now().toISOString().replace(/[:.]/g, "-")}-${errand.name}`;
+ const { record } = await runErrand({
+ errand,
+ source: file,
+ sha256,
+ inputs,
+ store,
+ declare: flags.declare === true,
+ dryRun: flags.dryRun === true,
+ headful: flags.headful === true,
+ interactive: deps.interactive,
+ prompt: deps.prompt,
+ say: deps.say,
+ now: deps.now,
+ random: deps.random,
+ vault,
+ fallbackVault,
+ account: flags.account ?? "default",
+ force: flags.force === true,
+ ...(deps.solver ? { solver: deps.solver } : {}),
+ ...(deps.pollMs !== undefined ? { pollMs: deps.pollMs } : {}),
+ ...(deps.rereadMs !== undefined ? { rereadMs: deps.rereadMs } : {}),
+ openDriver:
+ deps.openDriver ??
+ (() =>
+ openCdpDriver({
+ errand,
+ headless: flags.headful !== true,
+ profile: store.profile(origin),
+ pageTimeoutMs: durationMs(errand.limits?.page_timeout, 30_000),
+ downloadDir: store.downloadsDir(runId),
+ ...(flags.chrome ? { chrome: flags.chrome } : {}),
+ ...(deps.chromeArgs ? { args: deps.chromeArgs } : {}),
+ ...(deps.settleMs !== undefined ? { settleMs: deps.settleMs } : {}),
+ })),
+ });
+
+ if (flags.json) deps.out(JSON.stringify(record, null, 2));
+ else deps.out(`${record.name}: ${record.kind}${record.outcome !== record.kind ? ` (${record.outcome})` : ""}${record.reason ? `: ${record.reason}` : ""}`);
+ if (record.kind === "rejected") return EXIT.REJECTED;
+ if (record.kind === "stopped") return record.reason?.startsWith("throttle") ? EXIT.THROTTLED : EXIT.STOPPED;
+ return EXIT.OK;
+ } catch (error) {
+ deps.say((error as Error).message);
+ return error instanceof ErrandError && /not a valid OpenErrand|is not JSON|cannot read|^--input|^--candidate|^vault target/.test((error as Error).message) ? EXIT.INVALID : EXIT.STOPPED;
+ }
+}
+
+export { describeTarget, type LogicsrcExec };
diff --git a/packages/openerrand/src/driver.ts b/packages/openerrand/src/driver.ts
new file mode 100644
index 0000000..6bea055
--- /dev/null
+++ b/packages/openerrand/src/driver.ts
@@ -0,0 +1,285 @@
+/**
+ * The browser as the run loop sees it. {@link Driver} is an interface so the
+ * loop (gates, outcomes, loop detection) is tested against a scripted fake;
+ * {@link openCdpDriver} is the real one, on Chrome over CDP.
+ */
+
+import { existsSync } from "node:fs";
+import { join } from "node:path";
+import { type Browser, launchBrowser, plainUserAgent } from "./browser.js";
+import type { Errand, Field, Page } from "./types.js";
+import { ErrandError, sleep } from "./util.js";
+
+export type FillAction = { kind: "text" | "select"; value: string } | { kind: "check" };
+
+export interface DownloadedFile {
+ url: string;
+ filename: string;
+ path: string;
+}
+
+export interface Driver {
+ goto(url: string): Promise;
+ /** The current URL, read from the browser without running script on the page. */
+ url(): Promise;
+ read(): Promise;
+ /** Which of these CSS selectors find an element on the current page. */
+ selectorHits(selectors: string[]): Promise>;
+ fill(field: Field, action: FillAction): Promise;
+ /** Press the page's forward button. Returns its label, or `?a|b` with the buttons seen when none fits. */
+ submit(): Promise;
+ /** Wait for the page that follows a submit to load. */
+ settle(): Promise;
+ /** Files the site handed over during this run, completed. */
+ downloads(): DownloadedFile[];
+ /** Run script in the page. Used only by a captcha solver the errand allows; never on an identity provider's page. */
+ evaluate(expression: string): Promise;
+ close(): Promise;
+}
+
+const DEFAULT_LABELS = "^(submit|continue|next|send|verify|confirm|sign ?in|log ?in)$";
+const DEFAULT_NEVER = "^(back|cancel|previous)$";
+const DEFAULT_IGNORE = "#timer, .modal";
+
+/** Every visible, enabled control, with the label a person would read for it. Values are never read. */
+export function readPageScript(ignore: string): string {
+ return `(() => {
+ const IGNORE = ${JSON.stringify(ignore)};
+ const visible = (el) => !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length) && getComputedStyle(el).visibility !== 'hidden';
+ const clean = (s) => (s || '').replace(/\\*\\s*Required Field/gi, '').replace(/\\s+/g, ' ').trim();
+ const ignored = (el) => { try { return !!el.closest(IGNORE); } catch { return false; } };
+ const forLabel = (el) => el.id ? document.querySelector('label[for="' + CSS.escape(el.id) + '"]') : null;
+ const labelOf = (el) => {
+ const byFor = forLabel(el);
+ const wrap = el.closest('label');
+ const group = el.closest('fieldset, .form-group, .row');
+ const legend = group && group.querySelector('legend, .col-form-label');
+ return clean((byFor && byFor.innerText) || el.getAttribute('aria-label') || (wrap && wrap.innerText) || (legend && legend.innerText) || el.placeholder || '');
+ };
+ const selectorOf = (el) => {
+ if (el.id) return '#' + CSS.escape(el.id);
+ if (el.name) return el.tagName.toLowerCase() + '[name="' + CSS.escape(el.name) + '"]' + (el.type === 'radio' || el.type === 'checkbox' ? '[value="' + CSS.escape(el.value) + '"]' : '');
+ return null;
+ };
+ const controls = [...document.querySelectorAll('input, select, textarea')]
+ .filter((el) => !['hidden', 'submit', 'button', 'image', 'reset', 'file'].includes(el.type) && !el.disabled && visible(el) && !ignored(el));
+ let lastSelect = null;
+ const fields = [];
+ for (const el of controls) {
+ if (el.tagName === 'SELECT') lastSelect = el;
+ const selector = selectorOf(el);
+ if (!selector) continue;
+ const choice = el.type === 'radio' || el.type === 'checkbox';
+ const own = clean(((el.closest('label') || forLabel(el) || {}).innerText) || '');
+ const group = el.closest('fieldset, .form-group');
+ const label = choice ? (own || labelOf(el)) : labelOf(el);
+ const printed = group ? clean([...group.querySelectorAll('p, span, div')].map((n) => n.children.length ? '' : n.innerText).join(' ')) : '';
+ fields.push({
+ selector,
+ id: el.id || '',
+ name: el.name || '',
+ type: el.type,
+ label,
+ required: el.required || el.getAttribute('aria-required') === 'true',
+ options: el.tagName === 'SELECT' ? [...el.options].map((o) => ({ value: o.value, text: o.text.trim() })) : undefined,
+ question: el.tagName !== 'SELECT' && !choice ? (lastSelect && lastSelect.selectedIndex > 0 ? lastSelect.options[lastSelect.selectedIndex].text.trim() : (printed || undefined)) : undefined,
+ groupChecked: el.type === 'radio' && el.name ? !!document.querySelector('input[type=radio][name="' + CSS.escape(el.name) + '"]:checked') : undefined,
+ });
+ }
+ const errors = [...document.querySelectorAll('.alert-danger, .validation-summary-errors li, .field-validation-error, .error-message, .invalid-feedback, [role=alert]')]
+ .filter((n) => visible(n) && !ignored(n)).map((n) => clean(n.innerText)).filter(Boolean);
+ return { url: location.href, title: document.title, text: clean(document.body ? document.body.innerText : '').slice(0, 6000), errors: [...new Set(errors)], fields };
+})()`;
+}
+
+/** Set a value with the element's native setter, then fire input, change and blur so the page's own validation sees it. */
+export function fillScript(selector: string, action: FillAction): string {
+ const target = `document.querySelector(${JSON.stringify(selector)})`;
+ if (action.kind === "check") {
+ return `(() => { const el = ${target}; if (el && !el.checked) el.click(); return !!el && el.checked; })()`;
+ }
+ return `(() => {
+ const el = ${target}; if (!el) return false;
+ const proto = el.tagName === 'SELECT' ? HTMLSelectElement.prototype : el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
+ Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, ${JSON.stringify(action.value)});
+ for (const type of ['input', 'change', 'blur']) el.dispatchEvent(new Event(type, { bubbles: true }));
+ return el.value === ${JSON.stringify(action.value)};
+ })()`;
+}
+
+/**
+ * The page's own forward button: a label from `submit.labels`, else the only
+ * other button; never one matching `submit.never` or inside `submit.ignore`.
+ */
+export function submitScript(submit: Errand["submit"]): string {
+ const labels = submit?.labels ?? DEFAULT_LABELS;
+ const never = submit?.never ?? DEFAULT_NEVER;
+ const ignore = submit?.ignore ?? DEFAULT_IGNORE;
+ return `(() => {
+ const LABELS = new RegExp(${JSON.stringify(labels)}, 'i');
+ const NEVER = new RegExp(${JSON.stringify(never)}, 'i');
+ const IGNORE = ${JSON.stringify(ignore)};
+ const visible = (el) => !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
+ const ignored = (el) => { try { return !!el.closest(IGNORE); } catch { return false; } };
+ const text = (b) => (b.innerText || b.value || '').replace(/\\s+/g, ' ').trim();
+ const buttons = [...document.querySelectorAll('button, input[type=submit], input[type=button]')]
+ .filter((b) => visible(b) && !b.disabled && !ignored(b) && text(b) && !NEVER.test(text(b)));
+ const pick = buttons.find((b) => LABELS.test(text(b))) || (buttons.length === 1 ? buttons[0] : null);
+ if (!pick) return '?' + buttons.map(text).join('|');
+ pick.click();
+ return text(pick);
+})()`;
+}
+
+export interface CdpDriverOptions {
+ errand: Errand;
+ chrome?: string;
+ headless: boolean;
+ profile?: string;
+ pageTimeoutMs: number;
+ /** Where downloads land before they are filed. */
+ downloadDir?: string;
+ args?: string[];
+ /** Pause after each load so a page's own scripts finish (default 1500 ms). */
+ settleMs?: number;
+}
+
+/** The plain user agent of each Chrome binary, learned once per process. */
+const plainAgents = new Map();
+
+export async function openCdpDriver(options: CdpDriverOptions): Promise {
+ const launch = (extra: string[]): Promise =>
+ launchBrowser({
+ ...(options.chrome ? { chrome: options.chrome } : {}),
+ ...(options.profile ? { profile: options.profile } : {}),
+ headless: options.headless,
+ timeoutMs: 30_000,
+ args: [...(options.args ?? []), ...extra],
+ });
+ // Rule 11. A CDP user-agent override does not reach every request (a
+ // navigation the page's own script starts still says HeadlessChrome), so
+ // the plain string is given to Chrome at launch: the browser's own user
+ // agent with "HeadlessChrome" replaced by "Chrome", and nothing else.
+ const key = options.chrome ?? "default";
+ let browser: Browser;
+ if (options.headless && plainAgents.has(key)) {
+ const plain = plainAgents.get(key);
+ browser = await launch(plain ? [`--user-agent=${plain}`] : []);
+ } else {
+ browser = await launch([]);
+ if (options.headless) {
+ const { userAgent } = (await browser.cdp.send("Browser.getVersion")) as { userAgent: string };
+ const plain = plainUserAgent(userAgent);
+ plainAgents.set(key, plain);
+ if (plain) {
+ await browser.close();
+ browser = await launch([`--user-agent=${plain}`]);
+ }
+ }
+ }
+ const { cdp } = browser;
+ const settleMs = options.settleMs ?? 1_500;
+ try {
+ const { targetInfos } = (await cdp.send("Target.getTargets")) as { targetInfos: Array<{ targetId: string; type: string }> };
+ let targetId = targetInfos.find((t) => t.type === "page")?.targetId;
+ if (!targetId) ({ targetId } = (await cdp.send("Target.createTarget", { url: "about:blank" })) as { targetId: string });
+ const { sessionId } = (await cdp.send("Target.attachToTarget", { targetId, flatten: true })) as { sessionId: string };
+ await cdp.send("Page.enable", {}, sessionId);
+
+ const downloads: DownloadedFile[] = [];
+ if (options.downloadDir) {
+ await cdp.send("Browser.setDownloadBehavior", { behavior: "allowAndName", downloadPath: options.downloadDir, eventsEnabled: true });
+ const begun = new Map();
+ cdp.on((event) => {
+ if (event.method === "Browser.downloadWillBegin") {
+ begun.set(String(event.params.guid), { url: String(event.params.url), filename: String(event.params.suggestedFilename) });
+ } else if (event.method === "Browser.downloadProgress" && event.params.state === "completed") {
+ const guid = String(event.params.guid);
+ const meta = begun.get(guid);
+ const path = join(options.downloadDir!, guid);
+ if (meta && existsSync(path)) downloads.push({ ...meta, path });
+ }
+ });
+ }
+
+ const evaluate = async (expression: string): Promise => {
+ try {
+ const { result, exceptionDetails } = (await cdp.send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true }, sessionId)) as {
+ result: { value?: unknown };
+ exceptionDetails?: unknown;
+ };
+ return exceptionDetails ? null : ((result.value ?? null) as T | null);
+ } catch {
+ return null;
+ }
+ };
+ const ignore = options.errand.submit?.ignore ?? DEFAULT_IGNORE;
+ let pending: Promise | null = null;
+
+ return {
+ async goto(url) {
+ const loaded = cdp.waitFor("Page.loadEventFired", sessionId, options.pageTimeoutMs).catch(() => undefined);
+ const result = (await cdp.send("Page.navigate", { url }, sessionId)) as { errorText?: string };
+ if (result.errorText) throw new ErrandError(`could not open ${url}: ${result.errorText}`);
+ await loaded;
+ await sleep(settleMs);
+ },
+ async url() {
+ const { targetInfo } = (await cdp.send("Target.getTargetInfo", { targetId })) as { targetInfo: { url: string } };
+ return targetInfo.url;
+ },
+ async read() {
+ const page = await evaluate(readPageScript(ignore));
+ if (!page) throw new ErrandError("could not read the page");
+ return page;
+ },
+ async selectorHits(selectors) {
+ if (!selectors.length) return {};
+ const hits = await evaluate>(
+ `(() => { const out = {}; for (const s of ${JSON.stringify(selectors)}) { try { out[s] = !!document.querySelector(s); } catch { out[s] = false; } } return out; })()`,
+ );
+ return hits ?? {};
+ },
+ async fill(field, action) {
+ return (await evaluate(fillScript(field.selector, action))) === true;
+ },
+ async submit() {
+ const loaded = cdp.waitFor("Page.loadEventFired", sessionId, options.pageTimeoutMs).catch(() => undefined);
+ const pressed = (await evaluate(submitScript(options.errand.submit))) ?? "?";
+ if (!pressed.startsWith("?")) pending = loaded;
+ else void loaded;
+ return pressed;
+ },
+ async settle() {
+ await Promise.race([pending ?? Promise.resolve(), sleep(options.pageTimeoutMs)]);
+ pending = null;
+ await sleep(settleMs);
+ },
+ downloads: () => [...downloads],
+ evaluate: (expression) => evaluate(expression),
+ close: () => browser.close(),
+ };
+ } catch (error) {
+ await browser.close();
+ throw error;
+ }
+}
+
+/** A download is what its media type says, checked by its first bytes. Unknown types are refused. */
+export function looksLike(type: string, head: Buffer): boolean {
+ const starts = (bytes: number[]): boolean => bytes.every((b, i) => head[i] === b);
+ if (type === "application/pdf") return starts([0x25, 0x50, 0x44, 0x46]);
+ if (type === "image/png") return starts([0x89, 0x50, 0x4e, 0x47]);
+ if (type === "image/jpeg") return starts([0xff, 0xd8, 0xff]);
+ if (type === "application/zip") return starts([0x50, 0x4b, 0x03, 0x04]);
+ if (type === "application/json") {
+ try {
+ JSON.parse(head.toString("utf8"));
+ return true;
+ } catch {
+ return false;
+ }
+ }
+ if (type.startsWith("text/")) return !head.includes(0);
+ return false;
+}
diff --git a/packages/openerrand/src/extract.ts b/packages/openerrand/src/extract.ts
new file mode 100644
index 0000000..5515124
--- /dev/null
+++ b/packages/openerrand/src/extract.ts
@@ -0,0 +1,76 @@
+/**
+ * The one document extractor the runner ships: a command the principal names
+ * (`--extractor "python3 extract.py ~/taxes"`), run on this machine, that
+ * reads a JSON request on stdin and prints JSON records on stdout.
+ *
+ * Request: `{ "errand": "", "documents": [{ "input", "form", "field", "match"? }] }`
+ * Reply: `[{ "form", "field", "value", "year"?, "label"?, "file"?, "page"? }]`
+ * (or `{ "records": [...] }`).
+ *
+ * The extractor reads the documents; the runner only ever sees the values it
+ * prints, keeps them in memory, and records the file and page each came from
+ * for the person to see. Nothing about a document is sent anywhere else.
+ */
+
+import { spawn } from "node:child_process";
+import type { DocumentExtractor, DocumentRecord, DocumentRequest } from "./inputs.js";
+import { ErrandError } from "./util.js";
+
+function isRecord(row: unknown): row is DocumentRecord {
+ if (!row || typeof row !== "object") return false;
+ const r = row as Record;
+ return (
+ typeof r.form === "string" &&
+ typeof r.field === "string" &&
+ (typeof r.value === "string" || typeof r.value === "number") &&
+ (r.year === undefined || Number.isInteger(r.year)) &&
+ (r.label === undefined || typeof r.label === "string") &&
+ (r.file === undefined || typeof r.file === "string") &&
+ (r.page === undefined || Number.isInteger(r.page))
+ );
+}
+
+export function parseRecords(stdout: string): DocumentRecord[] {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(stdout);
+ } catch {
+ throw new ErrandError("the extractor printed something that is not JSON");
+ }
+ const rows = Array.isArray(parsed) ? parsed : (parsed as { records?: unknown })?.records;
+ if (!Array.isArray(rows)) throw new ErrandError("the extractor did not print a list of records");
+ return rows.filter(isRecord);
+}
+
+export function commandExtractor(command: string, options: { errand: string; timeoutMs?: number; cwd?: string } = { errand: "" }): DocumentExtractor {
+ return {
+ extract(requests: DocumentRequest[]): Promise {
+ return new Promise((resolve, reject) => {
+ const child = spawn("/bin/sh", ["-c", command], { stdio: ["pipe", "pipe", "pipe"], ...(options.cwd ? { cwd: options.cwd } : {}) });
+ let stdout = "";
+ let stderr = "";
+ const timer = setTimeout(() => {
+ child.kill();
+ reject(new ErrandError(`the extractor did not finish in ${(options.timeoutMs ?? 120_000) / 1000}s`));
+ }, options.timeoutMs ?? 120_000);
+ child.stdout.on("data", (chunk: Buffer) => (stdout += chunk.toString()));
+ child.stderr.on("data", (chunk: Buffer) => (stderr += chunk.toString()));
+ child.on("error", (error) => {
+ clearTimeout(timer);
+ reject(new ErrandError(`could not run the extractor: ${error.message}`));
+ });
+ child.on("close", (code) => {
+ clearTimeout(timer);
+ // stderr is the extractor's own; it is not echoed, since an extractor may print what it read.
+ if (code !== 0) return reject(new ErrandError(`the extractor exited with ${code}${stderr ? ` (${stderr.trim().split("\n").length} lines on stderr, not shown)` : ""}`));
+ try {
+ resolve(parseRecords(stdout));
+ } catch (error) {
+ reject(error);
+ }
+ });
+ child.stdin.end(JSON.stringify({ errand: options.errand, documents: requests }));
+ });
+ },
+ };
+}
diff --git a/packages/openerrand/src/fake-site.ts b/packages/openerrand/src/fake-site.ts
new file mode 100644
index 0000000..0b07c0b
--- /dev/null
+++ b/packages/openerrand/src/fake-site.ts
@@ -0,0 +1,231 @@
+/**
+ * A tiny fake MyFTB for the integration test: Terms, Profile, a
+ * proof-of-work interstitial that clears itself, Business, Phone, Code and
+ * Confirmation, served over HTTPS with a throwaway self-signed certificate.
+ * Chrome reaches it as https://webapp.ftb.ca.gov through
+ * `--host-resolver-rules`, with every other hostname mapped to NOTFOUND, so
+ * the test runs the published example file unchanged and cannot reach the
+ * real site. Every value it checks is fictional (Jane Doe, corporation
+ * 1234567).
+ *
+ * Not part of the build.
+ */
+
+import { execFileSync } from "node:child_process";
+import { mkdtempSync, readFileSync, rmSync } from "node:fs";
+import { createServer, type Server } from "node:https";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+export const FAKE = {
+ firstName: "Jane",
+ lastName: "Doe",
+ zip: "95814",
+ addressNumbers: "1234",
+ corpId: "1234567",
+ netIncome: "48210",
+ year: "2025",
+ phone: "5555550100",
+ code: "482913",
+} as const;
+
+export interface FakeSite {
+ port: number;
+ /** Every request: method, path and user agent. */
+ requests: Array<{ method: string; path: string; userAgent: string }>;
+ /** Called on GET /Code and on a wrong code, so the test can relay the next code. */
+ onCode: (attempt: number) => void;
+ posted: Record>;
+ close(): Promise;
+}
+
+const page = (title: string, body: string, error = ""): string => `${title}
+Franchise Tax Board e-Services
+
${title.replace(/^.*\| /, "")}
+${error ? `
${error}
` : ""}
+${body}
+`;
+
+const form = (action: string, inner: string, buttons = ' '): string =>
+ ``;
+
+const input = (id: string, label: string, type = "text", required = true): string =>
+ ``;
+
+const select = (id: string, label: string, options: string[], required = true): string =>
+ ``;
+
+const QUESTIONS = ["What was the name of your first pet?", "What city were you born in?", "What was your first car?", "What was the name of your elementary school?"];
+
+const PAGES = {
+ terms: () =>
+ page(
+ "Registration | Terms",
+ form(
+ "/MyFTBAccess/Registration/NewAccount",
+ `