logicsrc/packages/openerrand/src/commands.test.ts
Anthony Ettinger dde596b276
OpenErrand reference runner: @logicsrc/openerrand and logicsrc errand (#228)
* OpenErrand 0.1: an errand on a website with no API, with the human steps kept human

docs/openerrand.md mints OpenErrand: one JSON file per errand (register an
account, download a transcript) naming the site, the inputs with a
sensitivity class and ordered sources (document, vault, prompt, generate,
derive, candidate, literal), field rules matched by id then label, page and
wait steps, five human gates a runner never performs (declare,
identity-proofing, code, mail, captcha), outcomes, the never-retried shared
secret, vault and download outputs, hand-off cards that may name only public
inputs, the publisher index at /.well-known/openerrand.json, and thirteen
runner rules. The worked example is the MyFTB business registration that
cli-tools `ftb` performs (profullstack/cli-tools#125), with no personal data.

- @logicsrc/schemas: openerrand + openerrand-index schemas and fixtures
- @logicsrc/validators: semantic checks (references, templates, no personal
  or secret input on a card) and tests that validate the spec's own examples
- logicsrc-web: registry entry (process family), /openerrand landing page,
  the example and the index served as static files, contract tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* OpenErrand: hand-off cards stay on the surface that owns the data

Anthony's ruling: tax and finance data never touches a social or promotion
tool, and nothing is sent to a CPA or preparer.

- Hand-off cards are delivered only on the surface that owns the errand's
  data (for a tax or finance errand, the principal's finance app through its
  CLI, PWA, MCP server or API, such as CoinPay, or the runner's terminal),
  never a social, promotion or third-party posting service, and never to
  anyone but the principal. A card for an errand with personal or secret
  inputs does not leave that surface. Runner rule 9 says the same.
- The run record and the sample run name the card by an opaque id
  (pin-letter/7f3k2q) instead of a mynaposter.com URL; the myna mention is gone.
- `principal: represented` no longer cites a preparer with a power of attorney.
- The FTB card's last step no longer suggests sending the PIN to someone else.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* OpenErrand: user-agent rule, captcha solver policy, reference runner note

Anthony's answers on #227 ("go with your recommendations"):

- Rule 11: a runner may run headless with a normal desktop browser user agent
  (dropping HeadlessChrome) and nothing more: no fingerprint spoofing beyond
  the UA string, no stealth plugins, no solving or evading a bot challenge.
  A challenge the browser completes itself is a wait step; any other is a
  captcha gate.
- Captcha solvers: new site.sector and captcha step `solver`
  (forbidden by default | allowed). Never allowed on government, tax,
  financial, healthcare or identity-provider sites, nor on any errand with a
  declare or identity-proofing step or a secret input; elsewhere only when the
  file says so, with every use logged. The validator rejects `allowed` in the
  forbidden set or without a stated sector; six new tests. The FTB example
  states sector "tax".
- Reference runner: @logicsrc/openerrand / `logicsrc errand run`, marked in
  progress; ftb stays the runner the example was taken from.
- Name stays OpenErrand; family stays Agents and process.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* OpenErrand reference runner: @logicsrc/openerrand and logicsrc errand

Ship the runner docs/openerrand.md promised. `logicsrc errand run <file>`
reads an OpenErrand 0.1 file, validates it with @logicsrc/validators, and
drives headless Chrome through it under the spec's thirteen rules;
`errand validate` shows what a file will ask of you and `errand status`
shows the last run of each errand, its card and any lockout.

The engine is generalised from cli-tools `ftb` (PR #125) with no dependency
on cli-tools: the CDP client and Chrome finder from wcag.ts, the page reader,
native-setter fill and forward-button picker from ftb-run.ts, and the rule
matcher, throttle and outcome logic from ftb.ts, all now driven by the file.

- Inputs: document (an extractor hook; the one shipped runs a local command
  that reads JSON requests and prints records), vault (teams or OpenCreds),
  prompt (no echo for secrets), generate, derive, candidate, literal.
  `--input name=value` wins. Shared-secret candidates are ranked as the spec
  says, one is submitted, and a rejection lists the others for --candidate.
- Rules: id before label, step rules first, choices before text, an id match
  final, an unmatched required field stops the run naming it.
- Gates: declare only with --declare after the values are shown; identity
  proofing never touched (URL only) and handed over or stopped on; code from
  the terminal or a code file, used once, a wrong code waits for the next;
  mail ends the run waiting with the card; captcha is the person's, and a
  CaptchaSolver interface is called only where the spec permits (no solver is
  bundled); wait steps are polled, never solved.
- Throttle: 2 runs per errand and account in 30 minutes, 4 a day, 2 minutes
  between runs on a site, lockouts from metadata.lockout or a default, held
  per site and account, never lifted by --force.
- Outputs: credentials written before success to a teams vault by pull,
  merge, push (metadata.vault or --vault), else a 0600 file said aloud;
  downloads type-checked and never overwritten with different bytes; cards
  only in the local run record.
- The user agent is Chrome's own with HeadlessChrome replaced, given at
  launch: a CDP override did not reach a navigation the page's own script
  started, which is exactly the proof-of-work interstitial case.

Tests: 85 in the package (rule engine, inputs, gates, throttle, outcomes,
captcha gating, vaults, outputs, commands) including an integration test
that runs the published FTB example unchanged in real headless Chrome
against a local HTTPS fake site (Chrome maps webapp.ftb.ca.gov to it and
every other host to NOTFOUND; all data fictional), and 2 in the CLI.

CLI 0.6.0 -> 0.7.0; @logicsrc/schemas and @logicsrc/validators 0.3.0 ->
0.4.0 (the OpenErrand schemas, and the vocabularies now exported for
runners); PRD 0009; the spec's Reference runner section and the landing
page say it ships.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:17:06 -07:00

94 lines
4.2 KiB
TypeScript

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<Deps> = {}) {
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."]);
});
});