mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-06 06:28:11 +00:00
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>
This commit is contained in:
parent
1d69dc3804
commit
dde596b276
46 changed files with 5263 additions and 14 deletions
270
packages/openerrand/src/commands.ts
Normal file
270
packages/openerrand/src/commands.ts
Normal file
|
|
@ -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<boolean>;
|
||||
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<Driver>;
|
||||
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<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
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<Deps> = {}): void {
|
||||
const deps: Deps = { ...defaultDeps(), ...partial };
|
||||
|
||||
parent
|
||||
.command("run")
|
||||
.argument("<file>", "an OpenErrand 0.1 JSON file")
|
||||
.description("Run an errand in headless Chrome, stopping at every step that belongs to a person.")
|
||||
.option("--input <name=value>", "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 <path>", "the Chrome or Chromium binary (default: CHROME_PATH, then the usual places)")
|
||||
.option("--vault <target>", "teams:<team>/<project>/<env>, opencreds[:<field>] or file:<path> (default: the file's metadata.vault)")
|
||||
.option("--extractor <command>", "a command that reads the errand's document requests as JSON on stdin and prints records")
|
||||
.option("--candidate <n>", "which shared-secret candidate to submit (1 is the best); a rejection lists the others")
|
||||
.option("--account <name>", "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/<name>.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("<file>", "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<string, (typeof runs)[number]>();
|
||||
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<number> {
|
||||
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 };
|
||||
Loading…
Add table
Add a link
Reference in a new issue