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