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