From 2bd845f2b2f99db9ede23cb94e408da4a75614c0 Mon Sep 17 00:00:00 2001 From: Ricardo Vega Date: Wed, 30 Sep 2026 06:23:51 -0500 Subject: [PATCH] Slice 3: executor, agent-browser adapter, store, and `tyto run`. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hand-written recipes now run with no model: - core: executeRecipe (lint → render → one batch → verify), exit codes, the replay-session policy (tyto-rx with Tyto's own agent-browser config and a domain allowlist; tyto-rx-auth with saved logins only for approved auth recipes), launch-error retry, 15 s deadline, 6 s action timeout. New ports: BrowserRunner, RecipeStore, SessionLock, with fakes. Recipes gain `domains` for CDN hosts the page loads from. - @tyto/agent-browser: spawns the CLI with an argv array, sends batch steps as JSON on stdin, strips lifecycle noise, kills on abort. - @tyto/store: recipe files (validated on save and load, 0600, atomic), an O_EXCL session lock that reclaims dead holders, and the replay config/policy files. - @tyto/cli: `tyto run`, `tyto test`, `tyto recipes [show|rm]`; bin runs the TypeScript sources directly. Verified live with a hand-written GitHub recipe through the user's Edge: correct Latest tags for three repos (including Node, where an older LTS release is listed first), a safe miss for a missing repo, and exit 64 for a bad param. Opt-in live tests cover hit-then-miss and a fast failure on a missing element. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 5 +- docs/IMPLEMENTATION.md | 40 +++-- package-lock.json | 43 ++++- package.json | 7 +- packages/agent-browser/package.json | 11 ++ packages/agent-browser/src/index.ts | 1 + packages/agent-browser/src/runner.ts | 108 +++++++++++++ .../test/fixtures/fake-agent-browser.mjs | 33 ++++ packages/agent-browser/test/runner.test.ts | 65 ++++++++ packages/agent-browser/tsconfig.json | 9 ++ packages/cli/bin/tyto.mjs | 6 + packages/cli/package.json | 14 ++ packages/cli/src/compose.ts | 21 +++ packages/cli/src/index.ts | 2 + packages/cli/src/main.ts | 153 ++++++++++++++++++ packages/cli/test/cli.test.ts | 86 ++++++++++ packages/cli/test/live/replay.test.ts | 71 ++++++++ packages/cli/tsconfig.json | 9 ++ packages/core/src/index.ts | 5 + packages/core/src/ports/browser-runner.ts | 23 +++ packages/core/src/ports/index.ts | 3 + packages/core/src/ports/recipe-store.ts | 17 ++ packages/core/src/ports/session-lock.ts | 6 + packages/core/src/recipe/parse.ts | 6 + packages/core/src/recipe/types.ts | 2 + packages/core/src/run/errors.ts | 15 ++ packages/core/src/run/execute.ts | 79 +++++++++ packages/core/src/run/exit.ts | 13 ++ packages/core/src/run/launch-error.ts | 12 ++ packages/core/src/run/session.ts | 26 +++ packages/core/src/testing/fakes.ts | 98 +++++++++++ packages/core/src/testing/index.ts | 11 +- packages/core/test/execute.test.ts | 151 +++++++++++++++++ packages/core/test/recipe.test.ts | 10 ++ packages/store/package.json | 11 ++ packages/store/src/files.ts | 20 +++ packages/store/src/index.ts | 4 + packages/store/src/recipe-store.ts | 64 ++++++++ packages/store/src/replay-files.ts | 27 ++++ packages/store/src/session-lock.ts | 61 +++++++ packages/store/test/store.test.ts | 103 ++++++++++++ packages/store/tsconfig.json | 9 ++ 42 files changed, 1440 insertions(+), 20 deletions(-) create mode 100644 packages/agent-browser/package.json create mode 100644 packages/agent-browser/src/index.ts create mode 100644 packages/agent-browser/src/runner.ts create mode 100755 packages/agent-browser/test/fixtures/fake-agent-browser.mjs create mode 100644 packages/agent-browser/test/runner.test.ts create mode 100644 packages/agent-browser/tsconfig.json create mode 100755 packages/cli/bin/tyto.mjs create mode 100644 packages/cli/package.json create mode 100644 packages/cli/src/compose.ts create mode 100644 packages/cli/src/index.ts create mode 100644 packages/cli/src/main.ts create mode 100644 packages/cli/test/cli.test.ts create mode 100644 packages/cli/test/live/replay.test.ts create mode 100644 packages/cli/tsconfig.json create mode 100644 packages/core/src/ports/browser-runner.ts create mode 100644 packages/core/src/ports/recipe-store.ts create mode 100644 packages/core/src/ports/session-lock.ts create mode 100644 packages/core/src/run/errors.ts create mode 100644 packages/core/src/run/execute.ts create mode 100644 packages/core/src/run/exit.ts create mode 100644 packages/core/src/run/launch-error.ts create mode 100644 packages/core/src/run/session.ts create mode 100644 packages/core/test/execute.test.ts create mode 100644 packages/store/package.json create mode 100644 packages/store/src/files.ts create mode 100644 packages/store/src/index.ts create mode 100644 packages/store/src/recipe-store.ts create mode 100644 packages/store/src/replay-files.ts create mode 100644 packages/store/src/session-lock.ts create mode 100644 packages/store/test/store.test.ts create mode 100644 packages/store/tsconfig.json diff --git a/README.md b/README.md index bf35026..af4091e 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,9 @@ npm run test:live # opt-in: needs agent-browser installed |---|---| | `@tyto/core` | Recipes, lint, verify, brief, find, redaction, ports, fakes (pure) | | `@tyto/llm` | OpenAI-compatible and Anthropic HTTP model adapters | -| `@tyto/agent-browser`, `@tyto/store`, `@tyto/compiler`, `@tyto/cli` | Coming in slices 3–9 | +| `@tyto/agent-browser` | Runs the agent-browser CLI (argv, batch JSON) | +| `@tyto/store` | Recipes, session locks, replay config under `~/.tyto` | +| `@tyto/cli` | The `tyto` command (`run`, `test`, `recipes`) | +| `@tyto/compiler` | Coming in slice 6 | A [YOLOVibeCode](https://github.com/YOLOVibeCode) public repo. Product: Noctusoft, Inc. MIT license. diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index dc82d8d..52bc667 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -17,8 +17,8 @@ Separate fakes in `@tyto/core/testing`. | Package | Role | |---|---| | `packages/core` | Recipe schema, lint, render, verify; trace model; brief assembly and rendering; find; redaction; ports; fakes | -| `packages/agent-browser` | `BrowserRunner` and `EventSource` adapters: `execFile` argv, batch via stdin JSON, stream WebSocket, session lock | -| `packages/store` | `~/.tyto/{recipes,traces,locks}`, JSON, `0600`, atomic writes | +| `packages/agent-browser` | `BrowserRunner` and `EventSource` adapters: spawn with an argv array, batch via stdin JSON, stream WebSocket | +| `packages/store` | `~/.tyto/{recipes,traces,locks}`: recipe store, session lock, replay config files; JSON, `0600`, atomic writes | | `packages/compiler` | `ClaudeCodeCompiler` (default), `OpenAiCompatCompiler` (slice 9) | | `packages/llm` | OpenAI-compatible and Anthropic HTTP `ModelPort`s (kept); tool calls added in slice 9 | | `packages/cli` | `tyto` command | @@ -40,6 +40,7 @@ Separate fakes in `@tyto/core/testing`. ```json { "name": "github-release-info", "version": 1, "status": "draft", "auth": false, "intent": "…", "examples": ["…"], "origins": ["https://github.com"], + "domains": ["github.githubassets.com", "*.githubusercontent.com"], "params": { "repo": { "type": "string", "description": "owner/name", "example": "vercel-labs/agent-browser" }, "n": { "type": "int", "description": "position", "example": "1", "default": "1" } }, "steps": [["open", "https://github.com/{{repo|path}}/releases"], ["wait", "--load", "load"], @@ -50,17 +51,20 @@ Separate fakes in `@tyto/core/testing`. Filters: `{{p}}`, `{{p|lower}}`, `{{p|underscore}}`, `{{p|path}}` (percent-encode, keep `/`), `{{p|url}}` (full URL; origin must be in `origins`). Param types: `string`, `int`, `enum` (with `values`). +`origins` limits what the recipe may open; `domains` adds hosts the page may load resources from (CDNs); both +feed agent-browser's `--allowed-domains` in `tyto-rx`. **Executor.** lint → render (defaults, types) → prepend `const params = JSON.parse();` to eval code → base64 (`eval -b`) → one `batch --bail --json` → last eval parsed as a JSON object → verify (non-strings compared JSON-style; params regex-escaped in `match`). -Exit codes: 0 hit · 3 MISS `{miss, step}` · 64 usage · 69 agent-browser missing · 70 internal/agent-browser -error · 75 session busy · 77 auth recipe not approved. -Timeouts: `AGENT_BROWSER_DEFAULT_TIMEOUT=6000`, explicit `--timeout` on waits, 15 s overall deadline. Retry the -batch once only on browser launch errors. - -**Sessions.** `tyto-rx`: Tyto-owned agent-browser config (copies `executablePath` from the user's config; no -restore) with `--allowed-domains `. `tyto-rx-auth`: `--restore main`, only for approved `auth` recipes. +Exit codes: 0 hit · 3 MISS `{miss, step}` · 64 usage/bad params · 65 recipe fails parse or lint · 69 +agent-browser missing · 70 internal/agent-browser error · 75 session busy · 77 auth recipe not approved. +Timeouts: `AGENT_BROWSER_DEFAULT_TIMEOUT=6000` in the batch environment (waits read it per call; clicks and fills +read it when the `tyto-rx` daemon starts, which Tyto does), 15 s overall deadline. Retry the batch once only on +browser launch errors. + +**Sessions.** `tyto-rx`: Tyto-owned agent-browser config via `AGENT_BROWSER_CONFIG`, which replaces the user's +config entirely (keeps only `executablePath`; no restore), with `--allowed-domains `. `tyto-rx-auth`: `--restore main`, only for approved `auth` recipes. Both use an action policy denying download and upload. ## 4. Slices and spec sentences @@ -95,18 +99,24 @@ Each slice: tests first, then code, `npm run check` green, one PR. ### Slice 3 — executor, adapter, store, `tyto run` - `runs all steps as one batch in session tyto-rx with --allowed-domains from origins` -- `a hit prints the result JSON and exits 0` -- `a failing step misses with exit 3 and the step index` +- `the domain allowlist also includes the recipe's extra network domains` +- `a hit returns the verified result`; `tyto run prints the result JSON and exits 0 on a hit` +- `a failing step misses with the step index`; `tyto run prints the miss and exits 3` +- `a verify failure after a successful batch misses at the last step` - `retries once on a browser launch error and never on element-not-found` -- `sets AGENT_BROWSER_DEFAULT_TIMEOUT and adds --timeout to wait steps` +- `sets AGENT_BROWSER_DEFAULT_TIMEOUT and the Tyto config for the batch` - `stops at the overall deadline with exit 70` - `an auth recipe runs in tyto-rx-auth only when approved, otherwise exits 77` -- `a busy session exits 75 after the lock wait` +- `a busy session exits 75`; `the session lock is released after the run` - `a missing agent-browser binary exits 69` -- `recipes that fail lint are refused before any browser call` +- `recipes that fail lint exit 65 before any browser call`; `bad params exit 64 before any browser call` - `BrowserRunner.batch sends steps as JSON on stdin and parses results` (stub executable, no browser) +- `run puts --session and session args before the command`; `abort kills the child process` - `FilesystemRecipeStore writes atomically with mode 0600 and rejects invalid recipes` -- `tyto recipes lists name, intent, and params` +- `FileSessionLock gives the lock to one holder at a time` and `reclaims a lock whose holder process is gone` +- `ensureReplayFiles writes a Tyto agent-browser config with only executablePath and a policy denying download and upload` +- `tyto recipes lists name, intent, and params`; `tyto test runs every regression case and exits 3 if any fails` +- live: `a fixture recipe hits, then misses after the page changes`; `a missing locator fails in under 7 s` ### Slice 4 — brief, find, act - `brief lists failed Fetch/XHR requests with a redacted response snippet` diff --git a/package-lock.json b/package-lock.json index 9dad1a1..ea609af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,10 @@ "name": "tyto", "workspaces": [ "packages/core", - "packages/llm" + "packages/llm", + "packages/agent-browser", + "packages/store", + "packages/cli" ], "devDependencies": { "@types/node": "^26.3.0", @@ -912,6 +915,14 @@ "undici-types": "~8.3.0" } }, + "node_modules/@tyto/agent-browser": { + "resolved": "packages/agent-browser", + "link": true + }, + "node_modules/@tyto/cli": { + "resolved": "packages/cli", + "link": true + }, "node_modules/@tyto/core": { "resolved": "packages/core", "link": true @@ -920,6 +931,10 @@ "resolved": "packages/llm", "link": true }, + "node_modules/@tyto/store": { + "resolved": "packages/store", + "link": true + }, "node_modules/@vitest/expect": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", @@ -1707,6 +1722,25 @@ "node": ">=8" } }, + "packages/agent-browser": { + "name": "@tyto/agent-browser", + "version": "0.1.0", + "dependencies": { + "@tyto/core": "*" + } + }, + "packages/cli": { + "name": "@tyto/cli", + "version": "0.1.0", + "dependencies": { + "@tyto/agent-browser": "*", + "@tyto/core": "*", + "@tyto/store": "*" + }, + "bin": { + "tyto": "bin/tyto.mjs" + } + }, "packages/core": { "name": "@tyto/core", "version": "0.1.0" @@ -1717,6 +1751,13 @@ "dependencies": { "@tyto/core": "*" } + }, + "packages/store": { + "name": "@tyto/store", + "version": "0.1.0", + "dependencies": { + "@tyto/core": "*" + } } } } diff --git a/package.json b/package.json index 80928de..bfe734c 100644 --- a/package.json +++ b/package.json @@ -5,13 +5,16 @@ "description": "Learn-once, replay-fast browser recipes and one-call page briefs, on top of agent-browser.", "workspaces": [ "packages/core", - "packages/llm" + "packages/llm", + "packages/agent-browser", + "packages/store", + "packages/cli" ], "scripts": { "test": "vitest run", "test:watch": "vitest", "test:live": "TYTO_LIVE=1 vitest run", - "typecheck": "tsc --noEmit -p packages/core && tsc --noEmit -p packages/llm", + "typecheck": "tsc --noEmit -p packages/core && tsc --noEmit -p packages/llm && tsc --noEmit -p packages/agent-browser && tsc --noEmit -p packages/store && tsc --noEmit -p packages/cli", "secrets:scan": "node scripts/check-secrets.mjs", "lint:imports": "node scripts/check-core-imports.mjs", "check": "npm run lint:imports && npm run secrets:scan && npm test && npm run typecheck" diff --git a/packages/agent-browser/package.json b/packages/agent-browser/package.json new file mode 100644 index 0000000..0d83cc9 --- /dev/null +++ b/packages/agent-browser/package.json @@ -0,0 +1,11 @@ +{ + "name": "@tyto/agent-browser", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "BrowserRunner adapter: runs the agent-browser CLI with an argv array.", + "exports": { ".": "./src/index.ts" }, + "dependencies": { + "@tyto/core": "*" + } +} diff --git a/packages/agent-browser/src/index.ts b/packages/agent-browser/src/index.ts new file mode 100644 index 0000000..83f0648 --- /dev/null +++ b/packages/agent-browser/src/index.ts @@ -0,0 +1 @@ +export { AgentBrowserRunner, type AgentBrowserRunnerOptions } from "./runner.ts"; diff --git a/packages/agent-browser/src/runner.ts b/packages/agent-browser/src/runner.ts new file mode 100644 index 0000000..732779f --- /dev/null +++ b/packages/agent-browser/src/runner.ts @@ -0,0 +1,108 @@ +import { spawn } from "node:child_process"; +import { + AgentBrowserMissingError, + BatchOutputError, + type BatchStepResult, + type BrowserRunner, + type RunOptions, + type RunOutput, +} from "@tyto/core"; + +export type AgentBrowserRunnerOptions = { + /** Path or name of the agent-browser binary. Default: `agent-browser` on PATH. */ + bin?: string; + baseEnv?: NodeJS.ProcessEnv; + maxOutputBytes?: number; +}; + +const DEFAULT_MAX_OUTPUT = 64 * 1024 * 1024; + +function isRecord(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +function withoutLifecycle(result: unknown): unknown { + if (!isRecord(result) || !("lifecycle" in result)) return result; + const { lifecycle: _lifecycle, ...rest } = result; + return rest; +} + +function toStepResult(raw: unknown): BatchStepResult { + if (!isRecord(raw) || !Array.isArray(raw.command)) throw new BatchOutputError("batch output entry has no command"); + const error = raw.error; + return { + command: raw.command.map(String), + success: raw.success === true, + result: withoutLifecycle(raw.result), + error: error === null || error === undefined ? null : typeof error === "string" ? error : JSON.stringify(error), + }; +} + +/** Runs the agent-browser CLI with an argv array (never a shell string). */ +export class AgentBrowserRunner implements BrowserRunner { + readonly #bin: string; + readonly #baseEnv: NodeJS.ProcessEnv; + readonly #maxOutput: number; + + constructor(opts: AgentBrowserRunnerOptions = {}) { + this.#bin = opts.bin ?? "agent-browser"; + this.#baseEnv = opts.baseEnv ?? process.env; + this.#maxOutput = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT; + } + + run(argv: readonly string[], opts: RunOptions): Promise { + return this.#exec([...this.#prefix(opts), ...argv], opts, undefined); + } + + async batch(steps: readonly (readonly string[])[], opts: RunOptions): Promise { + const out = await this.#exec([...this.#prefix(opts), "batch", "--bail", "--json"], opts, JSON.stringify(steps)); + let parsed: unknown; + try { + parsed = JSON.parse(out.stdout); + } catch { + throw new BatchOutputError(`agent-browser batch did not print JSON: ${out.stderr.trim().slice(0, 300)}`); + } + if (!Array.isArray(parsed)) throw new BatchOutputError("agent-browser batch output is not an array"); + return parsed.map(toStepResult); + } + + #prefix(opts: RunOptions): string[] { + return ["--session", opts.session, ...(opts.args ?? [])]; + } + + #exec(args: readonly string[], opts: RunOptions, stdin: string | undefined): Promise { + return new Promise((resolve, reject) => { + const child = spawn(this.#bin, args, { + env: { ...this.#baseEnv, ...(opts.env ?? {}) }, + stdio: ["pipe", "pipe", "pipe"], + ...(opts.signal ? { signal: opts.signal } : {}), + }); + let stdout = ""; + let stderr = ""; + let size = 0; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + size += chunk.length; + if (size > this.#maxOutput) child.kill("SIGKILL"); + else stdout += chunk; + }); + child.stderr.on("data", (chunk: string) => { + if (stderr.length < 65_536) stderr += chunk; + }); + child.on("error", (err: NodeJS.ErrnoException) => { + if (err.code === "ENOENT" || err.code === "EACCES") reject(new AgentBrowserMissingError(`agent-browser not found at ${this.#bin}`)); + else reject(err); + }); + child.on("close", (code) => { + if (opts.signal?.aborted) reject(opts.signal.reason instanceof Error ? opts.signal.reason : new Error("aborted")); + else if (size > this.#maxOutput) reject(new BatchOutputError("agent-browser output exceeded the size limit")); + else resolve({ exitCode: code ?? 1, stdout, stderr }); + }); + child.stdin.on("error", () => { + // The child may exit before reading stdin (e.g. missing binary); `error`/`close` report the outcome. + }); + child.stdin.end(stdin ?? ""); + }); + } +} diff --git a/packages/agent-browser/test/fixtures/fake-agent-browser.mjs b/packages/agent-browser/test/fixtures/fake-agent-browser.mjs new file mode 100755 index 0000000..edf770e --- /dev/null +++ b/packages/agent-browser/test/fixtures/fake-agent-browser.mjs @@ -0,0 +1,33 @@ +#!/usr/bin/env node +// Stand-in for the agent-browser binary in offline tests. Behavior comes from FAKE_AB_MODE. +import { appendFileSync } from "node:fs"; + +const argv = process.argv.slice(2); +if (process.env.FAKE_AB_LOG) appendFileSync(process.env.FAKE_AB_LOG, JSON.stringify(argv) + "\n"); +const mode = process.env.FAKE_AB_MODE ?? "ok"; + +if (mode === "hang") { + setInterval(() => {}, 1000); +} else if (argv.includes("batch")) { + let input = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (c) => (input += c)); + process.stdin.on("end", () => { + if (mode === "garbage") { + process.stdout.write("not json\n"); + process.stderr.write("daemon exploded\n"); + process.exit(1); + } + const steps = JSON.parse(input); + const out = steps.map((command, i) => ({ + command, + error: mode === "fail-last" && i === steps.length - 1 ? "Element not found: #x" : null, + result: { lifecycle: { launched: i === 0 }, result: `ok:${command[0]}`, config: process.env.AGENT_BROWSER_CONFIG ?? null }, + success: !(mode === "fail-last" && i === steps.length - 1), + })); + process.stdout.write(JSON.stringify(out) + "\n"); + process.exit(mode === "fail-last" ? 1 : 0); + }); +} else { + process.stdout.write(`ran ${argv.join(" ")}\n`); +} diff --git a/packages/agent-browser/test/runner.test.ts b/packages/agent-browser/test/runner.test.ts new file mode 100644 index 0000000..775a453 --- /dev/null +++ b/packages/agent-browser/test/runner.test.ts @@ -0,0 +1,65 @@ +import { mkdtemp, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { AgentBrowserMissingError, BatchOutputError } from "@tyto/core"; +import { AgentBrowserRunner } from "../src/index.ts"; + +const FAKE = fileURLToPath(new URL("./fixtures/fake-agent-browser.mjs", import.meta.url)); + +async function logFile(): Promise { + return join(await mkdtemp(join(tmpdir(), "tyto-ab-")), "argv.log"); +} + +function runner(env: Record): AgentBrowserRunner { + return new AgentBrowserRunner({ bin: FAKE, baseEnv: { ...process.env, ...env } }); +} + +describe("AgentBrowserRunner", () => { + it("BrowserRunner.batch sends steps as JSON on stdin and parses results", async () => { + const r = runner({ FAKE_AB_MODE: "ok" }); + const out = await r.batch([["open", "https://example.com"], ["eval", "-b", "e30="]], { session: "s" }); + expect(out).toEqual([ + { command: ["open", "https://example.com"], success: true, result: { result: "ok:open", config: null }, error: null }, + { command: ["eval", "-b", "e30="], success: true, result: { result: "ok:eval", config: null }, error: null }, + ]); + }); + + it("run puts --session and session args before the command", async () => { + const log = await logFile(); + const r = runner({ FAKE_AB_LOG: log }); + await r.run(["get", "url"], { session: "tyto-rx", args: ["--allowed-domains", "example.com"] }); + const argv = JSON.parse((await readFile(log, "utf8")).trim()); + expect(argv).toEqual(["--session", "tyto-rx", "--allowed-domains", "example.com", "get", "url"]); + }); + + it("batch runs as `batch --bail --json` and passes env to the child", async () => { + const log = await logFile(); + const r = runner({ FAKE_AB_LOG: log }); + const out = await r.batch([["get", "url"]], { session: "s", env: { AGENT_BROWSER_CONFIG: "/tmp/tyto.json" } }); + expect(out[0]?.result).toEqual({ result: "ok:get", config: "/tmp/tyto.json" }); + expect(JSON.parse((await readFile(log, "utf8")).trim())).toEqual(["--session", "s", "batch", "--bail", "--json"]); + }); + + it("a failing step is reported, not thrown, even when agent-browser exits non-zero", async () => { + const out = await runner({ FAKE_AB_MODE: "fail-last" }).batch([["open", "https://x.test"], ["click", "#x"]], { session: "s" }); + expect(out[1]).toMatchObject({ success: false, error: "Element not found: #x" }); + }); + + it("a missing binary throws AgentBrowserMissingError", async () => { + const r = new AgentBrowserRunner({ bin: "/nonexistent/agent-browser" }); + await expect(r.batch([["get", "url"]], { session: "s" })).rejects.toBeInstanceOf(AgentBrowserMissingError); + }); + + it("abort kills the child process", async () => { + const started = Date.now(); + const p = runner({ FAKE_AB_MODE: "hang" }).batch([["get", "url"]], { session: "s", signal: AbortSignal.timeout(100) }); + await expect(p).rejects.toThrow(); + expect(Date.now() - started).toBeLessThan(3000); + }); + + it("unreadable batch output throws BatchOutputError", async () => { + await expect(runner({ FAKE_AB_MODE: "garbage" }).batch([["get", "url"]], { session: "s" })).rejects.toBeInstanceOf(BatchOutputError); + }); +}); diff --git a/packages/agent-browser/tsconfig.json b/packages/agent-browser/tsconfig.json new file mode 100644 index 0000000..fc03fbd --- /dev/null +++ b/packages/agent-browser/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true, + "erasableSyntaxOnly": true + }, + "include": ["src", "test"] +} diff --git a/packages/cli/bin/tyto.mjs b/packages/cli/bin/tyto.mjs new file mode 100755 index 0000000..af81821 --- /dev/null +++ b/packages/cli/bin/tyto.mjs @@ -0,0 +1,6 @@ +#!/usr/bin/env node +// Runs the TypeScript sources directly (Node ≥ 22.18 strips types). +import { composeDeps, main } from "../src/index.ts"; + +const deps = await composeDeps(); +process.exitCode = await main(process.argv.slice(2), deps); diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..343efc9 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,14 @@ +{ + "name": "@tyto/cli", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "The tyto command: learn browser tasks once, replay them with no model.", + "bin": { "tyto": "bin/tyto.mjs" }, + "exports": { ".": "./src/index.ts" }, + "dependencies": { + "@tyto/agent-browser": "*", + "@tyto/core": "*", + "@tyto/store": "*" + } +} diff --git a/packages/cli/src/compose.ts b/packages/cli/src/compose.ts new file mode 100644 index 0000000..f63641f --- /dev/null +++ b/packages/cli/src/compose.ts @@ -0,0 +1,21 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import { AgentBrowserRunner } from "@tyto/agent-browser"; +import { FileSessionLock, FilesystemRecipeStore, ensureReplayFiles } from "@tyto/store"; +import type { CliDeps } from "./main.ts"; + +/** Composition root: the only place real adapters are wired together. */ +export async function composeDeps(env: NodeJS.ProcessEnv = process.env): Promise { + const home = env.TYTO_HOME && env.TYTO_HOME !== "" ? env.TYTO_HOME : join(homedir(), ".tyto"); + const paths = await ensureReplayFiles(home); + return { + store: new FilesystemRecipeStore(join(home, "recipes")), + exec: { + runner: new AgentBrowserRunner({ bin: env.TYTO_AGENT_BROWSER ?? "agent-browser" }), + lock: new FileSessionLock(join(home, "locks")), + paths, + }, + out: (line) => process.stdout.write(`${line}\n`), + err: (line) => process.stderr.write(`${line}\n`), + }; +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts new file mode 100644 index 0000000..e6710a7 --- /dev/null +++ b/packages/cli/src/index.ts @@ -0,0 +1,2 @@ +export { USAGE, main, type CliDeps } from "./main.ts"; +export { composeDeps } from "./compose.ts"; diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts new file mode 100644 index 0000000..3bcd28b --- /dev/null +++ b/packages/cli/src/main.ts @@ -0,0 +1,153 @@ +import { EXIT, executeRecipe, type ExecDeps, type RecipeStore } from "@tyto/core"; + +export type CliDeps = { + store: RecipeStore; + exec: ExecDeps; + out: (line: string) => void; + err: (line: string) => void; +}; + +export const USAGE = `usage: tyto + + tyto run [--param value ...] replay a recipe with no model (exit 0 hit, 3 miss) + tyto test run the recipe's regression cases + tyto recipes [--json] list recipes + tyto recipes show print a recipe + tyto recipes rm delete a recipe`; + +class UsageError extends Error {} + +function parseParams(args: readonly string[]): Record { + const params: Record = {}; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i] ?? ""; + const m = /^--(\w+)(?:=(.*))?$/s.exec(arg); + if (!m?.[1]) throw new UsageError(`expected --param value, got ${arg}`); + if (m[2] !== undefined) params[m[1]] = m[2]; + else { + const value = args[i + 1]; + if (value === undefined) throw new UsageError(`--${m[1]} needs a value`); + params[m[1]] = value; + i += 1; + } + } + return params; +} + +function subset(expected: Readonly>, actual: Record): boolean { + return Object.entries(expected).every(([k, v]) => JSON.stringify(actual[k]) === JSON.stringify(v)); +} + +async function run(args: readonly string[], deps: CliDeps): Promise { + const [name, ...rest] = args; + if (!name) throw new UsageError("tyto run needs a recipe name"); + const recipe = await deps.store.get(name); + if (!recipe) { + deps.err(`unknown recipe: ${name} (see tyto recipes)`); + return EXIT.usage; + } + const outcome = await executeRecipe(recipe, parseParams(rest), deps.exec); + switch (outcome.kind) { + case "hit": + deps.out(JSON.stringify(outcome.result)); + return EXIT.hit; + case "miss": + deps.out(JSON.stringify({ miss: outcome.miss, step: outcome.step })); + return EXIT.miss; + case "error": + deps.err(outcome.message); + return outcome.code; + default: { + const never: never = outcome; + return never; + } + } +} + +async function test(args: readonly string[], deps: CliDeps): Promise { + const name = args[0]; + if (!name) throw new UsageError("tyto test needs a recipe name"); + const recipe = await deps.store.get(name); + if (!recipe) { + deps.err(`unknown recipe: ${name}`); + return EXIT.usage; + } + if (!recipe.regression.length) { + deps.out(`${name}: no regression cases`); + return EXIT.hit; + } + let passed = 0; + for (const c of recipe.regression) { + const outcome = await executeRecipe(recipe, c.params, deps.exec); + const ok = outcome.kind === "hit" && subset(c.expect, outcome.result); + if (ok) passed += 1; + const detail = outcome.kind === "hit" ? JSON.stringify(outcome.result) : outcome.kind === "miss" ? `miss: ${outcome.miss}` : outcome.message; + deps.out(`${ok ? "PASS" : "FAIL"} ${JSON.stringify(c.params)} ${ok ? "" : `→ ${detail}`}`.trimEnd()); + } + deps.out(`${name}: ${passed}/${recipe.regression.length} passed`); + return passed === recipe.regression.length ? EXIT.hit : EXIT.miss; +} + +async function recipes(args: readonly string[], deps: CliDeps): Promise { + const [sub, name] = args; + if (sub === "show" || sub === "rm") { + if (!name) throw new UsageError(`tyto recipes ${sub} needs a recipe name`); + if (sub === "show") { + const recipe = await deps.store.get(name); + if (!recipe) { + deps.err(`unknown recipe: ${name}`); + return EXIT.usage; + } + deps.out(JSON.stringify(recipe, null, 2)); + return EXIT.hit; + } + if (!(await deps.store.remove(name))) { + deps.err(`unknown recipe: ${name}`); + return EXIT.usage; + } + deps.out(`removed ${name}`); + return EXIT.hit; + } + if (sub !== undefined && sub !== "--json") throw new UsageError(`unknown recipes subcommand: ${sub}`); + const list = await deps.store.list(); + if (sub === "--json") { + deps.out(JSON.stringify(list)); + return EXIT.hit; + } + if (!list.length) deps.out("no recipes yet"); + for (const r of list) { + const flags = [r.status, ...(r.auth ? ["uses your logins"] : [])].join(", "); + deps.out(`${r.name} (${r.params.map((p) => `--${p}`).join(" ") || "no params"}) [${flags}] ${r.intent}`); + } + return EXIT.hit; +} + +/** Entry point with injected dependencies; returns the process exit code. */ +export async function main(argv: readonly string[], deps: CliDeps): Promise { + const [command, ...args] = argv; + try { + switch (command) { + case "run": + return await run(args, deps); + case "test": + return await test(args, deps); + case "recipes": + return await recipes(args, deps); + case undefined: + case "help": + case "--help": + case "-h": + deps.out(USAGE); + return EXIT.hit; + default: + throw new UsageError(`unknown command: ${command}`); + } + } catch (e) { + if (e instanceof UsageError) { + deps.err(`${e.message}\n\n${USAGE}`); + return EXIT.usage; + } + deps.err(e instanceof Error ? e.message : String(e)); + return EXIT.internal; + } +} diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts new file mode 100644 index 0000000..734e60c --- /dev/null +++ b/packages/cli/test/cli.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { parseRecipe, type Recipe } from "@tyto/core"; +import { FakeBrowserRunner, FakeSessionLock, MemoryRecipeStore, evalReturns, stepFails } from "@tyto/core/testing"; +import { main, type CliDeps } from "../src/index.ts"; + +function recipe(overrides: Record = {}): Recipe { + const parsed = parseRecipe({ + name: "release", + version: 1, + status: "draft", + intent: "Newest release tag for a GitHub repo.", + origins: ["https://github.com"], + params: { repo: { type: "string", description: "owner/name", example: "a/b" } }, + steps: [["open", "https://github.com/{{repo|path}}/releases"], ["eval", "JSON.stringify({ tag: 'v1', url: location.href })"]], + verify: { required: ["tag"] }, + ...overrides, + }); + if (!parsed.ok) throw new Error(parsed.errors.join("; ")); + return parsed.recipe; +} + +function harness(runner = new FakeBrowserRunner(evalReturns(JSON.stringify({ tag: "v1", url: "https://github.com/a/b/releases" }))), ...recipes: Recipe[]) { + const out: string[] = []; + const err: string[] = []; + const deps: CliDeps = { + store: new MemoryRecipeStore(...(recipes.length ? recipes : [recipe()])), + exec: { runner, lock: new FakeSessionLock(), paths: { config: "/c.json", policy: "/p.json" } }, + out: (s) => out.push(s), + err: (s) => err.push(s), + }; + return { deps, out, err, runner }; +} + +describe("tyto CLI", () => { + it("tyto run prints the result JSON and exits 0 on a hit", async () => { + const h = harness(); + expect(await main(["run", "release", "--repo", "a/b"], h.deps)).toBe(0); + expect(JSON.parse(h.out.join(""))).toEqual({ tag: "v1", url: "https://github.com/a/b/releases" }); + }); + + it("tyto run prints the miss and exits 3", async () => { + const h = harness(new FakeBrowserRunner(stepFails(0, "Timeout"))); + expect(await main(["run", "release", "--repo", "a/b"], h.deps)).toBe(3); + expect(JSON.parse(h.out.join(""))).toMatchObject({ miss: expect.stringMatching(/Timeout/), step: 1 }); + }); + + it("tyto run parses --param value and --param=value", async () => { + const h = harness(); + expect(await main(["run", "release", "--repo=a/b"], h.deps)).toBe(0); + expect(h.runner.batches[0]?.steps[0]).toEqual(["open", "https://github.com/a/b/releases"]); + }); + + it("tyto run with an unknown recipe exits 64", async () => { + const h = harness(); + expect(await main(["run", "nope"], h.deps)).toBe(64); + expect(h.err.join("")).toMatch(/nope/); + }); + + it("tyto recipes lists name, intent, and params", async () => { + const h = harness(); + expect(await main(["recipes"], h.deps)).toBe(0); + const text = h.out.join("\n"); + expect(text).toMatch(/release/); + expect(text).toMatch(/Newest release tag/); + expect(text).toMatch(/repo/); + expect(await main(["recipes", "--json"], h.deps)).toBe(0); + }); + + it("tyto test runs every regression case and exits 3 if any fails", async () => { + const withCases = recipe({ + regression: [ + { params: { repo: "a/b" }, expect: { tag: "v1" } }, + { params: { repo: "c/d" }, expect: { tag: "v2" } }, + ], + }); + const h = harness(undefined, withCases); + expect(await main(["test", "release"], h.deps)).toBe(3); + expect(h.out.join("\n")).toMatch(/1\/2 passed/); + }); + + it("unknown command exits 64 with usage", async () => { + const h = harness(); + expect(await main(["frobnicate"], h.deps)).toBe(64); + expect(h.err.join("")).toMatch(/usage/i); + }); +}); diff --git a/packages/cli/test/live/replay.test.ts b/packages/cli/test/live/replay.test.ts new file mode 100644 index 0000000..83a8f28 --- /dev/null +++ b/packages/cli/test/live/replay.test.ts @@ -0,0 +1,71 @@ +import { execFile } from "node:child_process"; +import { createServer, type Server } from "node:http"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { executeRecipe, parseRecipe, type Recipe } from "@tyto/core"; +import { composeDeps } from "../../src/index.ts"; + +// Live: real agent-browser (browser from the user's agent-browser config) against a loopback page. +const run = promisify(execFile); +let server: Server; +let origin = ""; +let variant: "v1" | "v2" = "v1"; + +function page(): string { + const status = variant === "v1" ? 'Least Concern' : "moved"; + return `Owl

Barn owl

${status}`; +} + +function recipe(steps: string[][]): Recipe { + const parsed = parseRecipe({ + name: "owl-status", + version: 1, + status: "draft", + intent: "Status on the fixture page.", + origins: [origin], + steps: [["open", `${origin}/owl`], ...steps, ["eval", "JSON.stringify({ name: document.querySelector('#name')?.textContent, status: document.querySelector('#status')?.textContent, url: location.href })"]], + verify: { required: ["name", "status"] }, + }); + if (!parsed.ok) throw new Error(parsed.errors.join("; ")); + return parsed.recipe; +} + +beforeAll(async () => { + server = createServer((_req, res) => { + res.writeHead(200, { "content-type": "text/html" }); + res.end(page()); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const addr = server.address(); + origin = `http://127.0.0.1:${typeof addr === "object" && addr ? addr.port : 0}`; + await run("agent-browser", ["--session", "tyto-rx", "close"]).catch(() => undefined); // fresh daemon picks up the 6 s timeout +}); + +afterAll(async () => { + await run("agent-browser", ["--session", "tyto-rx", "close"]).catch(() => undefined); + await new Promise((resolve) => server.close(() => resolve())); +}); + +describe("live replay", () => { + it("a fixture recipe hits, then misses after the page changes", async () => { + const deps = await composeDeps({ ...process.env, TYTO_HOME: await mkdtemp(join(tmpdir(), "tyto-live-")) }); + variant = "v1"; + const hit = await executeRecipe(recipe([]), {}, deps.exec); + expect(hit).toMatchObject({ kind: "hit", result: { name: "Barn owl", status: "Least Concern" } }); + variant = "v2"; + const miss = await executeRecipe(recipe([]), {}, deps.exec); + expect(miss).toMatchObject({ kind: "miss", miss: "required field empty: status" }); + }, 60_000); + + it("a missing locator fails in under 7 s", async () => { + const deps = await composeDeps({ ...process.env, TYTO_HOME: await mkdtemp(join(tmpdir(), "tyto-live-")) }); + variant = "v1"; + const started = Date.now(); + const out = await executeRecipe(recipe([["click", "#does-not-exist"]]), {}, deps.exec); + expect(out.kind).toBe("miss"); + expect(Date.now() - started).toBeLessThan(7_000); + }, 60_000); +}); diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 0000000..fc03fbd --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true, + "erasableSyntaxOnly": true + }, + "include": ["src", "test"] +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 207ad8a..7ca6bba 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -8,3 +8,8 @@ export { parseRecipe, type ParseResult } from "./recipe/parse.ts"; export { lintRecipe } from "./recipe/lint.ts"; export { evalWithParams, renderRecipe, type RenderResult } from "./recipe/render.ts"; export { verifyResult, type VerifyOutcome } from "./recipe/verify.ts"; +export { AgentBrowserMissingError, BatchOutputError } from "./run/errors.ts"; +export { EXIT, type ExitCode } from "./run/exit.ts"; +export { DEFAULT_ACTION_TIMEOUT_MS, replaySession, type ReplayPaths, type ReplaySession } from "./run/session.ts"; +export { isLaunchError } from "./run/launch-error.ts"; +export { executeRecipe, type ExecDeps, type ExecOptions, type ExecOutcome } from "./run/execute.ts"; diff --git a/packages/core/src/ports/browser-runner.ts b/packages/core/src/ports/browser-runner.ts new file mode 100644 index 0000000..04a2e37 --- /dev/null +++ b/packages/core/src/ports/browser-runner.ts @@ -0,0 +1,23 @@ +/** One agent-browser batch step result, with agent-browser's lifecycle noise removed. */ +export type BatchStepResult = { + command: readonly string[]; + success: boolean; + result: unknown; + error: string | null; +}; + +export type RunOptions = { + session: string; + /** Global flags placed after `--session` and before the command, e.g. `--allowed-domains x`. */ + args?: readonly string[]; + env?: Readonly>; + signal?: AbortSignal; +}; + +export type RunOutput = { exitCode: number; stdout: string; stderr: string }; + +/** The agent-browser CLI. Adapter: packages/agent-browser. */ +export interface BrowserRunner { + run(argv: readonly string[], opts: RunOptions): Promise; + batch(steps: readonly (readonly string[])[], opts: RunOptions): Promise; +} diff --git a/packages/core/src/ports/index.ts b/packages/core/src/ports/index.ts index 6027694..453c3fe 100644 --- a/packages/core/src/ports/index.ts +++ b/packages/core/src/ports/index.ts @@ -2,3 +2,6 @@ export type { Clock } from "./clock.ts"; export type { InjectionGuard } from "./injection-guard.ts"; export type { ModelCatalog, ModelPort } from "./model.ts"; export type { Redactor } from "./redactor.ts"; +export type { BatchStepResult, BrowserRunner, RunOptions, RunOutput } from "./browser-runner.ts"; +export type { RecipeStore, RecipeSummary } from "./recipe-store.ts"; +export type { Release, SessionLock } from "./session-lock.ts"; diff --git a/packages/core/src/ports/recipe-store.ts b/packages/core/src/ports/recipe-store.ts new file mode 100644 index 0000000..d4e533e --- /dev/null +++ b/packages/core/src/ports/recipe-store.ts @@ -0,0 +1,17 @@ +import type { Recipe } from "../recipe/types.ts"; + +export type RecipeSummary = { + name: string; + intent: string; + params: readonly string[]; + status: Recipe["status"]; + auth: boolean; +}; + +export interface RecipeStore { + get(name: string): Promise; + list(): Promise; + /** Rejects recipes that fail parse or lint. */ + save(recipe: Recipe): Promise; + remove(name: string): Promise; +} diff --git a/packages/core/src/ports/session-lock.ts b/packages/core/src/ports/session-lock.ts new file mode 100644 index 0000000..32f4c92 --- /dev/null +++ b/packages/core/src/ports/session-lock.ts @@ -0,0 +1,6 @@ +export type Release = () => Promise; + +/** One replay per agent-browser session at a time. `null` means the session stayed busy. */ +export interface SessionLock { + acquire(session: string, waitMs: number): Promise; +} diff --git a/packages/core/src/recipe/parse.ts b/packages/core/src/recipe/parse.ts index bbd3010..5caf241 100644 --- a/packages/core/src/recipe/parse.ts +++ b/packages/core/src/recipe/parse.ts @@ -3,6 +3,7 @@ import type { ParamSpec, ParamType, Recipe, RecipeStatus, RegressionCase, Step, export type ParseResult = { ok: true; recipe: Recipe } | { ok: false; errors: string[] }; const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const DOMAIN = /^(?:\*\.)?(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9-]+$|^(?:\d{1,3}\.){3}\d{1,3}$/i; const PARAM_TYPES: readonly ParamType[] = ["string", "int", "enum"]; const STATUSES: readonly RecipeStatus[] = ["draft", "approved"]; @@ -115,6 +116,10 @@ export function parseRecipe(input: unknown): ParseResult { if (!isStringArray(origins) || origins.length === 0) errors.push("origins must be a non-empty string array"); else for (const o of origins) if (!isOrigin(o)) errors.push(`origin ${o} must look like https://host[:port]`); + const domains = input.domains ?? []; + if (!isStringArray(domains)) errors.push("domains must be a string array"); + else for (const d of domains) if (!DOMAIN.test(d)) errors.push(`domain ${d} must be a host name or *.host`); + const params: Record = {}; if (input.params !== undefined) { if (!isRecord(input.params)) errors.push("params must be an object"); @@ -150,6 +155,7 @@ export function parseRecipe(input: unknown): ParseResult { intent: input.intent as string, examples: examples as string[], origins: origins as string[], + domains: domains as string[], params, steps, verify, diff --git a/packages/core/src/recipe/types.ts b/packages/core/src/recipe/types.ts index 2b79ce0..bfd6e30 100644 --- a/packages/core/src/recipe/types.ts +++ b/packages/core/src/recipe/types.ts @@ -34,6 +34,8 @@ export type Recipe = { examples: readonly string[]; /** Origins the recipe may open, e.g. "https://github.com". */ origins: readonly string[]; + /** Extra hosts the page may load resources from (CDNs), e.g. "github.githubassets.com" or "*.githubusercontent.com". */ + domains: readonly string[]; params: Readonly>; steps: readonly Step[]; verify: VerifyRule; diff --git a/packages/core/src/run/errors.ts b/packages/core/src/run/errors.ts new file mode 100644 index 0000000..e8b9176 --- /dev/null +++ b/packages/core/src/run/errors.ts @@ -0,0 +1,15 @@ +/** The agent-browser binary could not be found or started. */ +export class AgentBrowserMissingError extends Error { + constructor(message = "agent-browser is not installed or not on PATH") { + super(message); + this.name = "AgentBrowserMissingError"; + } +} + +/** agent-browser printed something that is not the expected JSON. */ +export class BatchOutputError extends Error { + constructor(message: string) { + super(message); + this.name = "BatchOutputError"; + } +} diff --git a/packages/core/src/run/execute.ts b/packages/core/src/run/execute.ts new file mode 100644 index 0000000..fd35d4e --- /dev/null +++ b/packages/core/src/run/execute.ts @@ -0,0 +1,79 @@ +import type { BatchStepResult, BrowserRunner } from "../ports/browser-runner.ts"; +import type { SessionLock } from "../ports/session-lock.ts"; +import { lintRecipe } from "../recipe/lint.ts"; +import { renderRecipe } from "../recipe/render.ts"; +import type { ParamValue, Recipe } from "../recipe/types.ts"; +import { verifyResult } from "../recipe/verify.ts"; +import { AgentBrowserMissingError } from "./errors.ts"; +import { EXIT, type ExitCode } from "./exit.ts"; +import { isLaunchError } from "./launch-error.ts"; +import { replaySession, type ReplayPaths } from "./session.ts"; + +export type ExecDeps = { runner: BrowserRunner; lock: SessionLock; paths: ReplayPaths }; + +export type ExecOptions = { deadlineMs?: number; lockWaitMs?: number; actionTimeoutMs?: number }; + +export type ExecOutcome = + | { kind: "hit"; result: Record; params: Record } + | { kind: "miss"; miss: string; step: number } + | { kind: "error"; code: ExitCode; message: string }; + +const DEFAULT_DEADLINE_MS = 15_000; +const DEFAULT_LOCK_WAIT_MS = 10_000; + +function firstFailure(results: readonly BatchStepResult[]): { index: number; error: string | null } | null { + const index = results.findIndex((r) => !r.success); + return index < 0 ? null : { index, error: results[index]?.error ?? null }; +} + +function evalValue(step: BatchStepResult | undefined): unknown { + const result = step?.result; + return typeof result === "object" && result !== null && "result" in result ? (result as { result: unknown }).result : undefined; +} + +/** Run a recipe with no model: lint → render → one batch → verify. */ +export async function executeRecipe( + recipe: Recipe, + input: Readonly>, + deps: ExecDeps, + opts: ExecOptions = {}, +): Promise { + const problems = lintRecipe(recipe); + if (problems.length) return { kind: "error", code: EXIT.invalid, message: `recipe fails lint: ${problems.join("; ")}` }; + if (recipe.auth && recipe.status !== "approved") { + return { kind: "error", code: EXIT.notApproved, message: `recipe ${recipe.name} uses your logins; approve it first (tyto recipes approve ${recipe.name})` }; + } + const rendered = renderRecipe(recipe, input); + if (!rendered.ok) return { kind: "error", code: EXIT.usage, message: rendered.error }; + + const session = replaySession(recipe, deps.paths, opts.actionTimeoutMs); + const release = await deps.lock.acquire(session.session, opts.lockWaitMs ?? DEFAULT_LOCK_WAIT_MS); + if (!release) return { kind: "error", code: EXIT.busy, message: `session ${session.session} is busy` }; + + const signal = AbortSignal.timeout(opts.deadlineMs ?? DEFAULT_DEADLINE_MS); + const runOpts = { session: session.session, args: session.args, env: session.env, signal }; + try { + let results = await deps.runner.batch(rendered.steps, runOpts); + let failure = firstFailure(results); + if (failure && isLaunchError(failure.error)) { + results = await deps.runner.batch(rendered.steps, runOpts); + failure = firstFailure(results); + } + if (failure) { + const cmd = rendered.steps[failure.index]?.[0] ?? "?"; + return { kind: "miss", miss: `${cmd} failed: ${failure.error ?? "unknown error"}`, step: failure.index + 1 }; + } + if (results.length !== rendered.steps.length) { + return { kind: "error", code: EXIT.internal, message: "agent-browser returned fewer results than steps" }; + } + const verified = verifyResult(evalValue(results.at(-1)), recipe.verify, rendered.params); + if (!verified.hit) return { kind: "miss", miss: verified.miss, step: rendered.steps.length }; + return { kind: "hit", result: verified.result, params: rendered.params }; + } catch (e) { + if (e instanceof AgentBrowserMissingError) return { kind: "error", code: EXIT.unavailable, message: e.message }; + if (signal.aborted) return { kind: "error", code: EXIT.internal, message: "recipe exceeded its overall deadline" }; + return { kind: "error", code: EXIT.internal, message: e instanceof Error ? e.message : String(e) }; + } finally { + await release(); + } +} diff --git a/packages/core/src/run/exit.ts b/packages/core/src/run/exit.ts new file mode 100644 index 0000000..0b0401a --- /dev/null +++ b/packages/core/src/run/exit.ts @@ -0,0 +1,13 @@ +/** `tyto run` exit codes (sysexits-style). */ +export const EXIT = { + hit: 0, + miss: 3, + usage: 64, + invalid: 65, + unavailable: 69, + internal: 70, + busy: 75, + notApproved: 77, +} as const; + +export type ExitCode = (typeof EXIT)[keyof typeof EXIT]; diff --git a/packages/core/src/run/launch-error.ts b/packages/core/src/run/launch-error.ts new file mode 100644 index 0000000..a979aee --- /dev/null +++ b/packages/core/src/run/launch-error.ts @@ -0,0 +1,12 @@ +const LAUNCH = [ + /Chrome exited early/i, + /Auto-launch failed/i, + /DevToolsActivePort/i, + /Failed to (?:launch|start) (?:the )?browser/i, + /browser (?:launch|start(?:up)?) failed/i, +]; + +/** Browser failed to start — worth one retry. Anything else (element not found, timeouts) is a real miss. */ +export function isLaunchError(message: string | null): boolean { + return message !== null && LAUNCH.some((re) => re.test(message)); +} diff --git a/packages/core/src/run/session.ts b/packages/core/src/run/session.ts new file mode 100644 index 0000000..0ffc1af --- /dev/null +++ b/packages/core/src/run/session.ts @@ -0,0 +1,26 @@ +import type { Recipe } from "../recipe/types.ts"; + +/** Files Tyto owns for replays: an agent-browser config (no saved logins) and an action policy. */ +export type ReplayPaths = { config: string; policy: string }; + +export type ReplaySession = { + session: string; + args: string[]; + env: Record; +}; + +export const DEFAULT_ACTION_TIMEOUT_MS = 6000; + +/** + * Public recipes run in `tyto-rx`: Tyto's own config (replaces the user's, so no saved logins) and a domain + * allowlist. Approved `auth` recipes run in `tyto-rx-auth` with the user's saved logins (`--restore main`); + * agent-browser refuses `--allowed-domains` together with `--restore`. + */ +export function replaySession(recipe: Recipe, paths: ReplayPaths, timeoutMs = DEFAULT_ACTION_TIMEOUT_MS): ReplaySession { + const env = { AGENT_BROWSER_DEFAULT_TIMEOUT: String(timeoutMs), AGENT_BROWSER_CONFIG: paths.config }; + if (recipe.auth) { + return { session: "tyto-rx-auth", args: ["--restore", "main", "--action-policy", paths.policy], env }; + } + const hosts = [...new Set([...recipe.origins.map((o) => new URL(o).hostname), ...recipe.domains])]; + return { session: "tyto-rx", args: ["--allowed-domains", hosts.join(","), "--action-policy", paths.policy], env }; +} diff --git a/packages/core/src/testing/fakes.ts b/packages/core/src/testing/fakes.ts index 299a1fa..26bc04c 100644 --- a/packages/core/src/testing/fakes.ts +++ b/packages/core/src/testing/fakes.ts @@ -1,3 +1,7 @@ +import type { BatchStepResult, BrowserRunner, RunOptions, RunOutput } from "../ports/browser-runner.ts"; +import type { RecipeStore, RecipeSummary } from "../ports/recipe-store.ts"; +import type { Release, SessionLock } from "../ports/session-lock.ts"; +import type { Recipe } from "../recipe/types.ts"; import type { Clock } from "../ports/clock.ts"; import type { ModelPort } from "../ports/model.ts"; import type { CompleteRequest, CompleteResponse } from "../types.ts"; @@ -39,3 +43,97 @@ export class FakeModel implements ModelPort { return this.canned; } } + + +export type BatchScript = (steps: readonly (readonly string[])[], opts: RunOptions) => BatchStepResult[] | Promise; + +/** Scripted agent-browser. Each batch call takes the next script; the last one repeats. */ +export class FakeBrowserRunner implements BrowserRunner { + readonly batches: Array<{ steps: readonly (readonly string[])[]; opts: RunOptions }> = []; + readonly runs: Array<{ argv: readonly string[]; opts: RunOptions }> = []; + private scripts: BatchScript[]; + runOutput: RunOutput = { exitCode: 0, stdout: "", stderr: "" }; + + constructor(...scripts: BatchScript[]) { + this.scripts = scripts; + } + + async run(argv: readonly string[], opts: RunOptions): Promise { + this.runs.push({ argv, opts }); + return this.runOutput; + } + + async batch(steps: readonly (readonly string[])[], opts: RunOptions): Promise { + this.batches.push({ steps, opts }); + const script = this.scripts.length > 1 ? this.scripts.shift() : this.scripts[0]; + if (!script) throw new Error("FakeBrowserRunner: no script"); + return script(steps, opts); + } +} + +/** Every step succeeds; the final eval returns `value`. */ +export function evalReturns(value: unknown): BatchScript { + return (steps) => + steps.map((command, i) => ({ + command, + success: true, + result: i === steps.length - 1 ? { result: value } : {}, + error: null, + })); +} + +/** Steps succeed until `index`, which fails with `error` (`--bail` stops there). */ +export function stepFails(index: number, error: string): BatchScript { + return (steps) => + steps.slice(0, index + 1).map((command, i) => ({ + command, + success: i !== index, + result: {}, + error: i === index ? error : null, + })); +} + +export class MemoryRecipeStore implements RecipeStore { + readonly recipes = new Map(); + + constructor(...recipes: Recipe[]) { + for (const r of recipes) this.recipes.set(r.name, r); + } + + async get(name: string): Promise { + return this.recipes.get(name) ?? null; + } + + async list(): Promise { + return [...this.recipes.values()].map((r) => ({ + name: r.name, + intent: r.intent, + params: Object.keys(r.params), + status: r.status, + auth: r.auth, + })); + } + + async save(recipe: Recipe): Promise { + this.recipes.set(recipe.name, recipe); + } + + async remove(name: string): Promise { + return this.recipes.delete(name); + } +} + +export class FakeSessionLock implements SessionLock { + readonly held = new Set(); + readonly acquired: string[] = []; + busy = false; + + async acquire(session: string, _waitMs: number): Promise { + if (this.busy || this.held.has(session)) return null; + this.held.add(session); + this.acquired.push(session); + return async () => { + this.held.delete(session); + }; + } +} diff --git a/packages/core/src/testing/index.ts b/packages/core/src/testing/index.ts index ff4317f..20a5a98 100644 --- a/packages/core/src/testing/index.ts +++ b/packages/core/src/testing/index.ts @@ -1 +1,10 @@ -export { FakeClock, FakeModel } from "./fakes.ts"; +export { + FakeBrowserRunner, + FakeClock, + FakeModel, + FakeSessionLock, + MemoryRecipeStore, + evalReturns, + stepFails, + type BatchScript, +} from "./fakes.ts"; diff --git a/packages/core/test/execute.test.ts b/packages/core/test/execute.test.ts new file mode 100644 index 0000000..0fdb6b2 --- /dev/null +++ b/packages/core/test/execute.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "vitest"; +import { AgentBrowserMissingError, EXIT, executeRecipe, parseRecipe, type Recipe } from "../src/index.ts"; +import { FakeBrowserRunner, FakeSessionLock, evalReturns, stepFails } from "../src/testing/index.ts"; + +const PATHS = { config: "/tmp/tyto/agent-browser.json", policy: "/tmp/tyto/policy.json" }; + +function recipe(overrides: Record = {}): Recipe { + const parsed = parseRecipe({ + name: "release", + version: 1, + status: "draft", + auth: false, + intent: "Newest release tag.", + examples: [], + origins: ["https://github.com", "https://api.github.com"], + params: { repo: { type: "string", description: "owner/name", example: "a/b" } }, + steps: [ + ["open", "https://github.com/{{repo|path}}/releases"], + ["wait", "--url", "**/releases"], + ["eval", "JSON.stringify({ tag: 'v1', url: location.href })"], + ], + verify: { required: ["tag", "url"], match: { url: "github\\.com/{{repo}}/releases" } }, + ...overrides, + }); + if (!parsed.ok) throw new Error(parsed.errors.join("; ")); + return parsed.recipe; +} + +const GOOD = { tag: "v1", url: "https://github.com/a/b/releases" }; + +function deps(runner: FakeBrowserRunner, lock = new FakeSessionLock()) { + return { runner, lock, paths: PATHS }; +} + +describe("executeRecipe", () => { + it("runs all steps as one batch in session tyto-rx with --allowed-domains from origins", async () => { + const runner = new FakeBrowserRunner(evalReturns(JSON.stringify(GOOD))); + await executeRecipe(recipe(), { repo: "a/b" }, deps(runner)); + expect(runner.batches).toHaveLength(1); + const call = runner.batches[0]; + expect(call?.steps).toHaveLength(3); + expect(call?.opts.session).toBe("tyto-rx"); + expect(call?.opts.args).toEqual(["--allowed-domains", "github.com,api.github.com", "--action-policy", PATHS.policy]); + }); + + it("the domain allowlist also includes the recipe's extra network domains", async () => { + const runner = new FakeBrowserRunner(evalReturns(JSON.stringify(GOOD))); + await executeRecipe(recipe({ domains: ["github.githubassets.com", "*.githubusercontent.com"] }), { repo: "a/b" }, deps(runner)); + expect(runner.batches[0]?.opts.args?.[1]).toBe("github.com,api.github.com,github.githubassets.com,*.githubusercontent.com"); + }); + + it("a hit returns the verified result", async () => { + const runner = new FakeBrowserRunner(evalReturns(JSON.stringify(GOOD))); + const out = await executeRecipe(recipe(), { repo: "a/b" }, deps(runner)); + expect(out).toEqual({ kind: "hit", result: GOOD, params: { repo: "a/b" } }); + }); + + it("a failing step misses with the step index", async () => { + const runner = new FakeBrowserRunner(stepFails(1, "Timeout waiting for URL **/releases")); + const out = await executeRecipe(recipe(), { repo: "a/b" }, deps(runner)); + expect(out.kind).toBe("miss"); + if (out.kind === "miss") { + expect(out.step).toBe(2); + expect(out.miss).toMatch(/wait/); + } + }); + + it("a verify failure after a successful batch misses at the last step", async () => { + const runner = new FakeBrowserRunner(evalReturns(JSON.stringify({ tag: "", url: GOOD.url }))); + const out = await executeRecipe(recipe(), { repo: "a/b" }, deps(runner)); + expect(out).toMatchObject({ kind: "miss", step: 3 }); + }); + + it("retries once on a browser launch error and never on element-not-found", async () => { + const launch = new FakeBrowserRunner(stepFails(0, "Chrome exited early (exit code: 21) without writing DevToolsActivePort"), evalReturns(JSON.stringify(GOOD))); + expect((await executeRecipe(recipe(), { repo: "a/b" }, deps(launch))).kind).toBe("hit"); + expect(launch.batches).toHaveLength(2); + + const missing = new FakeBrowserRunner(stepFails(1, "Element not found: #nope"), evalReturns(JSON.stringify(GOOD))); + expect((await executeRecipe(recipe(), { repo: "a/b" }, deps(missing))).kind).toBe("miss"); + expect(missing.batches).toHaveLength(1); + }); + + it("sets AGENT_BROWSER_DEFAULT_TIMEOUT and the Tyto config for the batch", async () => { + const runner = new FakeBrowserRunner(evalReturns(JSON.stringify(GOOD))); + await executeRecipe(recipe(), { repo: "a/b" }, deps(runner)); + expect(runner.batches[0]?.opts.env).toEqual({ AGENT_BROWSER_DEFAULT_TIMEOUT: "6000", AGENT_BROWSER_CONFIG: PATHS.config }); + }); + + it("stops at the overall deadline with exit 70", async () => { + const hang = new FakeBrowserRunner( + (_steps, opts) => + new Promise((_resolve, reject) => { + opts.signal?.addEventListener("abort", () => reject(opts.signal?.reason)); + }), + ); + const out = await executeRecipe(recipe(), { repo: "a/b" }, deps(hang), { deadlineMs: 20 }); + expect(out).toMatchObject({ kind: "error", code: EXIT.internal }); + }); + + it("an auth recipe runs in tyto-rx-auth only when approved, otherwise exits 77", async () => { + const runner = new FakeBrowserRunner(evalReturns(JSON.stringify(GOOD))); + const draft = await executeRecipe(recipe({ auth: true }), { repo: "a/b" }, deps(runner)); + expect(draft).toMatchObject({ kind: "error", code: EXIT.notApproved }); + expect(runner.batches).toHaveLength(0); + + const approved = await executeRecipe(recipe({ auth: true, status: "approved" }), { repo: "a/b" }, deps(runner)); + expect(approved.kind).toBe("hit"); + expect(runner.batches[0]?.opts.session).toBe("tyto-rx-auth"); + expect(runner.batches[0]?.opts.args).toEqual(["--restore", "main", "--action-policy", PATHS.policy]); + }); + + it("a busy session exits 75", async () => { + const lock = new FakeSessionLock(); + lock.busy = true; + const runner = new FakeBrowserRunner(evalReturns(JSON.stringify(GOOD))); + const out = await executeRecipe(recipe(), { repo: "a/b" }, deps(runner, lock)); + expect(out).toMatchObject({ kind: "error", code: EXIT.busy }); + expect(runner.batches).toHaveLength(0); + }); + + it("the session lock is released after the run", async () => { + const lock = new FakeSessionLock(); + await executeRecipe(recipe(), { repo: "a/b" }, deps(new FakeBrowserRunner(stepFails(0, "boom")), lock)); + expect(lock.held.size).toBe(0); + expect(lock.acquired).toEqual(["tyto-rx"]); + }); + + it("a missing agent-browser binary exits 69", async () => { + const runner = new FakeBrowserRunner(() => { + throw new AgentBrowserMissingError(); + }); + const out = await executeRecipe(recipe(), { repo: "a/b" }, deps(runner)); + expect(out).toMatchObject({ kind: "error", code: EXIT.unavailable }); + }); + + it("recipes that fail lint exit 65 before any browser call", async () => { + const runner = new FakeBrowserRunner(evalReturns(JSON.stringify(GOOD))); + const bad = recipe({ steps: [["open", "https://github.com/"], ["cookies", "set", "a", "b"], ["eval", "JSON.stringify({})"]] }); + const out = await executeRecipe(bad, {}, deps(runner)); + expect(out).toMatchObject({ kind: "error", code: EXIT.invalid }); + expect(runner.batches).toHaveLength(0); + }); + + it("bad params exit 64 before any browser call", async () => { + const runner = new FakeBrowserRunner(evalReturns(JSON.stringify(GOOD))); + const out = await executeRecipe(recipe(), { nope: "x" }, deps(runner)); + expect(out).toMatchObject({ kind: "error", code: EXIT.usage }); + expect(runner.batches).toHaveLength(0); + }); +}); diff --git a/packages/core/test/recipe.test.ts b/packages/core/test/recipe.test.ts index 485ce64..db18c4f 100644 --- a/packages/core/test/recipe.test.ts +++ b/packages/core/test/recipe.test.ts @@ -49,6 +49,16 @@ describe("parseRecipe", () => { expect(r.steps).toHaveLength(3); }); + it("accepts extra network domains and rejects malformed ones", () => { + expect(recipe({ domains: ["github.githubassets.com", "*.githubusercontent.com"] }).domains).toEqual([ + "github.githubassets.com", + "*.githubusercontent.com", + ]); + for (const bad of ["https://cdn.test", "cdn.test/path", "*", "a b.test"]) { + expect(parseRecipe(base({ domains: [bad] })).ok, bad).toBe(false); + } + }); + it("rejects a recipe whose last step is not eval", () => { const parsed = parseRecipe(base({ steps: [["open", "https://github.com/"], ["wait", "--load", "load"]] })); expect(parsed.ok).toBe(false); diff --git a/packages/store/package.json b/packages/store/package.json new file mode 100644 index 0000000..013e876 --- /dev/null +++ b/packages/store/package.json @@ -0,0 +1,11 @@ +{ + "name": "@tyto/store", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Tyto files under ~/.tyto: recipes, session locks, replay config. Private (0600), atomic writes.", + "exports": { ".": "./src/index.ts" }, + "dependencies": { + "@tyto/core": "*" + } +} diff --git a/packages/store/src/files.ts b/packages/store/src/files.ts new file mode 100644 index 0000000..c67fc41 --- /dev/null +++ b/packages/store/src/files.ts @@ -0,0 +1,20 @@ +import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; + +/** Write `text` to `path` with mode 0600 via a temp file + rename, so readers never see a partial file. */ +export async function writePrivate(path: string, text: string): Promise { + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + const tmp = `${path}.${process.pid}.${Date.now()}.tmp`; + await writeFile(tmp, text, { encoding: "utf8", mode: 0o600 }); + await chmod(tmp, 0o600); + await rename(tmp, path); +} + +export async function readIfExists(path: string): Promise { + try { + return await readFile(path, "utf8"); + } catch (err) { + if (err instanceof Error && "code" in err && err.code === "ENOENT") return null; + throw err; + } +} diff --git a/packages/store/src/index.ts b/packages/store/src/index.ts new file mode 100644 index 0000000..76c1e3f --- /dev/null +++ b/packages/store/src/index.ts @@ -0,0 +1,4 @@ +export { readIfExists, writePrivate } from "./files.ts"; +export { FilesystemRecipeStore } from "./recipe-store.ts"; +export { FileSessionLock } from "./session-lock.ts"; +export { DEFAULT_USER_CONFIG, ensureReplayFiles } from "./replay-files.ts"; diff --git a/packages/store/src/recipe-store.ts b/packages/store/src/recipe-store.ts new file mode 100644 index 0000000..9a541ce --- /dev/null +++ b/packages/store/src/recipe-store.ts @@ -0,0 +1,64 @@ +import { readdir, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { lintRecipe, parseRecipe, type Recipe, type RecipeStore, type RecipeSummary } from "@tyto/core"; +import { readIfExists, writePrivate } from "./files.ts"; + +const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +function checkName(name: string): void { + if (!NAME.test(name)) throw new Error(`invalid recipe name: ${name}`); +} + +function validate(input: unknown): Recipe { + const parsed = parseRecipe(input); + if (!parsed.ok) throw new Error(`recipe is invalid: ${parsed.errors.join("; ")}`); + const problems = lintRecipe(parsed.recipe); + if (problems.length) throw new Error(`recipe fails lint: ${problems.join("; ")}`); + return parsed.recipe; +} + +/** Recipes as `/.json`. Every save and load re-validates (parse + lint). */ +export class FilesystemRecipeStore implements RecipeStore { + readonly #dir: string; + + constructor(dir: string) { + this.#dir = dir; + } + + async get(name: string): Promise { + checkName(name); + const text = await readIfExists(join(this.#dir, `${name}.json`)); + if (text === null) return null; + return validate(JSON.parse(text) as unknown); + } + + async list(): Promise { + let files: string[]; + try { + files = await readdir(this.#dir); + } catch (err) { + if (err instanceof Error && "code" in err && err.code === "ENOENT") return []; + throw err; + } + const out: RecipeSummary[] = []; + for (const f of files.filter((f) => f.endsWith(".json")).sort()) { + const recipe = await this.get(f.slice(0, -".json".length)).catch(() => null); + if (recipe) out.push({ name: recipe.name, intent: recipe.intent, params: Object.keys(recipe.params), status: recipe.status, auth: recipe.auth }); + } + return out; + } + + async save(recipe: Recipe): Promise { + checkName(recipe.name); + const valid = validate(JSON.parse(JSON.stringify(recipe)) as unknown); + await writePrivate(join(this.#dir, `${valid.name}.json`), `${JSON.stringify(valid, null, 2)}\n`); + } + + async remove(name: string): Promise { + checkName(name); + const path = join(this.#dir, `${name}.json`); + if ((await readIfExists(path)) === null) return false; + await rm(path); + return true; + } +} diff --git a/packages/store/src/replay-files.ts b/packages/store/src/replay-files.ts new file mode 100644 index 0000000..b29c303 --- /dev/null +++ b/packages/store/src/replay-files.ts @@ -0,0 +1,27 @@ +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { ReplayPaths } from "@tyto/core"; +import { readIfExists, writePrivate } from "./files.ts"; + +export const DEFAULT_USER_CONFIG = join(homedir(), ".agent-browser", "config.json"); + +/** + * Write the files `tyto-rx` replays use: an agent-browser config that replaces the user's (so saved logins + * never load) but keeps their browser choice, and an action policy denying downloads and uploads. + */ +export async function ensureReplayFiles(tytoHome: string, userConfigPath: string = DEFAULT_USER_CONFIG): Promise { + let executablePath: string | undefined; + const text = await readIfExists(userConfigPath); + if (text !== null) { + try { + const user = JSON.parse(text) as { executablePath?: unknown }; + if (typeof user.executablePath === "string") executablePath = user.executablePath; + } catch { + executablePath = undefined; + } + } + const paths = { config: join(tytoHome, "agent-browser.json"), policy: join(tytoHome, "policy.json") }; + await writePrivate(paths.config, `${JSON.stringify(executablePath ? { executablePath } : {}, null, 2)}\n`); + await writePrivate(paths.policy, `${JSON.stringify({ default: "allow", deny: ["download", "upload"] }, null, 2)}\n`); + return paths; +} diff --git a/packages/store/src/session-lock.ts b/packages/store/src/session-lock.ts new file mode 100644 index 0000000..e80be97 --- /dev/null +++ b/packages/store/src/session-lock.ts @@ -0,0 +1,61 @@ +import { mkdir, open, rm } from "node:fs/promises"; +import { join } from "node:path"; +import type { Release, SessionLock } from "@tyto/core"; +import { readIfExists } from "./files.ts"; + +const SESSION = /^[\w-]+$/; +const POLL_MS = 50; + +function alive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (err) { + return err instanceof Error && "code" in err && err.code === "EPERM"; + } +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** `/.lock` created with O_EXCL; a lock whose holder process is gone is reclaimed. */ +export class FileSessionLock implements SessionLock { + readonly #dir: string; + + constructor(dir: string) { + this.#dir = dir; + } + + async acquire(session: string, waitMs: number): Promise { + if (!SESSION.test(session)) throw new Error(`invalid session name: ${session}`); + await mkdir(this.#dir, { recursive: true, mode: 0o700 }); + const path = join(this.#dir, `${session}.lock`); + const deadline = Date.now() + waitMs; + for (;;) { + try { + const handle = await open(path, "wx", 0o600); + await handle.writeFile(JSON.stringify({ pid: process.pid, at: Date.now() })); + await handle.close(); + return async () => { + await rm(path, { force: true }); + }; + } catch (err) { + if (!(err instanceof Error && "code" in err && err.code === "EEXIST")) throw err; + } + const holder = await readIfExists(path); + let pid = 0; + try { + pid = Number((JSON.parse(holder ?? "{}") as { pid?: unknown }).pid) || 0; + } catch { + pid = 0; + } + if (!pid || !alive(pid)) { + await rm(path, { force: true }); + continue; + } + if (Date.now() >= deadline) return null; + await delay(POLL_MS); + } + } +} diff --git a/packages/store/test/store.test.ts b/packages/store/test/store.test.ts new file mode 100644 index 0000000..a299d04 --- /dev/null +++ b/packages/store/test/store.test.ts @@ -0,0 +1,103 @@ +import { mkdtemp, readdir, readFile, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { parseRecipe, type Recipe } from "@tyto/core"; +import { FileSessionLock, FilesystemRecipeStore, ensureReplayFiles } from "../src/index.ts"; + +function recipe(name = "release", overrides: Record = {}): Recipe { + const parsed = parseRecipe({ + name, + version: 1, + status: "draft", + intent: "Newest release tag.", + origins: ["https://github.com"], + params: { repo: { type: "string", description: "owner/name", example: "a/b" } }, + steps: [["open", "https://github.com/{{repo|path}}/releases"], ["eval", "JSON.stringify({ tag: 'v1' })"]], + verify: { required: ["tag"] }, + ...overrides, + }); + if (!parsed.ok) throw new Error(parsed.errors.join("; ")); + return parsed.recipe; +} + +async function tmp(): Promise { + return mkdtemp(join(tmpdir(), "tyto-store-")); +} + +describe("FilesystemRecipeStore", () => { + it("writes atomically with mode 0600 and rejects invalid recipes", async () => { + const dir = join(await tmp(), "recipes"); + const store = new FilesystemRecipeStore(dir); + await store.save(recipe()); + expect((await stat(join(dir, "release.json"))).mode & 0o777).toBe(0o600); + expect((await readdir(dir)).filter((f) => f.endsWith(".tmp"))).toEqual([]); + const bad = recipe("bad", { steps: [["open", "https://github.com/"], ["cookies", "clear"], ["eval", "JSON.stringify({})"]] }); + await expect(store.save(bad)).rejects.toThrow(/lint/); + expect(await store.get("bad")).toBeNull(); + }); + + it("list returns name, intent, params, status, and auth", async () => { + const store = new FilesystemRecipeStore(join(await tmp(), "recipes")); + await store.save(recipe()); + expect(await store.list()).toEqual([{ name: "release", intent: "Newest release tag.", params: ["repo"], status: "draft", auth: false }]); + }); + + it("get returns null for an unknown recipe and round-trips a saved one", async () => { + const store = new FilesystemRecipeStore(join(await tmp(), "recipes")); + expect(await store.get("nope")).toBeNull(); + await store.save(recipe()); + expect(await store.get("release")).toEqual(recipe()); + }); + + it("get refuses names that are not kebab-case", async () => { + const store = new FilesystemRecipeStore(join(await tmp(), "recipes")); + await expect(store.get("../../etc/passwd")).rejects.toThrow(/name/); + }); + + it("remove deletes a recipe and reports whether it existed", async () => { + const store = new FilesystemRecipeStore(join(await tmp(), "recipes")); + await store.save(recipe()); + expect(await store.remove("release")).toBe(true); + expect(await store.remove("release")).toBe(false); + }); +}); + +describe("FileSessionLock", () => { + it("gives the lock to one holder at a time", async () => { + const lock = new FileSessionLock(await tmp()); + const first = await lock.acquire("tyto-rx", 0); + expect(first).not.toBeNull(); + expect(await lock.acquire("tyto-rx", 50)).toBeNull(); + await first?.(); + const again = await lock.acquire("tyto-rx", 0); + expect(again).not.toBeNull(); + await again?.(); + }); + + it("reclaims a lock whose holder process is gone", async () => { + const dir = await tmp(); + await writeFile(join(dir, "tyto-rx.lock"), JSON.stringify({ pid: 999_999_999, at: 0 })); + const release = await new FileSessionLock(dir).acquire("tyto-rx", 0); + expect(release).not.toBeNull(); + await release?.(); + }); +}); + +describe("ensureReplayFiles", () => { + it("writes a Tyto agent-browser config with only executablePath and a policy denying download and upload", async () => { + const home = await tmp(); + const userConfig = join(home, "user-config.json"); + await writeFile(userConfig, JSON.stringify({ restore: "main", executablePath: "/Applications/Edge", headed: true })); + const paths = await ensureReplayFiles(join(home, "tyto"), userConfig); + expect(JSON.parse(await readFile(paths.config, "utf8"))).toEqual({ executablePath: "/Applications/Edge" }); + expect(JSON.parse(await readFile(paths.policy, "utf8"))).toEqual({ default: "allow", deny: ["download", "upload"] }); + expect((await stat(paths.config)).mode & 0o777).toBe(0o600); + }); + + it("writes an empty config when the user has no agent-browser config", async () => { + const home = await tmp(); + const paths = await ensureReplayFiles(join(home, "tyto"), join(home, "missing.json")); + expect(JSON.parse(await readFile(paths.config, "utf8"))).toEqual({}); + }); +}); diff --git a/packages/store/tsconfig.json b/packages/store/tsconfig.json new file mode 100644 index 0000000..fc03fbd --- /dev/null +++ b/packages/store/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true, + "erasableSyntaxOnly": true + }, + "include": ["src", "test"] +}