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.145.0"
392 Agent::Codex => (0, 145, 0),
393 // `copilot --version` -> "GitHub Copilot CLI 1.0.75."
394 Agent::Copilot => (1, 0, 75),
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.75: `--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.75 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.75
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.75 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. That
935 // check guards against an agent editing files with no way to undo them, but
936 // this crate is embedded in hosts that legitimately run against scratch
937 // directories, worktrees and review checkouts, and a hard abort there is
938 // useless to them. The real containment is the sandbox below, which is
939 // `read-only` by default, so nothing is unrecoverable regardless.
940 a.bare("--skip-git-repo-check");
941
942 // `codex exec` takes `--sandbox`, but `codex exec resume` does **not**: it
943 // rejects the flag outright and takes the same setting as a `-c` config
944 // override instead. Verified against codex-cli 0.145.0, where passing
945 // `--sandbox` to a resume fails with "unexpected argument '--sandbox'".
946 // Dropping the sandbox on resume would silently run a continued turn under a
947 // different posture than the caller asked for.
948 let resuming = matches!(plan.cont, Continue::Resume(_));
949 let sandbox = match plan.permission {
950 Permission::Bypass => None,
951 Permission::ReadOnly | Permission::Plan => Some("read-only"),
952 Permission::Edit | Permission::Auto => Some("workspace-write"),
953 };
954 match (sandbox, resuming) {
955 (None, _) => a.bare("--dangerously-bypass-approvals-and-sandbox"),
956 (Some(mode), false) => a.pair("--sandbox", mode),
957 // The value is TOML-parsed, falling back to a raw string, so the bare
958 // token is read as the mode name.
959 (Some(mode), true) => a.pair("-c", format!("sandbox_mode={mode}")),
960 };
961
962 a.opt("--model", plan.model.as_ref());
963 // Verified against codex-cli 0.145.0: `codex exec` has no effort flag, it
964 // is a config override, and `--strict-config` accepts this key. A bad value
965 // is refused by the provider with its own enum rather than by the CLI.
966 if let Some(effort) = plan.effort.as_ref() {
967 a.pair("-c", format!("model_reasoning_effort={effort}"));
968 }
969 // Verified against codex-cli 0.145.0. Options remain options after the
970 // positional prompt, but keeping roots before it makes the command's
971 // security posture readable and matches the CLI's help shape.
972 for dir in &plan.extra_dirs {
973 a.pair("--add-dir", dir);
974 }
975 // Codex reads the schema from a file, which the runner writes before the
976 // spawn. `Request::argv` has no file to name, so it shows a placeholder:
977 // the preview is for display, and the real path exists only at spawn time.
978 if plan.schema.is_some() {
979 a.pair(
980 "--output-schema",
981 plan.schema_file.as_deref().unwrap_or("<schema-file>"),
982 );
983 }
984 // `--json` is Codex's event stream and the only place `thread_id` appears.
985 if plan.format != Format::Text {
986 a.bare("--json");
987 }
988 // Codex has no system flag, so the system text rides the prompt. A literal
989 // `-` makes it read the prompt from stdin instead, keeping a large one off
990 // the argv.
991 // Codex takes the prompt as a bare trailing positional, which is exactly
992 // the shape positional redaction guesswork gets wrong.
993 if plan.stdin_prompt {
994 a.arg("-");
995 } else {
996 a.arg_sensitive(Agent::Codex.effective_prompt(plan), Sensitivity::Prompt);
997 }
998 a.done()
999}
1000
1001/// `copilot -p <prompt> --allow-all-tools [...] [--session-id <uuid>]`
1002///
1003/// Flags verified against Copilot CLI 1.0.75. Two of its conventions matter:
1004/// `--allow-all-tools` is *required* for non-interactive mode, and the
1005/// repeatable tool filters are declared `--allow-tool[=tools...]`, an optional
1006/// value, which only binds with `=`, never across a space.
1007fn argv_copilot(plan: &Plan) -> Vec<Arg> {
1008 // Copilot reads stdin as the prompt only when `-p` is absent: a `-p` value
1009 // makes the pipe be ignored. So a piped prompt drops the flag entirely.
1010 let mut a = Argv::new(&plan.bin);
1011 if !plan.stdin_prompt {
1012 a.secret(
1013 "-p",
1014 Agent::Copilot.effective_prompt(plan),
1015 Sensitivity::Prompt,
1016 );
1017 }
1018
1019 // Without this, a headless run stops at the first tool confirmation.
1020 a.bare("--allow-all-tools").bare("--no-ask-user");
1021 match plan.permission {
1022 Permission::Bypass | Permission::Auto => a.bare("--allow-all-paths"),
1023 // Deny beats allow, so this is allow-all minus the mutating tools.
1024 // `--allow-all-paths` is deliberately NOT set: it disables path
1025 // verification entirely, which would widen filesystem reach in the one
1026 // posture that exists to narrow it.
1027 Permission::ReadOnly => a.bare("--deny-tool=shell").bare("--deny-tool=write"),
1028 // Edits run; shell stays denied so commands cannot.
1029 Permission::Edit => a.bare("--deny-tool=shell"),
1030 Permission::Plan => a.pair("--mode", "plan"),
1031 };
1032
1033 a.opt("--model", plan.model.as_ref());
1034 // Verified against Copilot CLI 1.0.75: `--effort` is the documented spelling
1035 // and `--reasoning-effort` its alias (none, minimal, low, medium, high,
1036 // xhigh, max). A wider set than Claude's, which is why the level is passed
1037 // through rather than mapped to a shared enum.
1038 a.opt("--effort", plan.effort.as_ref());
1039 // One flag serves both directions: it sets the UUID for a new session and
1040 // resumes an existing one by id.
1041 match &plan.cont {
1042 Continue::NewWith(id) | Continue::Resume(id) => {
1043 a.secret("--session-id", id, Sensitivity::SessionId);
1044 }
1045 // `Fork` is rejected by `Agent::check` before reaching here.
1046 Continue::New | Continue::Fork(_) => {}
1047 }
1048
1049 a.pair(
1050 "--output-format",
1051 if plan.format == Format::Text {
1052 "text"
1053 } else {
1054 // Copilot's `json` is JSONL, so it serves both structured formats.
1055 "json"
1056 },
1057 );
1058 a.done()
1059}
1060
1061#[cfg(test)]
1062mod tests {
1063 use super::*;
1064
1065 fn plan(bin: &str) -> Plan {
1066 Plan {
1067 bin: bin.into(),
1068 prompt: "hi".into(),
1069 system: None,
1070 model: None,
1071 effort: None,
1072 thinking: None,
1073 permission: Permission::ReadOnly,
1074 format: Format::Json,
1075 cont: Continue::New,
1076 extra_dirs: Vec::new(),
1077 stdin_prompt: false,
1078 duplex: false,
1079 approvals: false,
1080 schema: None,
1081 schema_file: None,
1082 is_command: false,
1083 }
1084 }
1085
1086 fn argv(agent: Agent, plan: &Plan) -> Vec<String> {
1087 agent.argv(plan).expect("plan is supported")
1088 }
1089
1090 #[test]
1091 fn interactive_capabilities_match_the_supported_request_paths() {
1092 for agent in [Agent::Claude, Agent::Codex] {
1093 let caps = agent.caps();
1094 assert!(caps.live_follow_up, "{agent} can take live follow-ups");
1095 assert!(caps.approvals, "{agent} has an approval channel");
1096 }
1097 let copilot = Agent::Copilot.caps();
1098 assert!(!copilot.live_follow_up);
1099 assert!(!copilot.approvals);
1100 }
1101
1102 fn pos(a: &[String], needle: &str) -> Option<usize> {
1103 a.iter().position(|s| s == needle)
1104 }
1105
1106 #[test]
1107 fn claude_builds_print_mode_with_format_and_permission() {
1108 let a = argv(Agent::Claude, &plan("claude"));
1109 assert_eq!(a[0..3], ["claude", "-p", "hi"]);
1110 assert!(pos(&a, "--permission-mode").is_some());
1111 assert!(a.contains(&"dontAsk".to_string()));
1112 assert_eq!(a[pos(&a, "--output-format").unwrap() + 1], "json");
1113 }
1114
1115 #[test]
1116 fn claude_read_only_removes_the_mutating_tools() {
1117 let a = argv(Agent::Claude, &plan("claude"));
1118 let at = pos(&a, "--disallowedTools").expect("read-only denies tools");
1119 assert_eq!(
1120 &a[at + 1..at + 5],
1121 ["Bash", "Edit", "Write", "NotebookEdit"]
1122 );
1123 }
1124
1125 /// Each agent takes the level a different way, and Codex takes it as a
1126 /// config override because it has no flag for it at all.
1127 /// The approvals posture rewires how Claude is invoked: the control channel
1128 /// replaces the posture flag, and the prompt leaves the argv because it
1129 /// travels as a JSON message on stdin instead.
1130 #[test]
1131 fn approvals_switch_claude_to_the_control_channel() {
1132 let mut p = plan("claude");
1133 p.approvals = true;
1134 p.permission = Permission::Edit;
1135 let a = argv(Agent::Claude, &p);
1136
1137 assert_eq!(a[pos(&a, "--permission-mode").unwrap() + 1], "manual");
1138 assert_eq!(a[pos(&a, "--permission-prompt-tool").unwrap() + 1], "stdio");
1139 assert_eq!(a[pos(&a, "--input-format").unwrap() + 1], "stream-json");
1140 assert!(
1141 !a.iter().any(|arg| arg == "hi"),
1142 "the prompt must not ride the argv as well: {a:?}"
1143 );
1144 // Suppressing the user's own settings is not part of this: it would
1145 // discard their CLAUDE.md, and a mutating command asks without it.
1146 assert!(
1147 pos(&a, "--setting-sources").is_none(),
1148 "the user's settings stay loaded: {a:?}"
1149 );
1150 }
1151
1152 /// Copilot has no headless approval channel, so asking must fail loudly. A
1153 /// run that quietly never asked is the dangerous outcome.
1154 #[test]
1155 fn agents_without_an_approval_channel_refuse_before_spawning() {
1156 let mut p = plan("x");
1157 p.approvals = true;
1158 p.permission = Permission::Edit;
1159 assert!(matches!(
1160 Agent::Copilot.typed_argv(&p),
1161 Err(Error::Unsupported { .. })
1162 ));
1163 assert!(Agent::Claude.typed_argv(&p).is_ok());
1164 assert_eq!(argv(Agent::Codex, &p), ["x", "app-server", "--stdio"]);
1165 }
1166
1167 /// Read-only removes the mutating tools outright, so there is nothing left
1168 /// to ask about. Opting into approvals there would mean never being asked,
1169 /// which reads as "the agent wanted nothing".
1170 #[test]
1171 fn approvals_under_read_only_are_refused_rather_than_silent() {
1172 let mut p = plan("claude");
1173 p.approvals = true;
1174 p.permission = Permission::ReadOnly;
1175 assert!(matches!(
1176 Agent::Claude.typed_argv(&p),
1177 Err(Error::Unsupported { .. })
1178 ));
1179
1180 // Every posture that leaves the tools in place is fine.
1181 for permission in [Permission::Edit, Permission::Auto, Permission::Bypass] {
1182 p.permission = permission;
1183 assert!(
1184 Agent::Claude.typed_argv(&p).is_ok(),
1185 "{permission:?} should allow approvals"
1186 );
1187 }
1188 }
1189
1190 /// Interactive alone opens the message channel without switching the
1191 /// permission posture: a caller who wants to send follow-ups is not
1192 /// thereby asking to be prompted about every tool.
1193 #[test]
1194 fn interactive_opens_the_channel_without_changing_the_posture() {
1195 let mut p = plan("claude");
1196 p.duplex = true;
1197 p.permission = Permission::Edit;
1198 let a = argv(Agent::Claude, &p);
1199
1200 assert_eq!(a[pos(&a, "--input-format").unwrap() + 1], "stream-json");
1201 assert_eq!(a[pos(&a, "--permission-mode").unwrap() + 1], "acceptEdits");
1202 assert!(
1203 pos(&a, "--permission-prompt-tool").is_none(),
1204 "no approval channel was asked for: {a:?}"
1205 );
1206 assert!(
1207 !a.iter().any(|arg| arg == "hi"),
1208 "the prompt travels as a stdin message: {a:?}"
1209 );
1210 }
1211
1212 /// Approvals need the same open channel, so they imply it. Checked on a
1213 /// hand-built `Plan` because the builder is not the only way to make one.
1214 #[test]
1215 fn approvals_imply_the_open_channel_even_without_the_builder() {
1216 let mut p = plan("claude");
1217 p.approvals = true;
1218 p.duplex = false;
1219 p.permission = Permission::Edit;
1220 let a = argv(Agent::Claude, &p);
1221 assert_eq!(a[pos(&a, "--input-format").unwrap() + 1], "stream-json");
1222 }
1223
1224 /// Copilot does not expose a structured message stream on stdin. Codex
1225 /// switches to app-server when a live follow-up is requested.
1226 #[test]
1227 fn agents_that_cannot_take_a_follow_up_refuse_before_spawning() {
1228 let mut p = plan("x");
1229 p.duplex = true;
1230 assert!(matches!(
1231 Agent::Copilot.typed_argv(&p),
1232 Err(Error::Unsupported { .. })
1233 ));
1234 assert_eq!(argv(Agent::Codex, &p), ["x", "app-server", "--stdio"]);
1235 }
1236
1237 /// An ordinary run is untouched, so nothing about the default path changes.
1238 #[test]
1239 fn without_approvals_claude_keeps_its_posture_flag() {
1240 let mut p = plan("claude");
1241 p.permission = Permission::ReadOnly;
1242 let a = argv(Agent::Claude, &p);
1243 assert_eq!(a[pos(&a, "--permission-mode").unwrap() + 1], "dontAsk");
1244 assert!(pos(&a, "--permission-prompt-tool").is_none());
1245 }
1246
1247 #[test]
1248 fn effort_reaches_each_cli_the_way_that_cli_takes_it() {
1249 let mut p = plan("x");
1250 p.effort = Some("xhigh".into());
1251
1252 let claude = argv(Agent::Claude, &p);
1253 assert_eq!(claude[pos(&claude, "--effort").unwrap() + 1], "xhigh");
1254
1255 let copilot = argv(Agent::Copilot, &p);
1256 assert_eq!(copilot[pos(&copilot, "--effort").unwrap() + 1], "xhigh");
1257
1258 let codex = argv(Agent::Codex, &p);
1259 assert!(
1260 pos(&codex, "--effort").is_none(),
1261 "codex has no effort flag: {codex:?}"
1262 );
1263 assert!(
1264 codex
1265 .windows(2)
1266 .any(|w| w[0] == "-c" && w[1] == "model_reasoning_effort=xhigh"),
1267 "codex takes it as a config override: {codex:?}"
1268 );
1269 }
1270
1271 /// An unset effort must add nothing, so the agent keeps its own default.
1272 #[test]
1273 fn no_effort_means_no_flag() {
1274 let p = plan("x");
1275 for agent in [Agent::Claude, Agent::Codex, Agent::Copilot] {
1276 let a = argv(agent, &p);
1277 assert!(pos(&a, "--effort").is_none(), "{agent}: {a:?}");
1278 assert!(
1279 !a.iter()
1280 .any(|arg| arg.starts_with("model_reasoning_effort")),
1281 "{agent}: {a:?}"
1282 );
1283 }
1284 }
1285
1286 /// Disabling thinking is Claude's `MAX_THINKING_TOKENS=0`, and only
1287 /// Claude's: the other two have no lever and must report none rather than
1288 /// pretend.
1289 #[test]
1290 fn disabling_thinking_is_claudes_env_switch_alone() {
1291 assert_eq!(
1292 Agent::Claude.thinking_env(Some(false)),
1293 Some(("MAX_THINKING_TOKENS", "0")),
1294 );
1295 for agent in [Agent::Codex, Agent::Copilot] {
1296 assert_eq!(
1297 agent.thinking_env(Some(false)),
1298 None,
1299 "{agent} has no lever"
1300 );
1301 }
1302 }
1303
1304 /// The default and an explicit on both leave the agent's own thinking
1305 /// default in place, so nothing is set for any agent.
1306 #[test]
1307 fn thinking_on_or_unset_sets_nothing() {
1308 for agent in [Agent::Claude, Agent::Codex, Agent::Copilot] {
1309 assert_eq!(agent.thinking_env(None), None, "{agent} default");
1310 assert_eq!(agent.thinking_env(Some(true)), None, "{agent} explicit on");
1311 }
1312 }
1313
1314 #[test]
1315 fn claude_bypass_does_not_deny_tools() {
1316 let mut p = plan("claude");
1317 p.permission = Permission::Bypass;
1318 let a = argv(Agent::Claude, &p);
1319 assert!(a.contains(&"bypassPermissions".to_string()));
1320 assert!(pos(&a, "--disallowedTools").is_none());
1321 }
1322
1323 #[test]
1324 fn claude_stream_format_adds_verbose_but_json_does_not() {
1325 let mut p = plan("claude");
1326 p.format = Format::Stream;
1327 assert!(argv(Agent::Claude, &p).contains(&"--verbose".to_string()));
1328 p.format = Format::Json;
1329 assert!(!argv(Agent::Claude, &p).contains(&"--verbose".to_string()));
1330 }
1331
1332 #[test]
1333 fn claude_mints_an_id_for_a_new_session_and_resumes_an_old_one() {
1334 let mut p = plan("claude");
1335 p.cont = Continue::NewWith("11111111-2222-3333-4444-555555555555".into());
1336 let a = argv(Agent::Claude, &p);
1337 assert_eq!(
1338 a[pos(&a, "--session-id").unwrap() + 1],
1339 "11111111-2222-3333-4444-555555555555"
1340 );
1341 assert!(pos(&a, "--resume").is_none());
1342
1343 p.cont = Continue::Resume("sess-1".into());
1344 let a = argv(Agent::Claude, &p);
1345 assert_eq!(a[pos(&a, "--resume").unwrap() + 1], "sess-1");
1346 assert!(!a.contains(&"--fork-session".to_string()));
1347 }
1348
1349 #[test]
1350 fn claude_fork_resumes_and_branches() {
1351 let mut p = plan("claude");
1352 p.cont = Continue::Fork("sess-1".into());
1353 let a = argv(Agent::Claude, &p);
1354 assert_eq!(a[pos(&a, "--resume").unwrap() + 1], "sess-1");
1355 assert!(a.contains(&"--fork-session".to_string()));
1356 }
1357
1358 #[test]
1359 fn claude_keeps_the_system_prompt_on_its_own_flag() {
1360 let mut p = plan("claude");
1361 p.system = Some("be terse".into());
1362 let a = argv(Agent::Claude, &p);
1363 assert_eq!(
1364 a[pos(&a, "--append-system-prompt").unwrap() + 1],
1365 "be terse"
1366 );
1367 // The prompt itself stays clean.
1368 assert!(a.contains(&"hi".to_string()));
1369 }
1370
1371 #[test]
1372 fn claude_stdin_prompt_leaves_the_argv() {
1373 let mut p = plan("claude");
1374 p.stdin_prompt = true;
1375 let a = argv(Agent::Claude, &p);
1376 assert_eq!(a[pos(&a, "--input-format").unwrap() + 1], "text");
1377 assert!(!a.contains(&"hi".to_string()), "prompt must not ride argv");
1378 }
1379
1380 #[test]
1381 fn codex_resume_is_a_subcommand_and_prompt_is_last() {
1382 let mut p = plan("codex");
1383 p.cont = Continue::Resume("thread-9".into());
1384 let a = argv(Agent::Codex, &p);
1385 assert_eq!(a[0..4], ["codex", "exec", "resume", "thread-9"]);
1386 assert_eq!(a.last().unwrap(), "hi");
1387 }
1388
1389 /// `Minimal` exists to withhold secrets, so nothing it passes through may
1390 /// be a credential carrier. Proxy URLs in particular routinely embed
1391 /// `user:pass`, which is why they are offered separately instead.
1392 #[test]
1393 fn the_minimal_environment_carries_no_proxy_variables() {
1394 for agent in Agent::ALL {
1395 let essential = agent.essential_env();
1396 for name in NETWORK_ENV {
1397 assert!(
1398 !essential.contains(name),
1399 "{agent} would pass {name} through EnvPolicy::Minimal"
1400 );
1401 }
1402 }
1403 }
1404
1405 /// The floor verified live on macOS: with exactly these set, all three CLIs
1406 /// authenticate and answer. Claude reports "Not logged in" without `USER`.
1407 #[test]
1408 fn every_agent_asks_for_the_verified_floor() {
1409 for agent in Agent::ALL {
1410 let essential = agent.essential_env();
1411 for name in ["PATH", "HOME", "USER"] {
1412 assert!(essential.contains(&name), "{agent} omits {name}");
1413 }
1414 }
1415 }
1416
1417 /// Each agent's own credentials, and nobody else's.
1418 #[test]
1419 fn agents_do_not_request_each_others_credentials() {
1420 let claude = Agent::Claude.essential_env();
1421 assert!(claude.contains(&"ANTHROPIC_API_KEY"));
1422 assert!(!claude.contains(&"OPENAI_API_KEY"));
1423 assert!(!claude.contains(&"GH_TOKEN"));
1424
1425 let codex = Agent::Codex.essential_env();
1426 assert!(codex.contains(&"OPENAI_API_KEY"));
1427 assert!(!codex.contains(&"ANTHROPIC_API_KEY"));
1428 }
1429
1430 /// The model is the caller's choice on every agent. It is forwarded
1431 /// verbatim and never defaulted, normalized, or validated here: a host with
1432 /// a model picker owns that list, and an unknown name must surface as the
1433 /// agent's own error rather than something this crate guessed at.
1434 #[test]
1435 fn every_agent_forwards_the_callers_model_verbatim() {
1436 for agent in Agent::ALL {
1437 let mut p = plan(agent.bin());
1438 // Deliberately not a real model id: nothing here may interpret it.
1439 p.model = Some("some-model-9".into());
1440 let a = argv(agent, &p);
1441 let at = pos(&a, "--model").unwrap_or_else(|| panic!("{agent} dropped --model: {a:?}"));
1442 assert_eq!(a[at + 1], "some-model-9", "{agent} rewrote the model");
1443 }
1444 }
1445
1446 /// No model means the agent picks its own, so a host can offer a "default"
1447 /// entry without this crate inventing one.
1448 #[test]
1449 fn no_model_means_no_model_flag() {
1450 for agent in Agent::ALL {
1451 let p = plan(agent.bin());
1452 assert!(p.model.is_none());
1453 let a = argv(agent, &p);
1454 assert!(
1455 pos(&a, "--model").is_none(),
1456 "{agent} invented a model: {a:?}"
1457 );
1458 }
1459 }
1460
1461 /// `codex exec` aborts outside a git repository. A host embedding this
1462 /// crate runs against scratch dirs and review checkouts, so the check is
1463 /// waived on every invocation; the sandbox is what actually contains a run.
1464 #[test]
1465 fn codex_always_waives_the_git_repo_check() {
1466 for cont in [Continue::New, Continue::Resume("t-1".into())] {
1467 let mut p = plan("codex");
1468 p.cont = cont.clone();
1469 assert!(
1470 argv(Agent::Codex, &p).contains(&"--skip-git-repo-check".to_string()),
1471 "{cont:?} must still run outside a repo"
1472 );
1473 }
1474 }
1475
1476 #[test]
1477 fn working_roots_reach_both_flag_based_agents() {
1478 let mut p = plan("x");
1479 p.extra_dirs = vec!["/repo-a".into(), "/repo-b".into()];
1480 for agent in [Agent::Claude, Agent::Codex] {
1481 let a = argv(agent, &p);
1482 let roots: Vec<_> = a
1483 .windows(2)
1484 .filter(|pair| pair[0] == "--add-dir")
1485 .map(|pair| pair[1].as_str())
1486 .collect();
1487 assert_eq!(roots, ["/repo-a", "/repo-b"], "{agent}: {a:?}");
1488 }
1489 }
1490
1491 /// `codex exec resume` rejects `--sandbox` and takes `-c sandbox_mode=`
1492 /// instead. Getting this wrong makes every second turn fail with an
1493 /// "unexpected argument" error, which only a multi-turn run reveals.
1494 /// The two CLIs take a schema differently, and the difference is the
1495 /// whole reason this needs handling rather than one shared flag.
1496 #[test]
1497 fn each_agent_takes_a_schema_in_its_own_shape() {
1498 let schema = r#"{"type":"object"}"#;
1499
1500 let mut claude = plan("claude");
1501 claude.schema = Some(schema.into());
1502 let a = argv(Agent::Claude, &claude);
1503 assert_eq!(
1504 a[pos(&a, "--json-schema").unwrap() + 1],
1505 schema,
1506 "claude takes it inline"
1507 );
1508
1509 let mut codex = plan("codex");
1510 codex.schema = Some(schema.into());
1511 codex.schema_file = Some("/tmp/s.json".into());
1512 let a = argv(Agent::Codex, &codex);
1513 assert_eq!(
1514 a[pos(&a, "--output-schema").unwrap() + 1],
1515 "/tmp/s.json",
1516 "codex takes a path, never the schema itself"
1517 );
1518 assert!(!a.iter().any(|arg| arg.contains("\"type\"")));
1519 }
1520
1521 /// Copilot 1.0.75 has no schema flag, and a prose answer presented as data
1522 /// is exactly the silent downgrade this crate refuses elsewhere.
1523 #[test]
1524 fn copilot_refuses_a_schema_rather_than_answering_in_prose() {
1525 let mut p = plan("copilot");
1526 p.schema = Some(r#"{"type":"object"}"#.into());
1527 assert!(matches!(
1528 Agent::Copilot.argv(&p),
1529 Err(Error::Unsupported { .. })
1530 ));
1531 }
1532
1533 /// A caller inspecting the command before running it has no file yet, since
1534 /// it is written at spawn time.
1535 #[test]
1536 fn a_codex_schema_preview_shows_a_placeholder_path() {
1537 let mut p = plan("codex");
1538 p.schema = Some(r#"{"type":"object"}"#.into());
1539 let a = argv(Agent::Codex, &p);
1540 assert_eq!(a[pos(&a, "--output-schema").unwrap() + 1], "<schema-file>");
1541 }
1542
1543 #[test]
1544 fn codex_sets_the_sandbox_by_flag_when_fresh_and_by_config_when_resuming() {
1545 let mut fresh = plan("codex");
1546 fresh.permission = Permission::ReadOnly;
1547 let a = argv(Agent::Codex, &fresh);
1548 assert_eq!(a[pos(&a, "--sandbox").unwrap() + 1], "read-only");
1549 assert!(pos(&a, "-c").is_none());
1550
1551 let mut resumed = fresh.clone();
1552 resumed.cont = Continue::Resume("thread-9".into());
1553 let a = argv(Agent::Codex, &resumed);
1554 assert!(
1555 pos(&a, "--sandbox").is_none(),
1556 "resume rejects --sandbox: {a:?}"
1557 );
1558 assert_eq!(a[pos(&a, "-c").unwrap() + 1], "sandbox_mode=read-only");
1559 }
1560
1561 #[test]
1562 fn codex_bypass_uses_the_same_flag_on_both_paths() {
1563 for cont in [Continue::New, Continue::Resume("t".into())] {
1564 let mut p = plan("codex");
1565 p.permission = Permission::Bypass;
1566 p.cont = cont.clone();
1567 let a = argv(Agent::Codex, &p);
1568 assert!(a.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string()));
1569 assert!(pos(&a, "--sandbox").is_none(), "{cont:?}: {a:?}");
1570 }
1571 }
1572
1573 #[test]
1574 fn codex_without_a_system_flag_prepends_it_to_the_prompt() {
1575 let mut p = plan("codex");
1576 p.system = Some("be terse".into());
1577 let a = argv(Agent::Codex, &p);
1578 assert_eq!(a.last().unwrap(), "be terse\n\nhi");
1579 }
1580
1581 #[test]
1582 fn codex_maps_each_posture_to_a_sandbox() {
1583 for (perm, expect) in [
1584 (Permission::ReadOnly, "read-only"),
1585 (Permission::Plan, "read-only"),
1586 (Permission::Edit, "workspace-write"),
1587 (Permission::Auto, "workspace-write"),
1588 ] {
1589 let mut p = plan("codex");
1590 p.permission = perm;
1591 let a = argv(Agent::Codex, &p);
1592 assert_eq!(a[pos(&a, "--sandbox").unwrap() + 1], expect, "{perm:?}");
1593 }
1594 let mut p = plan("codex");
1595 p.permission = Permission::Bypass;
1596 let a = argv(Agent::Codex, &p);
1597 assert!(a.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string()));
1598 assert!(pos(&a, "--sandbox").is_none());
1599 }
1600
1601 #[test]
1602 fn copilot_drops_dash_p_when_the_prompt_is_piped() {
1603 let mut p = plan("copilot");
1604 p.stdin_prompt = true;
1605 let a = argv(Agent::Copilot, &p);
1606 assert!(
1607 !a.contains(&"-p".to_string()),
1608 "a -p value shadows the pipe"
1609 );
1610 assert!(!a.contains(&"hi".to_string()));
1611 }
1612
1613 /// Copilot declares its tool filters as `--deny-tool[=tools...]`, an
1614 /// optional value, which binds only with `=`. Passed across a space the
1615 /// value is silently read as a positional instead, so the deny is lost.
1616 #[test]
1617 fn copilot_read_only_denies_shell_and_write_with_the_combined_form() {
1618 let a = argv(Agent::Copilot, &plan("copilot"));
1619 assert!(a.contains(&"--deny-tool=shell".to_string()));
1620 assert!(a.contains(&"--deny-tool=write".to_string()));
1621 assert!(
1622 !a.iter().any(|s| s == "--deny-tool"),
1623 "a bare --deny-tool would drop its value: {a:?}"
1624 );
1625 }
1626
1627 /// A headless Copilot run stalls at the first tool confirmation without it.
1628 #[test]
1629 fn copilot_always_allows_tools_and_silences_the_ask_tool() {
1630 for permission in [Permission::ReadOnly, Permission::Plan, Permission::Bypass] {
1631 let mut p = plan("copilot");
1632 p.permission = permission;
1633 let a = argv(Agent::Copilot, &p);
1634 assert!(
1635 a.contains(&"--allow-all-tools".to_string()),
1636 "{permission:?}"
1637 );
1638 assert!(a.contains(&"--no-ask-user".to_string()), "{permission:?}");
1639 }
1640 }
1641
1642 /// Copilot uses one flag in both directions: it sets the id for a new
1643 /// session and resumes an existing one.
1644 #[test]
1645 fn copilot_uses_session_id_for_both_new_and_resumed_sessions() {
1646 for cont in [
1647 Continue::NewWith("11111111-2222-3333-4444-555555555555".into()),
1648 Continue::Resume("11111111-2222-3333-4444-555555555555".into()),
1649 ] {
1650 let mut p = plan("copilot");
1651 p.cont = cont.clone();
1652 let a = argv(Agent::Copilot, &p);
1653 assert_eq!(
1654 a[pos(&a, "--session-id").unwrap() + 1],
1655 "11111111-2222-3333-4444-555555555555",
1656 "{cont:?}"
1657 );
1658 }
1659 }
1660
1661 #[test]
1662 fn unsupported_capabilities_are_refused_not_downgraded() {
1663 // Forking headlessly is Claude-only.
1664 for agent in [Agent::Codex, Agent::Copilot] {
1665 let mut p = plan(agent.bin());
1666 p.cont = Continue::Fork("s".into());
1667 assert!(
1668 matches!(agent.argv(&p), Err(Error::Unsupported { .. })),
1669 "{agent} must refuse a fork rather than resume linearly"
1670 );
1671 }
1672 // Codex's id is printed, not assigned, so it cannot be chosen up front.
1673 let mut p = plan("codex");
1674 p.cont = Continue::NewWith("id".into());
1675 assert!(matches!(
1676 Agent::Codex.argv(&p),
1677 Err(Error::Unsupported { .. })
1678 ));
1679 }
1680
1681 /// The failure mode this refusal exists to prevent is a silent one: an
1682 /// agent with no command vocabulary reads `/compact` as prose and answers a
1683 /// question *about* compaction, which looks from the outside exactly like
1684 /// the command having run.
1685 #[test]
1686 fn a_slash_command_is_refused_by_agents_that_only_read_prose() {
1687 for agent in [Agent::Codex, Agent::Copilot] {
1688 let mut p = plan(agent.bin());
1689 p.is_command = true;
1690 assert!(
1691 matches!(agent.argv(&p), Err(Error::Unsupported { .. })),
1692 "{agent} must refuse a slash command rather than send it as text"
1693 );
1694 }
1695 let mut p = plan("claude");
1696 p.is_command = true;
1697 assert!(
1698 Agent::Claude.argv(&p).is_ok(),
1699 "Claude publishes a catalogue and acts on them"
1700 );
1701 }
1702
1703 /// All three expose an id, so all three can back a named session, but only
1704 /// through a format that actually carries one.
1705 #[test]
1706 fn every_agent_has_a_format_that_carries_its_session_id() {
1707 assert_eq!(Agent::Claude.session_format(), Some(Format::Json));
1708 assert_eq!(Agent::Codex.session_format(), Some(Format::Stream));
1709 assert_eq!(Agent::Copilot.session_format(), Some(Format::Stream));
1710 }
1711
1712 /// Claude and Copilot let the caller assign the id, so a run that dies
1713 /// mid-turn still leaves a resumable session.
1714 #[test]
1715 fn the_minting_agents_are_claude_and_copilot() {
1716 let minting: Vec<_> = Agent::ALL
1717 .into_iter()
1718 .filter(|a| a.caps().session == SessionSupport::Minted)
1719 .collect();
1720 assert_eq!(minting, [Agent::Claude, Agent::Copilot]);
1721 }
1722}