From 36dec668480073231cef759499b8860380d0918e Mon Sep 17 00:00:00 2001 From: meh Date: Tue, 28 Jul 2026 23:22:58 +0700 Subject: [PATCH 01/12] Port oneharness to a pure-Rust embeddable crate Replaces the multi-language oneharness stack with a single Rust library, `agent-abstraction`, covering Claude Code, Codex and GitHub Copilot. The Python SDK, the TypeScript/npm SDK and the JSON-Schema codegen that fed both are dropped rather than ported: they existed only so non-Rust callers could shell out to the `oneharness` binary and re-validate its JSON. For a Rust consumer that layer collapses into the public API. The CLI and the 39 shell scripts go with them, along with five harnesses we do not use. The crate is async over tokio so a Tauri front end can render a run as it happens, and exposes: - one `Request` builder and one `Permission` posture mapped onto each CLI's own vocabulary - one `Event` vocabulary normalized from three different JSON dialects - a session store binding a caller-owned name to each agent's native id, with resume and fork Every flag mapping and output shape is verified against the installed CLIs (claude 2.1.205, codex-cli 0.145.0, Copilot CLI 1.0.75) rather than inherited. That turned up several places where upstream is now stale, notably Copilot, which oneharness models as having no headless session id and no event stream; 1.0.75 has both, and its tool filters only bind with `=`. Capability gaps are loud: asking an agent to fork when it cannot is an `Error::Unsupported`, never a silent linear resume. Quota refusals surface as `Error::RateLimited` and are never retried internally. Covered by 55 unit tests plus a live suite (ignored by default) that drives the real agents end to end: answer, usage, streaming, multi-turn memory and forking. --- .gitignore | 2 + AGENTS.md | 117 ++++++ CLAUDE.md | 15 + Cargo.toml | 47 +++ LICENSE | 26 ++ README.md | 165 +++++++- docs/operating-limits.md | 49 +++ src/agent.rs | 708 +++++++++++++++++++++++++++++++++++ src/error.rs | 138 +++++++ src/event.rs | 789 +++++++++++++++++++++++++++++++++++++++ src/lib.rs | 102 +++++ src/outcome.rs | 102 +++++ src/request.rs | 303 +++++++++++++++ src/run.rs | 430 +++++++++++++++++++++ src/session.rs | 440 ++++++++++++++++++++++ tests/live.rs | 237 ++++++++++++ 16 files changed, 3669 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 docs/operating-limits.md create mode 100644 src/agent.rs create mode 100644 src/error.rs create mode 100644 src/event.rs create mode 100644 src/lib.rs create mode 100644 src/outcome.rs create mode 100644 src/request.rs create mode 100644 src/run.rs create mode 100644 src/session.rs create mode 100644 tests/live.rs 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..8798d47 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,117 @@ +# 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. + +## 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/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..0db06a4 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "agent-abstraction" +version = "0.1.0" +edition = "2024" +rust-version = "1.85" +description = "Drive Claude Code, Codex and Copilot CLIs headlessly from Rust, as a library." +license = "MIT" +repository = "https://github.com/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. +which = "8" + +[dev-dependencies] +tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } + +[lints.rust] +missing_docs = "warn" +unsafe_code = "forbid" + +[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..4cc6102 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,165 @@ # 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` | + +**Minted beats printed.** Where the caller can assign the session id up front (Claude, +Copilot), the binding is written *before* the process starts, so a run that crashes +mid-turn still leaves a resumable session. Codex only reveals its `thread_id` in its output, +so its binding can only be recorded after a run produces one. + +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. + +Session names are reduced to a single safe path segment, so a name like `../../etc/passwd` +cannot escape the store. + +## 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. + +## Gotchas worth knowing + +- **`codex exec` refuses to run outside a git repository.** Pass + `.args(["--skip-git-repo-check"])` when your working directory may not be one. +- **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`. + +## 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..94f5b72 --- /dev/null +++ b/src/agent.rs @@ -0,0 +1,708 @@ +//! 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)] +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. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Permission { + /// Read and search only; mutating tools are denied. + #[default] + ReadOnly, + /// Plan without executing. + Plan, + /// Allow file edits, but still gate shell commands. + Edit, + /// Allow the agent's own default automation. + Auto, + /// Skip every permission check. For sandboxes. + Bypass, +} + +/// 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 const STDIN_THRESHOLD: usize = 128 * 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", + } + } + + /// 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, + } + } + + /// 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> { + 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()) + } +} + +/// 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 = vec![plan.bin.clone(), "-p".into()]; + 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.push("--input-format".into()); + a.push("text".into()); + } else { + a.push(Agent::Claude.effective_prompt(plan)); + } + + a.push("--permission-mode".into()); + a.push(claude_mode(plan.permission).into()); + if plan.permission == Permission::ReadOnly { + // Remove the mutating tools outright. Reads still run via Read/Grep/Glob. + a.push("--disallowedTools".into()); + for tool in ["Bash", "Edit", "Write", "NotebookEdit"] { + a.push(tool.into()); + } + } + + if let Some(model) = &plan.model { + a.push("--model".into()); + a.push(model.clone()); + } + if let Some(system) = &plan.system { + a.push("--append-system-prompt".into()); + a.push(system.clone()); + } + + match &plan.cont { + Continue::New => {} + Continue::NewWith(id) => { + a.push("--session-id".into()); + a.push(id.clone()); + } + Continue::Resume(id) => { + a.push("--resume".into()); + a.push(id.clone()); + } + Continue::Fork(id) => { + a.push("--resume".into()); + a.push(id.clone()); + // Mints a new id off `id`, leaving the original and its cached + // prefix untouched. The new id comes back in the output. + a.push("--fork-session".into()); + } + } + + a.push("--output-format".into()); + a.push( + match plan.format { + Format::Text => "text", + Format::Json => "json", + Format::Stream => "stream-json", + } + .into(), + ); + if plan.format == Format::Stream { + // Claude refuses `-p --output-format stream-json` without it: + // "--print with --output-format=stream-json requires --verbose". + a.push("--verbose".into()); + } + a +} + +/// `codex exec [resume ] [sandbox flags] [--model M] [--json] ` +fn argv_codex(plan: &Plan) -> Vec { + let mut a = vec![plan.bin.clone(), "exec".into()]; + if let Continue::Resume(id) = &plan.cont { + // Continuation is a subcommand, not a flag. + a.push("resume".into()); + a.push(id.clone()); + } + + match plan.permission { + Permission::Bypass => a.push("--dangerously-bypass-approvals-and-sandbox".into()), + Permission::ReadOnly | Permission::Plan => { + a.push("--sandbox".into()); + a.push("read-only".into()); + } + Permission::Edit | Permission::Auto => { + a.push("--sandbox".into()); + a.push("workspace-write".into()); + } + } + + if let Some(model) = &plan.model { + a.push("--model".into()); + a.push(model.clone()); + } + // `--json` is Codex's event stream and the only place `thread_id` appears. + if plan.format != Format::Text { + a.push("--json".into()); + } + // 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. + a.push(if plan.stdin_prompt { + "-".into() + } else { + Agent::Codex.effective_prompt(plan) + }); + a +} + +/// `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 = if plan.stdin_prompt { + vec![plan.bin.clone()] + } else { + vec![ + plan.bin.clone(), + "-p".into(), + Agent::Copilot.effective_prompt(plan), + ] + }; + + // Without this, a headless run stops at the first tool confirmation. + a.push("--allow-all-tools".into()); + a.push("--no-ask-user".into()); + match plan.permission { + Permission::Bypass | Permission::Auto => a.push("--allow-all-paths".into()), + // Deny beats allow, so this is allow-all minus the mutating tools. + Permission::ReadOnly => { + a.push("--allow-all-paths".into()); + a.push("--deny-tool=shell".into()); + a.push("--deny-tool=write".into()); + } + // Edits run; shell stays denied so commands cannot. + Permission::Edit => { + a.push("--allow-all-paths".into()); + a.push("--deny-tool=shell".into()); + } + Permission::Plan => { + a.push("--mode".into()); + a.push("plan".into()); + } + } + + if let Some(model) = &plan.model { + a.push("--model".into()); + a.push(model.clone()); + } + // 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.push("--session-id".into()); + a.push(id.clone()); + } + // `Fork` is rejected by `Agent::check` before reaching here. + Continue::New | Continue::Fork(_) => {} + } + + a.push("--output-format".into()); + a.push( + if plan.format == Format::Text { + "text" + } else { + "json" + } + .into(), + ); + a +} + +#[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"); + } + + #[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..96fdffc --- /dev/null +++ b/src/error.rs @@ -0,0 +1,138 @@ +//! 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, + }, +} + +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 { .. }) + } +} diff --git a/src/event.rs b/src/event.rs new file mode 100644 index 0000000..a706035 --- /dev/null +++ b/src/event.rs @@ -0,0 +1,789 @@ +//! 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")] +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), +} + +/// 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, +} + +/// Incrementally turns one agent's output into [`Event`]s and a [`Terminal`]. +#[derive(Debug)] +pub 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, +} + +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, + } + } + + /// 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. + 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 { + self.term.text.push_str(line); + self.term.text.push('\n'); + return vec![Event::Text(line.to_string())]; + } + let Ok(value) = serde_json::from_str::(line) else { + return Vec::new(); + }; + 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 + } + + /// 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" => { + 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.tools.insert(id.clone(), name.clone()); + } + 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) + .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.term.usage = codex_usage(v.get("usage")); + Vec::new() + } + "turn.failed" => { + 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 { + 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.tools.insert(id.clone(), name.clone()); + } + 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"), + 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" => { + 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`, which is either a plain string or an array +/// of content blocks. +fn flatten_text(v: Option<&Value>) -> String { + match v { + Some(Value::String(s)) => s.clone(), + Some(Value::Array(blocks)) => blocks + .iter() + .filter_map(|b| b.get("text").and_then(Value::as_str)) + .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 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..3751f47 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,102 @@ +//! 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 request; +mod run; +mod session; + +pub use agent::{Agent, Caps, Continue, Format, Permission, Plan, STDIN_THRESHOLD, SessionSupport}; +pub use error::{Error, Result}; +pub use event::{Event, Parser, Terminal}; +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..e83bf4a --- /dev/null +++ b/src/outcome.rs @@ -0,0 +1,102 @@ +//! 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)] +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)] +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")] +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)] +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. + pub stderr: String, +} + +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 + } +} diff --git a/src/request.rs b/src/request.rs new file mode 100644 index 0000000..e248c4a --- /dev/null +++ b/src/request.rs @@ -0,0 +1,303 @@ +//! Describing a run before it happens. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use crate::agent::{Agent, Continue, Format, 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) 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`], 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(), + 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 + } + + /// 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. For + /// example `codex exec` refuses to run outside a git repository unless given + /// `--skip-git-repo-check`. Arguments are passed straight to the binary + /// without a shell. + #[must_use] + pub fn 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 + } + + /// 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. + /// + /// # 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. + if self.format.is_none() { + self.format = self.agent.session_format(); + } + Ok(self) + } + + /// 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. + #[must_use] + pub 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(), + // A prompt too large for the argv is piped instead, so a long one + // never fails with E2BIG. + stdin_prompt: self.prompt.len() >= 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> { + let mut argv = self.agent.argv(&self.plan())?; + argv.extend(self.extra_args.iter().cloned()); + Ok(argv) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_are_read_only_and_structured() { + let request = Request::new(Agent::Claude, "hi"); + assert_eq!(request.permission, Permission::ReadOnly); + 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::Codex, "hi") + .args(["--skip-git-repo-check"]) + .argv() + .unwrap(); + assert_eq!(argv.last().unwrap(), "--skip-git-repo-check"); + } + + #[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" + ); + } + + #[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..9b05803 --- /dev/null +++ b/src/run.rs @@ -0,0 +1,430 @@ +//! 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::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{Child, Command}; +use tokio::sync::mpsc; + +use crate::agent::Continue; +use crate::error::{Error, Result}; +use crate::event::{Event, Parser, Terminal}; +use crate::outcome::{Outcome, Stop}; +use crate::request::Request; + +/// 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 it detaches the run; the child is not killed. +#[derive(Debug)] +pub struct Run { + events: mpsc::Receiver, + task: tokio::task::JoinHandle>, + 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. + #[must_use] + pub fn argv(&self) -> &[String] { + &self.argv + } + + /// 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() {} + match self.task.await { + Ok(result) => result, + // The driver task itself panicked or was cancelled; there is no + // outcome to report and no useful exit code to invent. + Err(join) => Err(Error::Spawn { + bin: self.argv.first().cloned().unwrap_or_default(), + source: std::io::Error::other(join), + }), + } + } +} + +/// 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 { + let plan = request.plan(); + let argv = request.argv()?; + + // Resolve on PATH first, so a missing agent is an actionable error with an + // install hint rather than a bare ENOENT out of the spawn. + which::which(&plan.bin).map_err(|_| Error::NotInstalled { + agent: request.agent, + bin: plan.bin.clone(), + hint: request.agent.install_hint(), + })?; + + 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); + } + for (key, value) in &request.env { + command.env(key, value); + } + + let child = command.spawn().map_err(|source| Error::Spawn { + bin: plan.bin.clone(), + source, + })?; + + let (tx, rx) = mpsc::channel(EVENT_BUFFER); + let request = request.clone(); + let task = tokio::spawn(drive(child, request, tx)); + Ok(Run { + events: rx, + task, + argv, + }) +} + +/// Feed the child, read both its pipes, and assemble the outcome. +async fn drive(mut child: Child, request: Request, events: mpsc::Sender) -> Result { + 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.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.stderr.take(); + let stderr_task = tokio::spawn(async move { + let mut buf = String::new(); + if let Some(handle) = stderr { + let mut lines = BufReader::new(handle).lines(); + while let Ok(Some(line)) = lines.next_line().await { + buf.push_str(&line); + buf.push('\n'); + } + } + buf + }); + + let stdout = child.stdout.take(); + let mut parser = Parser::new(request.agent, plan.format); + let mut raw = String::new(); + + let read_stdout = async { + if let Some(handle) = stdout { + let mut lines = BufReader::new(handle).lines(); + while let Some(line) = lines.next_line().await? { + raw.push_str(&line); + raw.push('\n'); + for event in parser.push(&line) { + // 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>(()) + }; + + // Apply the deadline to reading and waiting together, so a child that + // produces output forever is still bounded. + let status = match request.timeout { + Some(limit) => { + match tokio::time::timeout(limit, async { + read_stdout.await?; + child.wait().await + }) + .await + { + Ok(result) => result, + Err(_elapsed) => { + // Kill, then reap, so no zombie is left behind. + let _ = child.kill().await; + return Err(Error::Timeout { + bin, + timeout: limit, + partial: parser.finish().text, + }); + } + } + } + None => match read_stdout.await { + Ok(()) => child.wait().await, + Err(source) => Err(source), + }, + } + .map_err(|source| Error::Spawn { + bin: bin.clone(), + source, + })?; + + drop(events); + let stderr = stderr_task.await.unwrap_or_default(); + let terminal = parser.finish(); + let exit_code = status.code().unwrap_or(-1); + + // A run that produced no structured answer still has its raw stdout; better + // to hand back what the agent printed than an empty string. + let mut terminal = terminal; + if terminal.text.is_empty() { + terminal.text = raw.trim().to_string(); + } + + if exit_code != 0 { + return Err(classify(&bin, exit_code, &stderr, &raw, &terminal)); + } + + persist_session(&request, &terminal); + 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, + }) +} + +/// 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, if this run was attached to a name. +/// +/// Best-effort: a store that cannot be written must not discard a completed +/// run's result. The next turn simply starts a new conversation. +fn persist_session(request: &Request, terminal: &Terminal) { + let Some(binding) = &request.binding else { + return; + }; + // Prefer the id the agent reported. For a minted session it is the one we + // assigned, so the two agree; for a forked one the agent reports the *new* + // branch, which is what the name should now follow. + let token = terminal + .session + .clone() + .or_else(|| match &request.plan().cont { + Continue::NewWith(id) => Some(id.clone()), + _ => None, + }); + if let Some(token) = token { + let _ = binding + .store + .bind(request.agent, &binding.project, &binding.name, &token); + } +} + +/// 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"); + } + + #[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..7c67258 --- /dev/null +++ b/src/session.rs @@ -0,0 +1,440 @@ +//! 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)] +pub struct SessionRecord { + /// The caller's stable name, as stored (sanitized for a path). + 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. + #[must_use] + pub fn path_of(&self, project: &Path, name: &str) -> PathBuf { + self.dir + .join(project_slug(project)) + .join(format!("{}.json", sanitize(name))) + } + + /// The stored record, or `None` when absent. + /// + /// A corrupt record reads as absent: the next turn starts a fresh + /// conversation, which is recoverable, rather than failing the run over a + /// cache the caller never asked about. + #[must_use] + pub fn get(&self, project: &Path, name: &str) -> Option { + let text = fs::read_to_string(self.path_of(project, name)).ok()?; + serde_json::from_str(&text).ok() + } + + /// Every session recorded for `project`, in unspecified order. + #[must_use] + pub fn list(&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 [`Continue`] 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. + /// + /// # 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 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 { + let now = now_secs(); + let record = SessionRecord { + name: sanitize(name), + 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)?; + } + 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. + let tmp = path.with_extension("json.tmp"); + fs::write(&tmp, text).map_err(store_err)?; + fs::rename(&tmp, &path).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, + }), + } + } +} + +/// 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)) +} + +/// Reduce an arbitrary name to one safe path segment. +fn sanitize(name: &str) -> String { + let cleaned: String = name + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect(); + let trimmed = cleaned.trim_matches('-').to_ascii_lowercase(); + // Collapse runs of separators so `a//b` and `a-b` do not both appear. + let mut out = String::with_capacity(trimmed.len()); + for c in trimmed.chars() { + if c == '-' && out.ends_with('-') { + continue; + } + out.push(c); + } + if out.is_empty() { + "unnamed".into() + } else { + out + } +} + +/// A directory path reduced to one path segment, so sessions partition by +/// project without nesting the whole absolute path. +fn project_slug(project: &Path) -> String { + sanitize(&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() { + assert_eq!(sanitize("Greet Flow"), "greet-flow"); + assert_eq!(sanitize("../../etc/passwd"), "etc-passwd"); + assert_eq!(sanitize("!!!"), "unnamed"); + assert_eq!( + project_slug(Path::new("/home/me/My Proj")), + "home-me-my-proj" + ); + } + + #[test] + fn a_path_traversing_name_cannot_escape_the_store() { + let (store, project) = store("escape"); + let path = store.path_of(&project, "../../etc/passwd"); + assert_eq!(path.file_name().unwrap(), "etc-passwd.json"); + assert!(path.starts_with(&store.dir)); + } + + #[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(); + 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_reads_as_absent_rather_than_failing() { + 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(); + assert!(store.get(&project, "chat").is_none()); + assert_eq!( + store + .plan(Agent::Claude, &project, "chat", false) + .unwrap() + .0, + Phase::Create + ); + 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).into_iter().map(|r| r.name).collect(); + names.sort(); + assert_eq!(names, ["a", "b"]); + + store.forget(&project, "a").unwrap(); + assert!(store.get(&project, "a").is_none()); + // Forgetting twice is not an error. + store.forget(&project, "a").unwrap(); + assert_eq!(store.list(&project).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().token, "t-1"); + assert_eq!(store.get(&other, "chat").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..207bdcf --- /dev/null +++ b/tests/live.rs @@ -0,0 +1,237 @@ +//! 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, 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 = which::which(agent.bin()).is_ok(); + 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; Copilot picks its own. + Agent::Claude => request.model("haiku"), + // `codex exec` refuses to run outside a git repository. + Agent::Codex => request.args(["--skip-git-repo-check"]), + 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" + ); +} + +/// 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!( + matches!(err, agent_abstraction::Error::NotInstalled { .. }), + "got {err:?}" + ); + assert!(err.to_string().contains("npm install"), "{err}"); +} From 35f3c77645c104748d1dfa55ce7395b6bd303bee Mon Sep 17 00:00:00 2001 From: meh Date: Tue, 28 Jul 2026 23:30:58 +0700 Subject: [PATCH 02/12] Waive the git-repo check for codex on every invocation `codex exec` aborts outside a git repository. 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, where a hard abort is useless. The real containment is the sandbox, which defaults to read-only, so nothing is unrecoverable either way. Previously callers had to pass --skip-git-repo-check themselves, which meant every consumer rediscovered the failure the hard way. Covered by a live test that runs codex from a non-git scratch directory; the rest of the suite runs from this repo and so could never catch a regression. --- README.md | 6 ++++-- src/agent.rs | 25 ++++++++++++++++++++++++- src/request.rs | 13 ++++++------- tests/live.rs | 37 +++++++++++++++++++++++++++++++++---- 4 files changed, 67 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 4cc6102..65141bb 100644 --- a/README.md +++ b/README.md @@ -100,8 +100,10 @@ The default is `ReadOnly`. Widen it explicitly. ## Gotchas worth knowing -- **`codex exec` refuses to run outside a git repository.** Pass - `.args(["--skip-git-repo-check"])` when your working directory may not be one. +- **`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`. - **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. diff --git a/src/agent.rs b/src/agent.rs index 94f5b72..9af42b0 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -351,7 +351,7 @@ fn argv_claude(plan: &Plan) -> Vec { a } -/// `codex exec [resume ] [sandbox flags] [--model M] [--json] ` +/// `codex exec [resume ] --skip-git-repo-check [sandbox flags] [--json] ` fn argv_codex(plan: &Plan) -> Vec { let mut a = vec![plan.bin.clone(), "exec".into()]; if let Continue::Resume(id) = &plan.cont { @@ -360,6 +360,14 @@ fn argv_codex(plan: &Plan) -> Vec { a.push(id.clone()); } + // `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.push("--skip-git-repo-check".into()); + match plan.permission { Permission::Bypass => a.push("--dangerously-bypass-approvals-and-sandbox".into()), Permission::ReadOnly | Permission::Plan => { @@ -578,6 +586,21 @@ mod tests { assert_eq!(a.last().unwrap(), "hi"); } + /// `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" + ); + } + } + #[test] fn codex_without_a_system_flag_prepends_it_to_the_prompt() { let mut p = plan("codex"); diff --git a/src/request.rs b/src/request.rs index e248c4a..9fa6dc9 100644 --- a/src/request.rs +++ b/src/request.rs @@ -130,10 +130,9 @@ impl Request { /// Append raw arguments, after everything this crate builds. /// - /// The escape hatch for agent-specific flags with no unified spelling. For - /// example `codex exec` refuses to run outside a git repository unless given - /// `--skip-git-repo-check`. Arguments are passed straight to the binary - /// without a shell. + /// The escape hatch for agent-specific flags with no unified spelling, such + /// as `--add-dir` to widen file access or Codex's `-c key=value` config + /// overrides. Arguments are passed straight to the binary without a shell. #[must_use] pub fn args(mut self, args: I) -> Self where @@ -249,11 +248,11 @@ mod tests { #[test] fn extra_args_land_after_everything_the_crate_builds() { - let argv = Request::new(Agent::Codex, "hi") - .args(["--skip-git-repo-check"]) + let argv = Request::new(Agent::Claude, "hi") + .args(["--add-dir", "/tmp/extra"]) .argv() .unwrap(); - assert_eq!(argv.last().unwrap(), "--skip-git-repo-check"); + assert_eq!(argv[argv.len() - 2..], ["--add-dir", "/tmp/extra"]); } #[test] diff --git a/tests/live.rs b/tests/live.rs index 207bdcf..6344495 100644 --- a/tests/live.rs +++ b/tests/live.rs @@ -34,11 +34,9 @@ fn ping(agent: Agent) -> Request { .permission(Permission::ReadOnly) .timeout(Duration::from_secs(180)); match agent { - // The cheapest model on each side; Copilot picks its own. + // The cheapest model on each side; Codex and Copilot pick their own. Agent::Claude => request.model("haiku"), - // `codex exec` refuses to run outside a git repository. - Agent::Codex => request.args(["--skip-git-repo-check"]), - Agent::Copilot => request, + Agent::Codex | Agent::Copilot => request, } } @@ -98,6 +96,37 @@ async fn copilot_answers_and_reports_a_session() { ); } +/// `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(); +} + /// The streaming path must deliver events *before* the run settles, and the /// terminal answer must still be authoritative afterwards. #[tokio::test] From d2e79a17031c2f2ab50ba65489ae92f7859ec207 Mon Sep 17 00:00:00 2001 From: meh Date: Tue, 28 Jul 2026 23:35:41 +0700 Subject: [PATCH 03/12] Restore --model to the codex argv doc comment, and pin model passthrough The previous commit rewrote argv_codex's doc comment header to document --skip-git-repo-check and dropped [--model M] from that line. Only the comment was wrong; the code has always forwarded the model. Restoring it, and adding tests so the two cannot drift again: - every agent forwards a caller-supplied model verbatim - no model means no --model flag, so the agent picks its own default The model is never defaulted, normalized or validated here. A host with a model picker owns that list, and an unknown name should surface as the agent's own error rather than something this crate guessed at. --- src/agent.rs | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/src/agent.rs b/src/agent.rs index 9af42b0..5cf718d 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -351,7 +351,8 @@ fn argv_claude(plan: &Plan) -> Vec { a } -/// `codex exec [resume ] --skip-git-repo-check [sandbox flags] [--json] ` +/// `codex exec [resume ] --skip-git-repo-check [sandbox flags] [--model M] +/// [--json] ` fn argv_codex(plan: &Plan) -> Vec { let mut a = vec![plan.bin.clone(), "exec".into()]; if let Continue::Resume(id) = &plan.cont { @@ -586,6 +587,37 @@ mod tests { assert_eq!(a.last().unwrap(), "hi"); } + /// 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. From 47aff1bd9fc19c4098c379370b8a49a02cabcb7f Mon Sep 17 00:00:00 2001 From: meh Date: Tue, 28 Jul 2026 23:45:57 +0700 Subject: [PATCH 04/12] Address review: bounded capture, injective session encoding, CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Priority items from review: - Run::finish no longer forces a JoinError through io::Error and reports it as Error::Spawn. The process started fine, so a spawn failure names the wrong cause. New Error::Interrupted variant distinguishes a panicked driver task from a cancelled one. - Bound every captured buffer at MAX_CAPTURE (1 MiB), keeping the earliest output. Raw stdout, stderr and the parser's own text buffer all grew without limit, so a long-streaming agent ended a run in an OOM rather than an answer. Truncation cuts on a character boundary. stderr keeps draining past the cap, since an undrained pipe blocks the child. - Replace the lossy session-name sanitizer with injective percent-encoding. Folding unsafe characters to `-` mapped `café` and `cafe-` onto one file, so the second session to use that name silently resumed the first one's conversation. Uppercase is encoded too: macOS and Windows are case-insensitive and would otherwise collide `Chat` with `chat`. Over-long names truncate on an encoding boundary and disambiguate with FNV-1a, chosen over DefaultHasher because the latter may change between Rust releases and would repoint every stored session on a toolchain upgrade. Also from review: - SessionRecord keeps the caller's original name rather than the sanitized form, so list() returns the name that was actually passed. - Count unparseable output lines and keep the first as a sample, surfaced as Outcome::looks_like_a_format_change(). A vendor changing its output shape otherwise presents as a successful run that mysteriously returns nothing. - flatten_text keeps non-text content blocks as raw JSON instead of dropping them, and documents the tool-result shapes it assumes. - Extract argv construction into a small builder, keeping every flag literal at its call site so the per-agent flag lists stay greppable against --help. - Document the SessionStore clone in Request::session as a PathBuf, not the sessions themselves. - Assert on Error::NotInstalled's structured hint field rather than the rendered message, which is free to reword. - Add CI: fmt, clippy -D warnings, tests, doctests, doc link check, and an MSRV job. Live tests stay ignored there; they need three authenticated CLIs and spend real quota. CI caught a formatting drift and a clippy error on its first local run. --- .github/workflows/ci.yml | 59 ++++++++++++ README.md | 32 ++++++- src/agent.rs | 180 ++++++++++++++++++----------------- src/error.rs | 15 +++ src/event.rs | 154 ++++++++++++++++++++++++++++-- src/lib.rs | 2 +- src/outcome.rs | 23 ++++- src/request.rs | 5 + src/run.rs | 29 ++++-- src/session.rs | 200 ++++++++++++++++++++++++++++++++------- tests/live.rs | 12 ++- 11 files changed, 569 insertions(+), 142 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6b2b31f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,59 @@ +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 + + minimum-toolchain: + name: builds on the declared MSRV + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # Keep in step with `rust-version` in Cargo.toml. A dependency that + # quietly raises its own MSRV breaks consumers, and this is where that + # should surface. + - uses: dtolnay/rust-toolchain@1.85.0 + - uses: Swatinem/rust-cache@v2 + - run: cargo check --all-targets diff --git a/README.md b/README.md index 65141bb..3c76cca 100644 --- a/README.md +++ b/README.md @@ -81,8 +81,13 @@ in two checkouts never collides, and written through a temp file and a rename so 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. -Session names are reduced to a single safe path segment, so a name like `../../etc/passwd` -cannot escape the store. +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. ## Permissions @@ -113,6 +118,29 @@ The default is `ReadOnly`. Widen it explicitly. - **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. + ## No shell, ever Arguments are built as a `Vec` and passed straight to `exec`. There is no shell in diff --git a/src/agent.rs b/src/agent.rs index 5cf718d..cd602cf 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -268,6 +268,48 @@ impl fmt::Display for Agent { } } +/// 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`. +struct Argv(Vec); + +impl Argv { + /// Start with the binary. + fn new(bin: &str) -> Self { + Self(vec![bin.to_string()]) + } + + /// A bare flag with no value. + fn bare(&mut self, flag: &str) -> &mut Self { + self.0.push(flag.to_string()); + self + } + + /// A flag and its value, as two arguments. + fn pair(&mut self, flag: &str, value: impl AsRef) -> &mut Self { + self.0.push(flag.to_string()); + self.0.push(value.as_ref().to_string()); + self + } + + /// 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. + fn arg(&mut self, value: impl Into) -> &mut Self { + self.0.push(value.into()); + self + } + + 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. @@ -286,79 +328,67 @@ fn claude_mode(p: Permission) -> &'static str { /// `claude -p --permission-mode M --output-format F [...]` fn argv_claude(plan: &Plan) -> Vec { - let mut a = vec![plan.bin.clone(), "-p".into()]; + 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.push("--input-format".into()); - a.push("text".into()); + a.pair("--input-format", "text"); } else { - a.push(Agent::Claude.effective_prompt(plan)); + a.arg(Agent::Claude.effective_prompt(plan)); } - a.push("--permission-mode".into()); - a.push(claude_mode(plan.permission).into()); + a.pair("--permission-mode", claude_mode(plan.permission)); if plan.permission == Permission::ReadOnly { // Remove the mutating tools outright. Reads still run via Read/Grep/Glob. - a.push("--disallowedTools".into()); + a.bare("--disallowedTools"); for tool in ["Bash", "Edit", "Write", "NotebookEdit"] { - a.push(tool.into()); + a.arg(tool); } } - if let Some(model) = &plan.model { - a.push("--model".into()); - a.push(model.clone()); - } - if let Some(system) = &plan.system { - a.push("--append-system-prompt".into()); - a.push(system.clone()); - } + a.opt("--model", plan.model.as_ref()); + a.opt("--append-system-prompt", plan.system.as_ref()); match &plan.cont { Continue::New => {} Continue::NewWith(id) => { - a.push("--session-id".into()); - a.push(id.clone()); + a.pair("--session-id", id); } Continue::Resume(id) => { - a.push("--resume".into()); - a.push(id.clone()); + a.pair("--resume", id); } Continue::Fork(id) => { - a.push("--resume".into()); - a.push(id.clone()); // Mints a new id off `id`, leaving the original and its cached // prefix untouched. The new id comes back in the output. - a.push("--fork-session".into()); + a.pair("--resume", id).bare("--fork-session"); } } - a.push("--output-format".into()); - a.push( + a.pair( + "--output-format", match plan.format { Format::Text => "text", Format::Json => "json", Format::Stream => "stream-json", - } - .into(), + }, ); if plan.format == Format::Stream { // Claude refuses `-p --output-format stream-json` without it: // "--print with --output-format=stream-json requires --verbose". - a.push("--verbose".into()); + a.bare("--verbose"); } - a + a.done() } /// `codex exec [resume ] --skip-git-repo-check [sandbox flags] [--model M] /// [--json] ` fn argv_codex(plan: &Plan) -> Vec { - let mut a = vec![plan.bin.clone(), "exec".into()]; + 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.push("resume".into()); - a.push(id.clone()); + a.bare("resume").arg(id.clone()); } // `codex exec` aborts outside a git repository unless told not to. That @@ -367,37 +397,28 @@ fn argv_codex(plan: &Plan) -> Vec { // 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.push("--skip-git-repo-check".into()); + a.bare("--skip-git-repo-check"); match plan.permission { - Permission::Bypass => a.push("--dangerously-bypass-approvals-and-sandbox".into()), - Permission::ReadOnly | Permission::Plan => { - a.push("--sandbox".into()); - a.push("read-only".into()); - } - Permission::Edit | Permission::Auto => { - a.push("--sandbox".into()); - a.push("workspace-write".into()); - } - } + Permission::Bypass => a.bare("--dangerously-bypass-approvals-and-sandbox"), + Permission::ReadOnly | Permission::Plan => a.pair("--sandbox", "read-only"), + Permission::Edit | Permission::Auto => a.pair("--sandbox", "workspace-write"), + }; - if let Some(model) = &plan.model { - a.push("--model".into()); - a.push(model.clone()); - } + 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.push("--json".into()); + 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. - a.push(if plan.stdin_prompt { - "-".into() + a.arg(if plan.stdin_prompt { + "-".to_string() } else { Agent::Codex.effective_prompt(plan) }); - a + a.done() } /// `copilot -p --allow-all-tools [...] [--session-id ]` @@ -409,63 +430,46 @@ fn argv_codex(plan: &Plan) -> Vec { 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 = if plan.stdin_prompt { - vec![plan.bin.clone()] - } else { - vec![ - plan.bin.clone(), - "-p".into(), - Agent::Copilot.effective_prompt(plan), - ] - }; + let mut a = Argv::new(&plan.bin); + if !plan.stdin_prompt { + a.pair("-p", Agent::Copilot.effective_prompt(plan)); + } // Without this, a headless run stops at the first tool confirmation. - a.push("--allow-all-tools".into()); - a.push("--no-ask-user".into()); + a.bare("--allow-all-tools").bare("--no-ask-user"); match plan.permission { - Permission::Bypass | Permission::Auto => a.push("--allow-all-paths".into()), + Permission::Bypass | Permission::Auto => a.bare("--allow-all-paths"), // Deny beats allow, so this is allow-all minus the mutating tools. - Permission::ReadOnly => { - a.push("--allow-all-paths".into()); - a.push("--deny-tool=shell".into()); - a.push("--deny-tool=write".into()); - } + Permission::ReadOnly => a + .bare("--allow-all-paths") + .bare("--deny-tool=shell") + .bare("--deny-tool=write"), // Edits run; shell stays denied so commands cannot. - Permission::Edit => { - a.push("--allow-all-paths".into()); - a.push("--deny-tool=shell".into()); - } - Permission::Plan => { - a.push("--mode".into()); - a.push("plan".into()); - } - } + Permission::Edit => a.bare("--allow-all-paths").bare("--deny-tool=shell"), + Permission::Plan => a.pair("--mode", "plan"), + }; - if let Some(model) = &plan.model { - a.push("--model".into()); - a.push(model.clone()); - } + 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.push("--session-id".into()); - a.push(id.clone()); + a.pair("--session-id", id); } // `Fork` is rejected by `Agent::check` before reaching here. Continue::New | Continue::Fork(_) => {} } - a.push("--output-format".into()); - a.push( + a.pair( + "--output-format", if plan.format == Format::Text { "text" } else { + // Copilot's `json` is JSONL, so it serves both structured formats. "json" - } - .into(), + }, ); - a + a.done() } #[cfg(test)] diff --git a/src/error.rs b/src/error.rs index 96fdffc..b23f7d6 100644 --- a/src/error.rs +++ b/src/error.rs @@ -123,6 +123,21 @@ pub enum Error { /// What specifically was wrong. detail: String, }, + + /// 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 { diff --git a/src/event.rs b/src/event.rs index a706035..7a62153 100644 --- a/src/event.rs +++ b/src/event.rs @@ -65,6 +65,41 @@ pub enum Event { 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; + +/// 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 { @@ -78,6 +113,14 @@ pub struct Terminal { 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`]. @@ -109,7 +152,9 @@ impl Parser { /// /// 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. + /// 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() { @@ -118,11 +163,21 @@ impl Parser { // Under a plain-text format there is nothing to parse: the whole stream // is the answer. if self.format == Format::Text { - self.term.text.push_str(line); - self.term.text.push('\n'); + 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(); }; let mut out = match self.agent { @@ -495,14 +550,22 @@ fn codex_tool_input(item: &Value, item_ty: &str) -> Value { } } -/// Flatten a tool result's `content`, which is either a plain string or an array -/// of content blocks. +/// 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() - .filter_map(|b| b.get("text").and_then(Value::as_str)) + .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(), @@ -762,6 +825,85 @@ mod tests { 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( diff --git a/src/lib.rs b/src/lib.rs index 3751f47..cb9ab4e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -95,7 +95,7 @@ mod session; pub use agent::{Agent, Caps, Continue, Format, Permission, Plan, STDIN_THRESHOLD, SessionSupport}; pub use error::{Error, Result}; -pub use event::{Event, Parser, Terminal}; +pub use event::{Event, MAX_CAPTURE, Parser, Terminal}; pub use outcome::{Outcome, RateLimit, Stop, Usage}; pub use request::Request; pub use run::{Run, run, stream}; diff --git a/src/outcome.rs b/src/outcome.rs index e83bf4a..5706082 100644 --- a/src/outcome.rs +++ b/src/outcome.rs @@ -89,8 +89,18 @@ pub struct Outcome { pub rate_limit: Option, /// The process exit code. pub exit_code: i32, - /// Raw stderr, kept for diagnostics. + /// 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 { @@ -99,4 +109,15 @@ impl Outcome { 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/request.rs b/src/request.rs index 9fa6dc9..eff2678 100644 --- a/src/request.rs +++ b/src/request.rs @@ -157,6 +157,11 @@ impl Request { /// 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 diff --git a/src/run.rs b/src/run.rs index 9b05803..c9fbb6b 100644 --- a/src/run.rs +++ b/src/run.rs @@ -17,7 +17,7 @@ use tokio::sync::mpsc; use crate::agent::Continue; use crate::error::{Error, Result}; -use crate::event::{Event, Parser, Terminal}; +use crate::event::{Event, Parser, Terminal, append_capped}; use crate::outcome::{Outcome, Stop}; use crate::request::Request; @@ -60,11 +60,16 @@ impl Run { while self.events.recv().await.is_some() {} match self.task.await { Ok(result) => result, - // The driver task itself panicked or was cancelled; there is no - // outcome to report and no useful exit code to invent. - Err(join) => Err(Error::Spawn { + // 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(), - source: std::io::Error::other(join), + detail: if join.is_panic() { + "the driver task panicked".into() + } else { + "the driver task was cancelled".into() + }, }), } } @@ -162,8 +167,9 @@ async fn drive(mut child: Child, request: Request, events: mpsc::Sender) if let Some(handle) = stderr { let mut lines = BufReader::new(handle).lines(); while let Ok(Some(line)) = lines.next_line().await { - buf.push_str(&line); - buf.push('\n'); + // Keep draining after the cap is hit: an undrained pipe would + // block the child even though we no longer want the bytes. + append_capped(&mut buf, &line); } } buf @@ -171,14 +177,17 @@ async fn drive(mut child: Child, request: Request, events: mpsc::Sender) let stdout = 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(); let read_stdout = async { if let Some(handle) = stdout { let mut lines = BufReader::new(handle).lines(); while let Some(line) = lines.next_line().await? { - raw.push_str(&line); - raw.push('\n'); + append_capped(&mut raw, &line); for event in parser.push(&line) { // A receiver that went away is not a failure: the run should // still finish and produce its outcome. @@ -249,6 +258,8 @@ async fn drive(mut child: Child, request: Request, events: mpsc::Sender) rate_limit: terminal.rate_limit, exit_code, stderr, + unparsed: terminal.unparsed, + first_unparsed: terminal.first_unparsed, }) } diff --git a/src/session.rs b/src/session.rs index 7c67258..e2ccd08 100644 --- a/src/session.rs +++ b/src/session.rs @@ -28,7 +28,9 @@ use crate::error::{Error, Result}; /// One named conversation. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SessionRecord { - /// The caller's stable name, as stored (sanitized for a path). + /// 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, @@ -88,7 +90,7 @@ impl SessionStore { pub fn path_of(&self, project: &Path, name: &str) -> PathBuf { self.dir .join(project_slug(project)) - .join(format!("{}.json", sanitize(name))) + .join(format!("{}.json", encode_segment(name))) } /// The stored record, or `None` when absent. @@ -188,7 +190,7 @@ impl SessionStore { ) -> Result { let now = now_secs(); let record = SessionRecord { - name: sanitize(name), + name: name.to_string(), project: project.display().to_string(), agent, token: token.to_string(), @@ -240,32 +242,78 @@ fn now_secs() -> i64 { .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX)) } -/// Reduce an arbitrary name to one safe path segment. -fn sanitize(name: &str) -> String { - let cleaned: String = name - .chars() - .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) - .collect(); - let trimmed = cleaned.trim_matches('-').to_ascii_lowercase(); - // Collapse runs of separators so `a//b` and `a-b` do not both appear. - let mut out = String::with_capacity(trimmed.len()); - for c in trimmed.chars() { - if c == '-' && out.ends_with('-') { - continue; +/// 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. +/// +/// Names too long to encode whole are truncated and disambiguated with a hash of +/// the full input, so the length bound costs readability but never uniqueness. +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}"); } - out.push(c); } if out.is_empty() { - "unnamed".into() - } else { - out + return "unnamed".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 directory path reduced to one path segment, so sessions partition by -/// project without nesting the whole absolute path. +/// 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 { - sanitize(&project.display().to_string()) + encode_segment(&project.display().to_string()) } #[cfg(test)] @@ -284,21 +332,109 @@ mod tests { #[test] fn names_and_projects_reduce_to_one_safe_segment() { - assert_eq!(sanitize("Greet Flow"), "greet-flow"); - assert_eq!(sanitize("../../etc/passwd"), "etc-passwd"); - assert_eq!(sanitize("!!!"), "unnamed"); - assert_eq!( - project_slug(Path::new("/home/me/My Proj")), - "home-me-my-proj" - ); + // 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"); + assert_eq!(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", + "日本語", + "🙂", + ]; + 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(); + assert_eq!(record.name, "Greet Flow ☕"); + assert_eq!(store.list(&project)[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"); - let path = store.path_of(&project, "../../etc/passwd"); - assert_eq!(path.file_name().unwrap(), "etc-passwd.json"); - assert!(path.starts_with(&store.dir)); + 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] diff --git a/tests/live.rs b/tests/live.rs index 6344495..9376c21 100644 --- a/tests/live.rs +++ b/tests/live.rs @@ -258,9 +258,15 @@ async fn forking_branches_to_a_new_session() { 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!( - matches!(err, agent_abstraction::Error::NotInstalled { .. }), - "got {err:?}" + !hint.is_empty(), + "a missing agent must say how to install it" ); - assert!(err.to_string().contains("npm install"), "{err}"); } From 840b6e0b97159f1017dc0bed2f5e2414b2b6562c Mon Sep 17 00:00:00 2001 From: meh Date: Tue, 28 Jul 2026 23:54:00 +0700 Subject: [PATCH 05/12] Let callers assign a session id, and verify the round trip live There was no public way to supply a session id: only the store could mint one. A host that already has its own thread identifier had no way to make the agent's session match it. Request::session_id() fills that gap. Verified against the live CLIs rather than inferred from --help, since an id that is accepted but quietly ignored would be worse than one that is refused: - Claude and Copilot both honour an assigned UUID. The id comes back unchanged and resumes the same conversation on a later turn. - Codex has no --session-id on `exec` and cannot be told one. Asking is an Error::Unsupported raised before spawning, not a silently unrelated session. - Codex does reveal its thread_id early: `thread.started` is the first record of the stream and arrives before any answer text, so a binding can be stored when the stream opens rather than when the turn ends. Covered by a test that fails if any text precedes the id. Also drops the MSRV CI job. The claim is derived from the dependency graph (uuid and getrandom both sit at 1.85) and no consumer needs an older toolchain, so defending it in CI was cost without benefit. --- .github/workflows/ci.yml | 12 ------ Cargo.toml | 4 ++ README.md | 33 +++++++++++++-- src/request.rs | 23 +++++++++++ tests/live.rs | 87 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 143 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b2b31f..f1cfde2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,15 +45,3 @@ jobs: run: cargo doc --no-deps env: RUSTDOCFLAGS: -D warnings - - minimum-toolchain: - name: builds on the declared MSRV - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - # Keep in step with `rust-version` in Cargo.toml. A dependency that - # quietly raises its own MSRV breaks consumers, and this is where that - # should surface. - - uses: dtolnay/rust-toolchain@1.85.0 - - uses: Swatinem/rust-cache@v2 - - run: cargo check --all-targets diff --git a/Cargo.toml b/Cargo.toml index 0db06a4..2e001b1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,8 @@ 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" @@ -35,6 +37,8 @@ which = "8" [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" diff --git a/README.md b/README.md index 3c76cca..83e0c37 100644 --- a/README.md +++ b/README.md @@ -32,10 +32,35 @@ and not inferred from documentation. | **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` | -**Minted beats printed.** Where the caller can assign the session id up front (Claude, -Copilot), the binding is written *before* the process starts, so a run that crashes -mid-turn still leaves a resumable session. Codex only reveals its `thread_id` in its output, -so its binding can only be recorded after a run produces one. +### 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 diff --git a/src/request.rs b/src/request.rs index eff2678..97e1839 100644 --- a/src/request.rs +++ b/src/request.rs @@ -151,6 +151,29 @@ impl Request { 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 diff --git a/tests/live.rs b/tests/live.rs index 9376c21..9113dd6 100644 --- a/tests/live.rs +++ b/tests/live.rs @@ -127,6 +127,93 @@ async fn codex_runs_outside_a_git_repository() { 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); +} + /// The streaming path must deliver events *before* the run settles, and the /// terminal answer must still be authoritative afterwards. #[tokio::test] From 23943b3931505080c842f7eee8329d43508aea36 Mon Sep 17 00:00:00 2001 From: meh Date: Wed, 29 Jul 2026 00:18:43 +0700 Subject: [PATCH 06/12] Address senior review: cancellation, permissions, session integrity Cancellation and process containment: - Dropping a Run now kills the agent. Previously the driver task was detached, so closing a window left an agent running unobserved, spending quota and writing files. Added cancel() for a deterministic stop and detach() for the callers who genuinely want a background run. - Each run gets its own process group on Unix, torn down on drop, cancel and timeout. kill_on_drop only kills the CLI, orphaning the commands it spawned. A ChildGuard owns the teardown so every exit path from the driver is covered rather than each one remembering. - Ordering bug found by the new test: the timeout path reaped the child before signalling the group, and reaping clears the pid the group kill needs, so every grandchild survived. Group first, then reap. - stream() checked Handle::try_current() instead of letting tokio::spawn panic out of a Result-returning function. Permissions, stated honestly: - Copilot's ReadOnly passed --allow-all-paths, which disables path verification. The one posture that exists to narrow filesystem reach was widening it. - Claude's ReadOnly now also denies mcp__*, so an MCP server cannot mutate remote state during a run the caller asked to be read-only. - Documented what these postures cannot do. They constrain each CLI's built-in tools, not MCP servers, plugins or custom tools. Codex has no true plan mode, so Plan is its read-only sandbox: writes blocked, execution still permitted. - args() is now unchecked_args(), documented as voiding the crate's guarantees, since arguments land after the generated ones and can contradict them. Session integrity: - Persist an assigned session id *before* spawning, and a printed one as soon as the stream reveals it. The docs claimed a crashed run stayed resumable while the code only bound on a clean exit, which is exactly backwards. - Store failures are returned rather than discarded with `let _`. - get() returns Result>: only a missing file is None. A permission error or corrupt record silently became "start a new conversation", quietly abandoning a conversation the caller believed they were in. - bind() enforces the agent-conflict invariant too. It is public, so the check could not live only in plan(). - Records are written 0600 into a per-process temp name and fsynced before the rename. A shared .json.tmp let concurrent writers for one session corrupt each other. Also: - A named session with a format that cannot carry an id is refused up front instead of succeeding and silently failing to bind. - The stdin threshold measures the whole command line. A small prompt with a large system prompt could still exceed ARG_MAX, contradicting the comment. - Rate limits are classified regardless of exit code: Claude can report a blocking limit and still exit 0. - Run::redacted_argv(), and argv() documented as sensitive, since prompts and session ids ride the command line. - Dropped the `which` dependency; NotFound off the spawn is one PATH resolution instead of two and has no check-to-use gap. - Narrowed the public API: Plan, Continue, Parser, Terminal and STDIN_THRESHOLD were implementation machinery. New tests/process.rs proves the lifecycle end to end with a fake agent that spawns a grandchild: drop, cancel and timeout kill the tree, detach does not. Full live suite re-run: 9/9 pass. Not done, and why: the six-axis ExecutionPolicy split is a larger redesign than this crate's scope justifies, and Windows Job Objects are unimplemented, so process-tree containment there is documented as absent rather than assumed. --- Cargo.toml | 10 +- README.md | 31 ++++ src/agent.rs | 58 ++++++-- src/error.rs | 7 + src/lib.rs | 2 +- src/request.rs | 73 ++++++++-- src/run.rs | 359 +++++++++++++++++++++++++++++++++++++++++------ src/session.rs | 149 ++++++++++++++++---- tests/live.rs | 4 +- tests/process.rs | 167 ++++++++++++++++++++++ 10 files changed, 762 insertions(+), 98 deletions(-) create mode 100644 tests/process.rs diff --git a/Cargo.toml b/Cargo.toml index 2e001b1..e64cd1c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,11 @@ thiserror = "2" 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. -which = "8" + +# 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"] } @@ -42,7 +46,9 @@ uuid = { version = "1", features = ["v4"] } [lints.rust] missing_docs = "warn" -unsafe_code = "forbid" +# `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 } diff --git a/README.md b/README.md index 83e0c37..3d33b95 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,25 @@ the first one's conversation. Uppercase is encoded too, since macOS and Windows 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)?; +// ... user closes the tab +drop(running); // agent and its child processes are gone +running.cancel().await; // same, but waits until they are actually dead +running.detach(); // opt out: keep running unsupervised +``` + +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: @@ -128,6 +147,18 @@ original name, so `list()` hands back what you passed, not a mangled segment. 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. + ## Gotchas worth knowing - **`codex exec` refuses to run outside a git repository.** This crate always passes diff --git a/src/agent.rs b/src/agent.rs index cd602cf..20f2625 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -53,15 +53,38 @@ pub struct Caps { } /// 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 { - /// Read and search only; mutating tools are denied. + /// 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, - /// Plan without executing. + /// 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, but still gate shell commands. + /// Allow file edits, while still gating shell commands where the CLI can. Edit, /// Allow the agent's own default automation. Auto, @@ -210,6 +233,18 @@ impl Agent { } } + /// 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(); @@ -340,9 +375,12 @@ fn argv_claude(plan: &Plan) -> Vec { a.pair("--permission-mode", claude_mode(plan.permission)); if plan.permission == Permission::ReadOnly { - // Remove the mutating tools outright. Reads still run via Read/Grep/Glob. + // 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"] { + for tool in ["Bash", "Edit", "Write", "NotebookEdit", "mcp__*"] { a.arg(tool); } } @@ -440,12 +478,12 @@ fn argv_copilot(plan: &Plan) -> Vec { match plan.permission { Permission::Bypass | Permission::Auto => a.bare("--allow-all-paths"), // Deny beats allow, so this is allow-all minus the mutating tools. - Permission::ReadOnly => a - .bare("--allow-all-paths") - .bare("--deny-tool=shell") - .bare("--deny-tool=write"), + // `--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("--allow-all-paths").bare("--deny-tool=shell"), + Permission::Edit => a.bare("--deny-tool=shell"), Permission::Plan => a.pair("--mode", "plan"), }; diff --git a/src/error.rs b/src/error.rs index b23f7d6..44cf674 100644 --- a/src/error.rs +++ b/src/error.rs @@ -124,6 +124,13 @@ pub enum Error { detail: String, }, + /// [`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. /// diff --git a/src/lib.rs b/src/lib.rs index cb9ab4e..18b9d10 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -95,7 +95,7 @@ mod session; pub use agent::{Agent, Caps, Continue, Format, Permission, Plan, STDIN_THRESHOLD, SessionSupport}; pub use error::{Error, Result}; -pub use event::{Event, MAX_CAPTURE, Parser, Terminal}; +pub use event::{Event, MAX_CAPTURE}; pub use outcome::{Outcome, RateLimit, Stop, Usage}; pub use request::Request; pub use run::{Run, run, stream}; diff --git a/src/request.rs b/src/request.rs index 97e1839..d4e00c6 100644 --- a/src/request.rs +++ b/src/request.rs @@ -31,6 +31,7 @@ pub struct Request { pub(crate) cwd: Option, pub(crate) env: Vec<(String, String)>, pub(crate) extra_args: Vec, + pub(crate) clear_env: bool, pub(crate) timeout: Option, /// Set when [`Request::session`] resolved a named session, so the runner /// knows to write the binding back. @@ -64,6 +65,7 @@ impl Request { cwd: None, env: Vec::new(), extra_args: Vec::new(), + clear_env: false, timeout: None, binding: None, } @@ -121,6 +123,24 @@ impl Request { self } + /// Start from an empty environment instead of inheriting the host's. + /// + /// By default the agent inherits every variable this process holds, which + /// is how the CLIs find their own credentials, `PATH` and `HOME`. In an + /// embedded host that same inheritance also hands the agent, and anything + /// it runs, every unrelated secret in the process: cloud credentials, CI + /// tokens, database URLs. + /// + /// With this set, only variables passed to [`Request::env`] reach the child. + /// That is the stronger position, but it is opt-in because an empty + /// environment breaks every agent until you supply at least `PATH`, `HOME` + /// and the CLI's own credential variable. + #[must_use] + pub fn clear_env(mut self) -> Self { + self.clear_env = true; + self + } + /// Kill the run if it has not finished within `timeout`. #[must_use] pub fn timeout(mut self, timeout: Duration) -> Self { @@ -128,13 +148,21 @@ impl Request { self } - /// Append raw arguments, after everything this crate builds. + /// Append raw arguments after everything this crate builds. /// - /// The escape hatch for agent-specific flags with no unified spelling, such - /// as `--add-dir` to widen file access or Codex's `-c key=value` config - /// overrides. Arguments are passed straight to the binary without a shell. + /// 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 args(mut self, args: I) -> Self + pub fn unchecked_args(mut self, args: I) -> Self where I: IntoIterator, S: Into, @@ -208,12 +236,33 @@ impl Request { }); // A named session needs an id back, so it selects the format that // carries one unless the caller pinned a format explicitly. - if self.format.is_none() { - self.format = self.agent.session_format(); + 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 { @@ -243,9 +292,11 @@ impl Request { permission: self.permission, format: self.effective_format(), cont: self.cont.clone(), - // A prompt too large for the argv is piped instead, so a long one - // never fails with E2BIG. - stdin_prompt: self.prompt.len() >= STDIN_THRESHOLD, + // 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, } } @@ -277,7 +328,7 @@ mod tests { #[test] fn extra_args_land_after_everything_the_crate_builds() { let argv = Request::new(Agent::Claude, "hi") - .args(["--add-dir", "/tmp/extra"]) + .unchecked_args(["--add-dir", "/tmp/extra"]) .argv() .unwrap(); assert_eq!(argv[argv.len() - 2..], ["--add-dir", "/tmp/extra"]); diff --git a/src/run.rs b/src/run.rs index c9fbb6b..68f0acb 100644 --- a/src/run.rs +++ b/src/run.rs @@ -29,11 +29,21 @@ const EVENT_BUFFER: usize = 256; /// A run in progress. /// /// Yields events through [`Run::recv`] and settles into an [`Outcome`] through -/// [`Run::finish`]. Dropping it detaches the run; the child is not killed. +/// [`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, - task: tokio::task::JoinHandle>, + /// `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, } @@ -44,11 +54,22 @@ impl Run { } /// 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 prompt and session id replaced by placeholders, + /// safe to log. + #[must_use] + pub fn redacted_argv(&self) -> Vec { + redact(&self.argv) + } + /// Wait for the run to finish. /// /// Drains any events still queued, so a caller that only wants the result @@ -58,7 +79,12 @@ impl Run { /// Whatever the run failed with. See [`Error`]. pub async fn finish(mut self) -> Result { while self.events.recv().await.is_some() {} - match self.task.await { + // 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 @@ -73,6 +99,81 @@ impl Run { }), } } + + /// Stop the run and wait until the agent is actually gone. + /// + /// Deterministic, unlike dropping: when this returns, the process and its + /// children have been signalled and reaped. Prefer it over `drop` when you + /// need to know the agent has stopped before doing something else, such as + /// mutating the files it was working on. + pub async fn cancel(mut self) { + if let Some(task) = self.task.take() { + task.abort(); + // Aborting drops the driver's `Child`, whose `kill_on_drop` and + // process-group teardown do the actual killing. Awaiting the + // JoinError is what guarantees that has happened. + let _ = task.await; + } + } + + /// 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) { + // Dropping the handle without aborting is what detaches a tokio task. + drop(self.task.take()); + } +} + +impl Drop for Run { + fn drop(&mut self) { + // Still holding the handle means the caller abandoned this run rather + // than finishing, cancelling or detaching it. Abort, which drops the + // driver's `Child` and triggers the kill path. + if let Some(task) = self.task.take() { + task.abort(); + } + } +} + +/// Placeholders substituted for sensitive argv values. +const REDACTED: &str = ""; + +/// Replace prompt and session-id values with a placeholder. +/// +/// Flag *names* are kept so a redacted command line is still recognisable; only +/// the values that carry user content or a resumable handle are removed. +fn redact(argv: &[String]) -> Vec { + /// Flags whose following argument is sensitive. + const SENSITIVE_FLAGS: &[&str] = &[ + "-p", + "--prompt", + "--append-system-prompt", + "--system", + "--session-id", + "--resume", + ]; + let mut out = Vec::with_capacity(argv.len()); + let mut redact_next = false; + for (i, arg) in argv.iter().enumerate() { + if redact_next { + out.push(REDACTED.to_string()); + redact_next = false; + continue; + } + redact_next = SENSITIVE_FLAGS.contains(&arg.as_str()); + // Codex takes its prompt as the trailing positional rather than behind + // a flag, so the last argument is redacted unless it is a flag itself. + let trailing_prompt = i + 1 == argv.len() && !arg.starts_with('-') && i > 1; + out.push(if trailing_prompt { + REDACTED.to_string() + } else { + arg.clone() + }); + } + out } /// Run `request` to completion, discarding the intermediate events. @@ -92,17 +193,13 @@ pub async fn run(request: &Request) -> Result { /// [`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 argv = request.argv()?; - // Resolve on PATH first, so a missing agent is an actionable error with an - // install hint rather than a bare ENOENT out of the spawn. - which::which(&plan.bin).map_err(|_| Error::NotInstalled { - agent: request.agent, - bin: plan.bin.clone(), - hint: request.agent.install_hint(), - })?; - let mut command = Command::new(&argv[0]); command .args(&argv[1..]) @@ -120,33 +217,123 @@ pub fn stream(request: &Request) -> Result { if let Some(cwd) = &request.cwd { command.current_dir(cwd); } + if request.clear_env { + command.env_clear(); + } for (key, value) in &request.env { command.env(key, value); } - let child = command.spawn().map_err(|source| Error::Spawn { - bin: plan.bin.clone(), - source, + // 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); + + 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 request = request.clone(); - let task = tokio::spawn(drive(child, request, tx)); + let task = runtime.spawn(drive(child, request, tx)); Ok(Run { events: rx, - task, + task: Some(task), argv, }) } +/// 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); + } + } +} + +/// Signal an entire process group, so commands the agent spawned die with it. +/// +/// Best effort by nature: the group may already be gone, which is not a failure. +/// On Windows there is no equivalent here and only the direct child is killed; +/// containing a tree there needs a Job Object, which this crate does not yet +/// set up. +#[cfg(unix)] +fn kill_process_group(child: &tokio::process::Child) { + if let Some(pid) = child.id() { + // Negating the pid targets the group, which `process_group(0)` made this + // child the leader of. + // SAFETY: `libc::kill` has no safe wrapper. The pid comes from a live + // `Child`, and signalling a group that has already exited returns ESRCH + // rather than doing anything undefined. + // A pid always fits in i32; the cast back is how the group is addressed. + let Ok(pid) = i32::try_from(pid) else { return }; + #[allow(unsafe_code)] + unsafe { + libc::kill(-pid, libc::SIGKILL); + } + } +} + +#[cfg(not(unix))] +fn kill_process_group(_child: &tokio::process::Child) {} + /// Feed the child, read both its pipes, and assemble the outcome. -async fn drive(mut child: Child, request: Request, events: mpsc::Sender) -> Result { +#[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) -> 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(); + // An assigned id is known before anything runs, so record it now. This is + // what makes the session survive a run that times out, crashes, or is + // cancelled: the binding does not depend on reaching the end. + if let Some(token) = preassigned_token(&request) { + persist_session(&request, &token)?; + } + // Deliver a piped prompt and close the pipe, or the agent waits on EOF. if plan.stdin_prompt { - if let Some(mut stdin) = child.stdin.take() { + if let Some(mut stdin) = child.child.stdin.take() { let prompt = request.agent.effective_prompt(&plan); stdin .write_all(prompt.as_bytes()) @@ -161,7 +348,7 @@ async fn drive(mut child: Child, request: Request, events: mpsc::Sender) // Drain stderr on its own task: a full stderr pipe blocks the child even // while stdout still has room. - let stderr = child.stderr.take(); + let stderr = child.child.stderr.take(); let stderr_task = tokio::spawn(async move { let mut buf = String::new(); if let Some(handle) = stderr { @@ -175,13 +362,17 @@ async fn drive(mut child: Child, request: Request, events: mpsc::Sender) buf }); - let stdout = child.stdout.take(); + 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 { @@ -189,6 +380,15 @@ async fn drive(mut child: Child, request: Request, events: mpsc::Sender) while let Some(line) = lines.next_line().await? { 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() { @@ -206,14 +406,19 @@ async fn drive(mut child: Child, request: Request, events: mpsc::Sender) Some(limit) => { match tokio::time::timeout(limit, async { read_stdout.await?; - child.wait().await + child.child.wait().await }) .await { Ok(result) => result, Err(_elapsed) => { - // Kill, then reap, so no zombie is left behind. - let _ = child.kill().await; + // 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 doing it the other way round + // silently leaves every grandchild running. + kill_process_group(&child.child); + let _ = child.child.kill().await; + child.armed = false; return Err(Error::Timeout { bin, timeout: limit, @@ -223,7 +428,7 @@ async fn drive(mut child: Child, request: Request, events: mpsc::Sender) } } None => match read_stdout.await { - Ok(()) => child.wait().await, + Ok(()) => child.child.wait().await, Err(source) => Err(source), }, } @@ -232,6 +437,9 @@ async fn drive(mut child: Child, request: Request, events: mpsc::Sender) 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 terminal = parser.finish(); @@ -244,11 +452,26 @@ async fn drive(mut child: Child, request: Request, events: mpsc::Sender) terminal.text = raw.trim().to_string(); } - if exit_code != 0 { + // 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)); } - persist_session(&request, &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, @@ -312,28 +535,29 @@ fn first_meaningful_line(text: &str) -> Option { .map(str::to_string) } -/// Write the session binding back, if this run was attached to a name. +/// Write the session binding back, reporting any store failure. /// -/// Best-effort: a store that cannot be written must not discard a completed -/// run's result. The next turn simply starts a new conversation. -fn persist_session(request: &Request, terminal: &Terminal) { +/// 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; + return Ok(()); }; - // Prefer the id the agent reported. For a minted session it is the one we - // assigned, so the two agree; for a forked one the agent reports the *new* - // branch, which is what the name should now follow. - let token = terminal - .session - .clone() - .or_else(|| match &request.plan().cont { - Continue::NewWith(id) => Some(id.clone()), - _ => None, - }); - if let Some(token) = token { - let _ = binding - .store - .bind(request.agent, &binding.project, &binding.name, &token); + 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, } } @@ -409,6 +633,51 @@ mod tests { 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 argv = crate::Request::new(Agent::Claude, "my secret prompt") + .system("secret system") + .session_id("11111111-2222-3333-4444-555555555555") + .argv() + .unwrap(); + let safe = redact(&argv); + + 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 argv = crate::Request::new(Agent::Codex, "my secret prompt") + .argv() + .unwrap(); + let safe = redact(&argv); + assert_eq!(safe.last().unwrap(), REDACTED); + assert_eq!(safe[1], "exec", "the subcommand must survive"); + } + + /// `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"); diff --git a/src/session.rs b/src/session.rs index e2ccd08..8aee8a7 100644 --- a/src/session.rs +++ b/src/session.rs @@ -86,6 +86,11 @@ impl SessionStore { } /// 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 @@ -93,15 +98,33 @@ impl SessionStore { .join(format!("{}.json", encode_segment(name))) } - /// The stored record, or `None` when absent. + /// The stored record, or `None` when there is none. /// - /// A corrupt record reads as absent: the next turn starts a fresh - /// conversation, which is recoverable, rather than failing the run over a - /// cache the caller never asked about. - #[must_use] - pub fn get(&self, project: &Path, name: &str) -> Option { - let text = fs::read_to_string(self.path_of(project, name)).ok()?; - serde_json::from_str(&text).ok() + /// 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. @@ -141,7 +164,7 @@ impl SessionStore { what: "named sessions (it exposes no session id headlessly)", }); } - let existing = self.get(project, name); + let existing = self.get(project, name)?; if let Some(record) = &existing { if record.agent != agent { return Err(Error::SessionConflict { @@ -188,13 +211,26 @@ impl SessionStore { 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), + created: self.get(project, name)?.map_or(now, |r| r.created), updated: now, }; @@ -205,15 +241,26 @@ impl SessionStore { }; 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. - let tmp = path.with_extension("json.tmp"); - fs::write(&tmp, text).map_err(store_err)?; - fs::rename(&tmp, &path).map_err(store_err)?; + // 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) + })?; Ok(record) } @@ -234,6 +281,50 @@ impl SessionStore { } } +/// 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() +} + +/// 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 { @@ -415,7 +506,7 @@ mod tests { store .bind(Agent::Claude, &project, "Greet Flow ☕", "t-1") .unwrap(); - let record = store.get(&project, "Greet Flow ☕").unwrap(); + let record = store.get(&project, "Greet Flow ☕").unwrap().unwrap(); assert_eq!(record.name, "Greet Flow ☕"); assert_eq!(store.list(&project)[0].name, "Greet Flow ☕"); fs::remove_dir_all(&store.dir).ok(); @@ -468,7 +559,7 @@ mod tests { assert_eq!(phase, Phase::Continue); assert_eq!(cont, Continue::Resume("sess-1".into())); - let record = store.get(&project, "chat").unwrap(); + 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(); @@ -530,19 +621,21 @@ mod tests { } #[test] - fn a_corrupt_record_reads_as_absent_rather_than_failing() { + 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(); - assert!(store.get(&project, "chat").is_none()); - assert_eq!( - store - .plan(Agent::Claude, &project, "chat", false) - .unwrap() - .0, - Phase::Create - ); + // 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(); } @@ -556,7 +649,7 @@ mod tests { assert_eq!(names, ["a", "b"]); store.forget(&project, "a").unwrap(); - assert!(store.get(&project, "a").is_none()); + assert!(store.get(&project, "a").unwrap().is_none()); // Forgetting twice is not an error. store.forget(&project, "a").unwrap(); assert_eq!(store.list(&project).len(), 1); @@ -569,8 +662,8 @@ mod tests { 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().token, "t-1"); - assert_eq!(store.get(&other, "chat").unwrap().token, "t-2"); + 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 index 9113dd6..68211f1 100644 --- a/tests/live.rs +++ b/tests/live.rs @@ -21,7 +21,9 @@ const PING: &str = "Reply with the single word: pong. No punctuation, no explana /// Whether the agent's binary is on PATH; tests no-op without it. fn available(agent: Agent) -> bool { - let found = which::which(agent.bin()).is_ok(); + 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()); } diff --git a/tests/process.rs b/tests/process.rs new file mode 100644 index 0000000..302b61a --- /dev/null +++ b/tests/process.rs @@ -0,0 +1,167 @@ +//! 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, 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; + + running.cancel().await; + settle().await; + + assert!(!alive(grandchild), "cancel left a grandchild alive"); + 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(); +} From db44f365ede8c3a21ab68ca60b8b1186b443aefa Mon Sep 17 00:00:00 2001 From: meh Date: Wed, 29 Jul 2026 00:46:48 +0700 Subject: [PATCH 07/12] Replace clear_env with a policy the crate can actually satisfy clear_env() was a footgun: it exposed an option known not to work, since an empty environment breaks every agent until the caller re-adds PATH, HOME and the CLI's credential variable. Which variables an agent needs is knowledge about the agent, so the crate should hold it, not each caller. EnvPolicy::{Inherit, Minimal, Only} replaces it, with Minimal defined by Agent::essential_env(). The list is derived by experiment rather than assumed: PATH + HOME alone fails, because Claude's keychain lookup is keyed on USER and reports "Not logged in" without it. PATH + HOME + USER is the verified floor for all three on macOS. The Windows names are reasoned, not verified. Proxy and custom-CA variables are deliberately excluded. They are situational rather than required, and HTTP_PROXY/HTTPS_PROXY routinely embed credentials, so forwarding them automatically would leak one through the policy whose whole purpose is withholding secrets. NETWORK_ENV names them so a host can offer them as an explicit setting without hardcoding the list. Also fixes a real bug that only a multi-turn Codex run could reveal: `codex exec resume` does not accept --sandbox. It is a different option set from `codex exec` and rejects the flag outright, so every second turn carrying a permission posture failed with "unexpected argument '--sandbox'". Every existing test was single-turn, so none caught it. The resume path now applies the posture as `-c sandbox_mode=...`, verified live to both work and retain context. Dropping the sandbox on resume instead would have silently continued a conversation under a different posture than the caller asked for. Tests: EnvPolicy::Minimal must withhold the host's own variables (using the CARGO_* set cargo injects, so it needs no secret and no unsafe set_var) while still passing PATH and HOME; every agent must still authenticate under it, so an incomplete list fails loudly; no agent may request another's credentials or any proxy variable. Codex resume is now covered end to end across two processes, proving continuity comes from the captured thread_id and not from reading $CODEX_HOME/sessions. Narrowed the public API for real this time: Plan, Continue, Parser, Terminal and STDIN_THRESHOLD were still exported because the earlier edit silently failed to apply. --- README.md | 41 +++++++++ src/agent.rs | 214 +++++++++++++++++++++++++++++++++++++++++++++-- src/event.rs | 2 +- src/lib.rs | 2 +- src/request.rs | 37 ++++---- src/run.rs | 26 +++++- tests/live.rs | 90 +++++++++++++++++++- tests/process.rs | 69 ++++++++++++++- 8 files changed, 453 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 3d33b95..c681fb3 100644 --- a/README.md +++ b/README.md @@ -159,12 +159,53 @@ Two more honest limits: Codex has no true plan mode, so `Plan` maps to its read- sandbox (writes blocked, execution still permitted), and `unchecked_args` can contradict any of this by design. +## Environment isolation + +By default the agent inherits the whole parent environment, which is how the CLIs find their +credentials. In an embedded host that also hands the agent, and every command it runs, any +unrelated secret the process happens to hold. + +```rust +Request::new(Agent::Claude, "review this").env_policy(EnvPolicy::Minimal) +``` + +`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. diff --git a/src/agent.rs b/src/agent.rs index 20f2625..81c7f70 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -92,6 +92,60 @@ pub enum Permission { Bypass, } +/// Environment variables that route an agent's traffic through a corporate +/// proxy or a custom certificate authority. +/// +/// 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. +/// +/// ```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", + "NO_PROXY", + "http_proxy", + "https_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::Inherit`], matching how a CLI behaves when run +/// from a shell. In an embedded host that also hands the agent, and every +/// command it runs, whatever unrelated secrets the process happens to hold: +/// cloud credentials, CI tokens, database URLs. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum EnvPolicy { + /// Pass the whole parent environment through. + #[default] + Inherit, + /// 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. + Minimal, + /// 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")] @@ -145,7 +199,7 @@ pub struct Plan { /// 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 const STDIN_THRESHOLD: usize = 128 * 1024; +pub(crate) const STDIN_THRESHOLD: usize = 128 * 1024; impl Agent { /// Every agent, in a stable order. @@ -181,6 +235,68 @@ impl Agent { } } + /// 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. 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", + "COPILOT_ALLOW_ALL", + "XDG_CONFIG_HOME", + ], + }; + BASE.iter().chain(WINDOWS).chain(agent).copied().collect() + } + /// What this agent supports. #[must_use] pub fn caps(self) -> Caps { @@ -437,10 +553,24 @@ fn argv_codex(plan: &Plan) -> Vec { // `read-only` by default, so nothing is unrecoverable regardless. a.bare("--skip-git-repo-check"); - match plan.permission { - Permission::Bypass => a.bare("--dangerously-bypass-approvals-and-sandbox"), - Permission::ReadOnly | Permission::Plan => a.pair("--sandbox", "read-only"), - Permission::Edit | Permission::Auto => a.pair("--sandbox", "workspace-write"), + // `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()); @@ -629,6 +759,47 @@ mod tests { 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 @@ -675,6 +846,39 @@ mod tests { } } + /// `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"); diff --git a/src/event.rs b/src/event.rs index 7a62153..7cfbc38 100644 --- a/src/event.rs +++ b/src/event.rs @@ -125,7 +125,7 @@ pub struct Terminal { /// Incrementally turns one agent's output into [`Event`]s and a [`Terminal`]. #[derive(Debug)] -pub struct Parser { +pub(crate) struct Parser { agent: Agent, format: Format, term: Terminal, diff --git a/src/lib.rs b/src/lib.rs index 18b9d10..5e4241b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -93,7 +93,7 @@ mod request; mod run; mod session; -pub use agent::{Agent, Caps, Continue, Format, Permission, Plan, STDIN_THRESHOLD, SessionSupport}; +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}; diff --git a/src/request.rs b/src/request.rs index d4e00c6..0971dce 100644 --- a/src/request.rs +++ b/src/request.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use std::time::Duration; -use crate::agent::{Agent, Continue, Format, Permission, Plan, STDIN_THRESHOLD}; +use crate::agent::{Agent, Continue, EnvPolicy, Format, Permission, Plan, STDIN_THRESHOLD}; use crate::error::Result; use crate::session::{Phase, SessionStore}; @@ -31,7 +31,7 @@ pub struct Request { pub(crate) cwd: Option, pub(crate) env: Vec<(String, String)>, pub(crate) extra_args: Vec, - pub(crate) clear_env: bool, + 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. @@ -65,7 +65,7 @@ impl Request { cwd: None, env: Vec::new(), extra_args: Vec::new(), - clear_env: false, + env_policy: EnvPolicy::Inherit, timeout: None, binding: None, } @@ -123,21 +123,22 @@ impl Request { self } - /// Start from an empty environment instead of inheriting the host's. + /// Choose which of the host's environment variables reach the agent. /// - /// By default the agent inherits every variable this process holds, which - /// is how the CLIs find their own credentials, `PATH` and `HOME`. In an - /// embedded host that same inheritance also hands the agent, and anything - /// it runs, every unrelated secret in the process: cloud credentials, CI - /// tokens, database URLs. + /// Defaults to [`EnvPolicy::Inherit`]. [`EnvPolicy::Minimal`] is the one to + /// reach for when embedding in a process that holds unrelated secrets: it + /// passes through only what the selected agent actually needs, a list the + /// crate maintains per agent, so it isolates without a caller having to + /// work out what to put back. /// - /// With this set, only variables passed to [`Request::env`] reach the child. - /// That is the stronger position, but it is opt-in because an empty - /// environment breaks every agent until you supply at least `PATH`, `HOME` - /// and the CLI's own credential variable. + /// ```no_run + /// # use agent_abstraction::{Agent, EnvPolicy, Request}; + /// let request = Request::new(Agent::Claude, "review this") + /// .env_policy(EnvPolicy::Minimal); + /// ``` #[must_use] - pub fn clear_env(mut self) -> Self { - self.clear_env = true; + pub fn env_policy(mut self, policy: EnvPolicy) -> Self { + self.env_policy = policy; self } @@ -279,8 +280,12 @@ impl Request { } /// 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 fn plan(&self) -> Plan { + pub(crate) fn plan(&self) -> Plan { Plan { bin: self .bin diff --git a/src/run.rs b/src/run.rs index 68f0acb..d3ce8c2 100644 --- a/src/run.rs +++ b/src/run.rs @@ -15,7 +15,7 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::process::{Child, Command}; use tokio::sync::mpsc; -use crate::agent::Continue; +use crate::agent::{Continue, EnvPolicy}; use crate::error::{Error, Result}; use crate::event::{Event, Parser, Terminal, append_capped}; use crate::outcome::{Outcome, Stop}; @@ -217,8 +217,18 @@ pub fn stream(request: &Request) -> Result { if let Some(cwd) = &request.cwd { command.current_dir(cwd); } - if request.clear_env { - command.env_clear(); + // 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); @@ -262,6 +272,16 @@ pub fn stream(request: &Request) -> Result { }) } +/// 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 diff --git a/tests/live.rs b/tests/live.rs index 68211f1..156afb6 100644 --- a/tests/live.rs +++ b/tests/live.rs @@ -13,7 +13,9 @@ use std::time::Duration; -use agent_abstraction::{Agent, Event, Format, Permission, Request, SessionStore, run, stream}; +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. @@ -216,6 +218,92 @@ async fn codex_reveals_its_thread_id_before_it_answers() { 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] diff --git a/tests/process.rs b/tests/process.rs index 302b61a..00d8e96 100644 --- a/tests/process.rs +++ b/tests/process.rs @@ -13,7 +13,7 @@ use std::path::{Path, PathBuf}; use std::time::Duration; -use agent_abstraction::{Agent, Request, stream}; +use agent_abstraction::{Agent, EnvPolicy, Request, stream}; /// A scratch directory unique to one test. fn scratch(tag: &str) -> PathBuf { @@ -165,3 +165,70 @@ async fn detach_lets_a_run_outlive_its_handle() { .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()).await; + assert!( + inherited.contains("CARGO"), + "the control case is broken: Inherit should pass the host environment" + ); + + let minimal = captured_env(&base().env_policy(EnvPolicy::Minimal)).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(); +} From 0f598a7fbcf35f5f10980bc0828d9ed878f8b0f3 Mon Sep 17 00:00:00 2001 From: meh Date: Wed, 29 Jul 2026 00:53:19 +0700 Subject: [PATCH 08/12] Add ALL_PROXY to NETWORK_ENV, and stop overclaiming proxy support ALL_PROXY/all_proxy are referenced by Claude Code and Codex but were missing from the list. Reworded the doc comment: it previously said all three CLIs "honour these", which rested on finding the names in the shipped binaries. That shows the names are referenced, not that provider traffic respects them. No vendor documents proxy support and none exposes a proxy flag, so the list is a convenience for hosts that want to forward these names, not a supported-behaviour claim. --- src/agent.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/agent.rs b/src/agent.rs index 81c7f70..4a3e4be 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -95,11 +95,22 @@ pub enum Permission { /// 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); @@ -113,9 +124,11 @@ pub enum Permission { 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", From fb80dd4c692bf0b36e2aa8eb3e22aaebf7c845d8 Mon Sep 17 00:00:00 2001 From: meh Date: Wed, 29 Jul 2026 00:56:31 +0700 Subject: [PATCH 09/12] Make SessionStore::plan crate-internal It was public while returning `Continue`, which became private when the API was narrowed, so a public method returned a type no caller could name. CI caught it as a broken doc link, but the link was the symptom. Callers reach this through Request::session and can read the decision back via Request::session_phase, so nothing external loses access. Local verification now runs the full CI job (fmt, clippy, test, doctests, doc) rather than clippy and tests alone, which is why the doc step was missed. --- src/session.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/session.rs b/src/session.rs index 8aee8a7..a8746af 100644 --- a/src/session.rs +++ b/src/session.rs @@ -141,16 +141,20 @@ impl SessionStore { .collect() } - /// Decide how `name` continues, and produce the [`Continue`] to run with. + /// 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 fn plan( + pub(crate) fn plan( &self, agent: Agent, project: &Path, From 254bf31bdabd17ef282a3405ec43ecdae8b5763c Mon Sep 17 00:00:00 2001 From: meh Date: Wed, 29 Jul 2026 01:04:04 +0700 Subject: [PATCH 10/12] Isolate the crate's only unsafe, and document two known limits Review #3: - Moved process-group teardown into src/proc.rs. The unsafe was already reconciled with the lint (deny rather than forbid, an #[allow] on the block, a SAFETY comment, CI green), but it sat inside the runner, so "where does this crate use unsafe and why" was not answerable from one place. It is now one small module whose docs explain why signalling a group has no safe equivalent in std and why a dependency for one call costs more than it saves. The #[allow] became #[expect], so removing the unsafe later flags the exception as stale instead of leaving it behind. - Recorded the Windows gap in AGENTS.md as an invariant rather than only in the README: cancellation tears down the whole tree on Unix and kills only the direct child on Windows, containing a tree there needs a Job Object, and tests/process.rs stays #![cfg(unix)] instead of passing vacuously elsewhere. - Explained the encoding tradeoff in session.rs. ASCII names stay readable on disk while non-ASCII ones get verbose, because a collision resumes the wrong conversation and unreadable beats wrong. --- AGENTS.md | 10 ++++++++ src/lib.rs | 1 + src/proc.rs | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/run.rs | 27 +------------------- src/session.rs | 8 ++++++ 5 files changed, 88 insertions(+), 26 deletions(-) create mode 100644 src/proc.rs diff --git a/AGENTS.md b/AGENTS.md index 8798d47..0ea67dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,15 @@ Copilot CLIs headlessly behind one API. Consumed as a direct dependency by the - **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 @@ -59,6 +68,7 @@ Pure logic and I/O are kept apart so the mappings are testable without spawning | `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. | diff --git a/src/lib.rs b/src/lib.rs index 5e4241b..246620a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -89,6 +89,7 @@ mod agent; mod error; mod event; mod outcome; +mod proc; mod request; mod run; mod session; 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/run.rs b/src/run.rs index d3ce8c2..a25cfe1 100644 --- a/src/run.rs +++ b/src/run.rs @@ -19,6 +19,7 @@ use crate::agent::{Continue, EnvPolicy}; use crate::error::{Error, Result}; use crate::event::{Event, Parser, Terminal, append_capped}; use crate::outcome::{Outcome, Stop}; +use crate::proc::kill_process_group; use crate::request::Request; /// How many events may queue before the producer waits for the consumer. Deep @@ -303,32 +304,6 @@ impl Drop for ChildGuard { } } -/// Signal an entire process group, so commands the agent spawned die with it. -/// -/// Best effort by nature: the group may already be gone, which is not a failure. -/// On Windows there is no equivalent here and only the direct child is killed; -/// containing a tree there needs a Job Object, which this crate does not yet -/// set up. -#[cfg(unix)] -fn kill_process_group(child: &tokio::process::Child) { - if let Some(pid) = child.id() { - // Negating the pid targets the group, which `process_group(0)` made this - // child the leader of. - // SAFETY: `libc::kill` has no safe wrapper. The pid comes from a live - // `Child`, and signalling a group that has already exited returns ESRCH - // rather than doing anything undefined. - // A pid always fits in i32; the cast back is how the group is addressed. - let Ok(pid) = i32::try_from(pid) else { return }; - #[allow(unsafe_code)] - unsafe { - libc::kill(-pid, libc::SIGKILL); - } - } -} - -#[cfg(not(unix))] -fn kill_process_group(_child: &tokio::process::Child) {} - /// Feed the child, read both its pipes, and assemble the outcome. #[allow( clippy::too_many_lines, diff --git a/src/session.rs b/src/session.rs index a8746af..6eb6c31 100644 --- a/src/session.rs +++ b/src/session.rs @@ -356,6 +356,14 @@ const MAX_STEM: usize = 200; /// 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 hash of /// the full input, so the length bound costs readability but never uniqueness. fn encode_segment(name: &str) -> String { From 3bac2fb716db5bee8e1dfa25dd0991f6d4409a61 Mon Sep 17 00:00:00 2001 From: meh Date: Wed, 29 Jul 2026 01:19:38 +0700 Subject: [PATCH 11/12] Review #4: cooperative cancel, real output bounds, typed redaction Merge-blocking items, in order. 1. Cancellation is cooperative rather than an abort. Run::cancel now signals the driver, which kills the process group, reaps the child and joins its stderr reader before returning Error::Cancelled, so the tree really has exited when the call returns. The previous version aborted the task and awaited the JoinError, which killed the group but did not wait for the reap, so the doc claim of "signalled and reaped" was not quite true. Drop cannot await, so it signals and then aborts as a backstop. Covered by a test that checks the grandchild immediately after cancel returns, with no grace period. 2. Output limits are now real. lines() accumulates until a newline, so a stream that never emits one exhausted memory long before MAX_CAPTURE applied. Both pipes use a bounded reader that stops accumulating past MAX_LINE and keeps draining. Tool-call entries are removed when their result arrives and capped at MAX_PENDING_TOOLS. Covered by a test that floods 64 MiB on a single line. 3. Session naming: "" and "unnamed" both encoded to "unnamed", so an empty name collided with a caller who literally used that word. Empty now maps to "%", which no other input can produce because a literal % always escapes. Also stopped claiming the truncated-hash path preserves uniqueness absolutely; the docs now state the real guarantee and its bound. 5. Assigned session ids are reserved before spawning rather than at the start of the driver, closing the window where a half-successful spawn loses a binding the caller may already be showing. 6. Redaction is derived from per-argument sensitivity recorded where each argument is built, not reconstructed from the finished line. The heuristic missed exactly the cases that matter: Codex's bare trailing prompt and anything from unchecked_args. Both are now covered by tests. 7. COPILOT_ALLOW_ALL is out of the minimal environment. It is Copilot's env equivalent of --allow-all-tools, so inheriting it let the host's ambient environment widen a run's permissions behind Permission's back. This one was a real hole. 8. Structured runs are validated: no recognized records, or no terminal record, is Error::Parse rather than a silent fallback to raw stdout dressed up as an answer. Raw fallback remains only for Format::Text, where the stream is the answer. 9. The whole command line is size-checked, since moving the prompt to stdin does not move Claude's --append-system-prompt or any unchecked argument. Returns CommandLineTooLarge naming which input overflowed instead of leaving the OS to answer E2BIG. 10. list() returns Result; only a missing directory is empty. list_lossy() is the explicit skip-bad-records variant. 11. The session directory is fsynced after the rename, since syncing the file persists its contents but not the entry that names it. 14. #[non_exhaustive] on the types expected to grow. Deliberately not done: per-session cross-process leases (4), removing Codex's git-check bypass (12, the maintainer's call given a review-only use case), the full PreparedRun refactor (13, partly addressed by the typed argv), and typed wire structs for the parsers (15). Full CI locally and the live suite 11/11 against all three agents. --- README.md | 20 ++- src/agent.rs | 145 ++++++++++++++----- src/error.rs | 38 +++++ src/event.rs | 89 +++++++++++- src/outcome.rs | 4 + src/request.rs | 84 ++++++++++- src/run.rs | 358 ++++++++++++++++++++++++++++++++--------------- src/session.rs | 97 +++++++++++-- tests/process.rs | 48 ++++++- 9 files changed, 717 insertions(+), 166 deletions(-) diff --git a/README.md b/README.md index c681fb3..52b626c 100644 --- a/README.md +++ b/README.md @@ -122,12 +122,16 @@ quota and writing files with nobody watching. ```rust let running = stream(&request)?; -// ... user closes the tab -drop(running); // agent and its child processes are gone -running.cancel().await; // same, but waits until they are actually dead -running.detach(); // opt out: keep running unsupervised +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 @@ -236,7 +240,13 @@ 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. +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 diff --git a/src/agent.rs b/src/agent.rs index 4a3e4be..131d752 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -40,6 +40,7 @@ pub enum SessionSupport { /// 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, @@ -142,6 +143,7 @@ pub const NETWORK_ENV: &[&str] = &[ /// command it runs, whatever unrelated secrets the process happens to hold: /// cloud credentials, CI tokens, database URLs. #[derive(Debug, Clone, PartialEq, Eq, Default)] +#[non_exhaustive] pub enum EnvPolicy { /// Pass the whole parent environment through. #[default] @@ -214,6 +216,14 @@ pub struct Plan { /// 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]; @@ -252,7 +262,10 @@ impl Agent { /// [`EnvPolicy::Minimal`]. /// /// Two groups: what any process needs to start, and this agent's own - /// credential and config variables. A name absent from the parent + /// 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 @@ -300,12 +313,7 @@ impl Agent { "OPENAI_API_KEY", "OPENAI_BASE_URL", ], - Agent::Copilot => &[ - "GH_TOKEN", - "GITHUB_TOKEN", - "COPILOT_ALLOW_ALL", - "XDG_CONFIG_HOME", - ], + Agent::Copilot => &["GH_TOKEN", "GITHUB_TOKEN", "XDG_CONFIG_HOME"], }; BASE.iter().chain(WINDOWS).chain(agent).copied().collect() } @@ -407,6 +415,18 @@ impl Agent { /// # 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), @@ -432,27 +452,68 @@ impl fmt::Display for Agent { } } +/// 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`. -struct Argv(Vec); +/// 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![bin.to_string()]) + 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.0.push(flag.to_string()); - self + self.push(flag, Sensitivity::Public) } - /// A flag and its value, as two arguments. + /// A flag and a value that is safe to show. fn pair(&mut self, flag: &str, value: impl AsRef) -> &mut Self { - self.0.push(flag.to_string()); - self.0.push(value.as_ref().to_string()); - 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. @@ -463,13 +524,17 @@ impl Argv { self } - /// A positional argument. + /// A positional argument that is safe to show. fn arg(&mut self, value: impl Into) -> &mut Self { - self.0.push(value.into()); - 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 { + fn done(&mut self) -> Vec { std::mem::take(&mut self.0) } } @@ -491,7 +556,7 @@ fn claude_mode(p: Permission) -> &'static str { } /// `claude -p --permission-mode M --output-format F [...]` -fn argv_claude(plan: &Plan) -> Vec { +fn argv_claude(plan: &Plan) -> Vec { let mut a = Argv::new(&plan.bin); a.bare("-p"); if plan.stdin_prompt { @@ -499,7 +564,7 @@ fn argv_claude(plan: &Plan) -> Vec { // large prompt never has to fit on the argv. a.pair("--input-format", "text"); } else { - a.arg(Agent::Claude.effective_prompt(plan)); + a.arg_sensitive(Agent::Claude.effective_prompt(plan), Sensitivity::Prompt); } a.pair("--permission-mode", claude_mode(plan.permission)); @@ -515,20 +580,23 @@ fn argv_claude(plan: &Plan) -> Vec { } a.opt("--model", plan.model.as_ref()); - a.opt("--append-system-prompt", plan.system.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.pair("--session-id", id); + a.secret("--session-id", id, Sensitivity::SessionId); } Continue::Resume(id) => { - a.pair("--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.pair("--resume", id).bare("--fork-session"); + a.secret("--resume", id, Sensitivity::SessionId) + .bare("--fork-session"); } } @@ -550,12 +618,13 @@ fn argv_claude(plan: &Plan) -> Vec { /// `codex exec [resume ] --skip-git-repo-check [sandbox flags] [--model M] /// [--json] ` -fn argv_codex(plan: &Plan) -> Vec { +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(id.clone()); + a.bare("resume") + .arg_sensitive(id.clone(), Sensitivity::SessionId); } // `codex exec` aborts outside a git repository unless told not to. That @@ -594,11 +663,13 @@ fn argv_codex(plan: &Plan) -> Vec { // 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. - a.arg(if plan.stdin_prompt { - "-".to_string() + // 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 { - Agent::Codex.effective_prompt(plan) - }); + a.arg_sensitive(Agent::Codex.effective_prompt(plan), Sensitivity::Prompt); + } a.done() } @@ -608,12 +679,16 @@ fn argv_codex(plan: &Plan) -> Vec { /// `--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 { +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.pair("-p", Agent::Copilot.effective_prompt(plan)); + a.secret( + "-p", + Agent::Copilot.effective_prompt(plan), + Sensitivity::Prompt, + ); } // Without this, a headless run stops at the first tool confirmation. @@ -635,7 +710,7 @@ fn argv_copilot(plan: &Plan) -> Vec { // resumes an existing one by id. match &plan.cont { Continue::NewWith(id) | Continue::Resume(id) => { - a.pair("--session-id", id); + a.secret("--session-id", id, Sensitivity::SessionId); } // `Fork` is rejected by `Agent::check` before reaching here. Continue::New | Continue::Fork(_) => {} diff --git a/src/error.rs b/src/error.rs index 44cf674..b622de6 100644 --- a/src/error.rs +++ b/src/error.rs @@ -124,6 +124,37 @@ pub enum Error { 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 @@ -157,4 +188,11 @@ impl Error { 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 index 7cfbc38..b11ffa4 100644 --- a/src/event.rs +++ b/src/event.rs @@ -30,6 +30,7 @@ use crate::outcome::{RateLimit, Stop, Usage}; /// 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 { @@ -73,6 +74,20 @@ pub enum Event { /// 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. /// @@ -133,6 +148,10 @@ pub(crate) struct Parser { 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 { @@ -145,6 +164,8 @@ impl Parser { term: Terminal::default(), tools: HashMap::new(), started: false, + structured: false, + terminal_seen: false, } } @@ -180,6 +201,13 @@ impl Parser { } 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), @@ -202,6 +230,48 @@ impl Parser { 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 { @@ -231,6 +301,7 @@ impl Parser { // 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(); } @@ -281,7 +352,7 @@ impl Parser { .to_string(); let id = block.get("id").and_then(Value::as_str).map(str::to_string); if let Some(id) = &id { - self.tools.insert(id.clone(), name.clone()); + self.remember_tool(id, &name); } out.push(Event::ToolCall { id, @@ -293,6 +364,10 @@ impl Parser { 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") @@ -320,10 +395,12 @@ impl Parser { } 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() } @@ -372,6 +449,9 @@ impl Parser { // 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 @@ -432,7 +512,7 @@ impl Parser { let id = field("toolCallId"); let name = field("toolName").unwrap_or_else(|| "tool".into()); if let Some(id) = &id { - self.tools.insert(id.clone(), name.clone()); + self.remember_tool(id, &name); } vec![Event::ToolCall { id, @@ -444,7 +524,9 @@ impl Parser { }] } "tool.execution_complete" => vec![Event::ToolResult { - id: field("toolCallId"), + 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")) @@ -455,6 +537,7 @@ impl Parser { }], // 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()); } diff --git a/src/outcome.rs b/src/outcome.rs index 5706082..74745c4 100644 --- a/src/outcome.rs +++ b/src/outcome.rs @@ -11,6 +11,7 @@ use crate::agent::Agent; /// 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, @@ -40,6 +41,7 @@ impl Usage { /// 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, @@ -61,6 +63,7 @@ impl RateLimit { /// 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] @@ -73,6 +76,7 @@ pub enum Stop { /// The result of one completed run. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] pub struct Outcome { /// Which agent produced it. pub agent: Agent, diff --git a/src/request.rs b/src/request.rs index 0971dce..c5a44ed 100644 --- a/src/request.rs +++ b/src/request.rs @@ -3,7 +3,9 @@ use std::path::{Path, PathBuf}; use std::time::Duration; -use crate::agent::{Agent, Continue, EnvPolicy, Format, Permission, Plan, STDIN_THRESHOLD}; +use crate::agent::{ + Agent, Continue, EnvPolicy, Format, MAX_COMMAND_LINE, Permission, Plan, STDIN_THRESHOLD, +}; use crate::error::Result; use crate::session::{Phase, SessionStore}; @@ -311,8 +313,54 @@ impl Request { /// # Errors /// [`crate::Error::Unsupported`] if the agent cannot honour this request. pub fn argv(&self) -> Result> { - let mut argv = self.agent.argv(&self.plan())?; - argv.extend(self.extra_args.iter().cloned()); + 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) } } @@ -351,6 +399,36 @@ mod tests { ); } + /// 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); diff --git a/src/run.rs b/src/run.rs index a25cfe1..a13e6c9 100644 --- a/src/run.rs +++ b/src/run.rs @@ -11,17 +11,58 @@ use std::process::Stdio; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +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, Parser, Terminal, append_capped}; +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. @@ -41,6 +82,12 @@ const EVENT_BUFFER: usize = 256; #[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. @@ -64,11 +111,16 @@ impl Run { &self.argv } - /// The command line with prompt and session id replaced by placeholders, - /// safe to log. + /// 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.argv) + redact(&self.typed) } /// Wait for the run to finish. @@ -103,17 +155,34 @@ impl Run { /// Stop the run and wait until the agent is actually gone. /// - /// Deterministic, unlike dropping: when this returns, the process and its - /// children have been signalled and reaped. Prefer it over `drop` when you - /// need to know the agent has stopped before doing something else, such as - /// mutating the files it was working on. - pub async fn cancel(mut self) { - if let Some(task) = self.task.take() { - task.abort(); - // Aborting drops the driver's `Child`, whose `kill_on_drop` and - // process-group teardown do the actual killing. Awaiting the - // JoinError is what guarantees that has happened. - let _ = task.await; + /// 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() + }, + }), } } @@ -123,6 +192,11 @@ impl Run { /// 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()); } @@ -130,51 +204,35 @@ impl Run { impl Drop for Run { fn drop(&mut self) { - // Still holding the handle means the caller abandoned this run rather - // than finishing, cancelling or detaching it. Abort, which drops the - // driver's `Child` and triggers the kill path. + // 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(); } } } -/// Placeholders substituted for sensitive argv values. +/// Placeholder substituted for a sensitive argv value. const REDACTED: &str = ""; -/// Replace prompt and session-id values with a placeholder. +/// Render a typed command line for logging, keeping flag names and replacing +/// every value that is not `Public`. /// -/// Flag *names* are kept so a redacted command line is still recognisable; only -/// the values that carry user content or a resumable handle are removed. -fn redact(argv: &[String]) -> Vec { - /// Flags whose following argument is sensitive. - const SENSITIVE_FLAGS: &[&str] = &[ - "-p", - "--prompt", - "--append-system-prompt", - "--system", - "--session-id", - "--resume", - ]; - let mut out = Vec::with_capacity(argv.len()); - let mut redact_next = false; - for (i, arg) in argv.iter().enumerate() { - if redact_next { - out.push(REDACTED.to_string()); - redact_next = false; - continue; - } - redact_next = SENSITIVE_FLAGS.contains(&arg.as_str()); - // Codex takes its prompt as the trailing positional rather than behind - // a flag, so the last argument is redacted unless it is a flag itself. - let trailing_prompt = i + 1 == argv.len() && !arg.starts_with('-') && i > 1; - out.push(if trailing_prompt { - REDACTED.to_string() - } else { - arg.clone() - }); - } - out +/// 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. @@ -199,7 +257,8 @@ pub fn stream(request: &Request) -> Result { let runtime = tokio::runtime::Handle::try_current().map_err(|_| Error::NoRuntime)?; let plan = request.plan(); - let argv = request.argv()?; + let typed = request.typed_argv()?; + let argv: Vec = typed.iter().map(|a| a.value.clone()).collect(); let mut command = Command::new(&argv[0]); command @@ -244,6 +303,13 @@ pub fn stream(request: &Request) -> Result { #[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 @@ -264,10 +330,13 @@ pub fn stream(request: &Request) -> Result { })?; 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)); + let task = runtime.spawn(drive(child, request, tx, cancel_rx)); Ok(Run { events: rx, + typed, + cancel: Some(cancel_tx), task: Some(task), argv, }) @@ -312,20 +381,18 @@ impl Drop for ChildGuard { 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) -> Result { +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(); - // An assigned id is known before anything runs, so record it now. This is - // what makes the session survive a run that times out, crashes, or is - // cancelled: the binding does not depend on reaching the end. - if let Some(token) = preassigned_token(&request) { - persist_session(&request, &token)?; - } - // 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() { @@ -347,10 +414,11 @@ async fn drive(child: Child, request: Request, events: mpsc::Sender) -> R let stderr_task = tokio::spawn(async move { let mut buf = String::new(); if let Some(handle) = stderr { - let mut lines = BufReader::new(handle).lines(); - while let Ok(Some(line)) = lines.next_line().await { - // Keep draining after the cap is hit: an undrained pipe would - // block the child even though we no longer want the bytes. + 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); } } @@ -371,8 +439,9 @@ async fn drive(child: Child, request: Request, events: mpsc::Sender) -> R let read_stdout = async { if let Some(handle) = stdout { - let mut lines = BufReader::new(handle).lines(); - while let Some(line) = lines.next_line().await? { + 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 @@ -395,37 +464,45 @@ async fn drive(child: Child, request: Request, events: mpsc::Sender) -> R Ok::<_, std::io::Error>(()) }; - // Apply the deadline to reading and waiting together, so a child that - // produces output forever is still bounded. - let status = match request.timeout { - Some(limit) => { - match tokio::time::timeout(limit, async { - read_stdout.await?; - child.child.wait().await + // 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, }) - .await - { - Ok(result) => result, - Err(_elapsed) => { - // 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 doing it the other way round - // silently leaves every grandchild running. - kill_process_group(&child.child); - let _ = child.child.kill().await; - child.armed = false; - return Err(Error::Timeout { - bin, - timeout: limit, - 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 }); } - None => match read_stdout.await { - Ok(()) => child.child.wait().await, - Err(source) => Err(source), - }, } .map_err(|source| Error::Spawn { bin: bin.clone(), @@ -437,13 +514,39 @@ async fn drive(child: Child, request: Request, events: mpsc::Sender) -> R 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); - // A run that produced no structured answer still has its raw stdout; better - // to hand back what the agent printed than an empty string. + // 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() { + if terminal.text.is_empty() && !structured { terminal.text = raw.trim().to_string(); } @@ -481,6 +584,20 @@ async fn drive(child: Child, request: Request, events: mpsc::Sender) -> R }) } +/// 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 @@ -632,12 +749,10 @@ mod tests { /// it. The redacted form must keep the shape while dropping the content. #[test] fn redaction_removes_prompts_and_session_ids_but_keeps_flags() { - let argv = crate::Request::new(Agent::Claude, "my secret prompt") + let request = crate::Request::new(Agent::Claude, "my secret prompt") .system("secret system") - .session_id("11111111-2222-3333-4444-555555555555") - .argv() - .unwrap(); - let safe = redact(&argv); + .session_id("11111111-2222-3333-4444-555555555555"); + let safe = redact(&request.typed_argv().unwrap()); for secret in [ "my secret prompt", @@ -657,14 +772,39 @@ mod tests { #[test] fn codex_trailing_prompt_is_redacted_even_without_a_flag() { - let argv = crate::Request::new(Agent::Codex, "my secret prompt") - .argv() - .unwrap(); - let safe = redact(&argv); + 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] diff --git a/src/session.rs b/src/session.rs index 6eb6c31..8e50f73 100644 --- a/src/session.rs +++ b/src/session.rs @@ -27,6 +27,7 @@ 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 @@ -128,8 +129,50 @@ impl SessionStore { } /// 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(&self, project: &Path) -> Vec { + 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(); @@ -265,6 +308,12 @@ impl SessionStore { 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) } @@ -316,6 +365,18 @@ fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> { 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<()> { @@ -364,8 +425,14 @@ const MAX_STEM: usize = 200; /// 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 hash of -/// the full input, so the length bound costs readability but never uniqueness. +/// 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 _; @@ -380,7 +447,11 @@ fn encode_segment(name: &str) -> String { } } if out.is_empty() { - return "unnamed".into(); + // 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. @@ -439,7 +510,10 @@ mod tests { // only what has to be encoded. assert_eq!(encode_segment("greet-flow"), "greet-flow"); assert_eq!(encode_segment("v1.2_final"), "v1.2_final"); - assert_eq!(encode_segment(""), "unnamed"); + // 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. @@ -473,6 +547,8 @@ mod tests { "..", "%41", "A", + "", + "unnamed", "日本語", "🙂", ]; @@ -520,7 +596,7 @@ mod tests { .unwrap(); let record = store.get(&project, "Greet Flow ☕").unwrap().unwrap(); assert_eq!(record.name, "Greet Flow ☕"); - assert_eq!(store.list(&project)[0].name, "Greet Flow ☕"); + assert_eq!(store.list(&project).unwrap()[0].name, "Greet Flow ☕"); fs::remove_dir_all(&store.dir).ok(); } @@ -656,7 +732,12 @@ mod tests { 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).into_iter().map(|r| r.name).collect(); + let mut names: Vec<_> = store + .list(&project) + .unwrap() + .into_iter() + .map(|r| r.name) + .collect(); names.sort(); assert_eq!(names, ["a", "b"]); @@ -664,7 +745,7 @@ mod tests { assert!(store.get(&project, "a").unwrap().is_none()); // Forgetting twice is not an error. store.forget(&project, "a").unwrap(); - assert_eq!(store.list(&project).len(), 1); + assert_eq!(store.list(&project).unwrap().len(), 1); fs::remove_dir_all(&store.dir).ok(); } diff --git a/tests/process.rs b/tests/process.rs index 00d8e96..1240527 100644 --- a/tests/process.rs +++ b/tests/process.rs @@ -112,10 +112,16 @@ async fn cancel_stops_the_whole_tree_before_returning() { .expect("spawn failed"); let grandchild = grandchild_pid(&dir).await; - running.cancel().await; - settle().await; + let err = running.cancel().await.unwrap_err(); + assert!(err.is_cancelled(), "cancel should report itself: {err:?}"); - assert!(!alive(grandchild), "cancel left a grandchild alive"); + // 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(); } @@ -232,3 +238,39 @@ async fn a_minimal_environment_withholds_the_hosts_variables() { 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(); +} From 6d900905cf6aceb3449d90894f382b9933fd6a1c Mon Sep 17 00:00:00 2001 From: meh Date: Wed, 29 Jul 2026 01:29:47 +0700 Subject: [PATCH 12/12] Make EnvPolicy::Minimal the default Full environment inheritance is what a CLI gets from a shell, but this crate runs inside processes that hold unrelated secrets, and inheriting hands every one of them to the agent and to every command the agent runs. The safe posture should be what a caller gets when they configure nothing, so Inherit is now the opt-in. Safe to flip because Minimal is verified rather than assumed: the live suite asserts every agent still authenticates under it, and all 11 live tests now exercise it by default rather than only the one test that asked for it. Deferred items from the review are now tracked instead of only discussed: per-session concurrency control as #2, and Codex's unconditional --skip-git-repo-check as #3. --- README.md | 10 ++++++---- src/agent.rs | 23 +++++++++++++++-------- src/request.rs | 27 +++++++++++++++++---------- tests/process.rs | 6 ++++-- 4 files changed, 42 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 52b626c..61427b4 100644 --- a/README.md +++ b/README.md @@ -165,12 +165,14 @@ of this by design. ## Environment isolation -By default the agent inherits the whole parent environment, which is how the CLIs find their -credentials. In an embedded host that also hands the agent, and every command it runs, any -unrelated secret the process happens to hold. +**`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").env_policy(EnvPolicy::Minimal) +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 diff --git a/src/agent.rs b/src/agent.rs index 131d752..ef3e6d1 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -138,24 +138,31 @@ pub const NETWORK_ENV: &[&str] = &[ /// Which of the host's environment variables reach the agent. /// -/// The default is [`EnvPolicy::Inherit`], matching how a CLI behaves when run -/// from a shell. In an embedded host that also hands the agent, and every -/// command it runs, whatever unrelated secrets the process happens to hold: -/// cloud credentials, CI tokens, database URLs. +/// **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 the whole parent environment through. - #[default] - Inherit, /// 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. + /// 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), diff --git a/src/request.rs b/src/request.rs index c5a44ed..1999be4 100644 --- a/src/request.rs +++ b/src/request.rs @@ -52,8 +52,9 @@ pub(crate) struct Binding { impl Request { /// A request for `agent` with `prompt`. /// - /// Defaults are deliberately conservative: [`Permission::ReadOnly`], and the - /// agent's structured output format. Widen them explicitly. + /// 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, @@ -67,7 +68,7 @@ impl Request { cwd: None, env: Vec::new(), extra_args: Vec::new(), - env_policy: EnvPolicy::Inherit, + env_policy: EnvPolicy::Minimal, timeout: None, binding: None, } @@ -127,16 +128,15 @@ impl Request { /// Choose which of the host's environment variables reach the agent. /// - /// Defaults to [`EnvPolicy::Inherit`]. [`EnvPolicy::Minimal`] is the one to - /// reach for when embedding in a process that holds unrelated secrets: it - /// passes through only what the selected agent actually needs, a list the - /// crate maintains per agent, so it isolates without a caller having to - /// work out what to put back. + /// 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::Minimal); + /// .env_policy(EnvPolicy::Inherit); /// ``` #[must_use] pub fn env_policy(mut self, policy: EnvPolicy) -> Self { @@ -369,10 +369,17 @@ impl Request { 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_and_structured() { + 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())); diff --git a/tests/process.rs b/tests/process.rs index 1240527..4c2511e 100644 --- a/tests/process.rs +++ b/tests/process.rs @@ -212,13 +212,15 @@ async fn a_minimal_environment_withholds_the_hosts_variables() { .format(agent_abstraction::Format::Text) }; - let inherited = captured_env(&base()).await; + 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" ); - let minimal = captured_env(&base().env_policy(EnvPolicy::Minimal)).await; + // 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}"