Skip to main content

agent_abstraction/
agent.rs

1//! The three agents, what each can do, and how a request becomes an argv.
2//!
3//! Everything here is pure: [`Agent::argv`] builds a command line from a
4//! [`Plan`] without touching the filesystem, the clock, or a process, so every
5//! flag mapping is covered by an ordinary unit test. Spawning lives in
6//! [`crate::run`].
7
8use std::fmt;
9
10use serde::{Deserialize, Serialize};
11
12use crate::error::{Error, Result};
13
14/// A coding agent this crate can drive headlessly.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
16#[serde(rename_all = "kebab-case")]
17pub enum Agent {
18    /// Anthropic's Claude Code (`claude`).
19    Claude,
20    /// The `OpenAI` Codex CLI (`codex`).
21    Codex,
22    /// GitHub Copilot CLI (`copilot`).
23    Copilot,
24}
25
26/// How an agent's native session id is obtained. This is the axis deciding whether
27/// a caller-owned session name can be bound to it at all.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum SessionSupport {
30    /// The caller assigns the id up front (`claude --session-id <uuid>`), so the
31    /// binding is known before the process starts and survives a crashed run.
32    Minted,
33    /// The agent prints an id we read back out of its output (Codex's
34    /// `thread_id`). The binding only exists once the run produced output.
35    Printed,
36    /// No id is exposed headlessly. Named sessions are refused for this agent.
37    None,
38}
39
40/// What an agent supports. Used to reject an impossible request before spawning
41/// rather than silently doing something weaker than asked.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43#[non_exhaustive]
44#[expect(
45    clippy::struct_excessive_bools,
46    reason = "one flag per capability; a bitfield would read worse at every call site"
47)]
48pub struct Caps {
49    /// How a native session id is obtained, if at all.
50    pub session: SessionSupport,
51    /// Whether resuming can branch a new session instead of appending in place.
52    pub fork: bool,
53    /// Whether the agent emits a structured event stream this crate normalizes.
54    pub events: bool,
55    /// Whether the agent takes a real system-prompt flag. When false the system
56    /// text is prepended to the prompt so it still reaches the model.
57    pub native_system: bool,
58    /// How the agent accepts a JSON Schema for its answer, if at all.
59    pub schema: SchemaSupport,
60    /// Whether the agent answers slash commands such as `/compact`.
61    ///
62    /// Claude publishes a catalogue and acts on them; the others read the same
63    /// text as prose and would discuss the command rather than run it, which is
64    /// worse than refusing.
65    pub commands: bool,
66}
67
68/// How an agent accepts a JSON Schema constraining its answer.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum SchemaSupport {
71    /// The schema rides the command line (`claude --json-schema <schema>`).
72    Inline,
73    /// The schema must be a file the agent reads
74    /// (`codex exec --output-schema <FILE>`), so the runner writes one.
75    File,
76    /// No structured-output support. Asking is an error rather than a prose
77    /// answer dressed up as data.
78    None,
79}
80
81/// Permission posture for a run, mapped onto each agent's own vocabulary.
82///
83/// # What these do and do not guarantee
84///
85/// These postures constrain each CLI's **built-in** tools: its shell, its file
86/// writes, its sandbox. They do **not** constrain MCP servers, plugins or custom
87/// tools the agent is configured with. An MCP tool that files an issue, writes
88/// to a database or calls a deployment API is a separate tool category in all
89/// three CLIs and can still act during a nominally restricted run.
90///
91/// If a run must not cause remote side effects, the containment has to come from
92/// the agent's own configuration (which MCP servers are enabled at all), not
93/// from this enum. What is selected here is enforced by the CLI, and what the
94/// CLI does not model cannot be enforced from out here.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
96#[serde(rename_all = "kebab-case")]
97pub enum Permission {
98    /// No writes to the local filesystem, and no shell where the CLI can gate
99    /// one.
100    ///
101    /// The strongest posture this crate can express, and still not a guarantee
102    /// of "no side effects": see the type-level note about MCP tools. Codex
103    /// enforces it with a read-only sandbox, which blocks writes but still
104    /// permits command execution.
105    #[default]
106    ReadOnly,
107    /// Ask the agent to plan rather than act.
108    ///
109    /// Claude and Copilot have a real plan mode. **Codex has none**, so this
110    /// maps to its read-only sandbox: writes are blocked, but the model is not
111    /// instructed to withhold execution the way a true plan mode would.
112    Plan,
113    /// Allow file edits, while still gating shell commands where the CLI can.
114    Edit,
115    /// Allow the agent's own default automation.
116    Auto,
117    /// Skip every permission check. For sandboxes.
118    Bypass,
119}
120
121/// Environment variables that route an agent's traffic through a corporate
122/// proxy or a custom certificate authority.
123///
124/// None of the three vendors documents proxy support, and none exposes a proxy
125/// flag, so this is a convenience list of names a host may want to forward, not
126/// a claim that forwarding them works. (The names do appear in all three
127/// shipped binaries, but that shows they are referenced, not that provider
128/// traffic honours them.) Verify against your own proxy before relying on it.
129///
130/// Not included in [`EnvPolicy::Minimal`]: they are situational, and the proxy
131/// URLs frequently carry credentials. Offered here so a host can present them
132/// as an explicit setting and forward the ones it wants with
133/// [`crate::Request::env`], rather than every caller rediscovering the names.
134///
135/// Excluding them from `Minimal` does not block them. Under the default
136/// [`EnvPolicy::Inherit`] they flow exactly as they would for the CLI run from a
137/// shell; the only thing `Minimal` changes is that forwarding becomes a
138/// decision rather than an accident.
139///
140/// ```no_run
141/// # use agent_abstraction::{Agent, EnvPolicy, NETWORK_ENV, Request};
142/// let mut request = Request::new(Agent::Claude, "hi").env_policy(EnvPolicy::Minimal);
143/// // Forward only the proxy settings this host actually has.
144/// for name in NETWORK_ENV {
145///     if let Ok(value) = std::env::var(name) {
146///         request = request.env(*name, value);
147///     }
148/// }
149/// ```
150pub const NETWORK_ENV: &[&str] = &[
151    "HTTP_PROXY",
152    "HTTPS_PROXY",
153    "ALL_PROXY",
154    "NO_PROXY",
155    "http_proxy",
156    "https_proxy",
157    "all_proxy",
158    "no_proxy",
159    "SSL_CERT_FILE",
160    "SSL_CERT_DIR",
161    "NODE_EXTRA_CA_CERTS",
162];
163
164/// Which of the host's environment variables reach the agent.
165///
166/// **The default is [`EnvPolicy::Minimal`].** Inheriting the whole environment
167/// is what a CLI gets from a shell, but this crate is embedded in processes that
168/// hold unrelated secrets, and full inheritance hands every one of them to the
169/// agent and to every command the agent runs. That is a decision worth making
170/// deliberately, so it is the opt-in rather than the default.
171#[derive(Debug, Clone, PartialEq, Eq, Default)]
172#[non_exhaustive]
173pub enum EnvPolicy {
174    /// Pass through only what the selected agent needs, per
175    /// [`Agent::essential_env`], plus anything set with [`crate::Request::env`].
176    ///
177    /// The crate owns this list rather than the caller, because "what does this
178    /// CLI need to work" is knowledge about the agent, and an incomplete
179    /// hand-written list produces a run that fails in a way that looks like an
180    /// auth problem. Every agent is verified to authenticate under it by the
181    /// live test suite.
182    #[default]
183    Minimal,
184    /// Pass the whole parent environment through, as a shell would.
185    ///
186    /// Correct when the host process holds nothing the agent should not see, or
187    /// when something environment-specific (a proxy, a custom CA, a vendor
188    /// variable this crate does not know about) has to reach the CLI and
189    /// enumerating it is impractical.
190    Inherit,
191    /// Pass through only these names, plus anything set with
192    /// [`crate::Request::env`]. Names unset in the parent are skipped.
193    Only(Vec<String>),
194}
195
196/// Output shape requested from the agent.
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
198#[serde(rename_all = "kebab-case")]
199pub enum Format {
200    /// Plain prose on stdout. Carries no session id and no events.
201    Text,
202    /// One JSON result document, delivered when the turn ends.
203    ///
204    /// Nothing is observable until then, so a caller watching a run sees
205    /// nothing for its whole duration. Cheaper to parse, and fine when only the
206    /// answer matters.
207    Json,
208    /// A JSONL event stream, normalized into [`crate::Event`]s.
209    ///
210    /// The default, because the alternative is silence: under `Json` a run that
211    /// takes twenty minutes reports nothing for twenty minutes. This carries
212    /// everything `Json` does, the session id and any schema-conforming value
213    /// included, so defaulting to it costs only parsing.
214    #[default]
215    Stream,
216}
217
218/// How a run continues an earlier conversation.
219#[derive(Debug, Clone, PartialEq, Eq, Default)]
220pub enum Continue {
221    /// Start a fresh conversation.
222    #[default]
223    New,
224    /// Start a fresh conversation under an id the caller chose. Only valid for
225    /// [`SessionSupport::Minted`] agents.
226    NewWith(String),
227    /// Append to an existing conversation in place.
228    Resume(String),
229    /// Branch a new conversation off an existing one, leaving it untouched.
230    Fork(String),
231}
232
233/// A fully resolved run request, ready to become an argv. Built by
234/// [`crate::Request::plan`]; consumed by [`Agent::argv`].
235#[derive(Debug, Clone)]
236#[expect(
237    clippy::struct_excessive_bools,
238    reason = "each names a distinct thing the argv builder branches on"
239)]
240pub struct Plan {
241    /// The binary to invoke.
242    pub bin: String,
243    /// The user prompt.
244    pub prompt: String,
245    /// System prompt, if any.
246    pub system: Option<String>,
247    /// Model id or alias, if pinned.
248    pub model: Option<String>,
249    /// Reasoning effort level, if set. Passed through verbatim, for the same
250    /// reason [`Plan::model`] is: the accepted set is the provider's to define
251    /// and it has already grown once.
252    pub effort: Option<String>,
253    /// Permission posture.
254    pub permission: Permission,
255    /// Requested output shape.
256    pub format: Format,
257    /// How this run continues an earlier one.
258    pub cont: Continue,
259    /// True when the prompt is piped on stdin instead of riding the argv.
260    pub stdin_prompt: bool,
261    /// True when stdin stays open for the turn so the caller can send more.
262    ///
263    /// Implied by [`Plan::approvals`], which needs the same open channel.
264    pub duplex: bool,
265    /// True when gated tool calls are routed to the caller for a decision
266    /// rather than resolved by the posture.
267    pub approvals: bool,
268    /// A JSON Schema the answer must conform to, as text.
269    ///
270    /// Delivered differently per agent: Claude takes it inline, Codex takes a
271    /// path, so this is the source and `schema_file` is where the runner put it
272    /// when a file was needed.
273    pub schema: Option<String>,
274    /// Path to the schema on disk, materialized by the runner for agents that
275    /// take a file rather than an inline value.
276    pub schema_file: Option<String>,
277    /// True when the prompt is a slash command rather than something to answer.
278    ///
279    /// Changes nothing about the argv — a command travels as the prompt — and
280    /// exists so [`Agent::check`] can refuse an agent that would read it as
281    /// prose.
282    pub is_command: bool,
283}
284
285/// Prompts at or above this many bytes are piped on stdin rather than placed on
286/// the argv. Well under the ~1 MiB `ARG_MAX` floor on macOS, with room for the
287/// rest of the command line and the inherited environment.
288pub(crate) const STDIN_THRESHOLD: usize = 128 * 1024;
289
290/// The budget for everything on one command line.
291///
292/// `ARG_MAX` is about 1 MiB on macOS and covers the environment as well as the
293/// arguments, so half of it leaves room for a large inherited environment. Over
294/// this the spawn fails with a bare `E2BIG` that names nothing; the crate checks
295/// first so the error can say which input was too big.
296pub(crate) const MAX_COMMAND_LINE: usize = 512 * 1024;
297
298impl Agent {
299    /// Every agent, in a stable order.
300    pub const ALL: [Agent; 3] = [Agent::Claude, Agent::Codex, Agent::Copilot];
301
302    /// The stable identifier used in session records and logs.
303    #[must_use]
304    pub fn id(self) -> &'static str {
305        match self {
306            Agent::Claude => "claude-code",
307            Agent::Codex => "codex",
308            Agent::Copilot => "copilot",
309        }
310    }
311
312    /// The default binary name looked up on `PATH`.
313    #[must_use]
314    pub fn bin(self) -> &'static str {
315        match self {
316            Agent::Claude => "claude",
317            Agent::Codex => "codex",
318            Agent::Copilot => "copilot",
319        }
320    }
321
322    /// The command that asks this agent whether it is logged in, or `None`
323    /// when it offers no way to ask.
324    ///
325    /// Verified against each CLI: Claude has `auth status`, which answers JSON
326    /// by default, and Codex has `login status`, which answers prose. Copilot
327    /// has neither, so its credentials cannot be confirmed without spending a
328    /// request.
329    #[must_use]
330    pub fn auth_status_argv(self) -> Option<&'static [&'static str]> {
331        match self {
332            Agent::Claude => Some(&["auth", "status", "--json"]),
333            Agent::Codex => Some(&["login", "status"]),
334            Agent::Copilot => None,
335        }
336    }
337
338    /// The environment variables this agent accepts a credential in, most
339    /// preferred first.
340    ///
341    /// Copilot documents its precedence explicitly: `COPILOT_GITHUB_TOKEN`,
342    /// then `GH_TOKEN`, then `GITHUB_TOKEN`.
343    #[must_use]
344    pub fn auth_env_vars(self) -> &'static [&'static str] {
345        match self {
346            Agent::Claude => &["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"],
347            Agent::Codex => &["CODEX_API_KEY", "OPENAI_API_KEY"],
348            Agent::Copilot => &["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"],
349        }
350    }
351
352    /// The command that resolves a missing login for this agent.
353    ///
354    /// Verified against each CLI's own help: Codex and Copilot expose a `login`
355    /// subcommand, while Claude authenticates interactively or through a
356    /// long-lived token.
357    #[must_use]
358    pub fn login_hint(self) -> &'static str {
359        match self {
360            Agent::Claude => {
361                "run `claude` and use /login, or `claude setup-token` for a \
362                              long-lived token"
363            }
364            Agent::Codex => "run `codex login`",
365            Agent::Copilot => "run `copilot login`",
366        }
367    }
368
369    /// The release this crate's flag mappings were verified against.
370    ///
371    /// Every mapping in this module was checked by running these exact
372    /// versions, not by reading their documentation. [`crate::Probe`] compares
373    /// an installed CLI against this so drift is a question a host can ask up
374    /// front rather than something a failing run reveals.
375    #[must_use]
376    pub fn verified_version(self) -> crate::Version {
377        let (major, minor, patch) = match self {
378            // `claude --version` -> "2.1.212 (Claude Code)"
379            Agent::Claude => (2, 1, 212),
380            // `codex --version` -> "codex-cli 0.145.0"
381            Agent::Codex => (0, 145, 0),
382            // `copilot --version` -> "GitHub Copilot CLI 1.0.75."
383            Agent::Copilot => (1, 0, 75),
384        };
385        crate::Version {
386            major,
387            minor,
388            patch,
389        }
390    }
391
392    /// The documented install command, surfaced by [`Error::NotInstalled`].
393    #[must_use]
394    pub fn install_hint(self) -> &'static str {
395        match self {
396            Agent::Claude => "npm install -g @anthropic-ai/claude-code",
397            Agent::Codex => "npm install -g @openai/codex",
398            Agent::Copilot => "npm install -g @github/copilot",
399        }
400    }
401
402    /// The environment variables this agent needs to function, used by
403    /// [`EnvPolicy::Minimal`].
404    ///
405    /// Two groups: what any process needs to start, and this agent's own
406    /// credential and config variables. Permission-controlling variables are
407    /// excluded on principle: `COPILOT_ALLOW_ALL` is Copilot's env equivalent
408    /// of `--allow-all-tools`, so inheriting it would let the host's ambient
409    /// environment widen a run's permissions behind [`Permission`]'s back. A name absent from the parent
410    /// environment is skipped, so nothing here is fabricated.
411    ///
412    /// Proxy and custom-CA variables are deliberately **not** here. They are
413    /// environment-specific rather than required, and `HTTP_PROXY` /
414    /// `HTTPS_PROXY` routinely embed credentials (`http://user:pass@proxy`), so
415    /// passing them automatically would leak one through the very policy meant
416    /// to withhold secrets. A host that needs them should offer them as a
417    /// setting and pass them with [`crate::Request::env`]; [`NETWORK_ENV`] names
418    /// them so a settings screen does not have to hardcode the list.
419    ///
420    /// `PATH`, `HOME` and `USER` are the verified floor on macOS: all three CLIs
421    /// answer correctly with exactly those set, and Claude reports "Not logged
422    /// in" without `USER`, since its keychain lookup is keyed on it. The Windows
423    /// names are included on the same reasoning but are **not** verified, as
424    /// this crate has not been run there.
425    #[must_use]
426    pub fn essential_env(self) -> Vec<&'static str> {
427        // Needed by any child process, plus the locale and temp dir the CLIs
428        // use for scratch files.
429        const BASE: &[&str] = &[
430            "PATH", "HOME", "USER", "LOGNAME", "SHELL", "TMPDIR", "LANG", "LC_ALL",
431        ];
432        // Unverified: this crate has not been exercised on Windows.
433        const WINDOWS: &[&str] = &[
434            "USERPROFILE",
435            "APPDATA",
436            "LOCALAPPDATA",
437            "SystemRoot",
438            "SystemDrive",
439            "TEMP",
440            "TMP",
441            "PATHEXT",
442            "ComSpec",
443        ];
444        let agent: &[&str] = match self {
445            Agent::Claude => &[
446                "ANTHROPIC_API_KEY",
447                "ANTHROPIC_AUTH_TOKEN",
448                "ANTHROPIC_BASE_URL",
449                "CLAUDE_CONFIG_DIR",
450            ],
451            Agent::Codex => &[
452                "CODEX_HOME",
453                "CODEX_API_KEY",
454                "OPENAI_API_KEY",
455                "OPENAI_BASE_URL",
456            ],
457            // `COPILOT_GITHUB_TOKEN` takes precedence over the others per
458            // Copilot's own docs, and was missing here: a host using it would
459            // have failed to authenticate under EnvPolicy::Minimal.
460            Agent::Copilot => &[
461                "COPILOT_GITHUB_TOKEN",
462                "GH_TOKEN",
463                "GITHUB_TOKEN",
464                "XDG_CONFIG_HOME",
465            ],
466        };
467        BASE.iter().chain(WINDOWS).chain(agent).copied().collect()
468    }
469
470    /// What this agent supports.
471    #[must_use]
472    pub fn caps(self) -> Caps {
473        match self {
474            // Verified against claude 2.1.212: `--session-id <uuid>` assigns the
475            // id, `--fork-session` branches, `--output-format stream-json`
476            // streams (and demands `--verbose`), `--append-system-prompt` is a
477            // real flag.
478            Agent::Claude => Caps {
479                session: SessionSupport::Minted,
480                fork: true,
481                events: true,
482                native_system: true,
483                // Verified: `--json-schema <inline>` puts the conforming value
484                // in the result document's `structured_output`.
485                schema: SchemaSupport::Inline,
486                // Verified: `/compact` reports `compact_result` and the init
487                // record lists the whole catalogue.
488                commands: true,
489            },
490            // `codex exec --json` emits `thread_id`; continuation is the
491            // `resume` subcommand and is linear (`codex fork` is TUI-only).
492            Agent::Codex => Caps {
493                session: SessionSupport::Printed,
494                fork: false,
495                events: true,
496                native_system: false,
497                // Verified: `--output-schema <FILE>` makes the final
498                // `agent_message` the conforming JSON, with no separate field.
499                schema: SchemaSupport::File,
500                commands: false,
501            },
502            // Verified against Copilot CLI 1.0.75: `--session-id <uuid>` both
503            // mints a new session and resumes an existing one (one flag, both
504            // directions), and `--output-format json` is a JSONL event stream.
505            // There is no headless fork.
506            Agent::Copilot => Caps {
507                session: SessionSupport::Minted,
508                fork: false,
509                events: true,
510                native_system: false,
511                // Copilot 1.0.75 exposes no schema flag at all.
512                schema: SchemaSupport::None,
513                commands: false,
514            },
515        }
516    }
517
518    /// The format that can carry this agent's session id, if any. A named
519    /// session upgrades to this when the caller did not pin a format.
520    #[must_use]
521    pub fn session_format(self) -> Option<Format> {
522        match self.caps().session {
523            // Claude reports the id in both structured formats; `Json` is the
524            // cheaper default when the caller did not ask to stream.
525            SessionSupport::Minted | SessionSupport::Printed => Some(match self {
526                Agent::Claude => Format::Json,
527                // `--json` IS Codex's stream and Copilot's `json` is JSONL;
528                // neither has a single-document form.
529                Agent::Codex | Agent::Copilot => Format::Stream,
530            }),
531            SessionSupport::None => None,
532        }
533    }
534
535    /// Whether `format` can carry this agent's session id.
536    ///
537    /// Distinct from [`Agent::session_format`], which names the *preferred* one:
538    /// Claude reports its id under both `Json` and `Stream`, and only plain text
539    /// loses it. A named session needs this, not equality with the preferred
540    /// format, or streaming a named Claude session would be refused for no
541    /// reason.
542    #[must_use]
543    pub fn format_carries_session(self, format: Format) -> bool {
544        self.session_format().is_some() && format != Format::Text
545    }
546
547    /// Reject a plan this agent cannot honour, before anything is spawned.
548    fn check(self, plan: &Plan) -> Result<()> {
549        let caps = self.caps();
550        if matches!(plan.cont, Continue::Fork(_)) && !caps.fork {
551            return Err(Error::Unsupported {
552                agent: self,
553                what: "forking a session headlessly",
554            });
555        }
556        if matches!(plan.cont, Continue::NewWith(_)) && caps.session != SessionSupport::Minted {
557            return Err(Error::Unsupported {
558                agent: self,
559                what: "assigning a session id up front",
560            });
561        }
562        if plan.schema.is_some() && caps.schema == SchemaSupport::None {
563            return Err(Error::Unsupported {
564                agent: self,
565                what: "constraining its answer to a JSON schema",
566            });
567        }
568        if plan.format == Format::Stream && !caps.events {
569            return Err(Error::Unsupported {
570                agent: self,
571                what: "a structured event stream",
572            });
573        }
574        // Refused rather than passed through: an agent with no command
575        // vocabulary reads `/compact` as prose and answers a question about
576        // it, which looks like the command running and silently is not.
577        if plan.is_command && !caps.commands {
578            return Err(Error::Unsupported {
579                agent: self,
580                what: "slash commands such as /compact",
581            });
582        }
583        Ok(())
584    }
585
586    /// Build the command line for `plan`.
587    ///
588    /// The first element is the binary; the rest are its arguments. Returns
589    /// [`Error::Unsupported`] when the plan asks for a capability this agent
590    /// lacks, never a quiet downgrade.
591    ///
592    /// # Errors
593    /// [`Error::Unsupported`] if the plan needs a capability this agent lacks.
594    pub fn argv(self, plan: &Plan) -> Result<Vec<String>> {
595        Ok(self
596            .typed_argv(plan)?
597            .into_iter()
598            .map(|arg| arg.value)
599            .collect())
600    }
601
602    /// The command line with each argument's sensitivity attached.
603    ///
604    /// # Errors
605    /// [`Error::Unsupported`] if the plan needs a capability this agent lacks.
606    pub(crate) fn typed_argv(self, plan: &Plan) -> Result<Vec<Arg>> {
607        // Verified against codex-cli 0.145.0 and Copilot CLI 1.0.75: `codex
608        // exec` has no approval callback, its sandbox mode being the answer
609        // decided before the run starts, and Copilot needs `--allow-all-tools`
610        // to run headlessly at all and gates only through `--deny-tool`. A run
611        // that quietly never asked would be the worst outcome here, since a
612        // caller would read silence as "nothing needed approval".
613        if plan.approvals && self != Agent::Claude {
614            return Err(Error::Unsupported {
615                agent: self,
616                what: "routing tool approvals to the caller",
617            });
618        }
619        // Verified against codex-cli 0.145.0 and Copilot CLI 1.0.75: neither
620        // takes a structured message stream on stdin, so neither can be sent a
621        // second message once a turn is under way.
622        if (plan.duplex || plan.approvals) && self != Agent::Claude {
623            return Err(Error::Unsupported {
624                agent: self,
625                what: "sending a follow-up message while a turn is running",
626            });
627        }
628        // `ReadOnly` keeps its guarantee by removing the mutating tools
629        // outright, so under it there is nothing left to be asked about: a
630        // caller would opt into approvals and then never be asked, and read the
631        // silence as "the agent wanted nothing". Refused rather than quietly
632        // dropping either half, since dropping the tool removal would weaken a
633        // posture the caller asked for by name.
634        if plan.approvals && plan.permission == Permission::ReadOnly {
635            return Err(Error::Unsupported {
636                agent: self,
637                what: "approvals under a read-only posture, which removes the \
638                       tools that would be asked about; use `Permission::Edit` \
639                       and decide per call",
640            });
641        }
642        self.check(plan)?;
643        Ok(match self {
644            Agent::Claude => argv_claude(plan),
645            Agent::Codex => argv_codex(plan),
646            Agent::Copilot => argv_copilot(plan),
647        })
648    }
649
650    /// The prompt text actually delivered, with the system prompt folded in for
651    /// agents that have no flag for it. Never dropped silently.
652    #[must_use]
653    pub fn effective_prompt(self, plan: &Plan) -> String {
654        match (&plan.system, self.caps().native_system) {
655            (Some(system), false) => format!("{system}\n\n{}", plan.prompt),
656            _ => plan.prompt.clone(),
657        }
658    }
659}
660
661impl fmt::Display for Agent {
662    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
663        f.write_str(self.id())
664    }
665}
666
667/// How sensitive one argument's value is, decided where the argument is built
668/// rather than guessed back afterwards.
669///
670/// Reconstructing this from a finished command line means pattern-matching flag
671/// names and positions, which misses exactly the cases that matter: Codex's
672/// prompt is a bare trailing positional, and anything from `unchecked_args` has
673/// no recognizable shape at all. Recording it at construction cannot miss.
674#[derive(Debug, Clone, Copy, PartialEq, Eq)]
675pub(crate) enum Sensitivity {
676    /// A flag name or fixed token. Safe to show.
677    Public,
678    /// User or caller content: prompts and system prompts.
679    Prompt,
680    /// A session handle, which resumes a conversation.
681    SessionId,
682    /// Caller-supplied raw arguments. Unknowable, so assumed sensitive.
683    Unchecked,
684}
685
686/// One argument and how sensitive it is.
687#[derive(Debug, Clone)]
688pub(crate) struct Arg {
689    pub(crate) value: String,
690    pub(crate) sensitivity: Sensitivity,
691}
692
693/// Builds an argv, keeping every flag name literal at its call site so the flag
694/// list for an agent stays greppable and auditable against `--help`, and
695/// recording per-argument sensitivity so the executable and redacted forms come
696/// from one source.
697pub(crate) struct Argv(Vec<Arg>);
698
699impl Argv {
700    /// Start with the binary.
701    fn new(bin: &str) -> Self {
702        Self(vec![Arg {
703            value: bin.to_string(),
704            sensitivity: Sensitivity::Public,
705        }])
706    }
707
708    fn push(&mut self, value: impl Into<String>, sensitivity: Sensitivity) -> &mut Self {
709        self.0.push(Arg {
710            value: value.into(),
711            sensitivity,
712        });
713        self
714    }
715
716    /// A bare flag with no value.
717    fn bare(&mut self, flag: &str) -> &mut Self {
718        self.push(flag, Sensitivity::Public)
719    }
720
721    /// A flag and a value that is safe to show.
722    fn pair(&mut self, flag: &str, value: impl AsRef<str>) -> &mut Self {
723        self.bare(flag).push(value.as_ref(), Sensitivity::Public)
724    }
725
726    /// A flag and a value that must not be logged.
727    fn secret(&mut self, flag: &str, value: impl AsRef<str>, kind: Sensitivity) -> &mut Self {
728        self.bare(flag).push(value.as_ref(), kind)
729    }
730
731    /// A flag and its value, only when the value is present.
732    fn opt(&mut self, flag: &str, value: Option<&String>) -> &mut Self {
733        if let Some(value) = value {
734            self.pair(flag, value);
735        }
736        self
737    }
738
739    /// A positional argument that is safe to show.
740    fn arg(&mut self, value: impl Into<String>) -> &mut Self {
741        self.push(value, Sensitivity::Public)
742    }
743
744    /// A positional argument carrying caller content.
745    fn arg_sensitive(&mut self, value: impl Into<String>, kind: Sensitivity) -> &mut Self {
746        self.push(value, kind)
747    }
748
749    fn done(&mut self) -> Vec<Arg> {
750        std::mem::take(&mut self.0)
751    }
752}
753
754/// Claude Code's permission-mode token for each posture. Choices verified from
755/// `claude --help` (2.1.212): acceptEdits, auto, bypassPermissions, manual,
756/// dontAsk, plan.
757fn claude_mode(p: Permission) -> &'static str {
758    match p {
759        // `dontAsk` auto-denies gated tools and keeps going rather than
760        // blocking on a prompt no one can answer headlessly. The read-only
761        // guarantee comes from `--disallowedTools`, below.
762        Permission::ReadOnly => "dontAsk",
763        Permission::Plan => "plan",
764        Permission::Edit => "acceptEdits",
765        Permission::Auto => "auto",
766        Permission::Bypass => "bypassPermissions",
767    }
768}
769
770/// `claude -p <prompt> --permission-mode M --output-format F [...]`
771fn argv_claude(plan: &Plan) -> Vec<Arg> {
772    let mut a = Argv::new(&plan.bin);
773    a.bare("-p");
774    if plan.duplex || plan.approvals {
775        // Under `--input-format stream-json` the prompt is a JSON message on
776        // stdin, so it must not also ride the argv.
777    } else if plan.stdin_prompt {
778        // With `--input-format text` claude reads the prompt from stdin, so a
779        // large prompt never has to fit on the argv.
780        a.pair("--input-format", "text");
781    } else {
782        a.arg_sensitive(Agent::Claude.effective_prompt(plan), Sensitivity::Prompt);
783    }
784
785    // Approvals ride the same open channel, so they imply it.
786    if plan.duplex || plan.approvals {
787        // Messages travel as JSON on stdin, which is what lets a caller send
788        // another one mid-turn. Verified against claude 2.1.212: a message
789        // written while a turn is running is taken up at the next step
790        // boundary, so a three-command task interrupted after the first runs
791        // only that one.
792        a.pair("--input-format", "stream-json");
793    }
794    if plan.approvals {
795        // The control channel that carries a `can_use_tool` question out and a
796        // decision back. Verified against claude 2.1.212: `manual` is the mode
797        // that asks, `stdio` names this process as the answerer, and the
798        // handshake in `crate::approval` is what actually switches the requests
799        // on.
800        //
801        // Deliberately *not* `--setting-sources ""`. It would suppress the
802        // user's own settings, including their CLAUDE.md, and it is not needed:
803        // verified that a mutating command still asks with their settings
804        // loaded. Read-only commands are allowed without asking either way,
805        // which is Claude's decision to make and not this crate's to override.
806        a.pair("--permission-mode", "manual");
807        a.pair("--permission-prompt-tool", "stdio");
808    } else {
809        a.pair("--permission-mode", claude_mode(plan.permission));
810    }
811    if plan.permission == Permission::ReadOnly {
812        // Remove the mutating built-ins outright. Reads still run via
813        // Read/Grep/Glob. `mcp__*` covers every MCP tool: denying only the
814        // built-in writers would leave an MCP server free to mutate remote
815        // state during a run the caller asked to be read-only.
816        a.bare("--disallowedTools");
817        for tool in ["Bash", "Edit", "Write", "NotebookEdit", "mcp__*"] {
818            a.arg(tool);
819        }
820    }
821
822    a.opt("--model", plan.model.as_ref());
823    // Verified against claude 2.1.212: `--effort <level>` (low, medium, high,
824    // xhigh, max), a session-level flag rather than a per-model one.
825    a.opt("--effort", plan.effort.as_ref());
826    if let Some(system) = &plan.system {
827        a.secret("--append-system-prompt", system, Sensitivity::Prompt);
828    }
829
830    match &plan.cont {
831        Continue::New => {}
832        Continue::NewWith(id) => {
833            a.secret("--session-id", id, Sensitivity::SessionId);
834        }
835        Continue::Resume(id) => {
836            a.secret("--resume", id, Sensitivity::SessionId);
837        }
838        Continue::Fork(id) => {
839            // Mints a new id off `id`, leaving the original and its cached
840            // prefix untouched. The new id comes back in the output.
841            a.secret("--resume", id, Sensitivity::SessionId)
842                .bare("--fork-session");
843        }
844    }
845
846    a.pair(
847        "--output-format",
848        match plan.format {
849            Format::Text => "text",
850            Format::Json => "json",
851            Format::Stream => "stream-json",
852        },
853    );
854    if let Some(schema) = &plan.schema {
855        // Inline, and the conforming value comes back in `structured_output`.
856        a.secret("--json-schema", schema, Sensitivity::Prompt);
857    }
858    if plan.format == Format::Stream {
859        // Claude refuses `-p --output-format stream-json` without it:
860        // "--print with --output-format=stream-json requires --verbose".
861        a.bare("--verbose");
862        // Without this Claude emits only *completed* messages, so text arrives
863        // a paragraph at a time. With it, `stream_event` records carry the
864        // token-level deltas, which is what makes a transcript type rather than
865        // appear. Copilot streams deltas natively, so this is what puts the two
866        // on equal footing.
867        a.bare("--include-partial-messages");
868    }
869    a.done()
870}
871
872/// `codex exec [resume <id>] --skip-git-repo-check [sandbox flags] [--model M]
873/// [--json] <prompt>`
874fn argv_codex(plan: &Plan) -> Vec<Arg> {
875    let mut a = Argv::new(&plan.bin);
876    a.bare("exec");
877    if let Continue::Resume(id) = &plan.cont {
878        // Continuation is a subcommand, not a flag.
879        a.bare("resume")
880            .arg_sensitive(id.clone(), Sensitivity::SessionId);
881    }
882
883    // `codex exec` aborts outside a git repository unless told not to. That
884    // check guards against an agent editing files with no way to undo them, but
885    // this crate is embedded in hosts that legitimately run against scratch
886    // directories, worktrees and review checkouts, and a hard abort there is
887    // useless to them. The real containment is the sandbox below, which is
888    // `read-only` by default, so nothing is unrecoverable regardless.
889    a.bare("--skip-git-repo-check");
890
891    // `codex exec` takes `--sandbox`, but `codex exec resume` does **not**: it
892    // rejects the flag outright and takes the same setting as a `-c` config
893    // override instead. Verified against codex-cli 0.145.0, where passing
894    // `--sandbox` to a resume fails with "unexpected argument '--sandbox'".
895    // Dropping the sandbox on resume would silently run a continued turn under a
896    // different posture than the caller asked for.
897    let resuming = matches!(plan.cont, Continue::Resume(_));
898    let sandbox = match plan.permission {
899        Permission::Bypass => None,
900        Permission::ReadOnly | Permission::Plan => Some("read-only"),
901        Permission::Edit | Permission::Auto => Some("workspace-write"),
902    };
903    match (sandbox, resuming) {
904        (None, _) => a.bare("--dangerously-bypass-approvals-and-sandbox"),
905        (Some(mode), false) => a.pair("--sandbox", mode),
906        // The value is TOML-parsed, falling back to a raw string, so the bare
907        // token is read as the mode name.
908        (Some(mode), true) => a.pair("-c", format!("sandbox_mode={mode}")),
909    };
910
911    a.opt("--model", plan.model.as_ref());
912    // Verified against codex-cli 0.145.0: `codex exec` has no effort flag, it
913    // is a config override, and `--strict-config` accepts this key. A bad value
914    // is refused by the provider with its own enum rather than by the CLI.
915    if let Some(effort) = plan.effort.as_ref() {
916        a.pair("-c", format!("model_reasoning_effort={effort}"));
917    }
918    // Codex reads the schema from a file, which the runner writes before the
919    // spawn. `Request::argv` has no file to name, so it shows a placeholder:
920    // the preview is for display, and the real path exists only at spawn time.
921    if plan.schema.is_some() {
922        a.pair(
923            "--output-schema",
924            plan.schema_file.as_deref().unwrap_or("<schema-file>"),
925        );
926    }
927    // `--json` is Codex's event stream and the only place `thread_id` appears.
928    if plan.format != Format::Text {
929        a.bare("--json");
930    }
931    // Codex has no system flag, so the system text rides the prompt. A literal
932    // `-` makes it read the prompt from stdin instead, keeping a large one off
933    // the argv.
934    // Codex takes the prompt as a bare trailing positional, which is exactly
935    // the shape positional redaction guesswork gets wrong.
936    if plan.stdin_prompt {
937        a.arg("-");
938    } else {
939        a.arg_sensitive(Agent::Codex.effective_prompt(plan), Sensitivity::Prompt);
940    }
941    a.done()
942}
943
944/// `copilot -p <prompt> --allow-all-tools [...] [--session-id <uuid>]`
945///
946/// Flags verified against Copilot CLI 1.0.75. Two of its conventions matter:
947/// `--allow-all-tools` is *required* for non-interactive mode, and the
948/// repeatable tool filters are declared `--allow-tool[=tools...]`, an optional
949/// value, which only binds with `=`, never across a space.
950fn argv_copilot(plan: &Plan) -> Vec<Arg> {
951    // Copilot reads stdin as the prompt only when `-p` is absent: a `-p` value
952    // makes the pipe be ignored. So a piped prompt drops the flag entirely.
953    let mut a = Argv::new(&plan.bin);
954    if !plan.stdin_prompt {
955        a.secret(
956            "-p",
957            Agent::Copilot.effective_prompt(plan),
958            Sensitivity::Prompt,
959        );
960    }
961
962    // Without this, a headless run stops at the first tool confirmation.
963    a.bare("--allow-all-tools").bare("--no-ask-user");
964    match plan.permission {
965        Permission::Bypass | Permission::Auto => a.bare("--allow-all-paths"),
966        // Deny beats allow, so this is allow-all minus the mutating tools.
967        // `--allow-all-paths` is deliberately NOT set: it disables path
968        // verification entirely, which would widen filesystem reach in the one
969        // posture that exists to narrow it.
970        Permission::ReadOnly => a.bare("--deny-tool=shell").bare("--deny-tool=write"),
971        // Edits run; shell stays denied so commands cannot.
972        Permission::Edit => a.bare("--deny-tool=shell"),
973        Permission::Plan => a.pair("--mode", "plan"),
974    };
975
976    a.opt("--model", plan.model.as_ref());
977    // Verified against Copilot CLI 1.0.75: `--effort` is the documented spelling
978    // and `--reasoning-effort` its alias (none, minimal, low, medium, high,
979    // xhigh, max). A wider set than Claude's, which is why the level is passed
980    // through rather than mapped to a shared enum.
981    a.opt("--effort", plan.effort.as_ref());
982    // One flag serves both directions: it sets the UUID for a new session and
983    // resumes an existing one by id.
984    match &plan.cont {
985        Continue::NewWith(id) | Continue::Resume(id) => {
986            a.secret("--session-id", id, Sensitivity::SessionId);
987        }
988        // `Fork` is rejected by `Agent::check` before reaching here.
989        Continue::New | Continue::Fork(_) => {}
990    }
991
992    a.pair(
993        "--output-format",
994        if plan.format == Format::Text {
995            "text"
996        } else {
997            // Copilot's `json` is JSONL, so it serves both structured formats.
998            "json"
999        },
1000    );
1001    a.done()
1002}
1003
1004#[cfg(test)]
1005mod tests {
1006    use super::*;
1007
1008    fn plan(bin: &str) -> Plan {
1009        Plan {
1010            bin: bin.into(),
1011            prompt: "hi".into(),
1012            system: None,
1013            model: None,
1014            effort: None,
1015            permission: Permission::ReadOnly,
1016            format: Format::Json,
1017            cont: Continue::New,
1018            stdin_prompt: false,
1019            duplex: false,
1020            approvals: false,
1021            schema: None,
1022            schema_file: None,
1023            is_command: false,
1024        }
1025    }
1026
1027    fn argv(agent: Agent, plan: &Plan) -> Vec<String> {
1028        agent.argv(plan).expect("plan is supported")
1029    }
1030
1031    fn pos(a: &[String], needle: &str) -> Option<usize> {
1032        a.iter().position(|s| s == needle)
1033    }
1034
1035    #[test]
1036    fn claude_builds_print_mode_with_format_and_permission() {
1037        let a = argv(Agent::Claude, &plan("claude"));
1038        assert_eq!(a[0..3], ["claude", "-p", "hi"]);
1039        assert!(pos(&a, "--permission-mode").is_some());
1040        assert!(a.contains(&"dontAsk".to_string()));
1041        assert_eq!(a[pos(&a, "--output-format").unwrap() + 1], "json");
1042    }
1043
1044    #[test]
1045    fn claude_read_only_removes_the_mutating_tools() {
1046        let a = argv(Agent::Claude, &plan("claude"));
1047        let at = pos(&a, "--disallowedTools").expect("read-only denies tools");
1048        assert_eq!(
1049            &a[at + 1..at + 5],
1050            ["Bash", "Edit", "Write", "NotebookEdit"]
1051        );
1052    }
1053
1054    /// Each agent takes the level a different way, and Codex takes it as a
1055    /// config override because it has no flag for it at all.
1056    /// The approvals posture rewires how Claude is invoked: the control channel
1057    /// replaces the posture flag, and the prompt leaves the argv because it
1058    /// travels as a JSON message on stdin instead.
1059    #[test]
1060    fn approvals_switch_claude_to_the_control_channel() {
1061        let mut p = plan("claude");
1062        p.approvals = true;
1063        p.permission = Permission::Edit;
1064        let a = argv(Agent::Claude, &p);
1065
1066        assert_eq!(a[pos(&a, "--permission-mode").unwrap() + 1], "manual");
1067        assert_eq!(a[pos(&a, "--permission-prompt-tool").unwrap() + 1], "stdio");
1068        assert_eq!(a[pos(&a, "--input-format").unwrap() + 1], "stream-json");
1069        assert!(
1070            !a.iter().any(|arg| arg == "hi"),
1071            "the prompt must not ride the argv as well: {a:?}"
1072        );
1073        // Suppressing the user's own settings is not part of this: it would
1074        // discard their CLAUDE.md, and a mutating command asks without it.
1075        assert!(
1076            pos(&a, "--setting-sources").is_none(),
1077            "the user's settings stay loaded: {a:?}"
1078        );
1079    }
1080
1081    /// Neither other agent has a headless approval channel, so asking must fail
1082    /// loudly. A run that quietly never asked is the dangerous outcome: silence
1083    /// would read as "nothing needed approval".
1084    #[test]
1085    fn agents_without_an_approval_channel_refuse_before_spawning() {
1086        let mut p = plan("x");
1087        p.approvals = true;
1088        p.permission = Permission::Edit;
1089        for agent in [Agent::Codex, Agent::Copilot] {
1090            assert!(
1091                matches!(agent.typed_argv(&p), Err(Error::Unsupported { .. })),
1092                "{agent} should refuse to pretend it can ask"
1093            );
1094        }
1095        assert!(Agent::Claude.typed_argv(&p).is_ok());
1096    }
1097
1098    /// Read-only removes the mutating tools outright, so there is nothing left
1099    /// to ask about. Opting into approvals there would mean never being asked,
1100    /// which reads as "the agent wanted nothing".
1101    #[test]
1102    fn approvals_under_read_only_are_refused_rather_than_silent() {
1103        let mut p = plan("claude");
1104        p.approvals = true;
1105        p.permission = Permission::ReadOnly;
1106        assert!(matches!(
1107            Agent::Claude.typed_argv(&p),
1108            Err(Error::Unsupported { .. })
1109        ));
1110
1111        // Every posture that leaves the tools in place is fine.
1112        for permission in [Permission::Edit, Permission::Auto, Permission::Bypass] {
1113            p.permission = permission;
1114            assert!(
1115                Agent::Claude.typed_argv(&p).is_ok(),
1116                "{permission:?} should allow approvals"
1117            );
1118        }
1119    }
1120
1121    /// Interactive alone opens the message channel without switching the
1122    /// permission posture: a caller who wants to send follow-ups is not
1123    /// thereby asking to be prompted about every tool.
1124    #[test]
1125    fn interactive_opens_the_channel_without_changing_the_posture() {
1126        let mut p = plan("claude");
1127        p.duplex = true;
1128        p.permission = Permission::Edit;
1129        let a = argv(Agent::Claude, &p);
1130
1131        assert_eq!(a[pos(&a, "--input-format").unwrap() + 1], "stream-json");
1132        assert_eq!(a[pos(&a, "--permission-mode").unwrap() + 1], "acceptEdits");
1133        assert!(
1134            pos(&a, "--permission-prompt-tool").is_none(),
1135            "no approval channel was asked for: {a:?}"
1136        );
1137        assert!(
1138            !a.iter().any(|arg| arg == "hi"),
1139            "the prompt travels as a stdin message: {a:?}"
1140        );
1141    }
1142
1143    /// Approvals need the same open channel, so they imply it. Checked on a
1144    /// hand-built `Plan` because the builder is not the only way to make one.
1145    #[test]
1146    fn approvals_imply_the_open_channel_even_without_the_builder() {
1147        let mut p = plan("claude");
1148        p.approvals = true;
1149        p.duplex = false;
1150        p.permission = Permission::Edit;
1151        let a = argv(Agent::Claude, &p);
1152        assert_eq!(a[pos(&a, "--input-format").unwrap() + 1], "stream-json");
1153    }
1154
1155    /// Neither other agent reads a structured message stream on stdin.
1156    #[test]
1157    fn agents_that_cannot_take_a_follow_up_refuse_before_spawning() {
1158        let mut p = plan("x");
1159        p.duplex = true;
1160        for agent in [Agent::Codex, Agent::Copilot] {
1161            assert!(
1162                matches!(agent.typed_argv(&p), Err(Error::Unsupported { .. })),
1163                "{agent} cannot be sent a second message mid-turn"
1164            );
1165        }
1166    }
1167
1168    /// An ordinary run is untouched, so nothing about the default path changes.
1169    #[test]
1170    fn without_approvals_claude_keeps_its_posture_flag() {
1171        let mut p = plan("claude");
1172        p.permission = Permission::ReadOnly;
1173        let a = argv(Agent::Claude, &p);
1174        assert_eq!(a[pos(&a, "--permission-mode").unwrap() + 1], "dontAsk");
1175        assert!(pos(&a, "--permission-prompt-tool").is_none());
1176    }
1177
1178    #[test]
1179    fn effort_reaches_each_cli_the_way_that_cli_takes_it() {
1180        let mut p = plan("x");
1181        p.effort = Some("xhigh".into());
1182
1183        let claude = argv(Agent::Claude, &p);
1184        assert_eq!(claude[pos(&claude, "--effort").unwrap() + 1], "xhigh");
1185
1186        let copilot = argv(Agent::Copilot, &p);
1187        assert_eq!(copilot[pos(&copilot, "--effort").unwrap() + 1], "xhigh");
1188
1189        let codex = argv(Agent::Codex, &p);
1190        assert!(
1191            pos(&codex, "--effort").is_none(),
1192            "codex has no effort flag: {codex:?}"
1193        );
1194        assert!(
1195            codex
1196                .windows(2)
1197                .any(|w| w[0] == "-c" && w[1] == "model_reasoning_effort=xhigh"),
1198            "codex takes it as a config override: {codex:?}"
1199        );
1200    }
1201
1202    /// An unset effort must add nothing, so the agent keeps its own default.
1203    #[test]
1204    fn no_effort_means_no_flag() {
1205        let p = plan("x");
1206        for agent in [Agent::Claude, Agent::Codex, Agent::Copilot] {
1207            let a = argv(agent, &p);
1208            assert!(pos(&a, "--effort").is_none(), "{agent}: {a:?}");
1209            assert!(
1210                !a.iter()
1211                    .any(|arg| arg.starts_with("model_reasoning_effort")),
1212                "{agent}: {a:?}"
1213            );
1214        }
1215    }
1216
1217    #[test]
1218    fn claude_bypass_does_not_deny_tools() {
1219        let mut p = plan("claude");
1220        p.permission = Permission::Bypass;
1221        let a = argv(Agent::Claude, &p);
1222        assert!(a.contains(&"bypassPermissions".to_string()));
1223        assert!(pos(&a, "--disallowedTools").is_none());
1224    }
1225
1226    #[test]
1227    fn claude_stream_format_adds_verbose_but_json_does_not() {
1228        let mut p = plan("claude");
1229        p.format = Format::Stream;
1230        assert!(argv(Agent::Claude, &p).contains(&"--verbose".to_string()));
1231        p.format = Format::Json;
1232        assert!(!argv(Agent::Claude, &p).contains(&"--verbose".to_string()));
1233    }
1234
1235    #[test]
1236    fn claude_mints_an_id_for_a_new_session_and_resumes_an_old_one() {
1237        let mut p = plan("claude");
1238        p.cont = Continue::NewWith("11111111-2222-3333-4444-555555555555".into());
1239        let a = argv(Agent::Claude, &p);
1240        assert_eq!(
1241            a[pos(&a, "--session-id").unwrap() + 1],
1242            "11111111-2222-3333-4444-555555555555"
1243        );
1244        assert!(pos(&a, "--resume").is_none());
1245
1246        p.cont = Continue::Resume("sess-1".into());
1247        let a = argv(Agent::Claude, &p);
1248        assert_eq!(a[pos(&a, "--resume").unwrap() + 1], "sess-1");
1249        assert!(!a.contains(&"--fork-session".to_string()));
1250    }
1251
1252    #[test]
1253    fn claude_fork_resumes_and_branches() {
1254        let mut p = plan("claude");
1255        p.cont = Continue::Fork("sess-1".into());
1256        let a = argv(Agent::Claude, &p);
1257        assert_eq!(a[pos(&a, "--resume").unwrap() + 1], "sess-1");
1258        assert!(a.contains(&"--fork-session".to_string()));
1259    }
1260
1261    #[test]
1262    fn claude_keeps_the_system_prompt_on_its_own_flag() {
1263        let mut p = plan("claude");
1264        p.system = Some("be terse".into());
1265        let a = argv(Agent::Claude, &p);
1266        assert_eq!(
1267            a[pos(&a, "--append-system-prompt").unwrap() + 1],
1268            "be terse"
1269        );
1270        // The prompt itself stays clean.
1271        assert!(a.contains(&"hi".to_string()));
1272    }
1273
1274    #[test]
1275    fn claude_stdin_prompt_leaves_the_argv() {
1276        let mut p = plan("claude");
1277        p.stdin_prompt = true;
1278        let a = argv(Agent::Claude, &p);
1279        assert_eq!(a[pos(&a, "--input-format").unwrap() + 1], "text");
1280        assert!(!a.contains(&"hi".to_string()), "prompt must not ride argv");
1281    }
1282
1283    #[test]
1284    fn codex_resume_is_a_subcommand_and_prompt_is_last() {
1285        let mut p = plan("codex");
1286        p.cont = Continue::Resume("thread-9".into());
1287        let a = argv(Agent::Codex, &p);
1288        assert_eq!(a[0..4], ["codex", "exec", "resume", "thread-9"]);
1289        assert_eq!(a.last().unwrap(), "hi");
1290    }
1291
1292    /// `Minimal` exists to withhold secrets, so nothing it passes through may
1293    /// be a credential carrier. Proxy URLs in particular routinely embed
1294    /// `user:pass`, which is why they are offered separately instead.
1295    #[test]
1296    fn the_minimal_environment_carries_no_proxy_variables() {
1297        for agent in Agent::ALL {
1298            let essential = agent.essential_env();
1299            for name in NETWORK_ENV {
1300                assert!(
1301                    !essential.contains(name),
1302                    "{agent} would pass {name} through EnvPolicy::Minimal"
1303                );
1304            }
1305        }
1306    }
1307
1308    /// The floor verified live on macOS: with exactly these set, all three CLIs
1309    /// authenticate and answer. Claude reports "Not logged in" without `USER`.
1310    #[test]
1311    fn every_agent_asks_for_the_verified_floor() {
1312        for agent in Agent::ALL {
1313            let essential = agent.essential_env();
1314            for name in ["PATH", "HOME", "USER"] {
1315                assert!(essential.contains(&name), "{agent} omits {name}");
1316            }
1317        }
1318    }
1319
1320    /// Each agent's own credentials, and nobody else's.
1321    #[test]
1322    fn agents_do_not_request_each_others_credentials() {
1323        let claude = Agent::Claude.essential_env();
1324        assert!(claude.contains(&"ANTHROPIC_API_KEY"));
1325        assert!(!claude.contains(&"OPENAI_API_KEY"));
1326        assert!(!claude.contains(&"GH_TOKEN"));
1327
1328        let codex = Agent::Codex.essential_env();
1329        assert!(codex.contains(&"OPENAI_API_KEY"));
1330        assert!(!codex.contains(&"ANTHROPIC_API_KEY"));
1331    }
1332
1333    /// The model is the caller's choice on every agent. It is forwarded
1334    /// verbatim and never defaulted, normalized, or validated here: a host with
1335    /// a model picker owns that list, and an unknown name must surface as the
1336    /// agent's own error rather than something this crate guessed at.
1337    #[test]
1338    fn every_agent_forwards_the_callers_model_verbatim() {
1339        for agent in Agent::ALL {
1340            let mut p = plan(agent.bin());
1341            // Deliberately not a real model id: nothing here may interpret it.
1342            p.model = Some("some-model-9".into());
1343            let a = argv(agent, &p);
1344            let at = pos(&a, "--model").unwrap_or_else(|| panic!("{agent} dropped --model: {a:?}"));
1345            assert_eq!(a[at + 1], "some-model-9", "{agent} rewrote the model");
1346        }
1347    }
1348
1349    /// No model means the agent picks its own, so a host can offer a "default"
1350    /// entry without this crate inventing one.
1351    #[test]
1352    fn no_model_means_no_model_flag() {
1353        for agent in Agent::ALL {
1354            let p = plan(agent.bin());
1355            assert!(p.model.is_none());
1356            let a = argv(agent, &p);
1357            assert!(
1358                pos(&a, "--model").is_none(),
1359                "{agent} invented a model: {a:?}"
1360            );
1361        }
1362    }
1363
1364    /// `codex exec` aborts outside a git repository. A host embedding this
1365    /// crate runs against scratch dirs and review checkouts, so the check is
1366    /// waived on every invocation; the sandbox is what actually contains a run.
1367    #[test]
1368    fn codex_always_waives_the_git_repo_check() {
1369        for cont in [Continue::New, Continue::Resume("t-1".into())] {
1370            let mut p = plan("codex");
1371            p.cont = cont.clone();
1372            assert!(
1373                argv(Agent::Codex, &p).contains(&"--skip-git-repo-check".to_string()),
1374                "{cont:?} must still run outside a repo"
1375            );
1376        }
1377    }
1378
1379    /// `codex exec resume` rejects `--sandbox` and takes `-c sandbox_mode=`
1380    /// instead. Getting this wrong makes every second turn fail with an
1381    /// "unexpected argument" error, which only a multi-turn run reveals.
1382    /// The two CLIs take a schema differently, and the difference is the
1383    /// whole reason this needs handling rather than one shared flag.
1384    #[test]
1385    fn each_agent_takes_a_schema_in_its_own_shape() {
1386        let schema = r#"{"type":"object"}"#;
1387
1388        let mut claude = plan("claude");
1389        claude.schema = Some(schema.into());
1390        let a = argv(Agent::Claude, &claude);
1391        assert_eq!(
1392            a[pos(&a, "--json-schema").unwrap() + 1],
1393            schema,
1394            "claude takes it inline"
1395        );
1396
1397        let mut codex = plan("codex");
1398        codex.schema = Some(schema.into());
1399        codex.schema_file = Some("/tmp/s.json".into());
1400        let a = argv(Agent::Codex, &codex);
1401        assert_eq!(
1402            a[pos(&a, "--output-schema").unwrap() + 1],
1403            "/tmp/s.json",
1404            "codex takes a path, never the schema itself"
1405        );
1406        assert!(!a.iter().any(|arg| arg.contains("\"type\"")));
1407    }
1408
1409    /// Copilot 1.0.75 has no schema flag, and a prose answer presented as data
1410    /// is exactly the silent downgrade this crate refuses elsewhere.
1411    #[test]
1412    fn copilot_refuses_a_schema_rather_than_answering_in_prose() {
1413        let mut p = plan("copilot");
1414        p.schema = Some(r#"{"type":"object"}"#.into());
1415        assert!(matches!(
1416            Agent::Copilot.argv(&p),
1417            Err(Error::Unsupported { .. })
1418        ));
1419    }
1420
1421    /// A caller inspecting the command before running it has no file yet, since
1422    /// it is written at spawn time.
1423    #[test]
1424    fn a_codex_schema_preview_shows_a_placeholder_path() {
1425        let mut p = plan("codex");
1426        p.schema = Some(r#"{"type":"object"}"#.into());
1427        let a = argv(Agent::Codex, &p);
1428        assert_eq!(a[pos(&a, "--output-schema").unwrap() + 1], "<schema-file>");
1429    }
1430
1431    #[test]
1432    fn codex_sets_the_sandbox_by_flag_when_fresh_and_by_config_when_resuming() {
1433        let mut fresh = plan("codex");
1434        fresh.permission = Permission::ReadOnly;
1435        let a = argv(Agent::Codex, &fresh);
1436        assert_eq!(a[pos(&a, "--sandbox").unwrap() + 1], "read-only");
1437        assert!(pos(&a, "-c").is_none());
1438
1439        let mut resumed = fresh.clone();
1440        resumed.cont = Continue::Resume("thread-9".into());
1441        let a = argv(Agent::Codex, &resumed);
1442        assert!(
1443            pos(&a, "--sandbox").is_none(),
1444            "resume rejects --sandbox: {a:?}"
1445        );
1446        assert_eq!(a[pos(&a, "-c").unwrap() + 1], "sandbox_mode=read-only");
1447    }
1448
1449    #[test]
1450    fn codex_bypass_uses_the_same_flag_on_both_paths() {
1451        for cont in [Continue::New, Continue::Resume("t".into())] {
1452            let mut p = plan("codex");
1453            p.permission = Permission::Bypass;
1454            p.cont = cont.clone();
1455            let a = argv(Agent::Codex, &p);
1456            assert!(a.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string()));
1457            assert!(pos(&a, "--sandbox").is_none(), "{cont:?}: {a:?}");
1458        }
1459    }
1460
1461    #[test]
1462    fn codex_without_a_system_flag_prepends_it_to_the_prompt() {
1463        let mut p = plan("codex");
1464        p.system = Some("be terse".into());
1465        let a = argv(Agent::Codex, &p);
1466        assert_eq!(a.last().unwrap(), "be terse\n\nhi");
1467    }
1468
1469    #[test]
1470    fn codex_maps_each_posture_to_a_sandbox() {
1471        for (perm, expect) in [
1472            (Permission::ReadOnly, "read-only"),
1473            (Permission::Plan, "read-only"),
1474            (Permission::Edit, "workspace-write"),
1475            (Permission::Auto, "workspace-write"),
1476        ] {
1477            let mut p = plan("codex");
1478            p.permission = perm;
1479            let a = argv(Agent::Codex, &p);
1480            assert_eq!(a[pos(&a, "--sandbox").unwrap() + 1], expect, "{perm:?}");
1481        }
1482        let mut p = plan("codex");
1483        p.permission = Permission::Bypass;
1484        let a = argv(Agent::Codex, &p);
1485        assert!(a.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string()));
1486        assert!(pos(&a, "--sandbox").is_none());
1487    }
1488
1489    #[test]
1490    fn copilot_drops_dash_p_when_the_prompt_is_piped() {
1491        let mut p = plan("copilot");
1492        p.stdin_prompt = true;
1493        let a = argv(Agent::Copilot, &p);
1494        assert!(
1495            !a.contains(&"-p".to_string()),
1496            "a -p value shadows the pipe"
1497        );
1498        assert!(!a.contains(&"hi".to_string()));
1499    }
1500
1501    /// Copilot declares its tool filters as `--deny-tool[=tools...]`, an
1502    /// optional value, which binds only with `=`. Passed across a space the
1503    /// value is silently read as a positional instead, so the deny is lost.
1504    #[test]
1505    fn copilot_read_only_denies_shell_and_write_with_the_combined_form() {
1506        let a = argv(Agent::Copilot, &plan("copilot"));
1507        assert!(a.contains(&"--deny-tool=shell".to_string()));
1508        assert!(a.contains(&"--deny-tool=write".to_string()));
1509        assert!(
1510            !a.iter().any(|s| s == "--deny-tool"),
1511            "a bare --deny-tool would drop its value: {a:?}"
1512        );
1513    }
1514
1515    /// A headless Copilot run stalls at the first tool confirmation without it.
1516    #[test]
1517    fn copilot_always_allows_tools_and_silences_the_ask_tool() {
1518        for permission in [Permission::ReadOnly, Permission::Plan, Permission::Bypass] {
1519            let mut p = plan("copilot");
1520            p.permission = permission;
1521            let a = argv(Agent::Copilot, &p);
1522            assert!(
1523                a.contains(&"--allow-all-tools".to_string()),
1524                "{permission:?}"
1525            );
1526            assert!(a.contains(&"--no-ask-user".to_string()), "{permission:?}");
1527        }
1528    }
1529
1530    /// Copilot uses one flag in both directions: it sets the id for a new
1531    /// session and resumes an existing one.
1532    #[test]
1533    fn copilot_uses_session_id_for_both_new_and_resumed_sessions() {
1534        for cont in [
1535            Continue::NewWith("11111111-2222-3333-4444-555555555555".into()),
1536            Continue::Resume("11111111-2222-3333-4444-555555555555".into()),
1537        ] {
1538            let mut p = plan("copilot");
1539            p.cont = cont.clone();
1540            let a = argv(Agent::Copilot, &p);
1541            assert_eq!(
1542                a[pos(&a, "--session-id").unwrap() + 1],
1543                "11111111-2222-3333-4444-555555555555",
1544                "{cont:?}"
1545            );
1546        }
1547    }
1548
1549    #[test]
1550    fn unsupported_capabilities_are_refused_not_downgraded() {
1551        // Forking headlessly is Claude-only.
1552        for agent in [Agent::Codex, Agent::Copilot] {
1553            let mut p = plan(agent.bin());
1554            p.cont = Continue::Fork("s".into());
1555            assert!(
1556                matches!(agent.argv(&p), Err(Error::Unsupported { .. })),
1557                "{agent} must refuse a fork rather than resume linearly"
1558            );
1559        }
1560        // Codex's id is printed, not assigned, so it cannot be chosen up front.
1561        let mut p = plan("codex");
1562        p.cont = Continue::NewWith("id".into());
1563        assert!(matches!(
1564            Agent::Codex.argv(&p),
1565            Err(Error::Unsupported { .. })
1566        ));
1567    }
1568
1569    /// The failure mode this refusal exists to prevent is a silent one: an
1570    /// agent with no command vocabulary reads `/compact` as prose and answers a
1571    /// question *about* compaction, which looks from the outside exactly like
1572    /// the command having run.
1573    #[test]
1574    fn a_slash_command_is_refused_by_agents_that_only_read_prose() {
1575        for agent in [Agent::Codex, Agent::Copilot] {
1576            let mut p = plan(agent.bin());
1577            p.is_command = true;
1578            assert!(
1579                matches!(agent.argv(&p), Err(Error::Unsupported { .. })),
1580                "{agent} must refuse a slash command rather than send it as text"
1581            );
1582        }
1583        let mut p = plan("claude");
1584        p.is_command = true;
1585        assert!(
1586            Agent::Claude.argv(&p).is_ok(),
1587            "Claude publishes a catalogue and acts on them"
1588        );
1589    }
1590
1591    /// All three expose an id, so all three can back a named session, but only
1592    /// through a format that actually carries one.
1593    #[test]
1594    fn every_agent_has_a_format_that_carries_its_session_id() {
1595        assert_eq!(Agent::Claude.session_format(), Some(Format::Json));
1596        assert_eq!(Agent::Codex.session_format(), Some(Format::Stream));
1597        assert_eq!(Agent::Copilot.session_format(), Some(Format::Stream));
1598    }
1599
1600    /// Claude and Copilot let the caller assign the id, so a run that dies
1601    /// mid-turn still leaves a resumable session.
1602    #[test]
1603    fn the_minting_agents_are_claude_and_copilot() {
1604        let minting: Vec<_> = Agent::ALL
1605            .into_iter()
1606            .filter(|a| a.caps().session == SessionSupport::Minted)
1607            .collect();
1608        assert_eq!(minting, [Agent::Claude, Agent::Copilot]);
1609    }
1610}