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