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