diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f1cfde2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +env: + CARGO_TERM_COLOR: always + # Fail the build on a warning rather than letting lint debt accumulate. + RUSTFLAGS: -D warnings + +jobs: + check: + name: fmt, clippy, test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install the toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - uses: Swatinem/rust-cache@v2 + + - name: Formatting + run: cargo fmt --all --check + + # --all-targets covers tests and the live suite's compilation; the live + # tests themselves are #[ignore]d and never run here. They need three + # authenticated CLIs and spend real quota, neither of which belongs in CI. + - name: Clippy + run: cargo clippy --all-targets -- -D warnings + + - name: Tests + run: cargo test --all-targets + + # `cargo test --all-targets` skips doctests, and the crate's usage + # examples live in them. + - name: Doctests + run: cargo test --doc + + - name: Documentation builds without broken links + run: cargo doc --no-deps + env: + RUSTDOCFLAGS: -D warnings diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96ef6c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0ea67dc --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,127 @@ +# Working agreement: RustAgentAbstraction + +The operating contract for **any** coding agent working in this repository. This file is +the single source of truth for the rules: Codex, Cursor and Gemini CLI read `AGENTS.md` +natively, and Claude Code loads it through the `@AGENTS.md` import in +[`CLAUDE.md`](CLAUDE.md). **Never fork these rules into a per-vendor file.** + +A single Rust library crate, `agent-abstraction`: drives the Claude Code, Codex and GitHub +Copilot CLIs headlessly behind one API. Consumed as a direct dependency by the +`pathscale/agencyzero` Tauri app. See [README.md](README.md) for the API and the layout. + +## Invariants (don't break these) + +- **Every flag mapping must be verified against the real CLI, and the version recorded in a + comment next to it.** These CLIs change flags between releases: Copilot 1.0.75 gained a + headless session id and a JSONL event stream that oneharness (our upstream) still models as + absent. A mapping copied from documentation, from upstream, or from memory is a guess. + Check `--help`, or run the thing. +- **Never silently downgrade a capability.** If an agent cannot fork, cannot stream, or + cannot take a session id, that is an `Error::Unsupported`. A caller who asked to fork and + got a linear resume has just corrupted the conversation they meant to branch, and a loud + error is always better than a quiet wrong answer. +- **Never invent usage numbers.** `Usage` fields are `Option` because the three agents + report different subsets. Absent means "the agent did not say", never zero, and never a + figure derived from a local price table. +- **This crate is a library. It has no binary and no CLI.** If something seems to need a + command-line entry point, it belongs in the consumer, not here. +- **No shell.** Arguments are built as a `Vec` and handed to `exec`. Never + interpolate a prompt into a shell string; that is how a prompt containing `$(...)` becomes + a command. +- **Cancellation is complete on Unix and incomplete on Windows.** Dropping, cancelling or + timing out a run tears down the whole process group, so the commands an agent started die + with it. On Windows only the direct child is killed: containing a tree there needs a Job + Object, which this crate does not set up. Do not describe cancellation as cross-platform, + and keep `tests/process.rs` honest by leaving it `#![cfg(unix)]` rather than making it + pass vacuously elsewhere. +- **`src/proc.rs` holds the crate's only `unsafe`.** `Cargo.toml` sets + `unsafe_code = "deny"` rather than `forbid` so that one audited call can be excepted. A + second `unsafe` anywhere is a design question, not a local decision. + +## Build & run + +```bash +cargo build +cargo test +cargo fmt && cargo clippy --all-targets # run after every change +``` + +### Live tests + +```bash +cargo test --test live -- --ignored --test-threads 1 +``` + +Spawns the real agents and consumes real quota, so it is `#[ignore]`d by default. Each test +skips itself when its binary is absent. **Run it after touching any argv mapping or output +parser**. The unit tests prove the code does what it says, only the live suite proves the +CLI agrees. + +## Architecture + +Pure logic and I/O are kept apart so the mappings are testable without spawning anything. + +| File | Role | +|---|---| +| `src/agent.rs` | The three agents, their capabilities, and argv building. **Pure.** | +| `src/request.rs` | The fluent request builder and its resolution into a `Plan`. **Pure.** | +| `src/event.rs` | Normalizing three JSON dialects into one event vocabulary. **Pure.** | +| `src/session.rs` | Name → native-id bindings on disk. | +| `src/run.rs` | Spawning, streaming, timeouts, failure classification. | +| `src/proc.rs` | Process-group teardown. The crate's only `unsafe`. | +| `src/outcome.rs` | What a finished run produced. | +| `src/error.rs` | One error type; one variant per case a caller must branch on. | + +Anything pure gets ordinary unit tests in the same file. Keep it that way: a mapping that +needs a subprocess to test is a mapping in the wrong module. + +## Verification + +Run what you build before reporting it done. Type-checks and tests verify code correctness, +not feature correctness. **If you can't run it, say so explicitly** rather than implying +success. If an agent CLI isn't installed and you mapped its flags from a document, say that +plainly and mark it in the code. + +- Compare against the base branch rather than asserting: a pre-existing failing test or + clippy warning is not something you introduced, and saying so requires checking. +- `cargo build` finishing in under a second means it was cached, not that it rebuilt. Touch + the sources when a rebuild is the thing you're verifying. + +## PR discipline + +**Always paste the full PR URL** +(`https://github.com/pathscale/RustAgentAbstraction/pull/`), not just the number, so it's +clickable. + +## Keeping docs honest + +Hit a factual error here, such as a stale flag, a wrong version or a moved status? Fix +it in the same change. Don't open cosmetic rewording PRs. + +Learned something durable, such as a CLI gotcha, a flag that changed, or a shape that +differs from the docs? It belongs **in this repo** (a comment next to the mapping, or +the README's gotchas +section), not in your agent's private memory. Repo docs are versioned, reviewable, and +visible to every agent and human; private memory dies with your machine. + +## Git workflow + +- **Always specify the branch when pushing**: `git push origin branch-name` +- **Branch naming**: `fix/issue-description` or `feat/issue-description` +- **Default branch is `master`**, not `main`. +- **Force-push your own branch freely.** Rebasing a feature branch onto a moved base, or + amending before review, is normal and correct. Use `--force-with-lease` so you don't + clobber someone else's push. +- **Never force-push the default branch.** That is the history everyone else builds on. + +## No AI attribution + +Never add AI attribution to anything in this repo or leaving it: no "Generated with +Claude Code" / robot-emoji footers, no `Co-Authored-By: Claude` (or any AI) trailers, +and no AI credit in commit messages, PR or issue titles/bodies, changelogs, release +notes, or code comments. Applies to every agent and every vendor. Work product should +be indistinguishable from a human teammate's. + +## Writing style + +No em dashes in prose or documentation. Restructure the sentence instead. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0a6b46a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,15 @@ +@AGENTS.md + +# Claude Code notes: RustAgentAbstraction + +The import above is binding: [`AGENTS.md`](AGENTS.md) is the **working agreement** for this +repository, and every Claude Code session loads it automatically. Don't copy rules here, +since one source of truth means no drift. Only genuinely Claude-specific wiring belongs +below. + +- This crate drives `claude` itself. When you change the Claude adapter in + [`src/agent.rs`](src/agent.rs), you are changing how a program invokes the same CLI you + are running inside. Verify against `claude --help` for the installed version rather than + against your own knowledge of the flags, which may be from a different release. +- The live test suite spawns real agents and spends real quota. Don't run it on a loop, and + don't add it to a watch task. diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..e64cd1c --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,57 @@ +[package] +name = "agent-abstraction" +version = "0.1.0" +edition = "2024" +# The floor edition 2024 requires, and where the strictest dependencies (uuid, +# getrandom) sit. Derived from the dependency graph rather than compile-tested. +rust-version = "1.85" +description = "Drive Claude Code, Codex and Copilot CLIs headlessly from Rust, as a library." +license = "MIT" +repository = "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/pathscale/RustAgentAbstraction" +readme = "README.md" +keywords = ["agent", "claude", "codex", "copilot", "cli"] +categories = ["development-tools"] + +# Library only, by design. There is no binary: the consumer (an agencyzero GUI, +# a service, a test) links this crate and spawns the agent directly, so nothing +# marshals a request through a CLI and back out of stdout twice. +[lib] +name = "agent_abstraction" +path = "src/lib.rs" + +[dependencies] +# `process` gives an async child with piped stdio; `io-util` the line reader that +# turns a JSONL stream into events; `time` the run timeout; `sync` the event +# channel. No `full`: the consumer picks its own runtime features. +tokio = { version = "1", features = ["process", "io-util", "sync", "time", "rt", "macros"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +# Session ids are caller-minted UUIDv4 (`claude --session-id`, and Copilot's +# handle, which the CLI never prints). v4 only, since these are opaque handles and not +# sort keys, so the v7 timestamp would leak wall-clock into a stable id. +uuid = { version = "1", features = ["v4", "serde"] } +# Resolves the agent binary on PATH so "not installed" is a typed error with an +# install hint, rather than a bare ENOENT from the spawn. + +# Killing a process *group* on timeout or cancellation. Without it only the CLI +# dies and the commands it spawned keep running. +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[dev-dependencies] +tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } +# The live tests mint UUIDs to prove a caller-assigned session id round-trips. +uuid = { version = "1", features = ["v4"] } + +[lints.rust] +missing_docs = "warn" +# `deny` rather than `forbid`: one audited exception exists, `libc::kill` for +# process-group teardown, which has no safe wrapper. +unsafe_code = "deny" + +[lints.clippy] +pedantic = { level = "warn", priority = -1 } +# The crate's own error type is deliberately wide (one variant per failure a +# caller must branch on); boxing it would push that branch into a downcast. +result_large_err = "allow" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6469ab3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,26 @@ +MIT License + +Copyright (c) 2026 PathScale +Copyright (c) 2026 Nick DeRobertis + +This project is a Rust port of nickderobertis/oneharness, which is MIT +licensed. Portions of the harness flag mappings and the session-store design +are derived from that work; the copyright notice above is retained accordingly. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index b271971..61427b4 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,304 @@ # RustAgentAbstraction -A pure rust port of nickderobertis/oneharness to remove nonsense + +`agent-abstraction` drives **Claude Code**, **Codex** and **GitHub Copilot** headlessly from +Rust: one request type, one event vocabulary, one session model across three CLIs that agree +on none of those things. + +It is a **library, not a CLI**. Your program links it and spawns the agent directly, so +nothing marshals a request through a command line and back out of stdout twice. + +```rust +use agent_abstraction::{Agent, Permission, Request, run}; + +let outcome = run( + &Request::new(Agent::Claude, "Reply with the single word: pong") + .model("haiku") + .permission(Permission::ReadOnly), +) +.await?; + +println!("{}", outcome.text); // "pong" +println!("{:?}", outcome.usage.cost_usd); +``` + +## What each agent can actually do + +Verified live, against `claude 2.1.205`, `codex-cli 0.145.0` and `GitHub Copilot CLI 1.0.75` +and not inferred from documentation. + +| | session id | fork | events | system prompt | resume flag | +|---|---|---|---|---|---| +| **Claude Code** | caller-minted (`--session-id`) | yes (`--fork-session`) | `stream-json` | native (`--append-system-prompt`) | `--resume` | +| **Codex** | agent-printed (`thread_id`) | no | `--json` | prepended to prompt | `exec resume ` | +| **Copilot** | caller-minted (`--session-id`) | no | `--output-format json` | prepended to prompt | `--session-id` | + +### Can I choose the session id, or do I have to read it back? + +Both, depending on the agent. Verified by round-trip, not from `--help`: + +| | assign it up front | read it back | +|---|---|---| +| **Claude Code** | yes, `.session_id(uuid)` | also reported | +| **Copilot** | yes, `.session_id(uuid)` | also reported | +| **Codex** | **no** | `thread_id`, before it answers | + +```rust +// Claude and Copilot: the id is yours to pick, so it can match a thread id +// your app already has, with no mapping table in between. +let mine = uuid::Uuid::new_v4().to_string(); +let outcome = run(&Request::new(Agent::Claude, "hi").session_id(&mine)).await?; +assert_eq!(outcome.session.as_deref(), Some(mine.as_str())); +``` + +Both CLIs require a valid UUID. Asking Codex for an assigned id is an +`Error::Unsupported` raised before spawning, never a silently unrelated conversation. + +**Assigning beats reading back**, where you get the choice: the binding exists *before* the +process starts, so a run that dies mid-turn still leaves a resumable session. Codex's +`thread_id` can only be recorded once it has been printed. + +That said, Codex prints it early. It arrives in `thread.started`, the first record of the +stream, **before any answer text**, so a host can persist the binding the moment the stream +opens rather than waiting for the turn to finish. `Event::Started` is the normalized form of +that moment for all three agents. + +Asking an agent for something it cannot do is always an `Error::Unsupported`, never a quiet +downgrade. A caller that asked to fork and silently got a linear resume would corrupt the +conversation it meant to branch. + +## Streaming + +```rust +let mut running = stream(&Request::new(Agent::Claude, "audit this repo"))?; +while let Some(event) = running.recv().await { + match event { + Event::Text(text) => print!("{text}"), + Event::ToolCall { name, .. } => println!("[{name}]"), + Event::RateLimit(limit) => eprintln!("quota: {}", limit.status), + _ => {} + } +} +let outcome = running.finish().await?; +``` + +`Event::Text` is the *incremental display stream*. `Outcome::text` is the agent's own +*authoritative answer*. They are deliberately separate: concatenating the deltas is not +guaranteed to equal the final text (Copilot emits both; Claude emits only the latter), so +read `Outcome::text` for the answer and never sum the events. + +## Sessions + +Thread one stable name across turns; the store maps it to whatever handle the agent +understands. + +```rust +let store = SessionStore::open("/var/lib/myapp/sessions"); + +let turn = Request::new(Agent::Claude, "what did I ask you to remember?") + .session(&store, ".", "thread-42", /* fork */ false)?; + +assert_eq!(turn.session_phase(), Some(Phase::Continue)); +let outcome = run(&turn).await?; +``` + +Records live at `//.json`, partitioned by project so the same name +in two checkouts never collides, and written through a temp file and a rename so a +concurrent reader never sees a half-written record. A corrupt record reads as absent: the +next turn opens a fresh conversation rather than failing over a cache nobody asked about. + +Names are percent-encoded into a single path segment, which is **injective**: two different +names can never land on the same file. That matters more than it sounds, because the failure +mode of a lossy scheme is silent, not loud. Folding unsafe characters to `-` would map +`café` and `cafe-` together, and the second session to use that name would quietly resume +the first one's conversation. Uppercase is encoded too, since macOS and Windows are +case-insensitive and would otherwise collide `Chat` with `chat`. The record keeps your +original name, so `list()` hands back what you passed, not a mangled segment. + +## Cancellation + +**Dropping a `Run` kills the agent, and everything it spawned.** Closing a window or +cancelling a request should stop the work, not leave an agent running invisibly, spending +quota and writing files with nobody watching. + +```rust +let running = stream(&request)?; +drop(running); // agent and its children are killed +running.cancel().await?; // cooperative: returns only once the tree has exited +running.detach(); // opt out: keep running unsupervised +``` + +`cancel` is cooperative rather than an abort: the driver signals the process group, reaps +the child and joins its readers before returning `Error::Cancelled`. So when it returns the +tree really is gone, which matters if the next thing you do touches the files it was working +on. `drop` cannot await, so it signals and then aborts as a backstop. + +Each run gets its own process group on Unix, and cancellation, drop and timeout all tear +down the whole group. Killing only the CLI would orphan the commands *it* started, which +keep holding files and credentials afterwards. Windows has no equivalent here yet: only the +direct child is killed, since containing a tree there needs a Job Object. + +## Permissions + +`Permission` maps one posture onto each agent's own vocabulary: + +| | Claude | Codex | Copilot | +|---|---|---|---| +| `ReadOnly` | `dontAsk` + `--disallowedTools` | `--sandbox read-only` | `--deny-tool=shell,write` | +| `Plan` | `--permission-mode plan` | `--sandbox read-only` | `--mode plan` | +| `Edit` | `acceptEdits` | `--sandbox workspace-write` | `--deny-tool=shell` | +| `Auto` | `--permission-mode auto` | `--sandbox workspace-write` | `--allow-all-paths` | +| `Bypass` | `bypassPermissions` | `--dangerously-bypass-approvals-and-sandbox` | `--allow-all-paths` | + +The default is `ReadOnly`. Widen it explicitly. + +**What this does not cover.** These postures constrain each CLI's *built-in* tools: its +shell, its file writes, its sandbox. They do **not** constrain MCP servers, plugins or +custom tools, which are a separate tool category in all three CLIs. An MCP tool that files +an issue, writes to a database or calls a deploy API can still act during a nominally +read-only run. Claude's mapping denies `mcp__*` as well, but the other two have no +equivalent switch, so if a run must not cause remote side effects the containment has to be +which MCP servers are enabled at all. + +Two more honest limits: Codex has no true plan mode, so `Plan` maps to its read-only +sandbox (writes blocked, execution still permitted), and `unchecked_args` can contradict any +of this by design. + +## Environment isolation + +**`EnvPolicy::Minimal` is the default.** Inheriting the whole environment is what a CLI gets +from a shell, but this crate runs inside processes that hold unrelated secrets, and full +inheritance hands every one of them to the agent and to every command the agent runs. That +is worth deciding deliberately, so it is the opt-in: + +```rust +Request::new(Agent::Claude, "review this") // Minimal, nothing to configure +Request::new(Agent::Claude, "review this").env_policy(EnvPolicy::Inherit) // opt in +``` + +`Minimal` passes through only what the selected agent needs. The crate owns that list per +agent rather than the caller, because an incomplete hand-written one fails as an +authentication error rather than as an obvious config mistake. + +The list was derived by experiment, not assumption: `PATH` + `HOME` alone is **not** enough, +because Claude's keychain lookup is keyed on `USER` and returns "Not logged in" without it. +`PATH` + `HOME` + `USER` is the verified floor for all three on macOS. Windows names are +included on the same reasoning but are unverified. + +Proxy and custom-CA variables are deliberately **excluded**. They are situational rather +than required, and `HTTPS_PROXY` routinely embeds credentials (`http://user:pass@proxy`), +so forwarding them automatically would leak one through the policy meant to withhold +secrets. A host that needs them should surface them as a setting; `NETWORK_ENV` names them +so a settings screen does not have to hardcode the list: + +```rust +for name in NETWORK_ENV { + if let Ok(value) = std::env::var(name) { + request = request.env(*name, value); + } +} +``` + +Two tests keep it honest: a live one asserting every agent still authenticates under +`Minimal`, so an incomplete list fails loudly, and a deterministic one asserting the host's +own variables do not reach the child. + +## Gotchas worth knowing + +- **`codex exec` refuses to run outside a git repository.** This crate always passes + `--skip-git-repo-check`, so it runs anywhere. That check exists to stop an agent editing + files with no way to undo them; the sandbox is the real containment here, and it defaults + to `read-only`. +- **`codex exec resume` does not accept `--sandbox`.** It is a different option set from + `codex exec` and rejects the flag outright, so the permission posture is applied as + `-c sandbox_mode=...` on the resume path. Only a multi-turn run reveals this: every + single-turn test passes either way. +- **Copilot's tool filters need `=`.** They are declared `--deny-tool[=tools...]`, an + optional value, which binds only as `--deny-tool=shell`. Across a space the value is read + as a positional and the deny is silently lost. This crate always emits the combined form. +- **Copilot needs `--allow-all-tools` to run headlessly at all**, or it stalls at the first + tool confirmation. It is always emitted; `Permission` then narrows via denies. +- **Claude's `stream-json` requires `--verbose`**, or it refuses to start. Handled. +- **Large prompts move to stdin automatically** above 128 KiB, so a long prompt never fails + with `E2BIG`. + +## When a vendor changes its output + +The CLIs move. A format change shows up here as a run that exits `0` and returns nothing, +which is a miserable thing to debug from the outside, so `Outcome` carries the evidence: + +```rust +if outcome.looks_like_a_format_change() { + tracing::error!( + unparsed = outcome.unparsed, + sample = ?outcome.first_unparsed, + "the CLI is healthy; this crate's parser is not", + ); +} +``` + +Unparseable lines are counted rather than discarded. A non-zero count on its own is normal +(agents interleave banners with their JSON); a non-zero count *with an empty answer* is the +signature worth alerting on. + +Captured buffers (`text`, raw stdout, stderr) are bounded at `MAX_CAPTURE`, 1 MiB, keeping +the earliest output. An agent can stream for hours, and an unbounded capture turns a long +run into an OOM instead of an answer. Individual lines are bounded separately at `MAX_LINE`, +because a reader that accumulates until a newline can exhaust memory on one line that never +ends, long before any total cap applies. + +Under a structured format there is no silent fallback to raw stdout: a run that produced no +recognizable records, or never reached its terminal record, returns `Error::Parse` rather +than a plausible-looking answer assembled from whatever was printed. + +## No shell, ever + +Arguments are built as a `Vec` and passed straight to `exec`. There is no shell in +the path, so a prompt containing `;`, backticks or `$(...)` is data, not syntax, with no quoting +or escaping to get wrong. + +## Operating within the agents' terms + +This crate drives each vendor's own supported headless interface using the credentials that +CLI already holds. It does not reimplement a provider API, multiplex accounts, or retry +around a quota. A refusal surfaces as `Error::RateLimited` carrying the provider's own +wording, and backing off is the caller's decision. See +[`docs/operating-limits.md`](docs/operating-limits.md). + +## Testing + +```bash +cargo test +``` + +```bash +cargo test --test live -- --ignored --test-threads 1 +``` + +The live suite drives the installed agents end to end (answer, usage, streaming, multi-turn +memory, forking) and skips any agent whose binary is absent rather than failing on it. +It spawns real agents and consumes real quota, which is why it is ignored by default. + +## Relationship to oneharness + +A Rust port of [nickderobertis/oneharness](https://github.com/nickderobertis/oneharness) +(MIT), reduced to three agents and rebuilt as an embeddable library. What changed: + +- **The Python and TypeScript SDKs are gone**, along with the JSON-Schema codegen that fed + them. They existed only so non-Rust callers could shell out to the `oneharness` binary and + re-validate its JSON. In a Rust consumer that entire layer collapses into the public API: + the type system *is* the contract. +- **The CLI is gone.** A GUI embedding this crate should not pay for a process boundary and + two JSON round-trips to ask a question. +- **The shell scripts are gone**, 39 of them, mostly CI gates and per-harness e2e drivers. +- **Five harnesses are gone** (OpenCode, Goose, Qwen, Crush, Cursor). +- **Async throughout.** oneharness runs blocking; this streams over tokio, which is what a + Tauri front end needs to render a run as it happens. + +Some findings did not survive re-verification against the current CLIs. oneharness models +Copilot as having no headless session id and no event stream (`session_formats: &[]`, +`events_format: None`); Copilot 1.0.75 has both. Where this crate and oneharness disagree, +this crate matches what the CLI does today. + +## License + +MIT. See [LICENSE](LICENSE); the original oneharness copyright is retained alongside ours. diff --git a/docs/operating-limits.md b/docs/operating-limits.md new file mode 100644 index 0000000..f9cd213 --- /dev/null +++ b/docs/operating-limits.md @@ -0,0 +1,49 @@ +# Operating within the agents' terms + +This crate exists to let a program drive Claude Code, Codex and GitHub Copilot +programmatically. That is a supported thing to do: all three ship a documented +non-interactive mode (`claude -p`, `codex exec`, `copilot -p`) intended for scripting and +automation. This document records the design choices that keep a wrapper on the right side +of that line, so a future change does not quietly cross it. + +## What this crate does + +- **Drives each vendor's own CLI**, as a child process, using whatever credentials that CLI + already holds (its stored login, `ANTHROPIC_API_KEY`, `CODEX_HOME`, and so on). It never + handles, stores, or forwards credentials itself. +- **Reports quota refusals** as [`Error::RateLimited`], carrying the provider's own wording + unedited, and surfaces Claude's `rate_limit_event` as an ordinary `Event::RateLimit` + during a run. +- **Passes model names through verbatim.** An unknown model is the provider's error to + raise, not this crate's to guess around. + +## What this crate deliberately does not do + +- **No automatic retry around a quota.** `Error::RateLimited` is returned to the caller, + never absorbed. Burying a retry loop here would turn a limit the provider deliberately set + into something the library quietly works around, and it would do so invisibly, in a + dependency, where nobody reviewing the calling code would see it. `Error::is_transient()` + classifies the failure so a caller can decide; deciding is the caller's job. +- **No account multiplexing.** There is no facility for rotating between credentials, + config directories, or logins to widen an effective rate limit. The `env` and `bin` + builders exist so a caller can point at a specific installation, not so a scheduler can + cycle identities. +- **No reimplementation of a provider API.** This crate spawns the vendor's CLI. It does not + reconstruct the underlying HTTP API, forge client headers, or impersonate an interactive + session. +- **No permission bypass by default.** [`Permission::ReadOnly`] is the default and + `Bypass` must be asked for by name. + +## For the caller + +If you are building on this crate and you hit `Error::RateLimited`: + +- Back off. The [`RateLimit`] attached to an `Outcome` carries `resets_at` as Unix epoch + seconds when the provider supplied it, which is a real answer to "how long". +- Back off *per identity*, not per process. Spawning more workers against the same account + does not create more quota. +- Surface it to the human. A GUI that silently stalls for five hours is worse than one that + says the limit was reached and when it lifts. + +Rate limits are a pricing and capacity signal from the provider, not an obstacle for the +integration layer to route around. diff --git a/src/agent.rs b/src/agent.rs new file mode 100644 index 0000000..ef3e6d1 --- /dev/null +++ b/src/agent.rs @@ -0,0 +1,1104 @@ +//! The three agents, what each can do, and how a request becomes an argv. +//! +//! Everything here is pure: [`Agent::argv`] builds a command line from a +//! [`Plan`] without touching the filesystem, the clock, or a process, so every +//! flag mapping is covered by an ordinary unit test. Spawning lives in +//! [`crate::run`]. + +use std::fmt; + +use serde::{Deserialize, Serialize}; + +use crate::error::{Error, Result}; + +/// A coding agent this crate can drive headlessly. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Agent { + /// Anthropic's Claude Code (`claude`). + Claude, + /// The `OpenAI` Codex CLI (`codex`). + Codex, + /// GitHub Copilot CLI (`copilot`). + Copilot, +} + +/// How an agent's native session id is obtained. This is the axis deciding whether +/// a caller-owned session name can be bound to it at all. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionSupport { + /// The caller assigns the id up front (`claude --session-id `), so the + /// binding is known before the process starts and survives a crashed run. + Minted, + /// The agent prints an id we read back out of its output (Codex's + /// `thread_id`). The binding only exists once the run produced output. + Printed, + /// No id is exposed headlessly. Named sessions are refused for this agent. + None, +} + +/// What an agent supports. Used to reject an impossible request before spawning +/// rather than silently doing something weaker than asked. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub struct Caps { + /// How a native session id is obtained, if at all. + pub session: SessionSupport, + /// Whether resuming can branch a new session instead of appending in place. + pub fork: bool, + /// Whether the agent emits a structured event stream this crate normalizes. + pub events: bool, + /// Whether the agent takes a real system-prompt flag. When false the system + /// text is prepended to the prompt so it still reaches the model. + pub native_system: bool, +} + +/// Permission posture for a run, mapped onto each agent's own vocabulary. +/// +/// # What these do and do not guarantee +/// +/// These postures constrain each CLI's **built-in** tools: its shell, its file +/// writes, its sandbox. They do **not** constrain MCP servers, plugins or custom +/// tools the agent is configured with. An MCP tool that files an issue, writes +/// to a database or calls a deployment API is a separate tool category in all +/// three CLIs and can still act during a nominally restricted run. +/// +/// If a run must not cause remote side effects, the containment has to come from +/// the agent's own configuration (which MCP servers are enabled at all), not +/// from this enum. What is selected here is enforced by the CLI, and what the +/// CLI does not model cannot be enforced from out here. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Permission { + /// No writes to the local filesystem, and no shell where the CLI can gate + /// one. + /// + /// The strongest posture this crate can express, and still not a guarantee + /// of "no side effects": see the type-level note about MCP tools. Codex + /// enforces it with a read-only sandbox, which blocks writes but still + /// permits command execution. + #[default] + ReadOnly, + /// Ask the agent to plan rather than act. + /// + /// Claude and Copilot have a real plan mode. **Codex has none**, so this + /// maps to its read-only sandbox: writes are blocked, but the model is not + /// instructed to withhold execution the way a true plan mode would. + Plan, + /// Allow file edits, while still gating shell commands where the CLI can. + Edit, + /// Allow the agent's own default automation. + Auto, + /// Skip every permission check. For sandboxes. + Bypass, +} + +/// Environment variables that route an agent's traffic through a corporate +/// proxy or a custom certificate authority. +/// +/// None of the three vendors documents proxy support, and none exposes a proxy +/// flag, so this is a convenience list of names a host may want to forward, not +/// a claim that forwarding them works. (The names do appear in all three +/// shipped binaries, but that shows they are referenced, not that provider +/// traffic honours them.) Verify against your own proxy before relying on it. +/// +/// Not included in [`EnvPolicy::Minimal`]: they are situational, and the proxy +/// URLs frequently carry credentials. Offered here so a host can present them +/// as an explicit setting and forward the ones it wants with +/// [`crate::Request::env`], rather than every caller rediscovering the names. +/// +/// Excluding them from `Minimal` does not block them. Under the default +/// [`EnvPolicy::Inherit`] they flow exactly as they would for the CLI run from a +/// shell; the only thing `Minimal` changes is that forwarding becomes a +/// decision rather than an accident. +/// +/// ```no_run +/// # use agent_abstraction::{Agent, EnvPolicy, NETWORK_ENV, Request}; +/// let mut request = Request::new(Agent::Claude, "hi").env_policy(EnvPolicy::Minimal); +/// // Forward only the proxy settings this host actually has. +/// for name in NETWORK_ENV { +/// if let Ok(value) = std::env::var(name) { +/// request = request.env(*name, value); +/// } +/// } +/// ``` +pub const NETWORK_ENV: &[&str] = &[ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + "no_proxy", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + "NODE_EXTRA_CA_CERTS", +]; + +/// Which of the host's environment variables reach the agent. +/// +/// **The default is [`EnvPolicy::Minimal`].** Inheriting the whole environment +/// is what a CLI gets from a shell, but this crate is embedded in processes that +/// hold unrelated secrets, and full inheritance hands every one of them to the +/// agent and to every command the agent runs. That is a decision worth making +/// deliberately, so it is the opt-in rather than the default. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[non_exhaustive] +pub enum EnvPolicy { + /// Pass through only what the selected agent needs, per + /// [`Agent::essential_env`], plus anything set with [`crate::Request::env`]. + /// + /// The crate owns this list rather than the caller, because "what does this + /// CLI need to work" is knowledge about the agent, and an incomplete + /// hand-written list produces a run that fails in a way that looks like an + /// auth problem. Every agent is verified to authenticate under it by the + /// live test suite. + #[default] + Minimal, + /// Pass the whole parent environment through, as a shell would. + /// + /// Correct when the host process holds nothing the agent should not see, or + /// when something environment-specific (a proxy, a custom CA, a vendor + /// variable this crate does not know about) has to reach the CLI and + /// enumerating it is impractical. + Inherit, + /// Pass through only these names, plus anything set with + /// [`crate::Request::env`]. Names unset in the parent are skipped. + Only(Vec), +} + +/// Output shape requested from the agent. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Format { + /// Plain prose on stdout. Carries no session id and no events. + Text, + /// One JSON result document. + #[default] + Json, + /// A JSONL event stream, normalized into [`crate::Event`]s. + Stream, +} + +/// How a run continues an earlier conversation. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum Continue { + /// Start a fresh conversation. + #[default] + New, + /// Start a fresh conversation under an id the caller chose. Only valid for + /// [`SessionSupport::Minted`] agents. + NewWith(String), + /// Append to an existing conversation in place. + Resume(String), + /// Branch a new conversation off an existing one, leaving it untouched. + Fork(String), +} + +/// A fully resolved run request, ready to become an argv. Built by +/// [`crate::Request::plan`]; consumed by [`Agent::argv`]. +#[derive(Debug, Clone)] +pub struct Plan { + /// The binary to invoke. + pub bin: String, + /// The user prompt. + pub prompt: String, + /// System prompt, if any. + pub system: Option, + /// Model id or alias, if pinned. + pub model: Option, + /// Permission posture. + pub permission: Permission, + /// Requested output shape. + pub format: Format, + /// How this run continues an earlier one. + pub cont: Continue, + /// True when the prompt is piped on stdin instead of riding the argv. + pub stdin_prompt: bool, +} + +/// Prompts at or above this many bytes are piped on stdin rather than placed on +/// the argv. Well under the ~1 MiB `ARG_MAX` floor on macOS, with room for the +/// rest of the command line and the inherited environment. +pub(crate) const STDIN_THRESHOLD: usize = 128 * 1024; + +/// The budget for everything on one command line. +/// +/// `ARG_MAX` is about 1 MiB on macOS and covers the environment as well as the +/// arguments, so half of it leaves room for a large inherited environment. Over +/// this the spawn fails with a bare `E2BIG` that names nothing; the crate checks +/// first so the error can say which input was too big. +pub(crate) const MAX_COMMAND_LINE: usize = 512 * 1024; + +impl Agent { + /// Every agent, in a stable order. + pub const ALL: [Agent; 3] = [Agent::Claude, Agent::Codex, Agent::Copilot]; + + /// The stable identifier used in session records and logs. + #[must_use] + pub fn id(self) -> &'static str { + match self { + Agent::Claude => "claude-code", + Agent::Codex => "codex", + Agent::Copilot => "copilot", + } + } + + /// The default binary name looked up on `PATH`. + #[must_use] + pub fn bin(self) -> &'static str { + match self { + Agent::Claude => "claude", + Agent::Codex => "codex", + Agent::Copilot => "copilot", + } + } + + /// The documented install command, surfaced by [`Error::NotInstalled`]. + #[must_use] + pub fn install_hint(self) -> &'static str { + match self { + Agent::Claude => "npm install -g @anthropic-ai/claude-code", + Agent::Codex => "npm install -g @openai/codex", + Agent::Copilot => "npm install -g @github/copilot", + } + } + + /// The environment variables this agent needs to function, used by + /// [`EnvPolicy::Minimal`]. + /// + /// Two groups: what any process needs to start, and this agent's own + /// credential and config variables. Permission-controlling variables are + /// excluded on principle: `COPILOT_ALLOW_ALL` is Copilot's env equivalent + /// of `--allow-all-tools`, so inheriting it would let the host's ambient + /// environment widen a run's permissions behind [`Permission`]'s back. A name absent from the parent + /// environment is skipped, so nothing here is fabricated. + /// + /// Proxy and custom-CA variables are deliberately **not** here. They are + /// environment-specific rather than required, and `HTTP_PROXY` / + /// `HTTPS_PROXY` routinely embed credentials (`http://user:pass@proxy`), so + /// passing them automatically would leak one through the very policy meant + /// to withhold secrets. A host that needs them should offer them as a + /// setting and pass them with [`crate::Request::env`]; [`NETWORK_ENV`] names + /// them so a settings screen does not have to hardcode the list. + /// + /// `PATH`, `HOME` and `USER` are the verified floor on macOS: all three CLIs + /// answer correctly with exactly those set, and Claude reports "Not logged + /// in" without `USER`, since its keychain lookup is keyed on it. The Windows + /// names are included on the same reasoning but are **not** verified, as + /// this crate has not been run there. + #[must_use] + pub fn essential_env(self) -> Vec<&'static str> { + // Needed by any child process, plus the locale and temp dir the CLIs + // use for scratch files. + const BASE: &[&str] = &[ + "PATH", "HOME", "USER", "LOGNAME", "SHELL", "TMPDIR", "LANG", "LC_ALL", + ]; + // Unverified: this crate has not been exercised on Windows. + const WINDOWS: &[&str] = &[ + "USERPROFILE", + "APPDATA", + "LOCALAPPDATA", + "SystemRoot", + "SystemDrive", + "TEMP", + "TMP", + "PATHEXT", + "ComSpec", + ]; + let agent: &[&str] = match self { + Agent::Claude => &[ + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_BASE_URL", + "CLAUDE_CONFIG_DIR", + ], + Agent::Codex => &[ + "CODEX_HOME", + "CODEX_API_KEY", + "OPENAI_API_KEY", + "OPENAI_BASE_URL", + ], + Agent::Copilot => &["GH_TOKEN", "GITHUB_TOKEN", "XDG_CONFIG_HOME"], + }; + BASE.iter().chain(WINDOWS).chain(agent).copied().collect() + } + + /// What this agent supports. + #[must_use] + pub fn caps(self) -> Caps { + match self { + // Verified against claude 2.1.205: `--session-id ` assigns the + // id, `--fork-session` branches, `--output-format stream-json` + // streams (and demands `--verbose`), `--append-system-prompt` is a + // real flag. + Agent::Claude => Caps { + session: SessionSupport::Minted, + fork: true, + events: true, + native_system: true, + }, + // `codex exec --json` emits `thread_id`; continuation is the + // `resume` subcommand and is linear (`codex fork` is TUI-only). + Agent::Codex => Caps { + session: SessionSupport::Printed, + fork: false, + events: true, + native_system: false, + }, + // Verified against Copilot CLI 1.0.75: `--session-id ` both + // mints a new session and resumes an existing one (one flag, both + // directions), and `--output-format json` is a JSONL event stream. + // There is no headless fork. + Agent::Copilot => Caps { + session: SessionSupport::Minted, + fork: false, + events: true, + native_system: false, + }, + } + } + + /// The format that can carry this agent's session id, if any. A named + /// session upgrades to this when the caller did not pin a format. + #[must_use] + pub fn session_format(self) -> Option { + match self.caps().session { + // Claude reports the id in both structured formats; `Json` is the + // cheaper default when the caller did not ask to stream. + SessionSupport::Minted | SessionSupport::Printed => Some(match self { + Agent::Claude => Format::Json, + // `--json` IS Codex's stream and Copilot's `json` is JSONL; + // neither has a single-document form. + Agent::Codex | Agent::Copilot => Format::Stream, + }), + SessionSupport::None => None, + } + } + + /// Whether `format` can carry this agent's session id. + /// + /// Distinct from [`Agent::session_format`], which names the *preferred* one: + /// Claude reports its id under both `Json` and `Stream`, and only plain text + /// loses it. A named session needs this, not equality with the preferred + /// format, or streaming a named Claude session would be refused for no + /// reason. + #[must_use] + pub fn format_carries_session(self, format: Format) -> bool { + self.session_format().is_some() && format != Format::Text + } + + /// Reject a plan this agent cannot honour, before anything is spawned. + fn check(self, plan: &Plan) -> Result<()> { + let caps = self.caps(); + if matches!(plan.cont, Continue::Fork(_)) && !caps.fork { + return Err(Error::Unsupported { + agent: self, + what: "forking a session headlessly", + }); + } + if matches!(plan.cont, Continue::NewWith(_)) && caps.session != SessionSupport::Minted { + return Err(Error::Unsupported { + agent: self, + what: "assigning a session id up front", + }); + } + if plan.format == Format::Stream && !caps.events { + return Err(Error::Unsupported { + agent: self, + what: "a structured event stream", + }); + } + Ok(()) + } + + /// Build the command line for `plan`. + /// + /// The first element is the binary; the rest are its arguments. Returns + /// [`Error::Unsupported`] when the plan asks for a capability this agent + /// lacks, never a quiet downgrade. + /// + /// # Errors + /// [`Error::Unsupported`] if the plan needs a capability this agent lacks. + pub fn argv(self, plan: &Plan) -> Result> { + Ok(self + .typed_argv(plan)? + .into_iter() + .map(|arg| arg.value) + .collect()) + } + + /// The command line with each argument's sensitivity attached. + /// + /// # Errors + /// [`Error::Unsupported`] if the plan needs a capability this agent lacks. + pub(crate) fn typed_argv(self, plan: &Plan) -> Result> { + self.check(plan)?; + Ok(match self { + Agent::Claude => argv_claude(plan), + Agent::Codex => argv_codex(plan), + Agent::Copilot => argv_copilot(plan), + }) + } + + /// The prompt text actually delivered, with the system prompt folded in for + /// agents that have no flag for it. Never dropped silently. + #[must_use] + pub fn effective_prompt(self, plan: &Plan) -> String { + match (&plan.system, self.caps().native_system) { + (Some(system), false) => format!("{system}\n\n{}", plan.prompt), + _ => plan.prompt.clone(), + } + } +} + +impl fmt::Display for Agent { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.id()) + } +} + +/// How sensitive one argument's value is, decided where the argument is built +/// rather than guessed back afterwards. +/// +/// Reconstructing this from a finished command line means pattern-matching flag +/// names and positions, which misses exactly the cases that matter: Codex's +/// prompt is a bare trailing positional, and anything from `unchecked_args` has +/// no recognizable shape at all. Recording it at construction cannot miss. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Sensitivity { + /// A flag name or fixed token. Safe to show. + Public, + /// User or caller content: prompts and system prompts. + Prompt, + /// A session handle, which resumes a conversation. + SessionId, + /// Caller-supplied raw arguments. Unknowable, so assumed sensitive. + Unchecked, +} + +/// One argument and how sensitive it is. +#[derive(Debug, Clone)] +pub(crate) struct Arg { + pub(crate) value: String, + pub(crate) sensitivity: Sensitivity, +} + +/// Builds an argv, keeping every flag name literal at its call site so the flag +/// list for an agent stays greppable and auditable against `--help`, and +/// recording per-argument sensitivity so the executable and redacted forms come +/// from one source. +pub(crate) struct Argv(Vec); + +impl Argv { + /// Start with the binary. + fn new(bin: &str) -> Self { + Self(vec![Arg { + value: bin.to_string(), + sensitivity: Sensitivity::Public, + }]) + } + + fn push(&mut self, value: impl Into, sensitivity: Sensitivity) -> &mut Self { + self.0.push(Arg { + value: value.into(), + sensitivity, + }); + self + } + + /// A bare flag with no value. + fn bare(&mut self, flag: &str) -> &mut Self { + self.push(flag, Sensitivity::Public) + } + + /// A flag and a value that is safe to show. + fn pair(&mut self, flag: &str, value: impl AsRef) -> &mut Self { + self.bare(flag).push(value.as_ref(), Sensitivity::Public) + } + + /// A flag and a value that must not be logged. + fn secret(&mut self, flag: &str, value: impl AsRef, kind: Sensitivity) -> &mut Self { + self.bare(flag).push(value.as_ref(), kind) + } + + /// A flag and its value, only when the value is present. + fn opt(&mut self, flag: &str, value: Option<&String>) -> &mut Self { + if let Some(value) = value { + self.pair(flag, value); + } + self + } + + /// A positional argument that is safe to show. + fn arg(&mut self, value: impl Into) -> &mut Self { + self.push(value, Sensitivity::Public) + } + + /// A positional argument carrying caller content. + fn arg_sensitive(&mut self, value: impl Into, kind: Sensitivity) -> &mut Self { + self.push(value, kind) + } + + fn done(&mut self) -> Vec { + std::mem::take(&mut self.0) + } +} + +/// Claude Code's permission-mode token for each posture. Choices verified from +/// `claude --help` (2.1.205): acceptEdits, auto, bypassPermissions, manual, +/// dontAsk, plan. +fn claude_mode(p: Permission) -> &'static str { + match p { + // `dontAsk` auto-denies gated tools and keeps going rather than + // blocking on a prompt no one can answer headlessly. The read-only + // guarantee comes from `--disallowedTools`, below. + Permission::ReadOnly => "dontAsk", + Permission::Plan => "plan", + Permission::Edit => "acceptEdits", + Permission::Auto => "auto", + Permission::Bypass => "bypassPermissions", + } +} + +/// `claude -p --permission-mode M --output-format F [...]` +fn argv_claude(plan: &Plan) -> Vec { + let mut a = Argv::new(&plan.bin); + a.bare("-p"); + if plan.stdin_prompt { + // With `--input-format text` claude reads the prompt from stdin, so a + // large prompt never has to fit on the argv. + a.pair("--input-format", "text"); + } else { + a.arg_sensitive(Agent::Claude.effective_prompt(plan), Sensitivity::Prompt); + } + + a.pair("--permission-mode", claude_mode(plan.permission)); + if plan.permission == Permission::ReadOnly { + // Remove the mutating built-ins outright. Reads still run via + // Read/Grep/Glob. `mcp__*` covers every MCP tool: denying only the + // built-in writers would leave an MCP server free to mutate remote + // state during a run the caller asked to be read-only. + a.bare("--disallowedTools"); + for tool in ["Bash", "Edit", "Write", "NotebookEdit", "mcp__*"] { + a.arg(tool); + } + } + + a.opt("--model", plan.model.as_ref()); + if let Some(system) = &plan.system { + a.secret("--append-system-prompt", system, Sensitivity::Prompt); + } + + match &plan.cont { + Continue::New => {} + Continue::NewWith(id) => { + a.secret("--session-id", id, Sensitivity::SessionId); + } + Continue::Resume(id) => { + a.secret("--resume", id, Sensitivity::SessionId); + } + Continue::Fork(id) => { + // Mints a new id off `id`, leaving the original and its cached + // prefix untouched. The new id comes back in the output. + a.secret("--resume", id, Sensitivity::SessionId) + .bare("--fork-session"); + } + } + + a.pair( + "--output-format", + match plan.format { + Format::Text => "text", + Format::Json => "json", + Format::Stream => "stream-json", + }, + ); + if plan.format == Format::Stream { + // Claude refuses `-p --output-format stream-json` without it: + // "--print with --output-format=stream-json requires --verbose". + a.bare("--verbose"); + } + a.done() +} + +/// `codex exec [resume ] --skip-git-repo-check [sandbox flags] [--model M] +/// [--json] ` +fn argv_codex(plan: &Plan) -> Vec { + let mut a = Argv::new(&plan.bin); + a.bare("exec"); + if let Continue::Resume(id) = &plan.cont { + // Continuation is a subcommand, not a flag. + a.bare("resume") + .arg_sensitive(id.clone(), Sensitivity::SessionId); + } + + // `codex exec` aborts outside a git repository unless told not to. That + // check guards against an agent editing files with no way to undo them, but + // this crate is embedded in hosts that legitimately run against scratch + // directories, worktrees and review checkouts, and a hard abort there is + // useless to them. The real containment is the sandbox below, which is + // `read-only` by default, so nothing is unrecoverable regardless. + a.bare("--skip-git-repo-check"); + + // `codex exec` takes `--sandbox`, but `codex exec resume` does **not**: it + // rejects the flag outright and takes the same setting as a `-c` config + // override instead. Verified against codex-cli 0.145.0, where passing + // `--sandbox` to a resume fails with "unexpected argument '--sandbox'". + // Dropping the sandbox on resume would silently run a continued turn under a + // different posture than the caller asked for. + let resuming = matches!(plan.cont, Continue::Resume(_)); + let sandbox = match plan.permission { + Permission::Bypass => None, + Permission::ReadOnly | Permission::Plan => Some("read-only"), + Permission::Edit | Permission::Auto => Some("workspace-write"), + }; + match (sandbox, resuming) { + (None, _) => a.bare("--dangerously-bypass-approvals-and-sandbox"), + (Some(mode), false) => a.pair("--sandbox", mode), + // The value is TOML-parsed, falling back to a raw string, so the bare + // token is read as the mode name. + (Some(mode), true) => a.pair("-c", format!("sandbox_mode={mode}")), + }; + + a.opt("--model", plan.model.as_ref()); + // `--json` is Codex's event stream and the only place `thread_id` appears. + if plan.format != Format::Text { + a.bare("--json"); + } + // Codex has no system flag, so the system text rides the prompt. A literal + // `-` makes it read the prompt from stdin instead, keeping a large one off + // the argv. + // Codex takes the prompt as a bare trailing positional, which is exactly + // the shape positional redaction guesswork gets wrong. + if plan.stdin_prompt { + a.arg("-"); + } else { + a.arg_sensitive(Agent::Codex.effective_prompt(plan), Sensitivity::Prompt); + } + a.done() +} + +/// `copilot -p --allow-all-tools [...] [--session-id ]` +/// +/// Flags verified against Copilot CLI 1.0.75. Two of its conventions matter: +/// `--allow-all-tools` is *required* for non-interactive mode, and the +/// repeatable tool filters are declared `--allow-tool[=tools...]`, an optional +/// value, which only binds with `=`, never across a space. +fn argv_copilot(plan: &Plan) -> Vec { + // Copilot reads stdin as the prompt only when `-p` is absent: a `-p` value + // makes the pipe be ignored. So a piped prompt drops the flag entirely. + let mut a = Argv::new(&plan.bin); + if !plan.stdin_prompt { + a.secret( + "-p", + Agent::Copilot.effective_prompt(plan), + Sensitivity::Prompt, + ); + } + + // Without this, a headless run stops at the first tool confirmation. + a.bare("--allow-all-tools").bare("--no-ask-user"); + match plan.permission { + Permission::Bypass | Permission::Auto => a.bare("--allow-all-paths"), + // Deny beats allow, so this is allow-all minus the mutating tools. + // `--allow-all-paths` is deliberately NOT set: it disables path + // verification entirely, which would widen filesystem reach in the one + // posture that exists to narrow it. + Permission::ReadOnly => a.bare("--deny-tool=shell").bare("--deny-tool=write"), + // Edits run; shell stays denied so commands cannot. + Permission::Edit => a.bare("--deny-tool=shell"), + Permission::Plan => a.pair("--mode", "plan"), + }; + + a.opt("--model", plan.model.as_ref()); + // One flag serves both directions: it sets the UUID for a new session and + // resumes an existing one by id. + match &plan.cont { + Continue::NewWith(id) | Continue::Resume(id) => { + a.secret("--session-id", id, Sensitivity::SessionId); + } + // `Fork` is rejected by `Agent::check` before reaching here. + Continue::New | Continue::Fork(_) => {} + } + + a.pair( + "--output-format", + if plan.format == Format::Text { + "text" + } else { + // Copilot's `json` is JSONL, so it serves both structured formats. + "json" + }, + ); + a.done() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn plan(bin: &str) -> Plan { + Plan { + bin: bin.into(), + prompt: "hi".into(), + system: None, + model: None, + permission: Permission::ReadOnly, + format: Format::Json, + cont: Continue::New, + stdin_prompt: false, + } + } + + fn argv(agent: Agent, plan: &Plan) -> Vec { + agent.argv(plan).expect("plan is supported") + } + + fn pos(a: &[String], needle: &str) -> Option { + a.iter().position(|s| s == needle) + } + + #[test] + fn claude_builds_print_mode_with_format_and_permission() { + let a = argv(Agent::Claude, &plan("claude")); + assert_eq!(a[0..3], ["claude", "-p", "hi"]); + assert!(pos(&a, "--permission-mode").is_some()); + assert!(a.contains(&"dontAsk".to_string())); + assert_eq!(a[pos(&a, "--output-format").unwrap() + 1], "json"); + } + + #[test] + fn claude_read_only_removes_the_mutating_tools() { + let a = argv(Agent::Claude, &plan("claude")); + let at = pos(&a, "--disallowedTools").expect("read-only denies tools"); + assert_eq!( + &a[at + 1..at + 5], + ["Bash", "Edit", "Write", "NotebookEdit"] + ); + } + + #[test] + fn claude_bypass_does_not_deny_tools() { + let mut p = plan("claude"); + p.permission = Permission::Bypass; + let a = argv(Agent::Claude, &p); + assert!(a.contains(&"bypassPermissions".to_string())); + assert!(pos(&a, "--disallowedTools").is_none()); + } + + #[test] + fn claude_stream_format_adds_verbose_but_json_does_not() { + let mut p = plan("claude"); + p.format = Format::Stream; + assert!(argv(Agent::Claude, &p).contains(&"--verbose".to_string())); + p.format = Format::Json; + assert!(!argv(Agent::Claude, &p).contains(&"--verbose".to_string())); + } + + #[test] + fn claude_mints_an_id_for_a_new_session_and_resumes_an_old_one() { + let mut p = plan("claude"); + p.cont = Continue::NewWith("11111111-2222-3333-4444-555555555555".into()); + let a = argv(Agent::Claude, &p); + assert_eq!( + a[pos(&a, "--session-id").unwrap() + 1], + "11111111-2222-3333-4444-555555555555" + ); + assert!(pos(&a, "--resume").is_none()); + + p.cont = Continue::Resume("sess-1".into()); + let a = argv(Agent::Claude, &p); + assert_eq!(a[pos(&a, "--resume").unwrap() + 1], "sess-1"); + assert!(!a.contains(&"--fork-session".to_string())); + } + + #[test] + fn claude_fork_resumes_and_branches() { + let mut p = plan("claude"); + p.cont = Continue::Fork("sess-1".into()); + let a = argv(Agent::Claude, &p); + assert_eq!(a[pos(&a, "--resume").unwrap() + 1], "sess-1"); + assert!(a.contains(&"--fork-session".to_string())); + } + + #[test] + fn claude_keeps_the_system_prompt_on_its_own_flag() { + let mut p = plan("claude"); + p.system = Some("be terse".into()); + let a = argv(Agent::Claude, &p); + assert_eq!( + a[pos(&a, "--append-system-prompt").unwrap() + 1], + "be terse" + ); + // The prompt itself stays clean. + assert!(a.contains(&"hi".to_string())); + } + + #[test] + fn claude_stdin_prompt_leaves_the_argv() { + let mut p = plan("claude"); + p.stdin_prompt = true; + let a = argv(Agent::Claude, &p); + assert_eq!(a[pos(&a, "--input-format").unwrap() + 1], "text"); + assert!(!a.contains(&"hi".to_string()), "prompt must not ride argv"); + } + + #[test] + fn codex_resume_is_a_subcommand_and_prompt_is_last() { + let mut p = plan("codex"); + p.cont = Continue::Resume("thread-9".into()); + let a = argv(Agent::Codex, &p); + assert_eq!(a[0..4], ["codex", "exec", "resume", "thread-9"]); + assert_eq!(a.last().unwrap(), "hi"); + } + + /// `Minimal` exists to withhold secrets, so nothing it passes through may + /// be a credential carrier. Proxy URLs in particular routinely embed + /// `user:pass`, which is why they are offered separately instead. + #[test] + fn the_minimal_environment_carries_no_proxy_variables() { + for agent in Agent::ALL { + let essential = agent.essential_env(); + for name in NETWORK_ENV { + assert!( + !essential.contains(name), + "{agent} would pass {name} through EnvPolicy::Minimal" + ); + } + } + } + + /// The floor verified live on macOS: with exactly these set, all three CLIs + /// authenticate and answer. Claude reports "Not logged in" without `USER`. + #[test] + fn every_agent_asks_for_the_verified_floor() { + for agent in Agent::ALL { + let essential = agent.essential_env(); + for name in ["PATH", "HOME", "USER"] { + assert!(essential.contains(&name), "{agent} omits {name}"); + } + } + } + + /// Each agent's own credentials, and nobody else's. + #[test] + fn agents_do_not_request_each_others_credentials() { + let claude = Agent::Claude.essential_env(); + assert!(claude.contains(&"ANTHROPIC_API_KEY")); + assert!(!claude.contains(&"OPENAI_API_KEY")); + assert!(!claude.contains(&"GH_TOKEN")); + + let codex = Agent::Codex.essential_env(); + assert!(codex.contains(&"OPENAI_API_KEY")); + assert!(!codex.contains(&"ANTHROPIC_API_KEY")); + } + + /// The model is the caller's choice on every agent. It is forwarded + /// verbatim and never defaulted, normalized, or validated here: a host with + /// a model picker owns that list, and an unknown name must surface as the + /// agent's own error rather than something this crate guessed at. + #[test] + fn every_agent_forwards_the_callers_model_verbatim() { + for agent in Agent::ALL { + let mut p = plan(agent.bin()); + // Deliberately not a real model id: nothing here may interpret it. + p.model = Some("some-model-9".into()); + let a = argv(agent, &p); + let at = pos(&a, "--model").unwrap_or_else(|| panic!("{agent} dropped --model: {a:?}")); + assert_eq!(a[at + 1], "some-model-9", "{agent} rewrote the model"); + } + } + + /// No model means the agent picks its own, so a host can offer a "default" + /// entry without this crate inventing one. + #[test] + fn no_model_means_no_model_flag() { + for agent in Agent::ALL { + let p = plan(agent.bin()); + assert!(p.model.is_none()); + let a = argv(agent, &p); + assert!( + pos(&a, "--model").is_none(), + "{agent} invented a model: {a:?}" + ); + } + } + + /// `codex exec` aborts outside a git repository. A host embedding this + /// crate runs against scratch dirs and review checkouts, so the check is + /// waived on every invocation; the sandbox is what actually contains a run. + #[test] + fn codex_always_waives_the_git_repo_check() { + for cont in [Continue::New, Continue::Resume("t-1".into())] { + let mut p = plan("codex"); + p.cont = cont.clone(); + assert!( + argv(Agent::Codex, &p).contains(&"--skip-git-repo-check".to_string()), + "{cont:?} must still run outside a repo" + ); + } + } + + /// `codex exec resume` rejects `--sandbox` and takes `-c sandbox_mode=` + /// instead. Getting this wrong makes every second turn fail with an + /// "unexpected argument" error, which only a multi-turn run reveals. + #[test] + fn codex_sets_the_sandbox_by_flag_when_fresh_and_by_config_when_resuming() { + let mut fresh = plan("codex"); + fresh.permission = Permission::ReadOnly; + let a = argv(Agent::Codex, &fresh); + assert_eq!(a[pos(&a, "--sandbox").unwrap() + 1], "read-only"); + assert!(pos(&a, "-c").is_none()); + + let mut resumed = fresh.clone(); + resumed.cont = Continue::Resume("thread-9".into()); + let a = argv(Agent::Codex, &resumed); + assert!( + pos(&a, "--sandbox").is_none(), + "resume rejects --sandbox: {a:?}" + ); + assert_eq!(a[pos(&a, "-c").unwrap() + 1], "sandbox_mode=read-only"); + } + + #[test] + fn codex_bypass_uses_the_same_flag_on_both_paths() { + for cont in [Continue::New, Continue::Resume("t".into())] { + let mut p = plan("codex"); + p.permission = Permission::Bypass; + p.cont = cont.clone(); + let a = argv(Agent::Codex, &p); + assert!(a.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string())); + assert!(pos(&a, "--sandbox").is_none(), "{cont:?}: {a:?}"); + } + } + + #[test] + fn codex_without_a_system_flag_prepends_it_to_the_prompt() { + let mut p = plan("codex"); + p.system = Some("be terse".into()); + let a = argv(Agent::Codex, &p); + assert_eq!(a.last().unwrap(), "be terse\n\nhi"); + } + + #[test] + fn codex_maps_each_posture_to_a_sandbox() { + for (perm, expect) in [ + (Permission::ReadOnly, "read-only"), + (Permission::Plan, "read-only"), + (Permission::Edit, "workspace-write"), + (Permission::Auto, "workspace-write"), + ] { + let mut p = plan("codex"); + p.permission = perm; + let a = argv(Agent::Codex, &p); + assert_eq!(a[pos(&a, "--sandbox").unwrap() + 1], expect, "{perm:?}"); + } + let mut p = plan("codex"); + p.permission = Permission::Bypass; + let a = argv(Agent::Codex, &p); + assert!(a.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string())); + assert!(pos(&a, "--sandbox").is_none()); + } + + #[test] + fn copilot_drops_dash_p_when_the_prompt_is_piped() { + let mut p = plan("copilot"); + p.stdin_prompt = true; + let a = argv(Agent::Copilot, &p); + assert!( + !a.contains(&"-p".to_string()), + "a -p value shadows the pipe" + ); + assert!(!a.contains(&"hi".to_string())); + } + + /// Copilot declares its tool filters as `--deny-tool[=tools...]`, an + /// optional value, which binds only with `=`. Passed across a space the + /// value is silently read as a positional instead, so the deny is lost. + #[test] + fn copilot_read_only_denies_shell_and_write_with_the_combined_form() { + let a = argv(Agent::Copilot, &plan("copilot")); + assert!(a.contains(&"--deny-tool=shell".to_string())); + assert!(a.contains(&"--deny-tool=write".to_string())); + assert!( + !a.iter().any(|s| s == "--deny-tool"), + "a bare --deny-tool would drop its value: {a:?}" + ); + } + + /// A headless Copilot run stalls at the first tool confirmation without it. + #[test] + fn copilot_always_allows_tools_and_silences_the_ask_tool() { + for permission in [Permission::ReadOnly, Permission::Plan, Permission::Bypass] { + let mut p = plan("copilot"); + p.permission = permission; + let a = argv(Agent::Copilot, &p); + assert!( + a.contains(&"--allow-all-tools".to_string()), + "{permission:?}" + ); + assert!(a.contains(&"--no-ask-user".to_string()), "{permission:?}"); + } + } + + /// Copilot uses one flag in both directions: it sets the id for a new + /// session and resumes an existing one. + #[test] + fn copilot_uses_session_id_for_both_new_and_resumed_sessions() { + for cont in [ + Continue::NewWith("11111111-2222-3333-4444-555555555555".into()), + Continue::Resume("11111111-2222-3333-4444-555555555555".into()), + ] { + let mut p = plan("copilot"); + p.cont = cont.clone(); + let a = argv(Agent::Copilot, &p); + assert_eq!( + a[pos(&a, "--session-id").unwrap() + 1], + "11111111-2222-3333-4444-555555555555", + "{cont:?}" + ); + } + } + + #[test] + fn unsupported_capabilities_are_refused_not_downgraded() { + // Forking headlessly is Claude-only. + for agent in [Agent::Codex, Agent::Copilot] { + let mut p = plan(agent.bin()); + p.cont = Continue::Fork("s".into()); + assert!( + matches!(agent.argv(&p), Err(Error::Unsupported { .. })), + "{agent} must refuse a fork rather than resume linearly" + ); + } + // Codex's id is printed, not assigned, so it cannot be chosen up front. + let mut p = plan("codex"); + p.cont = Continue::NewWith("id".into()); + assert!(matches!( + Agent::Codex.argv(&p), + Err(Error::Unsupported { .. }) + )); + } + + /// All three expose an id, so all three can back a named session, but only + /// through a format that actually carries one. + #[test] + fn every_agent_has_a_format_that_carries_its_session_id() { + assert_eq!(Agent::Claude.session_format(), Some(Format::Json)); + assert_eq!(Agent::Codex.session_format(), Some(Format::Stream)); + assert_eq!(Agent::Copilot.session_format(), Some(Format::Stream)); + } + + /// Claude and Copilot let the caller assign the id, so a run that dies + /// mid-turn still leaves a resumable session. + #[test] + fn the_minting_agents_are_claude_and_copilot() { + let minting: Vec<_> = Agent::ALL + .into_iter() + .filter(|a| a.caps().session == SessionSupport::Minted) + .collect(); + assert_eq!(minting, [Agent::Claude, Agent::Copilot]); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..b622de6 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,198 @@ +//! The one error type every fallible call in this crate returns. +//! +//! Each variant is a case a caller has to branch on differently. A GUI shows +//! [`Error::NotInstalled`] as an install prompt, [`Error::RateLimited`] as +//! "wait", and [`Error::Unsupported`] as a programming mistake. Failures that +//! need no branch collapse into [`Error::Spawn`] / [`Error::Store`]. + +use std::time::Duration; + +use crate::agent::Agent; + +/// Result alias for this crate. +pub type Result = std::result::Result; + +/// Everything that can go wrong driving an agent CLI. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// The agent's binary is not on `PATH`. Carries the install command so a UI + /// can offer it directly instead of making the user go find it. + #[error("`{bin}` not found on PATH; install it: {hint}")] + NotInstalled { + /// The agent whose binary is missing. + agent: Agent, + /// The binary name that was looked up. + bin: String, + /// The documented install command. + hint: &'static str, + }, + + /// The child process could not be started, or its stdio could not be read. + #[error("failed to spawn `{bin}`: {source}")] + Spawn { + /// The binary that failed to start. + bin: String, + /// The underlying OS error. + #[source] + source: std::io::Error, + }, + + /// The run exceeded its deadline and the child was killed. Any output + /// captured before the kill is preserved so a caller can still show it. + #[error("`{bin}` exceeded its {} s timeout and was killed", timeout.as_secs())] + Timeout { + /// The binary that overran. + bin: String, + /// The deadline that was hit. + timeout: Duration, + /// Whatever the agent had printed before it was killed. + partial: String, + }, + + /// The agent ran to completion but exited non-zero. + #[error("`{bin}` exited with status {code}: {stderr}")] + Failed { + /// The binary that failed. + bin: String, + /// Its exit code, or `-1` when it died to a signal. + code: i32, + /// Its stderr, trimmed, for the message. + stderr: String, + }, + + /// The provider refused the request for quota reasons: a usage limit, a + /// rate limit, or an exhausted budget. + /// + /// This is deliberately its own variant and this crate never retries it + /// automatically. Backing off is the caller's decision and burying a + /// retry loop in here would turn a limit the provider set into something + /// the library quietly works around. See `docs/operating-limits.md`. + #[error("`{bin}` was rate limited or hit a usage limit: {message}")] + RateLimited { + /// The binary that was limited. + bin: String, + /// The provider's own wording, passed through unedited. + message: String, + }, + + /// The request asked an agent for something it cannot do headlessly: + /// forking on Codex, a named session on Copilot, an event stream on an + /// agent that only prints text. + /// + /// Always an error, never a silent downgrade: a caller that asked to fork + /// and got a linear resume would corrupt the conversation it meant to + /// branch. + #[error("{agent} does not support {what}")] + Unsupported { + /// The agent that was asked. + agent: Agent, + /// The capability it lacks. + what: &'static str, + }, + + /// A named session already belongs to a different agent. Sessions cannot + /// migrate: the stored handle is only meaningful to the CLI that minted it. + #[error("session `{name}` belongs to {bound}, cannot resume it on {requested}")] + SessionConflict { + /// The caller's session name. + name: String, + /// The agent that created the session. + bound: Agent, + /// The agent the caller tried to use. + requested: Agent, + }, + + /// The session store could not be read or written. + #[error("session store I/O failed at {path}: {source}")] + Store { + /// The file or directory involved. + path: String, + /// The underlying OS error. + #[source] + source: std::io::Error, + }, + + /// The agent produced output this crate could not interpret: a missing + /// session id under a format that promises one, or unparseable JSON where + /// the contract requires it. + #[error("could not parse {agent} output: {detail}")] + Parse { + /// The agent whose output was unreadable. + agent: Agent, + /// What specifically was wrong. + detail: String, + }, + + /// The run was stopped by [`crate::Run::cancel`] or by dropping its handle. + /// + /// Not a fault: the caller asked for this. Distinguished from + /// [`Error::Interrupted`], which means the driver died unexpectedly, and + /// from [`Error::Timeout`], which is a deadline rather than a request. + #[error("the run of `{bin}` was cancelled")] + Cancelled { + /// The binary that was stopped. + bin: String, + }, + + /// A prompt, system prompt or raw argument too large for the command line, + /// on an agent with no way to deliver it off the argv. + /// + /// Returned rather than letting the OS reject the spawn with a bare + /// `E2BIG`, which says nothing about which input was the problem. + #[error( + "{what} is {size} bytes, over the {limit} byte command-line budget for {agent}, \ + and it has no way to take it off the command line" + )] + CommandLineTooLarge { + /// The agent the request targeted. + agent: Agent, + /// Which input overflowed. + what: &'static str, + /// Its size in bytes. + size: usize, + /// The budget it exceeded. + limit: usize, + }, + + /// [`crate::stream`] was called outside a Tokio runtime. + /// + /// Spawning the driver task needs a runtime context. Reporting this rather + /// than letting `tokio::spawn` panic keeps the fallible signature honest. + #[error("no Tokio runtime is running; call this from within one")] + NoRuntime, + + /// The task driving the run panicked or was cancelled, so there is no + /// outcome to report. + /// + /// Distinct from [`Error::Spawn`] on purpose: the process started fine, and + /// reporting this as a spawn failure would name the wrong cause. It is also + /// why this is not squeezed into an [`std::io::Error`], which a dropped + /// runtime task is not. + #[error("the run of `{bin}` was interrupted: {detail}")] + Interrupted { + /// The binary that was running. + bin: String, + /// Whether the task panicked or was cancelled. + detail: String, + }, +} + +impl Error { + /// Whether retrying this exact request later could plausibly succeed. + /// + /// True for quota and timeout failures; false for a missing binary, an + /// unsupported capability, or a session conflict, which need the caller to + /// change something first. This classifies; it does not retry. + #[must_use] + pub fn is_transient(&self) -> bool { + matches!(self, Error::RateLimited { .. } | Error::Timeout { .. }) + } + + /// Whether this run was stopped because the caller asked, rather than + /// because anything went wrong. A UI should not show it as a failure. + #[must_use] + pub fn is_cancelled(&self) -> bool { + matches!(self, Error::Cancelled { .. }) + } +} diff --git a/src/event.rs b/src/event.rs new file mode 100644 index 0000000..b11ffa4 --- /dev/null +++ b/src/event.rs @@ -0,0 +1,1014 @@ +//! Normalizing three different JSON streams into one event vocabulary. +//! +//! Each agent narrates a run in its own shape. [`Parser`] is fed one output line +//! at a time and yields [`Event`]s a consumer can render without knowing which +//! agent produced them, while accumulating the terminal facts (session id, final +//! text, usage) into a [`Terminal`]. +//! +//! Two distinct notions of text are kept apart on purpose: +//! - [`Event::Text`] is the *incremental display stream*, what a GUI appends to +//! a transcript as it arrives. +//! - [`Terminal::text`] is the agent's own *authoritative final answer*, taken +//! from its terminal record. +//! +//! Concatenating the deltas is not guaranteed to equal the final text (Copilot +//! emits both; Claude emits only the latter), so a caller that needs the answer +//! reads `Terminal::text` and never sums the events. +//! +//! Every shape here was captured from the live CLIs, except where a comment says +//! otherwise. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::agent::{Agent, Format}; +use crate::outcome::{RateLimit, Stop, Usage}; + +/// One normalized thing an agent did, agent-agnostic so a single renderer works +/// across all three. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +#[non_exhaustive] +pub enum Event { + /// The session is live. Emitted once, as early as the agent reveals it. + Started { + /// The native session id. + session: String, + /// The model actually selected, when named. + model: Option, + }, + /// Reasoning text, where the agent exposes it. + Thinking(String), + /// Assistant text as it arrives. + Text(String), + /// The agent invoked a tool. + ToolCall { + /// Correlates with the matching [`Event::ToolResult`], when the agent + /// provides an id. + id: Option, + /// The tool's name. + name: String, + /// Its arguments, in the agent's own shape. + input: Value, + }, + /// A tool returned. + ToolResult { + /// Correlates with the originating [`Event::ToolCall`]. + id: Option, + /// Whether the tool reported success. `None` when the agent does not say. + ok: Option, + /// The observation the model saw. + output: String, + }, + /// A quota signal. Reported, never acted on. + RateLimit(RateLimit), +} + +/// The ceiling on any single captured buffer. +/// +/// An agent can stream for hours; `text`, raw stdout and stderr would otherwise +/// grow without bound and a long run would end in an OOM rather than an answer. +/// A megabyte is far more prose than any consumer displays, and the fields this +/// bounds are for reading and diagnosis, never for reconstructing the stream. +pub const MAX_CAPTURE: usize = 1024 * 1024; + +/// The ceiling on a single output line before it is truncated. +/// +/// [`MAX_CAPTURE`] bounds the *total* kept, but a reader that accumulates until +/// a newline can exhaust memory on one line that never ends. An agent emitting a +/// huge tool result as one JSON object is the ordinary case; a broken or hostile +/// one emitting an endless line is the case this exists for. +pub const MAX_LINE: usize = 512 * 1024; + +/// The ceiling on how many tool calls may be tracked at once. +/// +/// Entries are removed as results arrive, so this only bites when an agent +/// announces calls it never completes. +pub(crate) const MAX_PENDING_TOOLS: usize = 1024; + +/// Append `line` and a newline to `buf`, stopping once [`MAX_CAPTURE`] is +/// reached. Returns whether anything was written. +/// +/// Truncation keeps the *earliest* output, which is where a banner, a usage +/// error, or the start of an answer lives. Later output from a runaway agent is +/// the part worth dropping. +pub(crate) fn append_capped(buf: &mut String, line: &str) -> bool { + let remaining = MAX_CAPTURE.saturating_sub(buf.len()); + if remaining == 0 { + return false; + } + // `<` rather than `<=`, because the newline also has to fit. + if line.len() < remaining { + buf.push_str(line); + buf.push('\n'); + } else { + // Cut on a character boundary; a truncated buffer must stay valid UTF-8. + let mut cut = remaining - 1; + while cut > 0 && !line.is_char_boundary(cut) { + cut -= 1; + } + buf.push_str(&line[..cut]); + buf.push('\n'); + } + true +} + +/// Facts that are only known once the stream ends. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Terminal { + /// The native session id. + pub session: Option, + /// The agent's authoritative final answer. + pub text: String, + /// Token and cost accounting. + pub usage: Usage, + /// Why the agent stopped. + pub stop: Stop, + /// The last quota signal seen. + pub rate_limit: Option, + /// How many output lines could not be parsed. + /// + /// Non-zero is not automatically a fault: agents interleave banners and + /// warnings with their JSON. It matters when a run *also* came back empty, + /// which is what a vendor changing its output shape looks like from here. + pub unparsed: usize, + /// The first line that failed to parse, as evidence for the above. + pub first_unparsed: Option, +} + +/// Incrementally turns one agent's output into [`Event`]s and a [`Terminal`]. +#[derive(Debug)] +pub(crate) struct Parser { + agent: Agent, + format: Format, + term: Terminal, + /// Tool names by call id, so a result can be attributed to its call. + tools: HashMap, + /// True once a [`Event::Started`] has been emitted, so it fires only once. + started: bool, + /// Whether any record was recognized as this agent's own shape. + structured: bool, + /// Whether the agent's terminal record was seen. + terminal_seen: bool, +} + +impl Parser { + /// A parser for `agent` reading output in `format`. + #[must_use] + pub fn new(agent: Agent, format: Format) -> Self { + Self { + agent, + format, + term: Terminal::default(), + tools: HashMap::new(), + started: false, + structured: false, + terminal_seen: false, + } + } + + /// Feed one line of stdout, returning the events it produced. + /// + /// Unparseable lines yield nothing rather than failing the run: agents + /// interleave banners and warnings with their JSON, and a stray line is not + /// a reason to lose a completed turn. They are counted in + /// [`Terminal::unparsed`] so that a silent vendor format change is + /// diagnosable instead of merely producing an empty answer. + pub fn push(&mut self, line: &str) -> Vec { + let line = line.trim(); + if line.is_empty() { + return Vec::new(); + } + // Under a plain-text format there is nothing to parse: the whole stream + // is the answer. + if self.format == Format::Text { + append_capped(&mut self.term.text, line); + return vec![Event::Text(line.to_string())]; + } + let Ok(value) = serde_json::from_str::(line) else { + self.term.unparsed += 1; + if self.term.first_unparsed.is_none() { + // One short sample is enough to identify a shape change; keeping + // every stray line would reintroduce the unbounded growth this + // parser just capped. + let mut cut = line.len().min(512); + while cut > 0 && !line.is_char_boundary(cut) { + cut -= 1; + } + self.term.first_unparsed = Some(line[..cut].to_string()); + } + return Vec::new(); + }; + // A record that names a type this parser knows is evidence the stream + // really is the shape we asked for. + if let Some(ty) = value.get("type").and_then(Value::as_str) + && self.recognizes(ty) + { + self.structured = true; + } + let mut out = match self.agent { + Agent::Claude => self.claude(&value), + Agent::Codex => self.codex(&value), + Agent::Copilot => self.copilot(&value), + }; + // Fire `Started` exactly once, from whichever record first revealed the + // id, and put it ahead of that record's own events. + if !self.started { + if let Some(session) = self.term.session.clone() { + self.started = true; + out.insert( + 0, + Event::Started { + session, + model: model_of(&value), + }, + ); + } + } + out + } + + /// Whether `ty` is a record type this agent's parser understands. + fn recognizes(&self, ty: &str) -> bool { + match self.agent { + Agent::Claude => matches!( + ty, + "system" | "assistant" | "user" | "result" | "rate_limit_event" + ), + Agent::Codex => { + ty.starts_with("thread.") || ty.starts_with("turn.") || ty.starts_with("item.") + } + Agent::Copilot => { + ty == "result" + || ty.starts_with("assistant.") + || ty.starts_with("tool.") + || ty.starts_with("session.") + } + } + } + + /// Track a tool call so its result can be attributed, bounded so an agent + /// that announces calls it never finishes cannot grow this without limit. + fn remember_tool(&mut self, id: &str, name: &str) { + if self.tools.len() >= MAX_PENDING_TOOLS { + return; + } + self.tools.insert(id.to_string(), name.to_string()); + } + + /// Whether any structured record has been recognized on this stream. + /// + /// A structured run that recognized nothing did not merely fail to answer; + /// it means the output was not the shape this parser understands. + pub(crate) fn saw_structured_record(&self) -> bool { + self.structured + } + + /// Whether the stream carried its terminal record, the one that closes a + /// turn and carries the answer and usage. + pub(crate) fn saw_terminal_record(&self) -> bool { + self.terminal_seen + } + + /// Consume the parser for everything only knowable at the end. + #[must_use] + pub fn finish(mut self) -> Terminal { + if self.format == Format::Text { + self.term.text = self.term.text.trim_end().to_string(); + } + self.term + } + + /// Claude Code `--output-format json` / `stream-json`. + /// + /// Verified against claude 2.1.205: `system/init` opens with the id, + /// `assistant` records carry Anthropic content blocks, `rate_limit_event` + /// reports quota, and `result` closes with the answer and usage. + fn claude(&mut self, v: &Value) -> Vec { + let ty = v.get("type").and_then(Value::as_str).unwrap_or_default(); + if let Some(id) = v.get("session_id").and_then(Value::as_str) { + self.term.session.get_or_insert_with(|| id.to_string()); + } + match ty { + "rate_limit_event" => { + let limit = claude_rate_limit(v.get("rate_limit_info")); + self.term.rate_limit.clone_from(&limit); + limit.into_iter().map(Event::RateLimit).collect() + } + // Both roles carry content blocks: `assistant` holds text/thinking/ + // tool_use, `user` carries the tool_result observations back. + "assistant" | "user" => self.content_blocks(v), + "result" => { + self.terminal_seen = true; + if let Some(text) = v.get("result").and_then(Value::as_str) { + self.term.text = text.to_string(); + } + self.term.usage = claude_usage(v); + self.term.stop = if v.get("is_error").and_then(Value::as_bool) == Some(true) { + Stop::Error + } else { + stop_from(v.get("stop_reason")) + }; + Vec::new() + } + _ => Vec::new(), + } + } + + /// Anthropic content blocks, shared by Claude's `assistant` and `user` + /// records. + fn content_blocks(&mut self, v: &Value) -> Vec { + let blocks = v + .get("message") + .and_then(|m| m.get("content")) + .and_then(Value::as_array); + let Some(blocks) = blocks else { + return Vec::new(); + }; + let mut out = Vec::new(); + for block in blocks { + let ty = block + .get("type") + .and_then(Value::as_str) + .unwrap_or_default(); + match ty { + "text" => { + if let Some(t) = block.get("text").and_then(Value::as_str) { + out.push(Event::Text(t.to_string())); + } + } + "thinking" => { + if let Some(t) = block.get("thinking").and_then(Value::as_str) { + out.push(Event::Thinking(t.to_string())); + } + } + "tool_use" => { + let name = block + .get("name") + .and_then(Value::as_str) + .unwrap_or("tool") + .to_string(); + let id = block.get("id").and_then(Value::as_str).map(str::to_string); + if let Some(id) = &id { + self.remember_tool(id, &name); + } + out.push(Event::ToolCall { + id, + name, + input: block.get("input").cloned().unwrap_or(Value::Null), + }); + } + "tool_result" => out.push(Event::ToolResult { + id: block + .get("tool_use_id") + .and_then(Value::as_str) + .inspect(|id| { + // The call has been answered, so stop tracking it. + self.tools.remove(*id); + }) + .map(str::to_string), + ok: block + .get("is_error") + .and_then(Value::as_bool) + .map(|is_error| !is_error), + output: flatten_text(block.get("content")), + }), + _ => {} + } + } + out + } + + /// Codex `exec --json`. + /// + /// Verified against codex-cli 0.145.0: `thread.started` opens with + /// `thread_id`, items arrive as `item.started` → `item.completed` pairs, and + /// `turn.completed` carries usage. A tool item appears twice: once + /// in-progress with an empty `aggregated_output`, once finished, so the + /// call is emitted on first sighting and the result only once it completes. + fn codex(&mut self, v: &Value) -> Vec { + let ty = v.get("type").and_then(Value::as_str).unwrap_or_default(); + if let Some(id) = v.get("thread_id").and_then(Value::as_str) { + self.term.session.get_or_insert_with(|| id.to_string()); + } + match ty { + "turn.completed" => { + self.terminal_seen = true; + self.term.usage = codex_usage(v.get("usage")); + Vec::new() + } + "turn.failed" => { + self.terminal_seen = true; + self.term.stop = Stop::Error; + Vec::new() + } + "item.started" | "item.updated" | "item.completed" => { + let Some(item) = v.get("item") else { + return Vec::new(); + }; + let item_ty = item.get("type").and_then(Value::as_str).unwrap_or_default(); + let id = item.get("id").and_then(Value::as_str).map(str::to_string); + let done = ty == "item.completed"; + + // Every item is reported at least twice: in progress, then + // finished. Announce each one exactly once, on first sighting, + // and keep the id → name binding for the result. + let name = tool_name(item, item_ty); + let first = id + .as_ref() + .is_none_or(|id| self.tools.insert(id.clone(), name.clone()).is_none()); + + match item_ty { + // The settled text is authoritative; a turn may contain + // several messages, so the last one to complete wins. + "agent_message" => { + if !done { + return Vec::new(); + } + let text = item.get("text").and_then(Value::as_str).unwrap_or_default(); + self.term.text = text.to_string(); + vec![Event::Text(text.to_string())] + } + "reasoning" if done => item + .get("text") + .and_then(Value::as_str) + .map(|t| Event::Thinking(t.to_string())) + .into_iter() + .collect(), + "command_execution" | "mcp_tool_call" | "file_change" | "web_search" => { + let mut out = Vec::new(); + if first { + out.push(Event::ToolCall { + id: id.clone(), + name, + input: codex_tool_input(item, item_ty), + }); + } + // Only the finished record carries real output: the + // in-progress one has an empty string and a null code. + if done { + if let Some(id) = &id { + self.tools.remove(id); + } + out.push(Event::ToolResult { + id, + ok: item + .get("exit_code") + .and_then(Value::as_i64) + .map(|code| code == 0), + output: item + .get("aggregated_output") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + }); + } + out + } + _ => Vec::new(), + } + } + _ => Vec::new(), + } + } + + /// Copilot `--output-format json` (JSONL). + /// + /// Verified against GitHub Copilot CLI 1.0.75: `assistant.message_delta` + /// streams text, `assistant.message` carries the settled answer, + /// `tool.execution_start` / `_complete` bracket a tool, and the final + /// `result` carries `sessionId` and usage. + fn copilot(&mut self, v: &Value) -> Vec { + let ty = v.get("type").and_then(Value::as_str).unwrap_or_default(); + let data = v.get("data"); + let field = |key: &str| -> Option { + data.and_then(|d| d.get(key)) + .and_then(Value::as_str) + .map(str::to_string) + }; + match ty { + // The delta stream is what a live transcript renders. + "assistant.message_delta" => field("deltaContent") + .filter(|t| !t.is_empty()) + .map(Event::Text) + .into_iter() + .collect(), + // The settled message is authoritative but already shown as deltas, + // so it updates the terminal text without re-emitting it. + "assistant.message" => { + if let Some(content) = field("content") { + self.term.text = content; + } + Vec::new() + } + "assistant.reasoning" => field("content") + .filter(|t| !t.is_empty()) + .map(Event::Thinking) + .into_iter() + .collect(), + "tool.execution_start" => { + let id = field("toolCallId"); + let name = field("toolName").unwrap_or_else(|| "tool".into()); + if let Some(id) = &id { + self.remember_tool(id, &name); + } + vec![Event::ToolCall { + id, + name, + input: data + .and_then(|d| d.get("arguments")) + .cloned() + .unwrap_or(Value::Null), + }] + } + "tool.execution_complete" => vec![Event::ToolResult { + id: field("toolCallId").inspect(|id| { + self.tools.remove(id); + }), + ok: data.and_then(|d| d.get("success")).and_then(Value::as_bool), + output: data + .and_then(|d| d.get("result")) + .and_then(|r| r.get("content")) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + }], + // Copilot's terminal record is flat, not nested under `data`. + "result" => { + self.terminal_seen = true; + if let Some(id) = v.get("sessionId").and_then(Value::as_str) { + self.term.session = Some(id.to_string()); + } + if let Some(usage) = v.get("usage") { + self.term.usage.premium_requests = + usage.get("premiumRequests").and_then(Value::as_u64); + } + if v.get("exitCode").and_then(Value::as_i64).unwrap_or(0) != 0 { + self.term.stop = Stop::Error; + } + Vec::new() + } + _ => Vec::new(), + } + } +} + +/// The model named by a record, if it names one. Claude puts it at the top +/// level; Copilot nests it under `data`. +fn model_of(v: &Value) -> Option { + v.get("model") + .or_else(|| v.get("data").and_then(|d| d.get("model"))) + .and_then(Value::as_str) + .map(str::to_string) +} + +/// A `stop_reason` string that is neither absent nor the normal end. +fn stop_from(v: Option<&Value>) -> Stop { + match v.and_then(Value::as_str) { + None | Some("end_turn" | "stop" | "completed") => Stop::Completed, + Some(other) => Stop::Other(other.to_string()), + } +} + +/// Claude's `rate_limit_info` object. +fn claude_rate_limit(v: Option<&Value>) -> Option { + let v = v?; + Some(RateLimit { + status: v.get("status").and_then(Value::as_str)?.to_string(), + window: v + .get("rateLimitType") + .and_then(Value::as_str) + .map(str::to_string), + resets_at: v.get("resetsAt").and_then(Value::as_i64), + }) +} + +/// Claude's terminal `usage` block plus its top-level `total_cost_usd`. +fn claude_usage(v: &Value) -> Usage { + let u = v.get("usage"); + let get = |key: &str| u.and_then(|u| u.get(key)).and_then(Value::as_u64); + Usage { + input_tokens: get("input_tokens"), + output_tokens: get("output_tokens"), + cache_read_tokens: get("cache_read_input_tokens"), + cache_write_tokens: get("cache_creation_input_tokens"), + cost_usd: v.get("total_cost_usd").and_then(Value::as_f64), + premium_requests: None, + } +} + +/// Codex's `turn.completed` usage block. Codex prices nothing itself, so +/// `cost_usd` stays absent rather than being derived from a local table. +fn codex_usage(v: Option<&Value>) -> Usage { + let get = |key: &str| v.and_then(|u| u.get(key)).and_then(Value::as_u64); + Usage { + input_tokens: get("input_tokens"), + output_tokens: get("output_tokens"), + cache_read_tokens: get("cached_input_tokens"), + cache_write_tokens: get("cache_write_input_tokens"), + cost_usd: None, + premium_requests: None, + } +} + +/// The display name of a Codex item: MCP and collaboration items name the tool +/// they invoked, everything else is identified by its item type. +fn tool_name(item: &Value, item_ty: &str) -> String { + item.get("tool") + .and_then(Value::as_str) + .unwrap_or(item_ty) + .to_string() +} + +/// The arguments of a Codex tool item, in whatever shape that item uses. +fn codex_tool_input(item: &Value, item_ty: &str) -> Value { + match item_ty { + "command_execution" => serde_json::json!({ "command": item.get("command") }), + "mcp_tool_call" => item.get("arguments").cloned().unwrap_or(Value::Null), + // `file_change` carries `changes`, `web_search` a `query`; neither has a + // single canonical argument field, so the item stands in for itself. + _ => item.clone(), + } +} + +/// Flatten a tool result's `content` into the observation the model saw. +/// +/// Anthropic tool results are either a bare string or an array of content +/// blocks. Text blocks flatten to their text; any other block kind (an image, +/// or a shape added in a future API version) is kept as its raw JSON rather +/// than dropped, so a caller inspecting a tool result never silently loses part +/// of it. This is lossy in presentation, never in content. +fn flatten_text(v: Option<&Value>) -> String { + match v { + Some(Value::String(s)) => s.clone(), + Some(Value::Array(blocks)) => blocks + .iter() + .map(|b| match b.get("text").and_then(Value::as_str) { + Some(text) => text.to_string(), + None => b.to_string(), + }) + .collect::>() + .join("\n"), + Some(other) => other.to_string(), + None => String::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Drive a parser over `lines`, returning every event and the terminal. + fn run(agent: Agent, lines: &[&str]) -> (Vec, Terminal) { + let mut p = Parser::new(agent, Format::Stream); + let events = lines.iter().flat_map(|l| p.push(l)).collect(); + (events, p.finish()) + } + + // Lines below are trimmed copies of transcripts captured from the live CLIs. + + #[test] + fn claude_stream_yields_start_thinking_text_and_terminal_facts() { + let (events, term) = run( + Agent::Claude, + &[ + r#"{"type":"system","subtype":"init","session_id":"sess-a","model":"claude-haiku-4-5"}"#, + r#"{"type":"assistant","session_id":"sess-a","message":{"content":[{"type":"thinking","thinking":"brief"}]}}"#, + r#"{"type":"assistant","session_id":"sess-a","message":{"content":[{"type":"text","text":"pong"}]}}"#, + r#"{"type":"result","subtype":"success","is_error":false,"result":"pong","session_id":"sess-a","total_cost_usd":0.017,"usage":{"input_tokens":10,"output_tokens":45,"cache_read_input_tokens":18764,"cache_creation_input_tokens":7322}}"#, + ], + ); + assert_eq!( + events[0], + Event::Started { + session: "sess-a".into(), + model: Some("claude-haiku-4-5".into()) + } + ); + assert_eq!(events[1], Event::Thinking("brief".into())); + assert_eq!(events[2], Event::Text("pong".into())); + assert_eq!(term.session.as_deref(), Some("sess-a")); + assert_eq!(term.text, "pong"); + assert_eq!(term.stop, Stop::Completed); + assert_eq!(term.usage.input_tokens, Some(10)); + assert_eq!(term.usage.cache_read_tokens, Some(18764)); + assert_eq!(term.usage.cache_write_tokens, Some(7322)); + assert_eq!(term.usage.cost_usd, Some(0.017)); + } + + #[test] + fn claude_started_fires_only_once() { + let (events, _) = run( + Agent::Claude, + &[ + r#"{"type":"system","subtype":"init","session_id":"s"}"#, + r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"a"}]}}"#, + r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"text","text":"b"}]}}"#, + ], + ); + assert_eq!( + events + .iter() + .filter(|e| matches!(e, Event::Started { .. })) + .count(), + 1 + ); + } + + #[test] + fn claude_pairs_tool_use_with_its_result() { + let (events, _) = run( + Agent::Claude, + &[ + r#"{"type":"assistant","session_id":"s","message":{"content":[{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"ls"}}]}}"#, + r#"{"type":"user","session_id":"s","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"a.txt"}]}}"#, + ], + ); + let call = events + .iter() + .find(|e| matches!(e, Event::ToolCall { .. })) + .unwrap(); + let Event::ToolCall { id, name, input } = call else { + unreachable!() + }; + assert_eq!(id.as_deref(), Some("toolu_1")); + assert_eq!(name, "Bash"); + assert_eq!(input["command"], "ls"); + assert!(events.contains(&Event::ToolResult { + id: Some("toolu_1".into()), + ok: None, + output: "a.txt".into(), + })); + } + + #[test] + fn claude_reports_a_rate_limit_without_failing() { + let (events, term) = run( + Agent::Claude, + &[ + r#"{"type":"rate_limit_event","session_id":"s","rate_limit_info":{"status":"allowed","resetsAt":1785260400,"rateLimitType":"five_hour"}}"#, + ], + ); + let limit = RateLimit { + status: "allowed".into(), + window: Some("five_hour".into()), + resets_at: Some(1_785_260_400), + }; + assert!(events.contains(&Event::RateLimit(limit.clone()))); + assert_eq!(term.rate_limit, Some(limit.clone())); + assert!( + !limit.is_blocking(), + "an `allowed` heartbeat is not a block" + ); + } + + #[test] + fn claude_error_result_sets_the_stop_reason() { + let (_, term) = run( + Agent::Claude, + &[r#"{"type":"result","is_error":true,"result":"boom","session_id":"s"}"#], + ); + assert_eq!(term.stop, Stop::Error); + } + + #[test] + fn copilot_streams_deltas_and_takes_its_answer_from_the_settled_message() { + let (events, term) = run( + Agent::Copilot, + &[ + r#"{"type":"assistant.message_delta","data":{"messageId":"m","deltaContent":"po"}}"#, + r#"{"type":"assistant.message_delta","data":{"messageId":"m","deltaContent":"ng"}}"#, + r#"{"type":"assistant.message","data":{"messageId":"m","model":"gpt-5-mini","content":"pong"}}"#, + r#"{"type":"result","sessionId":"768c8e7d","exitCode":0,"usage":{"premiumRequests":0}}"#, + ], + ); + // The deltas stream; the settled message must not double them. + let texts: Vec<_> = events + .iter() + .filter_map(|e| match e { + Event::Text(t) => Some(t.as_str()), + _ => None, + }) + .collect(); + assert_eq!(texts, ["po", "ng"]); + assert_eq!(term.text, "pong", "the answer is the settled message"); + assert_eq!(term.session.as_deref(), Some("768c8e7d")); + assert_eq!(term.usage.premium_requests, Some(0)); + } + + #[test] + fn copilot_brackets_a_tool_call_with_its_completion() { + let (events, _) = run( + Agent::Copilot, + &[ + r#"{"type":"tool.execution_start","data":{"toolCallId":"call_1","toolName":"bash","arguments":{"command":"ls"}}}"#, + r#"{"type":"tool.execution_complete","data":{"toolCallId":"call_1","success":true,"result":{"content":"a.txt"}}}"#, + ], + ); + assert!(matches!( + &events[0], + Event::ToolCall { id, name, .. } + if id.as_deref() == Some("call_1") && name == "bash" + )); + assert_eq!( + events[1], + Event::ToolResult { + id: Some("call_1".into()), + ok: Some(true), + output: "a.txt".into() + } + ); + } + + #[test] + fn codex_reads_the_thread_id_and_the_completed_message() { + let (events, term) = run( + Agent::Codex, + &[ + r#"{"type":"thread.started","thread_id":"0199-xyz"}"#, + r#"{"type":"item.completed","item":{"id":"i1","type":"agent_message","text":"pong"}}"#, + r#"{"type":"turn.completed","usage":{"input_tokens":12,"output_tokens":3,"cached_input_tokens":9}}"#, + ], + ); + assert_eq!( + events[0], + Event::Started { + session: "0199-xyz".into(), + model: None + } + ); + assert_eq!(term.session.as_deref(), Some("0199-xyz")); + assert_eq!(term.text, "pong"); + assert_eq!(term.usage.input_tokens, Some(12)); + assert_eq!(term.usage.cache_read_tokens, Some(9)); + } + + #[test] + fn codex_command_execution_becomes_a_call_and_a_result() { + let (events, _) = run( + Agent::Codex, + &[ + r#"{"type":"item.completed","item":{"id":"c1","type":"command_execution","command":"ls","exit_code":0,"aggregated_output":"a.txt"}}"#, + ], + ); + assert!(matches!(&events[0], Event::ToolCall { name, .. } if name == "command_execution")); + assert_eq!( + events[1], + Event::ToolResult { + id: Some("c1".into()), + ok: Some(true), + output: "a.txt".into() + } + ); + } + + /// Codex reports one tool twice: in progress, then finished. The call must + /// be announced once and the empty in-progress output must never surface as + /// a result. Both lines are verbatim from a codex 0.145.0 transcript. + #[test] + fn codex_started_then_completed_yields_one_call_and_one_result() { + let (events, _) = run( + Agent::Codex, + &[ + r#"{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"/bin/zsh -lc ls","aggregated_output":"","exit_code":null,"status":"in_progress"}}"#, + r#"{"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"/bin/zsh -lc ls","aggregated_output":"a.txt\n","exit_code":0,"status":"completed"}}"#, + ], + ); + let calls = events + .iter() + .filter(|e| matches!(e, Event::ToolCall { .. })) + .count(); + assert_eq!(calls, 1, "the same item must not be announced twice"); + let results: Vec<_> = events + .iter() + .filter_map(|e| match e { + Event::ToolResult { output, .. } => Some(output.as_str()), + _ => None, + }) + .collect(); + assert_eq!( + results, + ["a.txt\n"], + "the in-progress blank must not appear" + ); + } + + /// A turn can hold several messages; the answer is the last to settle. + #[test] + fn codex_last_completed_message_is_the_answer() { + let (_, term) = run( + Agent::Codex, + &[ + r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"I'll list the directory."}}"#, + r#"{"type":"item.completed","item":{"id":"i2","type":"agent_message","text":"DONE"}}"#, + ], + ); + assert_eq!(term.text, "DONE"); + } + + #[test] + fn capture_is_bounded_and_keeps_the_earliest_output() { + let mut buf = String::new(); + // Far more than the cap, in chunks, as a streaming agent would. + for i in 0..50_000 { + append_capped(&mut buf, &format!("line {i} aaaaaaaaaaaaaaaaaaaaaaaaaaaa")); + } + assert!(buf.len() <= MAX_CAPTURE, "grew to {}", buf.len()); + assert!(buf.starts_with("line 0 "), "the earliest output is kept"); + } + + #[test] + fn capping_never_splits_a_multibyte_character() { + let mut buf = "x".repeat(MAX_CAPTURE - 3); + // A 4-byte character that cannot fit in the 3 bytes remaining. + assert!(append_capped(&mut buf, "🙂🙂")); + assert!(buf.len() <= MAX_CAPTURE); + // The invariant is simply that this is still a valid Rust string, which + // would have panicked on a mid-character slice above. + assert!(buf.is_char_boundary(buf.len())); + } + + #[test] + fn a_full_buffer_reports_that_it_took_nothing() { + let mut buf = "x".repeat(MAX_CAPTURE); + assert!(!append_capped(&mut buf, "more")); + assert_eq!(buf.len(), MAX_CAPTURE); + } + + /// A vendor changing its output shape looks like a clean exit with nothing + /// parsed. Counting the misses turns that from a mystery into a diagnosis. + #[test] + fn unparseable_lines_are_counted_and_sampled() { + let (_, term) = run( + Agent::Claude, + &[ + "an error page, not JSON", + "another bad line", + r#"{"type":"result","result":"ok","session_id":"s"}"#, + ], + ); + assert_eq!(term.unparsed, 2); + assert_eq!( + term.first_unparsed.as_deref(), + Some("an error page, not JSON") + ); + } + + #[test] + fn a_clean_stream_reports_no_parse_failures() { + let (_, term) = run( + Agent::Claude, + &[r#"{"type":"result","result":"ok","session_id":"s"}"#], + ); + assert_eq!(term.unparsed, 0); + assert!(term.first_unparsed.is_none()); + } + + /// Non-text content blocks are preserved as raw JSON rather than dropped, so + /// a caller inspecting a tool result never silently loses part of it. + #[test] + fn tool_result_blocks_that_are_not_text_are_kept_not_dropped() { + let (events, _) = run( + Agent::Claude, + &[ + r#"{"type":"user","session_id":"s","message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":[{"type":"text","text":"seen"},{"type":"image","source":{"data":"abc"}}]}]}}"#, + ], + ); + let output = events + .iter() + .find_map(|e| match e { + Event::ToolResult { output, .. } => Some(output), + _ => None, + }) + .unwrap_or_else(|| panic!("expected a tool result, got {events:?}")); + assert!(output.contains("seen")); + assert!(output.contains("image"), "the image block was dropped"); + } + + #[test] + fn garbage_lines_are_skipped_not_fatal() { + let (events, term) = run( + Agent::Claude, + &[ + "Warning: something on stdout", + "", + r#"{"type":"result","result":"ok","session_id":"s"}"#, + ], + ); + assert!(events.iter().all(|e| !matches!(e, Event::Text(_)))); + assert_eq!(term.text, "ok"); + } + + #[test] + fn text_format_passes_lines_through_verbatim() { + let mut p = Parser::new(Agent::Copilot, Format::Text); + let events: Vec<_> = ["hello", "world"].iter().flat_map(|l| p.push(l)).collect(); + assert_eq!( + events, + [Event::Text("hello".into()), Event::Text("world".into())] + ); + assert_eq!(p.finish().text, "hello\nworld"); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..246620a --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,103 @@ +//! Drive Claude Code, Codex and GitHub Copilot headlessly from Rust. +//! +//! One request type, one event vocabulary and one session model across three +//! agent CLIs that agree on none of those things. This is a **library**: your +//! program links it and spawns the agent itself, with no intermediate CLI +//! marshalling a request through stdout and back. +//! +//! # Running a prompt +//! +//! ```no_run +//! use agent_abstraction::{Agent, Permission, Request, run}; +//! +//! # async fn example() -> agent_abstraction::Result<()> { +//! let outcome = run( +//! &Request::new(Agent::Claude, "Reply with the single word: pong") +//! .model("haiku") +//! .permission(Permission::ReadOnly), +//! ) +//! .await?; +//! +//! println!("{}", outcome.text); +//! # Ok(()) +//! # } +//! ``` +//! +//! # Watching one as it works +//! +//! ```no_run +//! use agent_abstraction::{Agent, Event, Request, stream}; +//! +//! # async fn example() -> agent_abstraction::Result<()> { +//! let mut running = stream(&Request::new(Agent::Claude, "audit this repo"))?; +//! while let Some(event) = running.recv().await { +//! match event { +//! Event::Text(text) => print!("{text}"), +//! Event::ToolCall { name, .. } => println!("[{name}]"), +//! _ => {} +//! } +//! } +//! let outcome = running.finish().await?; +//! # Ok(()) +//! # } +//! ``` +//! +//! # Multi-turn conversations +//! +//! Thread one stable name across turns and let [`SessionStore`] map it to +//! whatever handle the agent understands: +//! +//! ```no_run +//! use agent_abstraction::{Agent, Request, SessionStore, run}; +//! +//! # async fn example() -> agent_abstraction::Result<()> { +//! let store = SessionStore::open("/var/lib/myapp/sessions"); +//! +//! // First turn creates the session; later turns continue it. +//! let first = Request::new(Agent::Claude, "remember the number 7") +//! .session(&store, ".", "thread-42", false)?; +//! run(&first).await?; +//! +//! let second = Request::new(Agent::Claude, "what number did I say?") +//! .session(&store, ".", "thread-42", false)?; +//! println!("{}", run(&second).await?.text); +//! # Ok(()) +//! # } +//! ``` +//! +//! # What each agent can do +//! +//! | | session id | fork | events | system prompt | +//! |---|---|---|---|---| +//! | Claude Code | caller-minted (`--session-id`) | yes | yes | native flag | +//! | Codex | agent-printed (`thread_id`) | no | yes | prepended | +//! | Copilot | caller-minted (`--session-id`) | no | yes | prepended | +//! +//! Asking for something an agent cannot do is always an [`Error::Unsupported`], +//! never a silent downgrade. A caller that asked to fork and got a linear +//! resume would corrupt the conversation it meant to branch. +//! +//! # Operating within the agents' terms +//! +//! This crate drives each vendor's own supported headless interface with the +//! credentials that CLI already uses. It does not reimplement a provider API, +//! multiplex accounts, or retry around a quota: a refusal surfaces as +//! [`Error::RateLimited`], carrying the provider's own wording, and backing off +//! is the caller's decision. See `docs/operating-limits.md`. + +mod agent; +mod error; +mod event; +mod outcome; +mod proc; +mod request; +mod run; +mod session; + +pub use agent::{Agent, Caps, EnvPolicy, Format, NETWORK_ENV, Permission, SessionSupport}; +pub use error::{Error, Result}; +pub use event::{Event, MAX_CAPTURE}; +pub use outcome::{Outcome, RateLimit, Stop, Usage}; +pub use request::Request; +pub use run::{Run, run, stream}; +pub use session::{Phase, SessionRecord, SessionStore}; diff --git a/src/outcome.rs b/src/outcome.rs new file mode 100644 index 0000000..74745c4 --- /dev/null +++ b/src/outcome.rs @@ -0,0 +1,127 @@ +//! What a finished run produced. + +use serde::{Deserialize, Serialize}; + +use crate::agent::Agent; + +/// Token and cost accounting for a run. +/// +/// Every field is optional because the three agents report different subsets: +/// Claude reports full token counts and a dollar cost, Codex reports tokens, +/// Copilot reports premium requests and no tokens at all. An absent field means +/// "this agent did not say", never zero. +#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] +pub struct Usage { + /// Non-cached input tokens. + pub input_tokens: Option, + /// Generated tokens. + pub output_tokens: Option, + /// Input tokens served from the prompt cache. + pub cache_read_tokens: Option, + /// Input tokens written into the prompt cache. + pub cache_write_tokens: Option, + /// Cost in USD, when the agent priced the run itself. Never inferred from a + /// local price table, because a guessed cost is worse than no cost. + pub cost_usd: Option, + /// Copilot's premium-request count, its only usage unit. + pub premium_requests: Option, +} + +impl Usage { + /// Whether the agent reported anything at all. + #[must_use] + pub fn is_empty(&self) -> bool { + *self == Usage::default() + } +} + +/// A quota signal the agent emitted mid-run. +/// +/// Surfaced rather than acted on: this crate reports what the provider said and +/// leaves backing off to the caller. See `docs/operating-limits.md`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] +pub struct RateLimit { + /// The provider's status word, e.g. `allowed`, `rejected`. + pub status: String, + /// Which window this refers to, e.g. `five_hour`. + pub window: Option, + /// Unix epoch seconds at which the window resets. + pub resets_at: Option, +} + +impl RateLimit { + /// Whether this signal means the request was actually refused, as opposed + /// to an informational "still allowed" heartbeat. + #[must_use] + pub fn is_blocking(&self) -> bool { + !self.status.eq_ignore_ascii_case("allowed") + } +} + +/// Why the agent stopped. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum Stop { + /// Completed normally. + #[default] + Completed, + /// The agent reported an error result. + Error, + /// The agent stopped for a reason it named but this crate does not model. + Other(String), +} + +/// The result of one completed run. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] +pub struct Outcome { + /// Which agent produced it. + pub agent: Agent, + /// The native session id, when the run had or produced one. This is the + /// handle a later turn resumes with. + pub session: Option, + /// The assistant's final text. + pub text: String, + /// Token and cost accounting. + pub usage: Usage, + /// Why it stopped. + pub stop: Stop, + /// The last quota signal seen, if any. + pub rate_limit: Option, + /// The process exit code. + pub exit_code: i32, + /// Raw stderr, kept for diagnostics and capped at + /// [`crate::MAX_CAPTURE`]. + pub stderr: String, + /// How many output lines could not be parsed, with the first as a sample. + /// + /// Agents interleave banners with their JSON, so a non-zero count is not + /// automatically a fault. It becomes one when paired with an empty [`Self::text`], + /// which is what a vendor changing its output shape looks like from here. + /// See [`Outcome::looks_like_a_format_change`]. + pub unparsed: usize, + /// The first line that failed to parse. + pub first_unparsed: Option, +} + +impl Outcome { + /// Whether the run finished cleanly: a zero exit and no error result. + #[must_use] + pub fn is_ok(&self) -> bool { + self.exit_code == 0 && self.stop == Stop::Completed + } + + /// Whether this run looks like the agent changed its output format. + /// + /// The signature is a process that exited successfully while every line it + /// printed was unreadable: the CLI is healthy and this crate's parser is + /// not. Worth logging loudly, because the alternative symptom is a + /// successful run that mysteriously returns nothing. + #[must_use] + pub fn looks_like_a_format_change(&self) -> bool { + self.exit_code == 0 && self.unparsed > 0 && self.text.trim().is_empty() + } +} diff --git a/src/proc.rs b/src/proc.rs new file mode 100644 index 0000000..164782e --- /dev/null +++ b/src/proc.rs @@ -0,0 +1,68 @@ +//! Process-group teardown: the crate's only `unsafe`. +//! +//! It lives in its own module so the answer to "where does this crate use +//! `unsafe`, and why" is one small file rather than a block buried in the +//! runner. +//! +//! # Why any unsafe at all +//! +//! Killing an agent means killing what the agent *started*. A code review runs +//! `git`, a test runner, a language server; those are children of the CLI, not +//! of us, and `Child::kill` reaches only the CLI itself. The portable answer is +//! to put each run in its own process group ([`std::process::Command::process_group`], +//! which is safe) and signal the group. +//! +//! Signalling a group is where safety runs out: `std` has no API for it, so the +//! options are `libc::kill`, which is `unsafe` because it is a raw FFI call, or +//! a dependency such as `nix` for a safe wrapper. A whole crate for one call is +//! the larger cost, so the crate takes the `unsafe` and confines it here. +//! +//! `Cargo.toml` sets `unsafe_code = "deny"` rather than `forbid` precisely so +//! this one audited use can be excepted; nothing else in the crate may add one +//! without also changing that lint. +//! +//! # Windows +//! +//! Not implemented. Containing a process tree on Windows needs a Job Object +//! (`CreateJobObject` + `AssignProcessToJobObject` with +//! `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`), which this crate does not set up, so +//! only the direct child is killed and grandchildren survive cancellation. This +//! is a real gap for a Windows host, tracked in the README's cancellation +//! section, and the tests that prove the Unix behaviour are `#![cfg(unix)]`. + +/// Signal an entire process group so commands the agent spawned die with it. +/// +/// Best effort by nature: the group may already have exited, which is not a +/// failure. Call this **before** reaping the child, because reaping clears the +/// pid this needs to address the group. +#[cfg(unix)] +pub(crate) fn kill_process_group(child: &tokio::process::Child) { + let Some(pid) = child.id() else { + // Already reaped, so there is no pid left to address. Signalling now + // would risk hitting a pid the OS has since recycled. + return; + }; + let Ok(pid) = i32::try_from(pid) else { + // Unreachable in practice: a pid always fits in an i32. + return; + }; + + // SAFETY: `libc::kill` is an FFI call with no safe wrapper in `std`. The + // negated pid addresses the process group this child leads, established by + // `process_group(0)` at spawn. Signalling a group that has already exited + // returns `ESRCH`, which is ignored here; it is not undefined behaviour. + // No pointers are passed and no memory is shared, so the call cannot + // violate any invariant this crate relies on. + #[expect( + unsafe_code, + reason = "process-group signalling has no safe equivalent in std; \ + taking a dependency for one call costs more than it saves" + )] + unsafe { + libc::kill(-pid, libc::SIGKILL); + } +} + +/// No-op: see the module docs. Only the direct child is killed on Windows. +#[cfg(not(unix))] +pub(crate) fn kill_process_group(_child: &tokio::process::Child) {} diff --git a/src/request.rs b/src/request.rs new file mode 100644 index 0000000..1999be4 --- /dev/null +++ b/src/request.rs @@ -0,0 +1,471 @@ +//! Describing a run before it happens. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use crate::agent::{ + Agent, Continue, EnvPolicy, Format, MAX_COMMAND_LINE, Permission, Plan, STDIN_THRESHOLD, +}; +use crate::error::Result; +use crate::session::{Phase, SessionStore}; + +/// A run, described but not yet started. +/// +/// Built fluently and then handed to [`crate::run`] or [`crate::stream`]: +/// +/// ```no_run +/// use agent_abstraction::{Agent, Permission, Request}; +/// +/// let request = Request::new(Agent::Claude, "summarize this repo") +/// .model("sonnet") +/// .permission(Permission::ReadOnly); +/// ``` +#[derive(Debug, Clone)] +pub struct Request { + pub(crate) agent: Agent, + pub(crate) bin: Option, + pub(crate) prompt: String, + pub(crate) system: Option, + pub(crate) model: Option, + pub(crate) permission: Permission, + pub(crate) format: Option, + pub(crate) cont: Continue, + pub(crate) cwd: Option, + pub(crate) env: Vec<(String, String)>, + pub(crate) extra_args: Vec, + pub(crate) env_policy: EnvPolicy, + pub(crate) timeout: Option, + /// Set when [`Request::session`] resolved a named session, so the runner + /// knows to write the binding back. + pub(crate) binding: Option, +} + +/// A named session this run is attached to. +#[derive(Debug, Clone)] +pub(crate) struct Binding { + pub(crate) store: SessionStore, + pub(crate) project: PathBuf, + pub(crate) name: String, + pub(crate) phase: Phase, +} + +impl Request { + /// A request for `agent` with `prompt`. + /// + /// Defaults are deliberately conservative: [`Permission::ReadOnly`], + /// [`EnvPolicy::Minimal`], and the agent's structured output format. Widen + /// them explicitly. + pub fn new(agent: Agent, prompt: impl Into) -> Self { + Self { + agent, + bin: None, + prompt: prompt.into(), + system: None, + model: None, + permission: Permission::ReadOnly, + format: None, + cont: Continue::New, + cwd: None, + env: Vec::new(), + extra_args: Vec::new(), + env_policy: EnvPolicy::Minimal, + timeout: None, + binding: None, + } + } + + /// Override the binary. Defaults to the agent's own name on `PATH`. + #[must_use] + pub fn bin(mut self, bin: impl Into) -> Self { + self.bin = Some(bin.into()); + self + } + + /// A system prompt. Delivered by flag where the agent has one and prepended + /// to the prompt where it does not. It is never dropped. + #[must_use] + pub fn system(mut self, system: impl Into) -> Self { + self.system = Some(system.into()); + self + } + + /// Pin the model. Passed through verbatim; this crate does not validate + /// model names, so an unknown one surfaces as the agent's own error. + #[must_use] + pub fn model(mut self, model: impl Into) -> Self { + self.model = Some(model.into()); + self + } + + /// Set the permission posture. + #[must_use] + pub fn permission(mut self, permission: Permission) -> Self { + self.permission = permission; + self + } + + /// Pin the output format. Left unset, a run picks the agent's structured + /// format, which is also the one that carries a session id. + #[must_use] + pub fn format(mut self, format: Format) -> Self { + self.format = Some(format); + self + } + + /// The working directory the agent runs in. + #[must_use] + pub fn cwd(mut self, cwd: impl Into) -> Self { + self.cwd = Some(cwd.into()); + self + } + + /// Set an environment variable for the child. Repeatable. + #[must_use] + pub fn env(mut self, key: impl Into, value: impl Into) -> Self { + self.env.push((key.into(), value.into())); + self + } + + /// Choose which of the host's environment variables reach the agent. + /// + /// Defaults to [`EnvPolicy::Minimal`], which passes through only what the + /// selected agent needs. Reach for [`EnvPolicy::Inherit`] when the host + /// holds nothing the agent should not see, or when something this crate + /// does not know about has to reach the CLI. + /// + /// ```no_run + /// # use agent_abstraction::{Agent, EnvPolicy, Request}; + /// let request = Request::new(Agent::Claude, "review this") + /// .env_policy(EnvPolicy::Inherit); + /// ``` + #[must_use] + pub fn env_policy(mut self, policy: EnvPolicy) -> Self { + self.env_policy = policy; + self + } + + /// Kill the run if it has not finished within `timeout`. + #[must_use] + pub fn timeout(mut self, timeout: Duration) -> Self { + self.timeout = Some(timeout); + self + } + + /// Append raw arguments after everything this crate builds. + /// + /// The escape hatch for agent-specific flags with no unified spelling. + /// + /// **This voids the crate's guarantees.** Arguments land after the generated + /// ones, so they can contradict [`Request::permission`], redirect the output + /// format the parser expects, or point the run at a different session. + /// Codex's `-c key=value` in particular can rewrite sandbox and approval + /// policy for the invocation. Nothing here is validated, and a security + /// review of the permission posture means little without also reviewing + /// whatever is passed here. + /// + /// Arguments are passed straight to the binary without a shell. + #[must_use] + pub fn unchecked_args(mut self, args: I) -> Self + where + I: IntoIterator, + S: Into, + { + self.extra_args.extend(args.into_iter().map(Into::into)); + self + } + + /// Continue an earlier conversation by its native id, bypassing the session + /// store. Prefer [`Request::session`] unless you are tracking ids yourself. + #[must_use] + pub fn resume(mut self, id: impl Into) -> Self { + self.cont = Continue::Resume(id.into()); + self + } + + /// Start a **new** conversation under an id you choose, rather than one the + /// agent picks. + /// + /// Useful when a host already has its own identifier for a thread and wants + /// the agent's session to match it, with no mapping table in between. The + /// id is known before the process starts, so the association survives a run + /// that dies mid-turn. + /// + /// Only Claude and Copilot accept an assigned id + /// ([`SessionSupport::Minted`]). Codex reveals its `thread_id` only in its + /// own output, so this is [`crate::Error::Unsupported`] for it, raised when + /// the argv is built rather than silently starting an unrelated session. + /// + /// Both CLIs require a valid UUID here; this crate passes the string through + /// without checking, so a non-UUID surfaces as the agent's own error. + /// + /// [`SessionSupport::Minted`]: crate::SessionSupport::Minted + #[must_use] + pub fn session_id(mut self, id: impl Into) -> Self { + self.cont = Continue::NewWith(id.into()); + self + } + + /// Attach this run to a caller-owned session name. + /// + /// The store decides whether this turn creates, continues, or forks, and the + /// binding is written back once the run yields an id. `fork` branches a new + /// conversation off the stored one instead of appending to it. + /// + /// The store is cloned into the request so the run can write the binding + /// back without borrowing it. That clone is a [`PathBuf`], not the sessions + /// themselves: records are read and written on demand and never held in + /// memory, so this stays cheap however many sessions exist. + /// + /// # Errors + /// [`crate::Error::SessionConflict`] if the name belongs to another agent, + /// or [`crate::Error::Unsupported`] if this agent cannot fork or has no + /// session id at all. + pub fn session( + mut self, + store: &SessionStore, + project: impl AsRef, + name: impl Into, + fork: bool, + ) -> Result { + let project = project.as_ref().to_path_buf(); + let name = name.into(); + let (phase, cont) = store.plan(self.agent, &project, &name, fork)?; + self.cont = cont; + self.binding = Some(Binding { + store: store.clone(), + project, + name, + phase, + }); + // A named session needs an id back, so it selects the format that + // carries one unless the caller pinned a format explicitly. + match self.format { + None => self.format = self.agent.session_format(), + // An explicit format that cannot carry an id would let the run + // succeed and then silently fail to update the binding, so the + // combination is refused here rather than discovered later. + Some(format) if !self.agent.format_carries_session(format) => { + return Err(crate::Error::Unsupported { + agent: self.agent, + what: "a named session under an output format that carries no session id", + }); + } + Some(_) => {} + } + Ok(self) + } + + /// Roughly how many bytes of command line this request needs. + /// + /// Only the caller-supplied text is counted; the flags themselves are a + /// bounded handful of short literals. Used to decide whether the prompt has + /// to move to stdin. + fn argv_weight(&self) -> usize { + self.prompt.len() + + self.system.as_ref().map_or(0, String::len) + + self.extra_args.iter().map(String::len).sum::() + } + + /// The format this request will actually use. + #[must_use] + pub fn effective_format(&self) -> Format { + self.format.unwrap_or_default() + } + + /// Whether this turn opens, continues, or branches its named session. + /// `None` when the request is not attached to one. + /// + /// Known before the run starts, so a UI can label the turn up front. + #[must_use] + pub fn session_phase(&self) -> Option { + self.binding.as_ref().map(|b| b.phase) + } + + /// Freeze the request into the [`Plan`] an argv is built from. + /// + /// Crate-internal: `Plan` is how the crate works, not what it promises, and + /// a caller that wants to see the command line should use + /// [`Request::argv`]. + #[must_use] + pub(crate) fn plan(&self) -> Plan { + Plan { + bin: self + .bin + .clone() + .unwrap_or_else(|| self.agent.bin().to_string()), + prompt: self.prompt.clone(), + system: self.system.clone(), + model: self.model.clone(), + permission: self.permission, + format: self.effective_format(), + cont: self.cont.clone(), + // Measure the whole command line, not just the prompt: for Codex + // and Copilot the system text is prepended to it, and for Claude the + // system prompt rides its own argument. A small prompt with a large + // system prompt would otherwise still hit E2BIG. + stdin_prompt: self.argv_weight() >= STDIN_THRESHOLD, + } + } + + /// The full command line, for logging or for showing a user exactly what + /// will run before they approve it. + /// + /// # Errors + /// [`crate::Error::Unsupported`] if the agent cannot honour this request. + pub fn argv(&self) -> Result> { + Ok(self + .typed_argv()? + .into_iter() + .map(|arg| arg.value) + .collect()) + } + + /// The command line with per-argument sensitivity, the single source both + /// the executable and the redacted forms are derived from. + pub(crate) fn typed_argv(&self) -> Result> { + use crate::agent::{Arg, Sensitivity}; + + let plan = self.plan(); + let mut argv = self.agent.typed_argv(&plan)?; + // Raw arguments have no known shape, so they are assumed to carry + // secrets rather than assumed not to. + argv.extend(self.extra_args.iter().map(|value| Arg { + value: value.clone(), + sensitivity: Sensitivity::Unchecked, + })); + + // Moving the prompt to stdin does not move anything else: Claude keeps + // the system prompt on its own argument, and raw arguments are always on + // the line, so a small prompt with a large system prompt still + // overflows. Name the culprit rather than letting the OS answer E2BIG. + let total: usize = argv.iter().map(|a| a.value.len()).sum(); + if total > MAX_COMMAND_LINE { + let system = self.system.as_ref().map_or(0, String::len); + let extra: usize = self.extra_args.iter().map(String::len).sum(); + let prompt = if plan.stdin_prompt { + 0 + } else { + self.prompt.len() + }; + let (what, size) = if system >= extra && system >= prompt { + ("the system prompt", system) + } else if extra >= prompt { + ("the unchecked arguments", extra) + } else { + ("the prompt", prompt) + }; + return Err(crate::Error::CommandLineTooLarge { + agent: self.agent, + what, + size, + limit: MAX_COMMAND_LINE, + }); + } + Ok(argv) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The defaults are the safe posture, so a caller who configures nothing + /// does not get the permissive one by accident. + #[test] + fn defaults_are_read_only_isolated_and_structured() { + let request = Request::new(Agent::Claude, "hi"); + assert_eq!(request.permission, Permission::ReadOnly); + assert_eq!( + request.env_policy, + EnvPolicy::Minimal, + "full environment inheritance must be an explicit decision" + ); + assert_eq!(request.effective_format(), Format::Json); + let argv = request.argv().unwrap(); + assert!(argv.contains(&"--disallowedTools".to_string())); + } + + #[test] + fn extra_args_land_after_everything_the_crate_builds() { + let argv = Request::new(Agent::Claude, "hi") + .unchecked_args(["--add-dir", "/tmp/extra"]) + .argv() + .unwrap(); + assert_eq!(argv[argv.len() - 2..], ["--add-dir", "/tmp/extra"]); + } + + #[test] + fn a_large_prompt_moves_to_stdin() { + let big = "x".repeat(STDIN_THRESHOLD + 1); + let plan = Request::new(Agent::Claude, big.clone()).plan(); + assert!(plan.stdin_prompt); + let argv = Request::new(Agent::Claude, big).argv().unwrap(); + assert!( + !argv.iter().any(|a| a.len() > STDIN_THRESHOLD), + "a large prompt must not ride the argv" + ); + } + + /// Moving the prompt to stdin does not move the system prompt, so a small + /// prompt with a huge system prompt still overflows the command line. The + /// OS would answer `E2BIG` naming nothing; this names the culprit. + #[test] + fn an_oversized_system_prompt_is_reported_rather_than_left_to_e2big() { + let err = Request::new(Agent::Claude, "tiny") + .system("s".repeat(MAX_COMMAND_LINE + 1)) + .argv() + .unwrap_err(); + let crate::Error::CommandLineTooLarge { what, .. } = err else { + panic!("expected CommandLineTooLarge, got {err:?}") + }; + assert_eq!(what, "the system prompt"); + } + + #[test] + fn oversized_unchecked_arguments_are_named_too() { + let err = Request::new(Agent::Claude, "tiny") + .unchecked_args([format!("--x={}", "y".repeat(MAX_COMMAND_LINE))]) + .argv() + .unwrap_err(); + assert!(matches!( + err, + crate::Error::CommandLineTooLarge { + what: "the unchecked arguments", + .. + } + )); + } + + #[test] + fn a_small_prompt_stays_on_the_argv() { + assert!(!Request::new(Agent::Claude, "hi").plan().stdin_prompt); + } + + #[test] + fn a_named_session_selects_a_format_that_carries_an_id() { + let dir = std::env::temp_dir().join(format!("aa-req-{}", std::process::id())); + let store = SessionStore::open(&dir); + let request = Request::new(Agent::Claude, "hi") + .session(&store, "/proj", "chat", false) + .unwrap(); + assert_eq!(request.effective_format(), Format::Json); + + // An explicit format is respected over the automatic upgrade. + let pinned = Request::new(Agent::Claude, "hi") + .format(Format::Stream) + .session(&store, "/proj", "chat2", false) + .unwrap(); + assert_eq!(pinned.effective_format(), Format::Stream); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn resume_bypasses_the_store() { + let argv = Request::new(Agent::Claude, "hi") + .resume("sess-9") + .argv() + .unwrap(); + let at = argv.iter().position(|a| a == "--resume").unwrap(); + assert_eq!(argv[at + 1], "sess-9"); + } +} diff --git a/src/run.rs b/src/run.rs new file mode 100644 index 0000000..a13e6c9 --- /dev/null +++ b/src/run.rs @@ -0,0 +1,845 @@ +//! Spawning an agent and turning its output into events and an outcome. +//! +//! Two entry points over the same machinery: +//! - [`run`] waits and hands back the finished [`Outcome`]. +//! - [`stream`] hands back a [`Run`] that yields [`Event`]s as they arrive, for +//! a UI that shows work in progress. +//! +//! Both read stdout and stderr concurrently. Draining only one would deadlock +//! the moment the other filled its pipe buffer, which for a chatty agent is a +//! matter of seconds. + +use std::process::Stdio; + +use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, Command}; +use tokio::sync::mpsc; + +use crate::agent::{Continue, EnvPolicy}; +use crate::error::{Error, Result}; +use crate::event::{Event, MAX_LINE, Parser, Terminal, append_capped}; +use crate::outcome::{Outcome, Stop}; +use crate::proc::kill_process_group; +use crate::request::Request; + +/// Read one line, giving up on a line that never ends. +/// +/// `AsyncBufReadExt::lines` buffers until a newline arrives, so a stream that +/// emits megabytes without one exhausts memory before any total cap applies. +/// This reads a bounded amount and, past the limit, returns what it has and +/// discards the remainder of that line. Returns `None` at end of input. +async fn read_bounded_line(reader: &mut R, buf: &mut String) -> std::io::Result> +where + R: tokio::io::AsyncBufRead + Unpin, +{ + buf.clear(); + let mut bytes = Vec::new(); + let mut truncated = false; + loop { + let mut byte = [0u8; 1]; + match reader.read(&mut byte).await? { + // End of input: a trailing fragment still counts as a line. + 0 => { + if bytes.is_empty() { + return Ok(None); + } + break; + } + _ if byte[0] == b'\n' => break, + _ => { + if bytes.len() < MAX_LINE { + bytes.push(byte[0]); + } else { + // Keep draining to the newline so the pipe does not block, + // but stop accumulating. + truncated = true; + } + } + } + } + // Output is not guaranteed to be valid UTF-8, and one bad byte should not + // end a run. + buf.push_str(&String::from_utf8_lossy(&bytes)); + Ok(Some(truncated)) +} + +/// How many events may queue before the producer waits for the consumer. Deep +/// enough that a burst of tool events does not stall the agent, shallow enough +/// that a consumer which stops reading does not grow without bound. +const EVENT_BUFFER: usize = 256; + +/// A run in progress. +/// +/// Yields events through [`Run::recv`] and settles into an [`Outcome`] through +/// [`Run::finish`]. +/// +/// **Dropping a `Run` kills the agent.** That is the safe default for the hosts +/// this crate targets: closing a window or cancelling a request should stop the +/// work, not leave an agent running invisibly, spending quota and touching +/// files with nobody watching. Call [`Run::detach`] when background execution is +/// genuinely what you want, or [`Run::cancel`] to stop one deterministically and +/// wait for it to die. +#[derive(Debug)] +pub struct Run { + events: mpsc::Receiver, + /// The typed command line, kept so both the plain and redacted views come + /// from the same source. + typed: Vec, + /// Dropping or firing this asks the driver to tear down in order. Held as + /// an `Option` so `detach` can discard it without signalling. + cancel: Option>, + /// `None` only after [`Run::finish`], [`Run::cancel`] or [`Run::detach`] + /// has taken ownership, which is what stops `Drop` from aborting a run that + /// was already settled deliberately. + task: Option>>, + argv: Vec, +} + +impl Run { + /// The next event, or `None` once the agent has finished producing them. + pub async fn recv(&mut self) -> Option { + self.events.recv().await + } + + /// The exact command line that was spawned. + /// + /// **This contains the prompt and any session id.** Treat it as sensitive: + /// logging it verbatim puts user content into your logs. Use + /// [`Run::redacted_argv`] for diagnostics. + #[must_use] + pub fn argv(&self) -> &[String] { + &self.argv + } + + /// The command line with every non-public value replaced by a placeholder. + /// + /// Prompts, system prompts, session ids and anything from + /// [`crate::Request::unchecked_args`] are removed; flag names are kept so + /// the command stays recognisable. Sensitivity is recorded where each + /// argument is built rather than inferred from the finished line, so a + /// bare positional prompt or an opaque raw argument is covered too. + #[must_use] + pub fn redacted_argv(&self) -> Vec { + redact(&self.typed) + } + + /// Wait for the run to finish. + /// + /// Drains any events still queued, so a caller that only wants the result + /// can call this without having consumed the stream. + /// + /// # Errors + /// Whatever the run failed with. See [`Error`]. + pub async fn finish(mut self) -> Result { + while self.events.recv().await.is_some() {} + // Taking the handle disarms the `Drop` guard: this run is settling + // normally, not being abandoned. + let Some(task) = self.task.take() else { + unreachable!("the handle is only taken by a consuming method") + }; + match task.await { + Ok(result) => result, + // The driver task panicked or was cancelled. The process itself + // started fine, so this is not a spawn failure and must not claim + // to be one. + Err(join) => Err(Error::Interrupted { + bin: self.argv.first().cloned().unwrap_or_default(), + detail: if join.is_panic() { + "the driver task panicked".into() + } else { + "the driver task was cancelled".into() + }, + }), + } + } + + /// Stop the run and wait until the agent is actually gone. + /// + /// Cooperative rather than an abort: the driver is asked to stop, signals + /// the process group, reaps the child and joins its readers, and only then + /// does this return. So when it returns the tree really has exited, which + /// matters if the next thing you do touches the files it was working on. + /// + /// Returns the partial [`Outcome`] if the run happened to finish first, + /// otherwise [`Error::Cancelled`]. + /// + /// # Errors + /// [`Error::Cancelled`] in the normal case, or whatever the run failed with + /// if it failed before the request arrived. + pub async fn cancel(mut self) -> Result { + // Dropping the sender is itself the signal, so this cannot fail in a + // way that leaves the driver waiting. + drop(self.cancel.take()); + let Some(task) = self.task.take() else { + unreachable!("the handle is only taken by a consuming method") + }; + match task.await { + Ok(result) => result, + Err(join) => Err(Error::Interrupted { + bin: self.argv.first().cloned().unwrap_or_default(), + detail: if join.is_panic() { + "the driver task panicked".into() + } else { + "the driver task was cancelled".into() + }, + }), + } + } + + /// Let the run continue after this handle goes away. + /// + /// The opposite of the default. Nothing can observe or stop the agent + /// afterwards, so reach for this only when an unsupervised background run + /// is genuinely intended. + pub fn detach(mut self) { + // Leak the cancel signal rather than dropping it: a dropped sender is + // read by the driver as "stop", which is the opposite of detaching. + if let Some(cancel) = self.cancel.take() { + std::mem::forget(cancel); + } + // Dropping the handle without aborting is what detaches a tokio task. + drop(self.task.take()); + } +} + +impl Drop for Run { + fn drop(&mut self) { + // Abandoned rather than finished, cancelled or detached. Signal the + // driver so it tears down in order if it gets the chance, then abort so + // the teardown happens even if nothing polls it again. `Drop` cannot + // await, so abort remains the backstop: it drops the driver's + // `ChildGuard`, which kills the process group synchronously. + drop(self.cancel.take()); + if let Some(task) = self.task.take() { + task.abort(); + } + } +} + +/// Placeholder substituted for a sensitive argv value. +const REDACTED: &str = ""; + +/// Render a typed command line for logging, keeping flag names and replacing +/// every value that is not `Public`. +/// +/// Derived from the sensitivity recorded where each argument was built, so it +/// cannot miss a case the way matching on flag names and positions can. +fn redact(argv: &[crate::agent::Arg]) -> Vec { + use crate::agent::Sensitivity; + + argv.iter() + .map(|arg| match arg.sensitivity { + Sensitivity::Public => arg.value.clone(), + _ => REDACTED.to_string(), + }) + .collect() +} + +/// Run `request` to completion, discarding the intermediate events. +/// +/// # Errors +/// See [`Error`]; notably [`Error::NotInstalled`], [`Error::Timeout`], +/// [`Error::RateLimited`] and [`Error::Failed`]. +pub async fn run(request: &Request) -> Result { + stream(request)?.finish().await +} + +/// Start `request`, returning a handle that streams its events. +/// +/// Returns as soon as the child is spawned; the work proceeds on a task. +/// +/// # Errors +/// [`Error::NotInstalled`] if the binary is missing, [`Error::Unsupported`] if +/// the agent cannot honour the request, or [`Error::Spawn`] on an OS failure. +pub fn stream(request: &Request) -> Result { + // `tokio::spawn` panics outside a runtime. A fallible signature must not + // hide that, so the context is checked and reported as an ordinary error. + let runtime = tokio::runtime::Handle::try_current().map_err(|_| Error::NoRuntime)?; + + let plan = request.plan(); + let typed = request.typed_argv()?; + let argv: Vec = typed.iter().map(|a| a.value.clone()).collect(); + + let mut command = Command::new(&argv[0]); + command + .args(&argv[1..]) + .stdin(if plan.stdin_prompt { + Stdio::piped() + } else { + // Close stdin so an agent that would otherwise wait on it exits + // instead of hanging forever with nothing to read. + Stdio::null() + }) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + // Without this a killed run can leave the child alive holding the pipes. + .kill_on_drop(true); + if let Some(cwd) = &request.cwd { + command.current_dir(cwd); + } + // Narrow the environment first, then apply explicit variables, so an + // explicit `env()` always wins over the policy. + match &request.env_policy { + EnvPolicy::Inherit => {} + EnvPolicy::Minimal => { + command.env_clear(); + inherit_named(&mut command, &request.agent.essential_env()); + } + EnvPolicy::Only(names) => { + command.env_clear(); + inherit_named(&mut command, names); + } + } + for (key, value) in &request.env { + command.env(key, value); + } + + // Put the agent in its own process group so the whole tree can be signalled + // together. Killing only the CLI leaves the commands *it* spawned running: + // a build, a test run, a server, still holding files and credentials after + // the run is supposedly over. + // 0 means "make this child its own group leader". `tokio::process::Command` + // exposes this directly on unix. + #[cfg(unix)] + command.process_group(0); + + // Reserve an assigned session id before the child exists. Doing it inside + // the driver leaves a window where a spawn that half-succeeds loses the + // binding, and this is the id the caller may already be showing in a UI. + if let Some(token) = preassigned_token(request) { + persist_session(request, &token)?; + } + + let child = command.spawn().map_err(|source| { + // A missing binary is the common case and deserves an actionable error + // with an install hint. Reading it off the spawn avoids resolving PATH + // twice, and with it the window where the resolved path is replaced + // between the check and the exec. + if source.kind() == std::io::ErrorKind::NotFound { + Error::NotInstalled { + agent: request.agent, + bin: plan.bin.clone(), + hint: request.agent.install_hint(), + } + } else { + Error::Spawn { + bin: plan.bin.clone(), + source, + } + } + })?; + + let (tx, rx) = mpsc::channel(EVENT_BUFFER); + let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel(); + let request = request.clone(); + let task = runtime.spawn(drive(child, request, tx, cancel_rx)); + Ok(Run { + events: rx, + typed, + cancel: Some(cancel_tx), + task: Some(task), + argv, + }) +} + +/// Copy the named variables from this process into `command`, skipping any that +/// are unset so nothing is invented. +fn inherit_named>(command: &mut Command, names: &[S]) { + for name in names { + if let Some(value) = std::env::var_os(name.as_ref()) { + command.env(name.as_ref(), value); + } + } +} + +/// Owns the child and tears down its whole process group when dropped. +/// +/// `kill_on_drop` alone is not enough: it kills the CLI, leaving the commands +/// *it* spawned running. Since aborting the driver task drops this guard, the +/// same teardown covers cancellation, a dropped [`Run`] and a timeout, without +/// each path having to remember to do it. +struct ChildGuard { + child: Child, + /// Cleared once the child has been reaped, so a pid the OS may since have + /// recycled is never signalled. + armed: bool, +} + +impl Drop for ChildGuard { + fn drop(&mut self) { + if self.armed { + kill_process_group(&self.child); + } + } +} + +/// Feed the child, read both its pipes, and assemble the outcome. +#[allow( + clippy::too_many_lines, + reason = "one linear lifecycle: feed, read, wait, classify. Splitting it \ + would thread the child, parser, buffers and cancellation state \ + through helpers and obscure the ordering that matters, such as \ + killing the group before reaping." +)] +async fn drive( + child: Child, + request: Request, + events: mpsc::Sender, + cancel: tokio::sync::oneshot::Receiver<()>, +) -> Result { + // From here on the child is owned by a guard, so every exit path from this + // task, including an abort, takes the process group with it. + let mut child = ChildGuard { child, armed: true }; + let plan = request.plan(); + let bin = plan.bin.clone(); + + // Deliver a piped prompt and close the pipe, or the agent waits on EOF. + if plan.stdin_prompt { + if let Some(mut stdin) = child.child.stdin.take() { + let prompt = request.agent.effective_prompt(&plan); + stdin + .write_all(prompt.as_bytes()) + .await + .map_err(|source| Error::Spawn { + bin: bin.clone(), + source, + })?; + drop(stdin); + } + } + + // Drain stderr on its own task: a full stderr pipe blocks the child even + // while stdout still has room. + let stderr = child.child.stderr.take(); + let stderr_task = tokio::spawn(async move { + let mut buf = String::new(); + if let Some(handle) = stderr { + let mut reader = BufReader::new(handle); + let mut line = String::new(); + // Keep draining after the cap is hit: an undrained pipe blocks the + // child even though we no longer want the bytes. + while let Ok(Some(_)) = read_bounded_line(&mut reader, &mut line).await { + append_capped(&mut buf, &line); + } + } + buf + }); + + let stdout = child.child.stdout.take(); + let mut parser = Parser::new(request.agent, plan.format); + // Raw stdout is retained only as a fallback answer for a run that exited + // cleanly without producing a structured one, and as evidence when + // classifying a failure. It is capped for the same reason as everything + // else here: an agent can stream for hours. + let mut raw = String::new(); + // Tracks the first `Started`, so the binding is written once, and carries a + // store failure back out instead of discarding it. + let mut bound = false; + let mut persist_result: Result<()> = Ok(()); + + let read_stdout = async { + if let Some(handle) = stdout { + let mut reader = BufReader::new(handle); + let mut line = String::new(); + while read_bounded_line(&mut reader, &mut line).await?.is_some() { + append_capped(&mut raw, &line); + for event in parser.push(&line) { + // Bind a printed id the moment it appears rather than at the + // end. Codex announces its thread before answering, so a + // turn killed mid-answer stays resumable. + if let Event::Started { session, .. } = &event + && !bound + { + bound = true; + persist_result = persist_session(&request, session); + } + // A receiver that went away is not a failure: the run should + // still finish and produce its outcome. + if events.send(event).await.is_err() { + break; + } + } + } + } + Ok::<_, std::io::Error>(()) + }; + + // Race three outcomes: the run finishing, the deadline, and a cancellation + // request. Reading and waiting are one future so a child that produces + // output forever is still bounded by the timeout. + let work = async { + read_stdout.await?; + child.child.wait().await + }; + // A timeout is optional; `pending()` makes the un-timed case the same shape + // rather than duplicating the whole select. + let deadline = async { + match request.timeout { + Some(limit) => tokio::time::sleep(limit).await, + None => std::future::pending().await, + } + }; + + let status = tokio::select! { + // Biased so a finished run is reported as finished even if a deadline + // or cancellation lands in the same tick. + biased; + result = work => result, + () = deadline => { + // Order matters: signal the group *before* reaping. Reaping clears + // the child's pid, and the group kill needs that pid to target the + // group, so the other order silently leaves grandchildren running. + let partial = shut_down(&mut child, stderr_task).await; + return Err(Error::Timeout { + bin, + timeout: request.timeout.unwrap_or_default(), + partial: parser.finish().text, + }) + .inspect_err(|_| drop(partial)); + } + _ = cancel => { + // Cooperative teardown: the caller is waiting on this, so the tree + // is signalled, reaped and joined before returning. + shut_down(&mut child, stderr_task).await; + return Err(Error::Cancelled { bin }); + } + } + .map_err(|source| Error::Spawn { + bin: bin.clone(), + source, + })?; + + // The child has been reaped, so its pid must not be signalled again. + child.armed = false; + + drop(events); + let stderr = stderr_task.await.unwrap_or_default(); + let saw_structured = parser.saw_structured_record(); + let saw_terminal = parser.saw_terminal_record(); + let terminal = parser.finish(); + let exit_code = status.code().unwrap_or(-1); + + // Under a structured format, silently handing back raw stdout would turn a + // protocol failure into a plausible-looking answer. A run that recognized + // nothing, or never reached its terminal record, did not produce a result + // this crate can vouch for, so it is reported rather than papered over. + let structured = plan.format != crate::Format::Text; + if structured && exit_code == 0 { + if !saw_structured { + return Err(Error::Parse { + agent: request.agent, + detail: format!( + "no recognizable {} records in {} lines of output; the CLI's output shape has probably changed", + request.agent, + raw.lines().count() + ), + }); + } + if !saw_terminal { + return Err(Error::Parse { + agent: request.agent, + detail: "the stream ended without its terminal record, so the turn did not complete" + .into(), + }); + } + } + + // Plain text has no structure to validate: the stream is the answer. + let mut terminal = terminal; + if terminal.text.is_empty() && !structured { + terminal.text = raw.trim().to_string(); + } + + // A provider refusal is not always an exit code. Claude can report a + // blocking `rate_limit_event` and still exit 0, and the crate promises that + // quota refusals surface as `Error::RateLimited`, so the terminal state is + // checked regardless of how the process exited. + let quota_blocked = terminal + .rate_limit + .as_ref() + .is_some_and(crate::outcome::RateLimit::is_blocking); + if exit_code != 0 || quota_blocked { + return Err(classify(&bin, exit_code, &stderr, &raw, &terminal)); + } + + // A fork lands on a *new* id the agent only reveals at the end, so the name + // has to be repointed once the run settles. Everything else was bound above. + persist_result?; + if let Some(token) = &terminal.session + && !bound + { + persist_session(&request, token)?; + } + Ok(Outcome { + agent: request.agent, + session: terminal.session, + text: terminal.text, + usage: terminal.usage, + stop: terminal.stop, + rate_limit: terminal.rate_limit, + exit_code, + stderr, + unparsed: terminal.unparsed, + first_unparsed: terminal.first_unparsed, + }) +} + +/// Kill the process group, reap the child, and join the stderr reader. +/// +/// The orderly teardown both cancellation and timeout share. Returns whatever +/// stderr had been captured, so a caller can still report why a run was stopped. +async fn shut_down(child: &mut ChildGuard, stderr_task: tokio::task::JoinHandle) -> String { + kill_process_group(&child.child); + // Reap, so the caller is not left with a zombie once this returns. + let _ = child.child.kill().await; + child.armed = false; + // The pipes are closed now that the child is gone, so this finishes + // promptly rather than hanging the cancellation. + stderr_task.await.unwrap_or_default() +} + +/// Turn a non-zero exit into the most specific error available. +fn classify(bin: &str, code: i32, stderr: &str, stdout: &str, terminal: &Terminal) -> Error { + let quota_signalled = terminal + .rate_limit + .as_ref() + .is_some_and(crate::outcome::RateLimit::is_blocking); + if quota_signalled || looks_rate_limited(stderr) || looks_rate_limited(stdout) { + return Error::RateLimited { + bin: bin.to_string(), + message: first_meaningful_line(stderr) + .or_else(|| first_meaningful_line(stdout)) + .unwrap_or_else(|| "usage limit reached".to_string()), + }; + } + Error::Failed { + bin: bin.to_string(), + code, + stderr: first_meaningful_line(stderr).unwrap_or_default(), + } +} + +/// Whether text carries a provider quota refusal. +/// +/// Deliberately a small set of unambiguous phrases: a false positive here would +/// relabel an ordinary failure as a quota problem and send a caller into a +/// pointless backoff. +fn looks_rate_limited(text: &str) -> bool { + let lower = text.to_ascii_lowercase(); + [ + "rate limit", + "rate_limit", + "usage limit", + "quota exceeded", + "too many requests", + "429", + ] + .iter() + .any(|needle| lower.contains(needle)) +} + +/// The first non-blank line, trimmed. Enough to identify a failure without +/// pasting an entire stack trace into an error message. +fn first_meaningful_line(text: &str) -> Option { + text.lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .map(str::to_string) +} + +/// Write the session binding back, reporting any store failure. +/// +/// Called as soon as an id is known rather than only on a clean exit. Waiting +/// for success would lose the binding for exactly the runs where continuity +/// matters most: a timeout, a crash, or a cancelled turn. +fn persist_session(request: &Request, token: &str) -> Result<()> { + let Some(binding) = &request.binding else { + return Ok(()); + }; + binding + .store + .bind(request.agent, &binding.project, &binding.name, token) + .map(|_| ()) +} + +/// The id this run is already known by before it starts, if any. +/// +/// Only a caller-assigned id qualifies: a printed id does not exist yet. This +/// is what makes an assigned session survive a run that never finishes. +fn preassigned_token(request: &Request) -> Option { + match &request.plan().cont { + Continue::NewWith(id) => Some(id.clone()), + _ => None, + } +} + +/// Reported by an agent that exited cleanly but said nothing useful. +impl Outcome { + /// Whether the agent produced any answer at all. + #[must_use] + pub fn is_empty(&self) -> bool { + self.text.trim().is_empty() && self.stop == Stop::Completed + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::Agent; + + #[test] + fn quota_phrases_are_recognized_and_ordinary_errors_are_not() { + assert!(looks_rate_limited("Error: rate limit exceeded")); + assert!(looks_rate_limited("HTTP 429 Too Many Requests")); + assert!(looks_rate_limited("You have hit your usage limit")); + // A plain failure must not be mistaken for a quota problem. + assert!(!looks_rate_limited("error: no such file or directory")); + assert!(!looks_rate_limited("model not found")); + } + + #[test] + fn a_blocking_rate_limit_event_classifies_as_rate_limited() { + let terminal = Terminal { + rate_limit: Some(crate::outcome::RateLimit { + status: "rejected".into(), + window: Some("five_hour".into()), + resets_at: None, + }), + ..Terminal::default() + }; + assert!(matches!( + classify("claude", 1, "", "", &terminal), + Error::RateLimited { .. } + )); + } + + #[test] + fn an_allowed_rate_limit_event_is_not_a_failure_cause() { + let terminal = Terminal { + rate_limit: Some(crate::outcome::RateLimit { + status: "allowed".into(), + window: None, + resets_at: None, + }), + ..Terminal::default() + }; + assert!(matches!( + classify("claude", 1, "boom", "", &terminal), + Error::Failed { .. } + )); + } + + #[test] + fn failures_report_the_first_useful_line() { + let err = classify( + "claude", + 2, + "\n\n real problem \nstack", + "", + &Terminal::default(), + ); + let Error::Failed { code, stderr, .. } = err else { + panic!("expected a plain failure") + }; + assert_eq!(code, 2); + assert_eq!(stderr, "real problem"); + } + + /// Prompts and session ids ride the argv, and `Run::argv` invites logging + /// it. The redacted form must keep the shape while dropping the content. + #[test] + fn redaction_removes_prompts_and_session_ids_but_keeps_flags() { + let request = crate::Request::new(Agent::Claude, "my secret prompt") + .system("secret system") + .session_id("11111111-2222-3333-4444-555555555555"); + let safe = redact(&request.typed_argv().unwrap()); + + for secret in [ + "my secret prompt", + "secret system", + "11111111-2222-3333-4444-555555555555", + ] { + assert!( + !safe.iter().any(|a| a.contains(secret)), + "{secret:?} survived redaction: {safe:?}" + ); + } + // Still recognisable as the same command. + assert_eq!(safe[0], "claude"); + assert!(safe.contains(&"--permission-mode".to_string())); + assert!(safe.contains(&"--session-id".to_string())); + } + + #[test] + fn codex_trailing_prompt_is_redacted_even_without_a_flag() { + let request = crate::Request::new(Agent::Codex, "my secret prompt"); + let safe = redact(&request.typed_argv().unwrap()); + assert_eq!(safe.last().unwrap(), REDACTED); + assert_eq!(safe[1], "exec", "the subcommand must survive"); + } + + /// Redaction must cover the two shapes positional guesswork misses: Codex's + /// bare trailing prompt, and raw arguments whose contents are unknowable. + #[test] + fn redaction_covers_positional_prompts_and_unchecked_arguments() { + let request = crate::Request::new(Agent::Codex, "my secret prompt") + .unchecked_args(["-c", "api_key=hunter2"]); + let safe = redact(&request.typed_argv().unwrap()); + assert!(!safe.iter().any(|a| a.contains("my secret prompt"))); + assert!( + !safe.iter().any(|a| a.contains("hunter2")), + "unchecked arguments may hold secrets: {safe:?}" + ); + assert_eq!(safe[1], "exec", "the subcommand must survive"); + } + + /// A resume id is a capability: it continues someone's conversation. + #[test] + fn redaction_covers_the_codex_positional_resume_id() { + let request = crate::Request::new(Agent::Codex, "hi").resume("thread-secret-9"); + let safe = redact(&request.typed_argv().unwrap()); + assert!( + !safe.iter().any(|a| a.contains("thread-secret-9")), + "{safe:?}" + ); + assert!(safe.contains(&"resume".to_string())); + } + + /// `stream` is synchronous but spawns a task. Outside a runtime that would + /// panic, which a `Result`-returning function must not do. + #[test] + fn stream_outside_a_runtime_errors_instead_of_panicking() { + let err = stream(&crate::Request::new(Agent::Claude, "hi")).unwrap_err(); + assert!(matches!(err, Error::NoRuntime), "got {err:?}"); + } + + #[tokio::test] + async fn a_missing_binary_names_the_install_command() { + let request = Request::new(Agent::Claude, "hi").bin("definitely-not-a-real-binary-xyz"); + let err = run(&request).await.unwrap_err(); + let Error::NotInstalled { hint, agent, .. } = err else { + panic!("expected NotInstalled, got {err:?}") + }; + assert_eq!(agent, Agent::Claude); + assert!(hint.contains("claude-code")); + } + + #[test] + fn transient_errors_are_distinguished_from_permanent_ones() { + assert!( + Error::RateLimited { + bin: "claude".into(), + message: String::new() + } + .is_transient() + ); + assert!( + !Error::NotInstalled { + agent: Agent::Claude, + bin: "claude".into(), + hint: "" + } + .is_transient() + ); + } +} diff --git a/src/session.rs b/src/session.rs new file mode 100644 index 0000000..8e50f73 --- /dev/null +++ b/src/session.rs @@ -0,0 +1,762 @@ +//! Binding a caller-owned session *name* to an agent's native session id. +//! +//! A consumer threads one stable name ("thread-42") across turns; this module +//! keeps the mapping to whatever handle the agent actually understands, so the +//! consumer never extracts or re-passes an id itself. +//! +//! Two agents let the caller **mint** the id ([`SessionSupport::Minted`]): +//! Claude via `--session-id`, Copilot via the same flag in both directions. For +//! those the binding is written *before* the process starts, so a run that +//! crashes mid-turn still leaves a resumable session. Codex only **prints** its +//! `thread_id`, so its binding can only be recorded after the run produced one. +//! +//! Layout is one JSON file per session, `//.json`, +//! partitioned by project so the same name in two checkouts never collides. +//! Writes go through a temp file and a rename, so a concurrent reader never sees +//! a half-written record. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::agent::{Agent, Continue, SessionSupport}; +use crate::error::{Error, Result}; + +/// One named conversation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] +pub struct SessionRecord { + /// The caller's stable name, exactly as they supplied it. The on-disk + /// filename is an encoded form of this; the record keeps the original so a + /// listing hands back the name the caller actually used. + pub name: String, + /// The project this session belongs to. + pub project: String, + /// The agent that owns it. A session cannot migrate between agents. + pub agent: Agent, + /// The agent's native handle, which the next turn resumes with. + pub token: String, + /// Unix epoch seconds when the session was first created. + pub created: i64, + /// Unix epoch seconds of the most recent turn. + pub updated: i64, +} + +/// Whether the next turn starts a conversation or continues one. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Phase { + /// No prior record: this turn opens the conversation. + Create, + /// A record exists: this turn appends to it. + Continue, + /// A record exists and this turn branches off it, leaving it untouched. + Fork, +} + +/// The store of named sessions. +#[derive(Debug, Clone)] +pub struct SessionStore { + dir: PathBuf, +} + +impl SessionStore { + /// A store rooted at `dir`. The directory is created lazily on first write. + pub fn open(dir: impl Into) -> Self { + Self { dir: dir.into() } + } + + /// The default per-user location: `$XDG_STATE_HOME/agent-abstraction/sessions` + /// (falling back to `~/.local/state`), or `%LOCALAPPDATA%` on Windows. + /// `None` when neither the platform state dir nor `$HOME` can be resolved. + #[must_use] + pub fn default_dir() -> Option { + let base = if cfg!(windows) { + std::env::var_os("LOCALAPPDATA").map(PathBuf::from) + } else { + std::env::var_os("XDG_STATE_HOME") + .map(PathBuf::from) + .or_else(|| { + std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local").join("state")) + }) + }; + Some(base?.join("agent-abstraction").join("sessions")) + } + + /// The file backing `name` for `project`. Pure path arithmetic. + /// + /// The agent is **not** part of the key on purpose: one name must resolve to + /// one file across agents, so asking for a Claude session under a name + /// Codex already owns is a loud [`Error::SessionConflict`] rather than two + /// unrelated conversations quietly sharing a name. + #[must_use] + pub fn path_of(&self, project: &Path, name: &str) -> PathBuf { + self.dir + .join(project_slug(project)) + .join(format!("{}.json", encode_segment(name))) + } + + /// The stored record, or `None` when there is none. + /// + /// Only a genuinely absent file is `Ok(None)`. A permission error, an I/O + /// failure or a corrupt record is an [`Error::Store`], because treating + /// those as "no session" silently starts a new conversation and abandons + /// one the caller believes they are still in. + /// + /// # Errors + /// [`Error::Store`] if the record exists but cannot be read or parsed. + pub fn get(&self, project: &Path, name: &str) -> Result> { + let path = self.path_of(project, name); + let text = match fs::read_to_string(&path) { + Ok(text) => text, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(source) => { + return Err(Error::Store { + path: path.display().to_string(), + source, + }); + } + }; + serde_json::from_str(&text) + .map(Some) + .map_err(|e| Error::Store { + path: path.display().to_string(), + source: std::io::Error::new(std::io::ErrorKind::InvalidData, e), + }) + } + + /// Every session recorded for `project`, in unspecified order. + /// + /// A record that cannot be read or parsed is an error rather than an + /// omission: silently returning a short list makes a corrupt store look + /// like a store with fewer sessions. Use [`SessionStore::list_lossy`] when + /// skipping bad records is genuinely what you want. + /// + /// # Errors + /// [`Error::Store`] if the directory or any record within it is unreadable. + pub fn list(&self, project: &Path) -> Result> { + let dir = self.dir.join(project_slug(project)); + let store_err = |path: &Path, source| Error::Store { + path: path.display().to_string(), + source, + }; + let entries = match fs::read_dir(&dir) { + Ok(entries) => entries, + // No directory means no sessions, which is not a fault. + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(store_err(&dir, e)), + }; + let mut out = Vec::new(); + for entry in entries { + let path = entry.map_err(|e| store_err(&dir, e))?.path(); + // Skip the temp files a concurrent write may have in flight. + if path.extension().is_some_and(|ext| ext == "tmp") { + continue; + } + let text = fs::read_to_string(&path).map_err(|e| store_err(&path, e))?; + out.push(serde_json::from_str(&text).map_err(|e| { + store_err( + &path, + std::io::Error::new(std::io::ErrorKind::InvalidData, e), + ) + })?); + } + Ok(out) + } + + /// Every readable session for `project`, skipping any that are not. + /// + /// The deliberately lossy counterpart to [`SessionStore::list`], for a UI + /// that would rather show the sessions it can than fail the whole listing. + #[must_use] + pub fn list_lossy(&self, project: &Path) -> Vec { + let dir = self.dir.join(project_slug(project)); + let Ok(entries) = fs::read_dir(dir) else { + return Vec::new(); + }; + entries + .flatten() + .filter_map(|e| fs::read_to_string(e.path()).ok()) + .filter_map(|text| serde_json::from_str(&text).ok()) + .collect() + } + + /// Decide how `name` continues, and produce the continuation to run with. + /// + /// For a minting agent with no prior record this allocates the id here and + /// now, so the caller can persist the binding before spawning. + /// + /// Crate-internal: it returns `Continue`, which is machinery rather than + /// API. Callers reach this through [`crate::Request::session`], and can see + /// the decision it made via [`crate::Request::session_phase`]. + /// + /// # Errors + /// [`Error::SessionConflict`] when the name already belongs to another + /// agent; [`Error::Unsupported`] when `fork` is asked of an agent that + /// cannot fork, or when the agent exposes no session id at all. + pub(crate) fn plan( + &self, + agent: Agent, + project: &Path, + name: &str, + fork: bool, + ) -> Result<(Phase, Continue)> { + let caps = agent.caps(); + if caps.session == SessionSupport::None { + return Err(Error::Unsupported { + agent, + what: "named sessions (it exposes no session id headlessly)", + }); + } + let existing = self.get(project, name)?; + if let Some(record) = &existing { + if record.agent != agent { + return Err(Error::SessionConflict { + name: name.to_string(), + bound: record.agent, + requested: agent, + }); + } + } + + Ok(match (existing, fork) { + (Some(record), true) => { + if !caps.fork { + return Err(Error::Unsupported { + agent, + what: "forking a session headlessly", + }); + } + (Phase::Fork, Continue::Fork(record.token)) + } + (Some(record), false) => (Phase::Continue, Continue::Resume(record.token)), + // Forking a conversation that does not exist yet is just starting + // one; there is nothing to branch from. + (None, _) => ( + Phase::Create, + match caps.session { + SessionSupport::Minted => Continue::NewWith(Uuid::new_v4().to_string()), + // The id only exists once the agent prints it. + SessionSupport::Printed | SessionSupport::None => Continue::New, + }, + ), + }) + } + + /// Record `token` as the handle for `name`, preserving the original + /// creation time when the session already existed. + /// + /// # Errors + /// [`Error::Store`] if the record cannot be written. + pub fn bind( + &self, + agent: Agent, + project: &Path, + name: &str, + token: &str, + ) -> Result { + // The same invariant `plan` enforces, applied here too: `bind` is + // public, so the check cannot live only on the path that happens to + // call it first. + if let Some(existing) = self.get(project, name)? + && existing.agent != agent + { + return Err(Error::SessionConflict { + name: name.to_string(), + bound: existing.agent, + requested: agent, + }); + } + + let now = now_secs(); + let record = SessionRecord { + name: name.to_string(), + project: project.display().to_string(), + agent, + token: token.to_string(), + created: self.get(project, name)?.map_or(now, |r| r.created), + updated: now, + }; + + let path = self.path_of(project, name); + let store_err = |source| Error::Store { + path: path.display().to_string(), + source, + }; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(store_err)?; + restrict_to_owner(parent).map_err(store_err)?; + } + let mut text = serde_json::to_string_pretty(&record) + .map_err(|e| store_err(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))?; + text.push('\n'); + + // Write beside the target and rename, so a reader never observes a + // partial record. The temp name carries the pid and a counter: a single + // shared `.json.tmp` would let two concurrent writers for the same + // session scribble over each other's half-written file and then rename + // the result into place. + let tmp = path.with_extension(format!("{}.{}.tmp", std::process::id(), next_temp_id())); + write_private(&tmp, text.as_bytes()).map_err(store_err)?; + // Rename is atomic within a directory, so the last writer wins cleanly + // rather than producing a torn record. + fs::rename(&tmp, &path).map_err(|e| { + // Do not leave the temp file behind if the rename failed. + let _ = fs::remove_file(&tmp); + store_err(e) + })?; + // Syncing the file persists its contents, not the directory entry that + // names it. Without this a crash can leave the rename unrecorded and + // the session lost, which is the failure this store exists to avoid. + if let Some(parent) = path.parent() { + sync_dir(parent).map_err(store_err)?; + } + Ok(record) + } + + /// Drop the binding for `name`. Removing an absent session is not an error. + /// + /// # Errors + /// [`Error::Store`] if an existing record cannot be removed. + pub fn forget(&self, project: &Path, name: &str) -> Result<()> { + let path = self.path_of(project, name); + match fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(source) => Err(Error::Store { + path: path.display().to_string(), + source, + }), + } + } +} + +/// A per-process counter making each temp filename unique, so concurrent writes +/// to one session cannot share a scratch file. +fn next_temp_id() -> u64 { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + COUNTER.fetch_add(1, Ordering::Relaxed) +} + +/// Write `bytes` to a newly created file that only the owner can read. +/// +/// Session tokens resume conversations, so they are closer to a credential than +/// to a cache entry and should not be readable by other users on the machine. +/// Permissions are set at creation rather than afterwards, leaving no window +/// where the file exists world-readable. +fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + use std::io::Write as _; + + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + let mut file = options.open(path)?; + file.write_all(bytes)?; + // Flush to disk before the rename, so a crash cannot leave an empty record + // where a valid one is expected. + file.sync_all() +} + +/// Flush a directory entry to disk. A no-op where directories cannot be opened +/// for syncing, which is the case on Windows. +fn sync_dir(dir: &Path) -> std::io::Result<()> { + #[cfg(unix)] + { + fs::File::open(dir)?.sync_all()?; + } + #[cfg(not(unix))] + let _ = dir; + Ok(()) +} + +/// Restrict a directory to its owner. A no-op on platforms without Unix modes, +/// where the parent directory's inherited ACL governs instead. +fn restrict_to_owner(dir: &Path) -> std::io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(dir, fs::Permissions::from_mode(0o700))?; + } + #[cfg(not(unix))] + let _ = dir; + Ok(()) +} + +/// Seconds since the epoch. A pre-1970 clock reads as 0 rather than panicking; +/// these timestamps are for display, not for correctness. +fn now_secs() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX)) +} + +/// The longest encoded stem written before truncation applies. Filenames cap +/// near 255 bytes on every filesystem this targets, leaving room for the +/// disambiguating suffix and the extension. +const MAX_STEM: usize = 200; + +/// Encode an arbitrary name as one filesystem-safe path segment, injectively. +/// +/// Percent-encodes every byte outside `[a-z0-9._-]`, which keeps the mapping +/// reversible and, more importantly, **collision-free**. A lossy scheme that +/// folded unsafe characters to `-` would map `café` and `cafe-` onto one file, +/// and the second session to use that name would silently resume the first +/// one's conversation. +/// +/// Uppercase letters are encoded rather than lowercased because macOS and +/// Windows are case-insensitive: leaving them intact would let `Chat` and `chat` +/// collide on exactly the platforms this crate targets. `%` is itself always +/// encoded, so an escape marker is unambiguous and no literal character can be +/// mistaken for one. +/// +/// The tradeoff this makes, deliberately: an ASCII name stays readable on disk +/// (`greet-flow.json`), while a non-ASCII one becomes verbose, since every byte +/// outside the safe set costs three characters and a multi-byte character +/// several of those (`日本語` encodes to 27). Readability is a debugging +/// convenience; a collision resumes the wrong conversation. So the scheme keeps +/// names distinguishable first and legible second, and a caller who wants +/// pretty filenames should choose ASCII names. +/// +/// Names too long to encode whole are truncated and disambiguated with a 64-bit +/// FNV-1a of the full input. Note the weaker guarantee there: encoding is +/// injective, but any fixed-width digest of unbounded input cannot be, so two +/// names sharing a 200-character encoded prefix *and* a hash would collide. +/// That needs on the order of 2^32 such names to become likely, which is not a +/// concern for names a host chooses. It is not a cryptographic guarantee: if +/// session names are attacker-controlled, hash them yourself before passing +/// them here. +fn encode_segment(name: &str) -> String { + use std::fmt::Write as _; + + let mut out = String::with_capacity(name.len()); + for byte in name.bytes() { + if byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_' | b'.') + { + out.push(byte as char); + } else { + // Uppercase hex only, so an escape never varies by case either. + let _ = write!(out, "%{byte:02X}"); + } + } + if out.is_empty() { + // Only the empty name reaches here, and it needs a segment no other + // input can produce. A bare `%` qualifies: every literal `%` escapes to + // `%25`, so no non-empty name ever encodes to it. Mapping empty to a + // word like "unnamed" would collide with the literal name `unnamed`. + return "%".into(); + } + if out.len() > MAX_STEM { + // Cut between encoded units so a half-written `%4` is never emitted. + let mut cut = MAX_STEM; + while cut > 0 && !is_encoding_boundary(&out, cut) { + cut -= 1; + } + return format!("{}-{:016x}", &out[..cut], fnv1a(name.as_bytes())); + } + out +} + +/// Whether `at` splits `s` between encoded units rather than inside a `%XX`. +fn is_encoding_boundary(s: &str, at: usize) -> bool { + let b = s.as_bytes(); + !((at >= 1 && b[at - 1] == b'%') || (at >= 2 && b[at - 2] == b'%')) +} + +/// FNV-1a, 64-bit. Chosen over [`std::hash::DefaultHasher`], whose algorithm is +/// explicitly allowed to change between Rust releases: that would silently +/// repoint every stored session on a toolchain upgrade. This is fixed forever. +/// It is not cryptographic and does not need to be, since it only disambiguates +/// names the caller chose. +fn fnv1a(bytes: &[u8]) -> u64 { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + hash +} + +/// A project path reduced to one path segment, so sessions partition by project +/// without nesting the whole absolute path. +fn project_slug(project: &Path) -> String { + encode_segment(&project.display().to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A store in a unique temp directory, plus the project path to use. + fn store(tag: &str) -> (SessionStore, PathBuf) { + let dir = std::env::temp_dir().join(format!( + "agent-abstraction-{tag}-{}-{}", + std::process::id(), + now_secs() + )); + (SessionStore::open(dir), PathBuf::from("/home/me/proj")) + } + + #[test] + fn names_and_projects_reduce_to_one_safe_segment() { + // Readable names pass through untouched, which is the point of encoding + // only what has to be encoded. + assert_eq!(encode_segment("greet-flow"), "greet-flow"); + assert_eq!(encode_segment("v1.2_final"), "v1.2_final"); + // The empty name gets a segment no other input can produce. Mapping it + // to a word would collide with a caller who literally used that word. + assert_eq!(encode_segment(""), "%"); + assert_ne!(encode_segment(""), encode_segment("unnamed")); + + // `.` stays literal for readability, so traversal safety rests entirely + // on the separator being encoded. A `..` embedded in a segment is inert. + for name in ["../../etc/passwd", "..", ".", "a/b", "a\\b"] { + let encoded = encode_segment(name); + assert!(!encoded.contains('/'), "{name:?} kept a separator"); + assert!(!encoded.contains('\\'), "{name:?} kept a separator"); + assert!( + Path::new(&encoded).components().count() == 1, + "{name:?} encoded to more than one component" + ); + } + assert!(!project_slug(Path::new("/home/me/My Proj")).contains('/')); + } + + /// The property that matters: distinct names never share a file. The old + /// fold-to-dash scheme mapped `café` and `cafe-` together, so the second + /// session to use that name silently resumed the first one's conversation. + #[test] + fn distinct_names_never_share_an_encoded_segment() { + let names = [ + "café", + "cafe-", + "cafe", + "Chat", + "chat", + "CHAT", + "a/b", + "a-b", + "a b", + "..", + "%41", + "A", + "", + "unnamed", + "日本語", + "🙂", + ]; + let mut seen = std::collections::HashMap::new(); + for name in names { + // Compare case-insensitively: macOS and Windows would treat two + // segments differing only by case as the same file. + let key = encode_segment(name).to_ascii_lowercase(); + if let Some(previous) = seen.insert(key.clone(), name) { + panic!("{name:?} and {previous:?} both encode to {key:?}"); + } + } + } + + #[test] + fn a_very_long_name_stays_within_filename_limits_and_stays_unique() { + let a = "x".repeat(5_000); + let b = format!("{a}different"); + let (ea, eb) = (encode_segment(&a), encode_segment(&b)); + + // Room for the `.json` extension under a 255-byte filename cap. + assert!(ea.len() < 250, "{}", ea.len()); + assert!(eb.len() < 250); + assert_ne!(ea, eb, "truncation must not collapse distinct names"); + } + + #[test] + fn truncation_never_splits_an_escape_sequence() { + // All-uppercase encodes to three bytes per character, forcing the cut. + let encoded = encode_segment(&"A".repeat(2_000)); + let stem = encoded.rsplit_once('-').unwrap().0; + // Every `%` in the stem must still be followed by two hex digits. + for (i, _) in stem.match_indices('%') { + assert!(i + 2 < stem.len(), "escape split at {i} in {stem:?}"); + } + } + + /// The record keeps the caller's name verbatim, so a listing can hand back + /// what they actually passed rather than a mangled path segment. + #[test] + fn the_record_preserves_the_original_name() { + let (store, project) = store("original-name"); + store + .bind(Agent::Claude, &project, "Greet Flow ☕", "t-1") + .unwrap(); + let record = store.get(&project, "Greet Flow ☕").unwrap().unwrap(); + assert_eq!(record.name, "Greet Flow ☕"); + assert_eq!(store.list(&project).unwrap()[0].name, "Greet Flow ☕"); + fs::remove_dir_all(&store.dir).ok(); + } + + #[test] + fn a_path_traversing_name_cannot_escape_the_store() { + let (store, project) = store("escape"); + for name in ["../../etc/passwd", "..", "/etc/passwd", "a/../../b"] { + let path = store.path_of(&project, name); + assert!(path.starts_with(&store.dir), "{name:?} escaped to {path:?}"); + // The whole name has to land in exactly one filename, so no part of + // it can be reinterpreted as a directory step. + assert_eq!( + path.strip_prefix(&store.dir).unwrap().components().count(), + 2, + "{name:?} produced extra path components: {path:?}" + ); + } + } + + #[test] + fn a_missing_session_plans_a_create() { + let (store, project) = store("create"); + let (phase, cont) = store.plan(Agent::Claude, &project, "chat", false).unwrap(); + assert_eq!(phase, Phase::Create); + // Claude mints, so the id exists before the process does. + let Continue::NewWith(id) = cont else { + panic!("a minting agent must allocate an id up front, got {cont:?}") + }; + assert!(Uuid::parse_str(&id).is_ok(), "{id} must be a UUID"); + } + + #[test] + fn a_printing_agent_starts_without_an_id() { + let (store, project) = store("printed"); + let (phase, cont) = store.plan(Agent::Codex, &project, "chat", false).unwrap(); + assert_eq!(phase, Phase::Create); + assert_eq!(cont, Continue::New, "codex's id only exists once printed"); + } + + #[test] + fn a_bound_session_plans_a_continue_and_survives_a_round_trip() { + let (store, project) = store("continue"); + store + .bind(Agent::Claude, &project, "chat", "sess-1") + .unwrap(); + + let (phase, cont) = store.plan(Agent::Claude, &project, "chat", false).unwrap(); + assert_eq!(phase, Phase::Continue); + assert_eq!(cont, Continue::Resume("sess-1".into())); + + let record = store.get(&project, "chat").unwrap().unwrap(); + assert_eq!(record.token, "sess-1"); + assert_eq!(record.agent, Agent::Claude); + fs::remove_dir_all(&store.dir).ok(); + } + + #[test] + fn rebinding_refreshes_the_token_but_keeps_the_creation_time() { + let (store, project) = store("rebind"); + let first = store + .bind(Agent::Claude, &project, "chat", "sess-1") + .unwrap(); + let second = store + .bind(Agent::Claude, &project, "chat", "sess-2") + .unwrap(); + assert_eq!(second.token, "sess-2"); + assert_eq!(second.created, first.created); + assert!(second.updated >= first.updated); + fs::remove_dir_all(&store.dir).ok(); + } + + #[test] + fn a_session_cannot_migrate_between_agents() { + let (store, project) = store("conflict"); + store + .bind(Agent::Claude, &project, "chat", "sess-1") + .unwrap(); + let err = store + .plan(Agent::Codex, &project, "chat", false) + .unwrap_err(); + assert!( + matches!(err, Error::SessionConflict { bound, requested, .. } + if bound == Agent::Claude && requested == Agent::Codex), + "got {err:?}" + ); + fs::remove_dir_all(&store.dir).ok(); + } + + #[test] + fn forking_is_refused_by_agents_that_cannot_fork() { + let (store, project) = store("fork"); + store.bind(Agent::Codex, &project, "chat", "t-1").unwrap(); + assert!(matches!( + store.plan(Agent::Codex, &project, "chat", true), + Err(Error::Unsupported { .. }) + )); + + store.bind(Agent::Claude, &project, "c2", "sess-1").unwrap(); + let (phase, cont) = store.plan(Agent::Claude, &project, "c2", true).unwrap(); + assert_eq!(phase, Phase::Fork); + assert_eq!(cont, Continue::Fork("sess-1".into())); + fs::remove_dir_all(&store.dir).ok(); + } + + #[test] + fn forking_a_session_that_does_not_exist_yet_just_creates_one() { + let (store, project) = store("fork-new"); + let (phase, _) = store.plan(Agent::Claude, &project, "fresh", true).unwrap(); + assert_eq!(phase, Phase::Create, "nothing to branch from yet"); + } + + #[test] + fn a_corrupt_record_is_reported_rather_than_silently_ignored() { + let (store, project) = store("corrupt"); + let path = store.path_of(&project, "chat"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, b"{ not json").unwrap(); + // Treating this as "no session" would silently abandon a conversation + // the caller believes they are still in. + assert!(matches!( + store.get(&project, "chat"), + Err(Error::Store { .. }) + )); + assert!(matches!( + store.plan(Agent::Claude, &project, "chat", false), + Err(Error::Store { .. }) + )); + fs::remove_dir_all(&store.dir).ok(); + } + + #[test] + fn sessions_list_per_project_and_forgetting_is_idempotent() { + let (store, project) = store("list"); + store.bind(Agent::Claude, &project, "a", "t-a").unwrap(); + store.bind(Agent::Claude, &project, "b", "t-b").unwrap(); + let mut names: Vec<_> = store + .list(&project) + .unwrap() + .into_iter() + .map(|r| r.name) + .collect(); + names.sort(); + assert_eq!(names, ["a", "b"]); + + store.forget(&project, "a").unwrap(); + assert!(store.get(&project, "a").unwrap().is_none()); + // Forgetting twice is not an error. + store.forget(&project, "a").unwrap(); + assert_eq!(store.list(&project).unwrap().len(), 1); + fs::remove_dir_all(&store.dir).ok(); + } + + #[test] + fn the_same_name_in_two_projects_does_not_collide() { + let (store, project) = store("projects"); + let other = PathBuf::from("/home/me/other"); + store.bind(Agent::Claude, &project, "chat", "t-1").unwrap(); + store.bind(Agent::Claude, &other, "chat", "t-2").unwrap(); + assert_eq!(store.get(&project, "chat").unwrap().unwrap().token, "t-1"); + assert_eq!(store.get(&other, "chat").unwrap().unwrap().token, "t-2"); + fs::remove_dir_all(&store.dir).ok(); + } +} diff --git a/tests/live.rs b/tests/live.rs new file mode 100644 index 0000000..156afb6 --- /dev/null +++ b/tests/live.rs @@ -0,0 +1,449 @@ +//! End-to-end tests against the real agent CLIs. +//! +//! Ignored by default: they spawn actual agents, consume real quota, and need +//! each CLI installed and authenticated. Run them deliberately: +//! +//! ```console +//! cargo test --test live -- --ignored --test-threads 1 +//! ``` +//! +//! Each test skips itself when its binary is absent, so running the set on a +//! machine with only one agent installed reports on that one rather than +//! failing on the others. + +use std::time::Duration; + +use agent_abstraction::{ + Agent, EnvPolicy, Event, Format, Permission, Request, SessionStore, run, stream, +}; + +/// A prompt with exactly one correct answer, so the assertion is about the +/// plumbing rather than the model's judgement. +const PING: &str = "Reply with the single word: pong. No punctuation, no explanation."; + +/// Whether the agent's binary is on PATH; tests no-op without it. +fn available(agent: Agent) -> bool { + let found = std::env::var_os("PATH").is_some_and(|paths| { + std::env::split_paths(&paths).any(|dir| dir.join(agent.bin()).is_file()) + }); + if !found { + eprintln!("skipping: `{}` is not installed", agent.bin()); + } + found +} + +/// A request that keeps the run cheap, sandboxed and bounded. +fn ping(agent: Agent) -> Request { + let request = Request::new(agent, PING) + .permission(Permission::ReadOnly) + .timeout(Duration::from_secs(180)); + match agent { + // The cheapest model on each side; Codex and Copilot pick their own. + Agent::Claude => request.model("haiku"), + Agent::Codex | Agent::Copilot => request, + } +} + +#[tokio::test] +#[ignore = "spawns a real agent and consumes quota"] +async fn claude_answers_and_reports_usage() { + if !available(Agent::Claude) { + return; + } + let outcome = run(&ping(Agent::Claude)).await.expect("claude run failed"); + + assert!(outcome.is_ok(), "unexpected stop: {outcome:?}"); + assert_eq!(outcome.text.trim().to_lowercase(), "pong"); + assert!(outcome.session.is_some(), "claude must report a session id"); + // Claude prices its own runs, so both tokens and cost should be present. + assert!(outcome.usage.output_tokens.is_some(), "{:?}", outcome.usage); + assert!(outcome.usage.cost_usd.is_some(), "{:?}", outcome.usage); +} + +#[tokio::test] +#[ignore = "spawns a real agent and consumes quota"] +async fn codex_answers_and_reports_usage() { + if !available(Agent::Codex) { + return; + } + let outcome = run(&ping(Agent::Codex)).await.expect("codex run failed"); + + assert!(outcome.is_ok(), "unexpected stop: {outcome:?}"); + assert!( + outcome.text.trim().to_lowercase().contains("pong"), + "got {:?}", + outcome.text + ); + assert!(outcome.session.is_some(), "codex must report a thread id"); + assert!(outcome.usage.input_tokens.is_some(), "{:?}", outcome.usage); +} + +#[tokio::test] +#[ignore = "spawns a real agent and consumes quota"] +async fn copilot_answers_and_reports_a_session() { + if !available(Agent::Copilot) { + return; + } + let outcome = run(&ping(Agent::Copilot)) + .await + .expect("copilot run failed"); + + assert!(outcome.is_ok(), "unexpected stop: {outcome:?}"); + assert!( + outcome.text.trim().to_lowercase().contains("pong"), + "got {:?}", + outcome.text + ); + assert!( + outcome.session.is_some(), + "copilot must report a session id" + ); +} + +/// `codex exec` aborts outside a git repository unless the check is waived. +/// This crate waives it on every invocation, so a run from a scratch directory +/// must still work. Running the rest of the suite from the repo would never +/// catch a regression here, because the repo *is* a git checkout. +#[tokio::test] +#[ignore = "spawns a real agent and consumes quota"] +async fn codex_runs_outside_a_git_repository() { + if !available(Agent::Codex) { + return; + } + let scratch = std::env::temp_dir().join(format!("aa-nogit-{}", std::process::id())); + std::fs::create_dir_all(&scratch).unwrap(); + assert!( + !scratch.join(".git").exists(), + "the point of this test is that it is not a repo" + ); + + let outcome = run(&ping(Agent::Codex).cwd(&scratch)) + .await + .expect("codex refused to run outside a git repo"); + + assert!(outcome.is_ok(), "unexpected stop: {outcome:?}"); + assert!( + outcome.text.trim().to_lowercase().contains("pong"), + "got {:?}", + outcome.text + ); + + std::fs::remove_dir_all(&scratch).ok(); +} + +/// Claude and Copilot let the caller *assign* the session id up front. That is +/// only worth relying on if the agent actually honours the id we hand it, so +/// this asserts the round trip rather than trusting `--help`: the id we chose +/// must come back unchanged, and must then be resumable. +#[tokio::test] +#[ignore = "spawns a real agent and consumes quota"] +async fn a_caller_assigned_session_id_is_honoured_and_resumable() { + for agent in [Agent::Claude, Agent::Copilot] { + if !available(agent) { + continue; + } + // Both CLIs require a valid UUID. + let chosen = uuid::Uuid::new_v4().to_string(); + + let first = run(&ping(agent).session_id(&chosen)) + .await + .unwrap_or_else(|e| panic!("{agent} rejected an assigned id: {e}")); + assert_eq!( + first.session.as_deref(), + Some(chosen.as_str()), + "{agent} did not honour the id it was given" + ); + + // The id is only useful if it also resumes the same conversation. + let second = run(&ping(agent).resume(&chosen)) + .await + .unwrap_or_else(|e| panic!("{agent} could not resume the assigned id: {e}")); + assert_eq!( + second.session.as_deref(), + Some(chosen.as_str()), + "{agent} moved to a different session on resume" + ); + } +} + +/// Codex cannot be told an id: `codex exec` has no `--session-id`, so the only +/// way to learn its `thread_id` is to read it back. Asking for an assigned one +/// must fail loudly rather than silently starting an unrelated conversation. +#[tokio::test] +async fn codex_refuses_an_assigned_session_id() { + let err = Request::new(Agent::Codex, "hi") + .session_id("11111111-2222-3333-4444-555555555555") + .argv() + .unwrap_err(); + assert!( + matches!(err, agent_abstraction::Error::Unsupported { .. }), + "got {err:?}" + ); +} + +/// Codex reports its `thread_id` in `thread.started`, which is the very first +/// record of the stream and arrives *before* the model replies. A host can +/// therefore persist the binding as soon as the stream opens rather than +/// waiting for the turn to finish. +#[tokio::test] +#[ignore = "spawns a real agent and consumes quota"] +async fn codex_reveals_its_thread_id_before_it_answers() { + if !available(Agent::Codex) { + return; + } + let mut running = stream(&ping(Agent::Codex).format(Format::Stream)).expect("spawn failed"); + + let mut first_event = None; + let mut text_seen_before_start = false; + while let Some(event) = running.recv().await { + match (&first_event, &event) { + (None, Event::Started { session, .. }) => { + assert!(!session.is_empty()); + first_event = Some(session.clone()); + } + (None, Event::Text(_)) => text_seen_before_start = true, + _ => {} + } + } + let outcome = running.finish().await.expect("run failed"); + + assert!( + first_event.is_some(), + "codex never announced a thread id on the stream" + ); + assert!( + !text_seen_before_start, + "the id must arrive before any answer text, so a binding can be stored early" + ); + assert_eq!(outcome.session, first_event); +} + +/// `EnvPolicy::Minimal` only earns its place if a run under it still works. +/// An isolation setting that silently breaks authentication is worse than none, +/// because the failure surfaces as "not logged in" rather than as a config +/// mistake. This is the test that keeps the per-agent list honest. +#[tokio::test] +#[ignore = "spawns a real agent and consumes quota"] +async fn every_agent_still_works_under_a_minimal_environment() { + for agent in Agent::ALL { + if !available(agent) { + continue; + } + let outcome = run(&ping(agent).env_policy(EnvPolicy::Minimal)) + .await + .unwrap_or_else(|e| { + panic!( + "{agent} could not authenticate under EnvPolicy::Minimal, \ + so its essential_env list is incomplete: {e}" + ) + }); + assert!( + outcome.text.trim().to_lowercase().contains("pong"), + "{agent} answered {:?}", + outcome.text + ); + } +} + +/// Codex cannot be *told* a session id, so continuity depends entirely on +/// reading its `thread_id` back off the stream and storing it ourselves. +/// +/// This is the test that proves that path works end to end, with no reading of +/// `$CODEX_HOME/sessions/`: turn one states a fact, the id is captured and +/// persisted, and a second run in a separate process resumes from the store and +/// recalls it. If Codex ever stopped emitting `thread.started`, this fails. +#[tokio::test] +#[ignore = "spawns a real agent and consumes quota"] +async fn codex_resumes_from_a_captured_thread_id_without_scraping() { + if !available(Agent::Codex) { + return; + } + let dir = std::env::temp_dir().join(format!("aa-codex-resume-{}", std::process::id())); + let store = SessionStore::open(&dir); + let project = std::env::current_dir().unwrap(); + let name = "codex-memory"; + + let first = Request::new(Agent::Codex, "Remember the number 5619. Reply OK.") + .permission(Permission::ReadOnly) + .timeout(Duration::from_secs(180)) + .session(&store, &project, name, false) + .expect("planning the first turn failed"); + let first = run(&first).await.expect("first turn failed"); + let thread = first + .session + .clone() + .expect("codex must report a thread id on the stream"); + + // The binding must be on disk, since that is the only place the id exists + // for us: nothing reads Codex's own session directory. + let stored = store + .get(&project, name) + .expect("store read failed") + .expect("no binding was persisted"); + assert_eq!(stored.token, thread); + assert_eq!(stored.agent, Agent::Codex); + + let second = Request::new(Agent::Codex, "What number did I ask you to remember?") + .permission(Permission::ReadOnly) + .timeout(Duration::from_secs(180)) + .session(&store, &project, name, false) + .expect("planning the second turn failed"); + assert_eq!( + second.session_phase(), + Some(agent_abstraction::Phase::Continue), + "the second turn must continue the stored thread" + ); + let second = run(&second).await.expect("second turn failed"); + + assert!( + second.text.contains("5619"), + "codex lost its context on resume: {:?}", + second.text + ); + + std::fs::remove_dir_all(&dir).ok(); +} + +/// The streaming path must deliver events *before* the run settles, and the +/// terminal answer must still be authoritative afterwards. +#[tokio::test] +#[ignore = "spawns a real agent and consumes quota"] +async fn streaming_delivers_events_then_an_outcome() { + if !available(Agent::Claude) { + return; + } + let request = ping(Agent::Claude).format(Format::Stream); + let mut running = stream(&request).expect("spawn failed"); + + let mut started = false; + let mut text = String::new(); + while let Some(event) = running.recv().await { + match event { + Event::Started { session, .. } => { + assert!(!session.is_empty()); + started = true; + } + Event::Text(chunk) => text.push_str(&chunk), + _ => {} + } + } + let outcome = running.finish().await.expect("run failed"); + + assert!(started, "the stream must announce the session"); + assert!(text.to_lowercase().contains("pong"), "streamed {text:?}"); + assert_eq!(outcome.text.trim().to_lowercase(), "pong"); +} + +/// The point of the whole session layer: a second turn on the same name must +/// see what the first turn was told, without the caller handling any id. +#[tokio::test] +#[ignore = "spawns a real agent and consumes quota"] +async fn a_named_session_carries_context_across_turns() { + if !available(Agent::Claude) { + return; + } + let dir = std::env::temp_dir().join(format!("aa-live-{}", std::process::id())); + let store = SessionStore::open(&dir); + let project = std::env::current_dir().unwrap(); + let name = "live-memory"; + + let first = Request::new(Agent::Claude, "Remember the number 4271. Reply OK.") + .model("haiku") + .permission(Permission::ReadOnly) + .timeout(Duration::from_secs(180)) + .session(&store, &project, name, false) + .expect("planning the first turn failed"); + assert_eq!( + first.session_phase(), + Some(agent_abstraction::Phase::Create) + ); + let first = run(&first).await.expect("first turn failed"); + let session = first.session.clone().expect("no session id captured"); + + let second = Request::new(Agent::Claude, "What number did I ask you to remember?") + .model("haiku") + .permission(Permission::ReadOnly) + .timeout(Duration::from_secs(180)) + .session(&store, &project, name, false) + .expect("planning the second turn failed"); + assert_eq!( + second.session_phase(), + Some(agent_abstraction::Phase::Continue), + "the second turn must continue, not create" + ); + let second = run(&second).await.expect("second turn failed"); + + assert!( + second.text.contains("4271"), + "the resumed turn lost its context: {:?}", + second.text + ); + assert_eq!( + second.session.as_deref(), + Some(session.as_str()), + "a linear resume must stay on the same session" + ); + + std::fs::remove_dir_all(&dir).ok(); +} + +/// Forking must branch: the new turn sees the parent's context but lands on a +/// different session id, leaving the original resumable. +#[tokio::test] +#[ignore = "spawns a real agent and consumes quota"] +async fn forking_branches_to_a_new_session() { + if !available(Agent::Claude) { + return; + } + let dir = std::env::temp_dir().join(format!("aa-fork-{}", std::process::id())); + let store = SessionStore::open(&dir); + let project = std::env::current_dir().unwrap(); + let name = "live-fork"; + + let first = Request::new(Agent::Claude, "Remember the number 8813. Reply OK.") + .model("haiku") + .timeout(Duration::from_secs(180)) + .session(&store, &project, name, false) + .unwrap(); + let parent = run(&first).await.expect("first turn failed"); + let parent_id = parent.session.expect("no session id"); + + let forked = Request::new(Agent::Claude, "What number did I ask you to remember?") + .model("haiku") + .timeout(Duration::from_secs(180)) + .session(&store, &project, name, true) + .unwrap(); + assert_eq!(forked.session_phase(), Some(agent_abstraction::Phase::Fork)); + let forked = run(&forked).await.expect("forked turn failed"); + + assert!( + forked.text.contains("8813"), + "the fork lost the parent's context: {:?}", + forked.text + ); + assert_ne!( + forked.session.as_deref(), + Some(parent_id.as_str()), + "a fork must land on a new session id, not append to the parent" + ); + + std::fs::remove_dir_all(&dir).ok(); +} + +/// A missing binary must be an actionable error, not a spawn failure. +#[tokio::test] +async fn a_missing_agent_reports_how_to_install_it() { + let request = Request::new(Agent::Codex, "hi").bin("agent-abstraction-no-such-binary"); + let err = run(&request).await.unwrap_err(); + // Assert on the structured field rather than the rendered message: the + // wording is free to change, the contract that an install hint is carried + // at all is not. + let agent_abstraction::Error::NotInstalled { agent, hint, .. } = &err else { + panic!("expected NotInstalled, got {err:?}") + }; + assert_eq!(*agent, Agent::Codex); + assert!( + !hint.is_empty(), + "a missing agent must say how to install it" + ); +} diff --git a/tests/process.rs b/tests/process.rs new file mode 100644 index 0000000..4c2511e --- /dev/null +++ b/tests/process.rs @@ -0,0 +1,278 @@ +//! Process lifecycle: cancelling a run must actually stop the work. +//! +//! These use a shell script as a stand-in agent rather than a real CLI, so they +//! are deterministic, spend no quota, and run in CI. The script ignores the argv +//! entirely, which is fine because what is under test is the process handling, +//! not the flag mapping. +//! +//! Unix only: containing a process tree on Windows needs a Job Object, which +//! this crate does not set up yet. + +#![cfg(unix)] + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use agent_abstraction::{Agent, EnvPolicy, Request, stream}; + +/// A scratch directory unique to one test. +fn scratch(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("aa-proc-{tag}-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// Write an executable script that spawns a **grandchild** and then waits. +/// +/// The grandchild is the point: killing only the direct child would leave it +/// running, which is precisely the leak being tested for. +fn fake_agent(dir: &Path) -> PathBuf { + let script = dir.join("agent.sh"); + let pidfile = dir.join("grandchild.pid"); + std::fs::write( + &script, + format!( + "#!/bin/sh\n\ + # A long-running helper, as an agent's shell tool would spawn.\n\ + sh -c 'echo $$ > {pid}; sleep 120' &\n\ + # Emit something so the reader has work to do, then outlive it.\n\ + echo '{{\"type\":\"system\",\"session_id\":\"s\"}}'\n\ + sleep 120\n", + pid = pidfile.display() + ), + ) + .unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + script +} + +/// Whether a pid is still alive, via a null signal. +fn alive(pid: i32) -> bool { + // `kill -0` reports liveness without actually signalling. + std::process::Command::new("kill") + .args(["-0", &pid.to_string()]) + .stderr(std::process::Stdio::null()) + .status() + .is_ok_and(|s| s.success()) +} + +/// Wait for the grandchild to record its pid, then return it. +async fn grandchild_pid(dir: &Path) -> i32 { + let pidfile = dir.join("grandchild.pid"); + for _ in 0..100 { + if let Ok(text) = std::fs::read_to_string(&pidfile) { + if let Ok(pid) = text.trim().parse::() { + return pid; + } + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + panic!("the fake agent never spawned its grandchild"); +} + +/// Give the OS a moment to reap after a kill. +async fn settle() { + tokio::time::sleep(Duration::from_millis(300)).await; +} + +/// The default that matters for a GUI: closing a window must stop the agent, +/// not leave it running invisibly and spending quota. +#[tokio::test] +async fn dropping_a_run_kills_the_agent_and_its_children() { + let dir = scratch("drop"); + let script = fake_agent(&dir); + + let running = stream(&Request::new(Agent::Claude, "hi").bin(script.to_str().unwrap())) + .expect("spawn failed"); + let grandchild = grandchild_pid(&dir).await; + assert!(alive(grandchild), "the grandchild should be running"); + + drop(running); + settle().await; + + assert!( + !alive(grandchild), + "dropping the run left a grandchild ({grandchild}) alive; \ + killing only the CLI orphans whatever it spawned" + ); + std::fs::remove_dir_all(&dir).ok(); +} + +/// `cancel` is the deterministic form: when it returns, the tree is gone. +#[tokio::test] +async fn cancel_stops_the_whole_tree_before_returning() { + let dir = scratch("cancel"); + let script = fake_agent(&dir); + + let running = stream(&Request::new(Agent::Claude, "hi").bin(script.to_str().unwrap())) + .expect("spawn failed"); + let grandchild = grandchild_pid(&dir).await; + + let err = running.cancel().await.unwrap_err(); + assert!(err.is_cancelled(), "cancel should report itself: {err:?}"); + + // No settle(): cooperative cancellation must have reaped the tree before + // returning, so the check is immediate rather than after a grace period. + assert!( + !alive(grandchild), + "cancel returned while a grandchild was still alive, so it is not \ + awaiting its own cleanup" + ); + std::fs::remove_dir_all(&dir).ok(); +} + +/// A timeout must contain the tree too, not just the process it timed out. +#[tokio::test] +async fn a_timed_out_run_kills_its_children() { + let dir = scratch("timeout"); + let script = fake_agent(&dir); + + let request = Request::new(Agent::Claude, "hi") + .bin(script.to_str().unwrap()) + .timeout(Duration::from_secs(3)); + let running = stream(&request).expect("spawn failed"); + let grandchild = grandchild_pid(&dir).await; + + let err = running.finish().await.unwrap_err(); + assert!( + matches!(err, agent_abstraction::Error::Timeout { .. }), + "got {err:?}" + ); + settle().await; + + assert!(!alive(grandchild), "the timeout left a grandchild alive"); + std::fs::remove_dir_all(&dir).ok(); +} + +/// The opt-out still works: an explicitly detached run survives its handle. +#[tokio::test] +async fn detach_lets_a_run_outlive_its_handle() { + let dir = scratch("detach"); + let script = fake_agent(&dir); + + let running = stream(&Request::new(Agent::Claude, "hi").bin(script.to_str().unwrap())) + .expect("spawn failed"); + let grandchild = grandchild_pid(&dir).await; + + running.detach(); + settle().await; + + assert!( + alive(grandchild), + "detach must not kill the run; that is the whole point of it" + ); + // Do not leave it behind for the rest of the suite. + let _ = std::process::Command::new("kill") + .args(["-9", &grandchild.to_string()]) + .status(); + std::fs::remove_dir_all(&dir).ok(); +} + +/// Write a script that dumps its own environment, as a stand-in for an agent +/// (or any command an agent runs) observing what it inherited. +fn env_dumping_agent(dir: &Path) -> PathBuf { + use std::os::unix::fs::PermissionsExt as _; + + let script = dir.join("dump-env.sh"); + std::fs::write(&script, "#!/bin/sh\nenv\n").unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700)).unwrap(); + script +} + +/// Collect everything the fake agent printed. +async fn captured_env(request: &Request) -> String { + let mut running = stream(request).expect("spawn failed"); + let mut seen = String::new(); + while let Some(event) = running.recv().await { + if let agent_abstraction::Event::Text(line) = event { + seen.push_str(&line); + seen.push('\n'); + } + } + let _ = running.finish().await; + seen +} + +/// `EnvPolicy::Minimal` has to actually withhold the host's environment. +/// +/// Cargo injects a pile of `CARGO_*` variables into this test process, which +/// stand in for the unrelated secrets a Tauri or server host would be holding. +/// Under `Inherit` they reach the agent; under `Minimal` they must not. +#[tokio::test] +async fn a_minimal_environment_withholds_the_hosts_variables() { + let dir = scratch("env"); + let script = env_dumping_agent(&dir); + let base = || { + Request::new(Agent::Claude, "hi") + .bin(script.to_str().unwrap()) + .format(agent_abstraction::Format::Text) + }; + + let inherited = captured_env(&base().env_policy(EnvPolicy::Inherit)).await; + assert!( + inherited.contains("CARGO"), + "the control case is broken: Inherit should pass the host environment" + ); + + // No explicit policy: Minimal is the default, which is the property under + // test as much as the filtering itself. + let minimal = captured_env(&base()).await; + assert!( + !minimal.contains("CARGO"), + "host variables leaked under EnvPolicy::Minimal:\n{minimal}" + ); + // ...while still passing what the agent needs to work at all. + assert!(minimal.contains("PATH="), "PATH must survive:\n{minimal}"); + assert!(minimal.contains("HOME="), "HOME must survive:\n{minimal}"); + + // An explicit variable always wins over the policy. + let explicit = captured_env( + &base() + .env_policy(EnvPolicy::Minimal) + .env("AA_EXPLICIT", "kept"), + ) + .await; + assert!(explicit.contains("AA_EXPLICIT=kept"), "{explicit}"); + + std::fs::remove_dir_all(&dir).ok(); +} + +/// A line with no newline must not be buffered without limit. `lines()` would +/// accumulate the whole thing, so a stream that never emits `\n` could exhaust +/// memory long before any total cap applied. +#[tokio::test] +async fn an_endless_line_does_not_exhaust_memory() { + use std::os::unix::fs::PermissionsExt as _; + + let dir = scratch("longline"); + let script = dir.join("flood.sh"); + // 64 MiB on a single line, no trailing newline until the very end. + std::fs::write( + &script, + "#!/bin/sh\nawk 'BEGIN{for(i=0;i<1000000;i++)printf \"%s\", \"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\"; print \"\"}'\n", + ) + .unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700)).unwrap(); + + let request = Request::new(Agent::Claude, "hi") + .bin(script.to_str().unwrap()) + .format(agent_abstraction::Format::Text) + .timeout(Duration::from_secs(60)); + let outcome = stream(&request) + .expect("spawn failed") + .finish() + .await + .expect("run failed"); + + // Whatever is kept must respect the cap rather than the 64 MiB produced. + assert!( + outcome.text.len() <= agent_abstraction::MAX_CAPTURE, + "kept {} bytes, over the cap", + outcome.text.len() + ); + std::fs::remove_dir_all(&dir).ok(); +}