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}
55
56/// Permission posture for a run, mapped onto each agent's own vocabulary.
57///
58/// # What these do and do not guarantee
59///
60/// These postures constrain each CLI's **built-in** tools: its shell, its file
61/// writes, its sandbox. They do **not** constrain MCP servers, plugins or custom
62/// tools the agent is configured with. An MCP tool that files an issue, writes
63/// to a database or calls a deployment API is a separate tool category in all
64/// three CLIs and can still act during a nominally restricted run.
65///
66/// If a run must not cause remote side effects, the containment has to come from
67/// the agent's own configuration (which MCP servers are enabled at all), not
68/// from this enum. What is selected here is enforced by the CLI, and what the
69/// CLI does not model cannot be enforced from out here.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
71#[serde(rename_all = "kebab-case")]
72pub enum Permission {
73 /// No writes to the local filesystem, and no shell where the CLI can gate
74 /// one.
75 ///
76 /// The strongest posture this crate can express, and still not a guarantee
77 /// of "no side effects": see the type-level note about MCP tools. Codex
78 /// enforces it with a read-only sandbox, which blocks writes but still
79 /// permits command execution.
80 #[default]
81 ReadOnly,
82 /// Ask the agent to plan rather than act.
83 ///
84 /// Claude and Copilot have a real plan mode. **Codex has none**, so this
85 /// maps to its read-only sandbox: writes are blocked, but the model is not
86 /// instructed to withhold execution the way a true plan mode would.
87 Plan,
88 /// Allow file edits, while still gating shell commands where the CLI can.
89 Edit,
90 /// Allow the agent's own default automation.
91 Auto,
92 /// Skip every permission check. For sandboxes.
93 Bypass,
94}
95
96/// Environment variables that route an agent's traffic through a corporate
97/// proxy or a custom certificate authority.
98///
99/// None of the three vendors documents proxy support, and none exposes a proxy
100/// flag, so this is a convenience list of names a host may want to forward, not
101/// a claim that forwarding them works. (The names do appear in all three
102/// shipped binaries, but that shows they are referenced, not that provider
103/// traffic honours them.) Verify against your own proxy before relying on it.
104///
105/// Not included in [`EnvPolicy::Minimal`]: they are situational, and the proxy
106/// URLs frequently carry credentials. Offered here so a host can present them
107/// as an explicit setting and forward the ones it wants with
108/// [`crate::Request::env`], rather than every caller rediscovering the names.
109///
110/// Excluding them from `Minimal` does not block them. Under the default
111/// [`EnvPolicy::Inherit`] they flow exactly as they would for the CLI run from a
112/// shell; the only thing `Minimal` changes is that forwarding becomes a
113/// decision rather than an accident.
114///
115/// ```no_run
116/// # use agent_abstraction::{Agent, EnvPolicy, NETWORK_ENV, Request};
117/// let mut request = Request::new(Agent::Claude, "hi").env_policy(EnvPolicy::Minimal);
118/// // Forward only the proxy settings this host actually has.
119/// for name in NETWORK_ENV {
120/// if let Ok(value) = std::env::var(name) {
121/// request = request.env(*name, value);
122/// }
123/// }
124/// ```
125pub const NETWORK_ENV: &[&str] = &[
126 "HTTP_PROXY",
127 "HTTPS_PROXY",
128 "ALL_PROXY",
129 "NO_PROXY",
130 "http_proxy",
131 "https_proxy",
132 "all_proxy",
133 "no_proxy",
134 "SSL_CERT_FILE",
135 "SSL_CERT_DIR",
136 "NODE_EXTRA_CA_CERTS",
137];
138
139/// Which of the host's environment variables reach the agent.
140///
141/// **The default is [`EnvPolicy::Minimal`].** Inheriting the whole environment
142/// is what a CLI gets from a shell, but this crate is embedded in processes that
143/// hold unrelated secrets, and full inheritance hands every one of them to the
144/// agent and to every command the agent runs. That is a decision worth making
145/// deliberately, so it is the opt-in rather than the default.
146#[derive(Debug, Clone, PartialEq, Eq, Default)]
147#[non_exhaustive]
148pub enum EnvPolicy {
149 /// Pass through only what the selected agent needs, per
150 /// [`Agent::essential_env`], plus anything set with [`crate::Request::env`].
151 ///
152 /// The crate owns this list rather than the caller, because "what does this
153 /// CLI need to work" is knowledge about the agent, and an incomplete
154 /// hand-written list produces a run that fails in a way that looks like an
155 /// auth problem. Every agent is verified to authenticate under it by the
156 /// live test suite.
157 #[default]
158 Minimal,
159 /// Pass the whole parent environment through, as a shell would.
160 ///
161 /// Correct when the host process holds nothing the agent should not see, or
162 /// when something environment-specific (a proxy, a custom CA, a vendor
163 /// variable this crate does not know about) has to reach the CLI and
164 /// enumerating it is impractical.
165 Inherit,
166 /// Pass through only these names, plus anything set with
167 /// [`crate::Request::env`]. Names unset in the parent are skipped.
168 Only(Vec<String>),
169}
170
171/// Output shape requested from the agent.
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
173#[serde(rename_all = "kebab-case")]
174pub enum Format {
175 /// Plain prose on stdout. Carries no session id and no events.
176 Text,
177 /// One JSON result document.
178 #[default]
179 Json,
180 /// A JSONL event stream, normalized into [`crate::Event`]s.
181 Stream,
182}
183
184/// How a run continues an earlier conversation.
185#[derive(Debug, Clone, PartialEq, Eq, Default)]
186pub enum Continue {
187 /// Start a fresh conversation.
188 #[default]
189 New,
190 /// Start a fresh conversation under an id the caller chose. Only valid for
191 /// [`SessionSupport::Minted`] agents.
192 NewWith(String),
193 /// Append to an existing conversation in place.
194 Resume(String),
195 /// Branch a new conversation off an existing one, leaving it untouched.
196 Fork(String),
197}
198
199/// A fully resolved run request, ready to become an argv. Built by
200/// [`crate::Request::plan`]; consumed by [`Agent::argv`].
201#[derive(Debug, Clone)]
202pub struct Plan {
203 /// The binary to invoke.
204 pub bin: String,
205 /// The user prompt.
206 pub prompt: String,
207 /// System prompt, if any.
208 pub system: Option<String>,
209 /// Model id or alias, if pinned.
210 pub model: Option<String>,
211 /// Permission posture.
212 pub permission: Permission,
213 /// Requested output shape.
214 pub format: Format,
215 /// How this run continues an earlier one.
216 pub cont: Continue,
217 /// True when the prompt is piped on stdin instead of riding the argv.
218 pub stdin_prompt: bool,
219}
220
221/// Prompts at or above this many bytes are piped on stdin rather than placed on
222/// the argv. Well under the ~1 MiB `ARG_MAX` floor on macOS, with room for the
223/// rest of the command line and the inherited environment.
224pub(crate) const STDIN_THRESHOLD: usize = 128 * 1024;
225
226/// The budget for everything on one command line.
227///
228/// `ARG_MAX` is about 1 MiB on macOS and covers the environment as well as the
229/// arguments, so half of it leaves room for a large inherited environment. Over
230/// this the spawn fails with a bare `E2BIG` that names nothing; the crate checks
231/// first so the error can say which input was too big.
232pub(crate) const MAX_COMMAND_LINE: usize = 512 * 1024;
233
234impl Agent {
235 /// Every agent, in a stable order.
236 pub const ALL: [Agent; 3] = [Agent::Claude, Agent::Codex, Agent::Copilot];
237
238 /// The stable identifier used in session records and logs.
239 #[must_use]
240 pub fn id(self) -> &'static str {
241 match self {
242 Agent::Claude => "claude-code",
243 Agent::Codex => "codex",
244 Agent::Copilot => "copilot",
245 }
246 }
247
248 /// The default binary name looked up on `PATH`.
249 #[must_use]
250 pub fn bin(self) -> &'static str {
251 match self {
252 Agent::Claude => "claude",
253 Agent::Codex => "codex",
254 Agent::Copilot => "copilot",
255 }
256 }
257
258 /// The documented install command, surfaced by [`Error::NotInstalled`].
259 #[must_use]
260 pub fn install_hint(self) -> &'static str {
261 match self {
262 Agent::Claude => "npm install -g @anthropic-ai/claude-code",
263 Agent::Codex => "npm install -g @openai/codex",
264 Agent::Copilot => "npm install -g @github/copilot",
265 }
266 }
267
268 /// The environment variables this agent needs to function, used by
269 /// [`EnvPolicy::Minimal`].
270 ///
271 /// Two groups: what any process needs to start, and this agent's own
272 /// credential and config variables. Permission-controlling variables are
273 /// excluded on principle: `COPILOT_ALLOW_ALL` is Copilot's env equivalent
274 /// of `--allow-all-tools`, so inheriting it would let the host's ambient
275 /// environment widen a run's permissions behind [`Permission`]'s back. A name absent from the parent
276 /// environment is skipped, so nothing here is fabricated.
277 ///
278 /// Proxy and custom-CA variables are deliberately **not** here. They are
279 /// environment-specific rather than required, and `HTTP_PROXY` /
280 /// `HTTPS_PROXY` routinely embed credentials (`http://user:pass@proxy`), so
281 /// passing them automatically would leak one through the very policy meant
282 /// to withhold secrets. A host that needs them should offer them as a
283 /// setting and pass them with [`crate::Request::env`]; [`NETWORK_ENV`] names
284 /// them so a settings screen does not have to hardcode the list.
285 ///
286 /// `PATH`, `HOME` and `USER` are the verified floor on macOS: all three CLIs
287 /// answer correctly with exactly those set, and Claude reports "Not logged
288 /// in" without `USER`, since its keychain lookup is keyed on it. The Windows
289 /// names are included on the same reasoning but are **not** verified, as
290 /// this crate has not been run there.
291 #[must_use]
292 pub fn essential_env(self) -> Vec<&'static str> {
293 // Needed by any child process, plus the locale and temp dir the CLIs
294 // use for scratch files.
295 const BASE: &[&str] = &[
296 "PATH", "HOME", "USER", "LOGNAME", "SHELL", "TMPDIR", "LANG", "LC_ALL",
297 ];
298 // Unverified: this crate has not been exercised on Windows.
299 const WINDOWS: &[&str] = &[
300 "USERPROFILE",
301 "APPDATA",
302 "LOCALAPPDATA",
303 "SystemRoot",
304 "SystemDrive",
305 "TEMP",
306 "TMP",
307 "PATHEXT",
308 "ComSpec",
309 ];
310 let agent: &[&str] = match self {
311 Agent::Claude => &[
312 "ANTHROPIC_API_KEY",
313 "ANTHROPIC_AUTH_TOKEN",
314 "ANTHROPIC_BASE_URL",
315 "CLAUDE_CONFIG_DIR",
316 ],
317 Agent::Codex => &[
318 "CODEX_HOME",
319 "CODEX_API_KEY",
320 "OPENAI_API_KEY",
321 "OPENAI_BASE_URL",
322 ],
323 Agent::Copilot => &["GH_TOKEN", "GITHUB_TOKEN", "XDG_CONFIG_HOME"],
324 };
325 BASE.iter().chain(WINDOWS).chain(agent).copied().collect()
326 }
327
328 /// What this agent supports.
329 #[must_use]
330 pub fn caps(self) -> Caps {
331 match self {
332 // Verified against claude 2.1.205: `--session-id <uuid>` assigns the
333 // id, `--fork-session` branches, `--output-format stream-json`
334 // streams (and demands `--verbose`), `--append-system-prompt` is a
335 // real flag.
336 Agent::Claude => Caps {
337 session: SessionSupport::Minted,
338 fork: true,
339 events: true,
340 native_system: true,
341 },
342 // `codex exec --json` emits `thread_id`; continuation is the
343 // `resume` subcommand and is linear (`codex fork` is TUI-only).
344 Agent::Codex => Caps {
345 session: SessionSupport::Printed,
346 fork: false,
347 events: true,
348 native_system: false,
349 },
350 // Verified against Copilot CLI 1.0.75: `--session-id <uuid>` both
351 // mints a new session and resumes an existing one (one flag, both
352 // directions), and `--output-format json` is a JSONL event stream.
353 // There is no headless fork.
354 Agent::Copilot => Caps {
355 session: SessionSupport::Minted,
356 fork: false,
357 events: true,
358 native_system: false,
359 },
360 }
361 }
362
363 /// The format that can carry this agent's session id, if any. A named
364 /// session upgrades to this when the caller did not pin a format.
365 #[must_use]
366 pub fn session_format(self) -> Option<Format> {
367 match self.caps().session {
368 // Claude reports the id in both structured formats; `Json` is the
369 // cheaper default when the caller did not ask to stream.
370 SessionSupport::Minted | SessionSupport::Printed => Some(match self {
371 Agent::Claude => Format::Json,
372 // `--json` IS Codex's stream and Copilot's `json` is JSONL;
373 // neither has a single-document form.
374 Agent::Codex | Agent::Copilot => Format::Stream,
375 }),
376 SessionSupport::None => None,
377 }
378 }
379
380 /// Whether `format` can carry this agent's session id.
381 ///
382 /// Distinct from [`Agent::session_format`], which names the *preferred* one:
383 /// Claude reports its id under both `Json` and `Stream`, and only plain text
384 /// loses it. A named session needs this, not equality with the preferred
385 /// format, or streaming a named Claude session would be refused for no
386 /// reason.
387 #[must_use]
388 pub fn format_carries_session(self, format: Format) -> bool {
389 self.session_format().is_some() && format != Format::Text
390 }
391
392 /// Reject a plan this agent cannot honour, before anything is spawned.
393 fn check(self, plan: &Plan) -> Result<()> {
394 let caps = self.caps();
395 if matches!(plan.cont, Continue::Fork(_)) && !caps.fork {
396 return Err(Error::Unsupported {
397 agent: self,
398 what: "forking a session headlessly",
399 });
400 }
401 if matches!(plan.cont, Continue::NewWith(_)) && caps.session != SessionSupport::Minted {
402 return Err(Error::Unsupported {
403 agent: self,
404 what: "assigning a session id up front",
405 });
406 }
407 if plan.format == Format::Stream && !caps.events {
408 return Err(Error::Unsupported {
409 agent: self,
410 what: "a structured event stream",
411 });
412 }
413 Ok(())
414 }
415
416 /// Build the command line for `plan`.
417 ///
418 /// The first element is the binary; the rest are its arguments. Returns
419 /// [`Error::Unsupported`] when the plan asks for a capability this agent
420 /// lacks, never a quiet downgrade.
421 ///
422 /// # Errors
423 /// [`Error::Unsupported`] if the plan needs a capability this agent lacks.
424 pub fn argv(self, plan: &Plan) -> Result<Vec<String>> {
425 Ok(self
426 .typed_argv(plan)?
427 .into_iter()
428 .map(|arg| arg.value)
429 .collect())
430 }
431
432 /// The command line with each argument's sensitivity attached.
433 ///
434 /// # Errors
435 /// [`Error::Unsupported`] if the plan needs a capability this agent lacks.
436 pub(crate) fn typed_argv(self, plan: &Plan) -> Result<Vec<Arg>> {
437 self.check(plan)?;
438 Ok(match self {
439 Agent::Claude => argv_claude(plan),
440 Agent::Codex => argv_codex(plan),
441 Agent::Copilot => argv_copilot(plan),
442 })
443 }
444
445 /// The prompt text actually delivered, with the system prompt folded in for
446 /// agents that have no flag for it. Never dropped silently.
447 #[must_use]
448 pub fn effective_prompt(self, plan: &Plan) -> String {
449 match (&plan.system, self.caps().native_system) {
450 (Some(system), false) => format!("{system}\n\n{}", plan.prompt),
451 _ => plan.prompt.clone(),
452 }
453 }
454}
455
456impl fmt::Display for Agent {
457 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
458 f.write_str(self.id())
459 }
460}
461
462/// How sensitive one argument's value is, decided where the argument is built
463/// rather than guessed back afterwards.
464///
465/// Reconstructing this from a finished command line means pattern-matching flag
466/// names and positions, which misses exactly the cases that matter: Codex's
467/// prompt is a bare trailing positional, and anything from `unchecked_args` has
468/// no recognizable shape at all. Recording it at construction cannot miss.
469#[derive(Debug, Clone, Copy, PartialEq, Eq)]
470pub(crate) enum Sensitivity {
471 /// A flag name or fixed token. Safe to show.
472 Public,
473 /// User or caller content: prompts and system prompts.
474 Prompt,
475 /// A session handle, which resumes a conversation.
476 SessionId,
477 /// Caller-supplied raw arguments. Unknowable, so assumed sensitive.
478 Unchecked,
479}
480
481/// One argument and how sensitive it is.
482#[derive(Debug, Clone)]
483pub(crate) struct Arg {
484 pub(crate) value: String,
485 pub(crate) sensitivity: Sensitivity,
486}
487
488/// Builds an argv, keeping every flag name literal at its call site so the flag
489/// list for an agent stays greppable and auditable against `--help`, and
490/// recording per-argument sensitivity so the executable and redacted forms come
491/// from one source.
492pub(crate) struct Argv(Vec<Arg>);
493
494impl Argv {
495 /// Start with the binary.
496 fn new(bin: &str) -> Self {
497 Self(vec![Arg {
498 value: bin.to_string(),
499 sensitivity: Sensitivity::Public,
500 }])
501 }
502
503 fn push(&mut self, value: impl Into<String>, sensitivity: Sensitivity) -> &mut Self {
504 self.0.push(Arg {
505 value: value.into(),
506 sensitivity,
507 });
508 self
509 }
510
511 /// A bare flag with no value.
512 fn bare(&mut self, flag: &str) -> &mut Self {
513 self.push(flag, Sensitivity::Public)
514 }
515
516 /// A flag and a value that is safe to show.
517 fn pair(&mut self, flag: &str, value: impl AsRef<str>) -> &mut Self {
518 self.bare(flag).push(value.as_ref(), Sensitivity::Public)
519 }
520
521 /// A flag and a value that must not be logged.
522 fn secret(&mut self, flag: &str, value: impl AsRef<str>, kind: Sensitivity) -> &mut Self {
523 self.bare(flag).push(value.as_ref(), kind)
524 }
525
526 /// A flag and its value, only when the value is present.
527 fn opt(&mut self, flag: &str, value: Option<&String>) -> &mut Self {
528 if let Some(value) = value {
529 self.pair(flag, value);
530 }
531 self
532 }
533
534 /// A positional argument that is safe to show.
535 fn arg(&mut self, value: impl Into<String>) -> &mut Self {
536 self.push(value, Sensitivity::Public)
537 }
538
539 /// A positional argument carrying caller content.
540 fn arg_sensitive(&mut self, value: impl Into<String>, kind: Sensitivity) -> &mut Self {
541 self.push(value, kind)
542 }
543
544 fn done(&mut self) -> Vec<Arg> {
545 std::mem::take(&mut self.0)
546 }
547}
548
549/// Claude Code's permission-mode token for each posture. Choices verified from
550/// `claude --help` (2.1.205): acceptEdits, auto, bypassPermissions, manual,
551/// dontAsk, plan.
552fn claude_mode(p: Permission) -> &'static str {
553 match p {
554 // `dontAsk` auto-denies gated tools and keeps going rather than
555 // blocking on a prompt no one can answer headlessly. The read-only
556 // guarantee comes from `--disallowedTools`, below.
557 Permission::ReadOnly => "dontAsk",
558 Permission::Plan => "plan",
559 Permission::Edit => "acceptEdits",
560 Permission::Auto => "auto",
561 Permission::Bypass => "bypassPermissions",
562 }
563}
564
565/// `claude -p <prompt> --permission-mode M --output-format F [...]`
566fn argv_claude(plan: &Plan) -> Vec<Arg> {
567 let mut a = Argv::new(&plan.bin);
568 a.bare("-p");
569 if plan.stdin_prompt {
570 // With `--input-format text` claude reads the prompt from stdin, so a
571 // large prompt never has to fit on the argv.
572 a.pair("--input-format", "text");
573 } else {
574 a.arg_sensitive(Agent::Claude.effective_prompt(plan), Sensitivity::Prompt);
575 }
576
577 a.pair("--permission-mode", claude_mode(plan.permission));
578 if plan.permission == Permission::ReadOnly {
579 // Remove the mutating built-ins outright. Reads still run via
580 // Read/Grep/Glob. `mcp__*` covers every MCP tool: denying only the
581 // built-in writers would leave an MCP server free to mutate remote
582 // state during a run the caller asked to be read-only.
583 a.bare("--disallowedTools");
584 for tool in ["Bash", "Edit", "Write", "NotebookEdit", "mcp__*"] {
585 a.arg(tool);
586 }
587 }
588
589 a.opt("--model", plan.model.as_ref());
590 if let Some(system) = &plan.system {
591 a.secret("--append-system-prompt", system, Sensitivity::Prompt);
592 }
593
594 match &plan.cont {
595 Continue::New => {}
596 Continue::NewWith(id) => {
597 a.secret("--session-id", id, Sensitivity::SessionId);
598 }
599 Continue::Resume(id) => {
600 a.secret("--resume", id, Sensitivity::SessionId);
601 }
602 Continue::Fork(id) => {
603 // Mints a new id off `id`, leaving the original and its cached
604 // prefix untouched. The new id comes back in the output.
605 a.secret("--resume", id, Sensitivity::SessionId)
606 .bare("--fork-session");
607 }
608 }
609
610 a.pair(
611 "--output-format",
612 match plan.format {
613 Format::Text => "text",
614 Format::Json => "json",
615 Format::Stream => "stream-json",
616 },
617 );
618 if plan.format == Format::Stream {
619 // Claude refuses `-p --output-format stream-json` without it:
620 // "--print with --output-format=stream-json requires --verbose".
621 a.bare("--verbose");
622 }
623 a.done()
624}
625
626/// `codex exec [resume <id>] --skip-git-repo-check [sandbox flags] [--model M]
627/// [--json] <prompt>`
628fn argv_codex(plan: &Plan) -> Vec<Arg> {
629 let mut a = Argv::new(&plan.bin);
630 a.bare("exec");
631 if let Continue::Resume(id) = &plan.cont {
632 // Continuation is a subcommand, not a flag.
633 a.bare("resume")
634 .arg_sensitive(id.clone(), Sensitivity::SessionId);
635 }
636
637 // `codex exec` aborts outside a git repository unless told not to. That
638 // check guards against an agent editing files with no way to undo them, but
639 // this crate is embedded in hosts that legitimately run against scratch
640 // directories, worktrees and review checkouts, and a hard abort there is
641 // useless to them. The real containment is the sandbox below, which is
642 // `read-only` by default, so nothing is unrecoverable regardless.
643 a.bare("--skip-git-repo-check");
644
645 // `codex exec` takes `--sandbox`, but `codex exec resume` does **not**: it
646 // rejects the flag outright and takes the same setting as a `-c` config
647 // override instead. Verified against codex-cli 0.145.0, where passing
648 // `--sandbox` to a resume fails with "unexpected argument '--sandbox'".
649 // Dropping the sandbox on resume would silently run a continued turn under a
650 // different posture than the caller asked for.
651 let resuming = matches!(plan.cont, Continue::Resume(_));
652 let sandbox = match plan.permission {
653 Permission::Bypass => None,
654 Permission::ReadOnly | Permission::Plan => Some("read-only"),
655 Permission::Edit | Permission::Auto => Some("workspace-write"),
656 };
657 match (sandbox, resuming) {
658 (None, _) => a.bare("--dangerously-bypass-approvals-and-sandbox"),
659 (Some(mode), false) => a.pair("--sandbox", mode),
660 // The value is TOML-parsed, falling back to a raw string, so the bare
661 // token is read as the mode name.
662 (Some(mode), true) => a.pair("-c", format!("sandbox_mode={mode}")),
663 };
664
665 a.opt("--model", plan.model.as_ref());
666 // `--json` is Codex's event stream and the only place `thread_id` appears.
667 if plan.format != Format::Text {
668 a.bare("--json");
669 }
670 // Codex has no system flag, so the system text rides the prompt. A literal
671 // `-` makes it read the prompt from stdin instead, keeping a large one off
672 // the argv.
673 // Codex takes the prompt as a bare trailing positional, which is exactly
674 // the shape positional redaction guesswork gets wrong.
675 if plan.stdin_prompt {
676 a.arg("-");
677 } else {
678 a.arg_sensitive(Agent::Codex.effective_prompt(plan), Sensitivity::Prompt);
679 }
680 a.done()
681}
682
683/// `copilot -p <prompt> --allow-all-tools [...] [--session-id <uuid>]`
684///
685/// Flags verified against Copilot CLI 1.0.75. Two of its conventions matter:
686/// `--allow-all-tools` is *required* for non-interactive mode, and the
687/// repeatable tool filters are declared `--allow-tool[=tools...]`, an optional
688/// value, which only binds with `=`, never across a space.
689fn argv_copilot(plan: &Plan) -> Vec<Arg> {
690 // Copilot reads stdin as the prompt only when `-p` is absent: a `-p` value
691 // makes the pipe be ignored. So a piped prompt drops the flag entirely.
692 let mut a = Argv::new(&plan.bin);
693 if !plan.stdin_prompt {
694 a.secret(
695 "-p",
696 Agent::Copilot.effective_prompt(plan),
697 Sensitivity::Prompt,
698 );
699 }
700
701 // Without this, a headless run stops at the first tool confirmation.
702 a.bare("--allow-all-tools").bare("--no-ask-user");
703 match plan.permission {
704 Permission::Bypass | Permission::Auto => a.bare("--allow-all-paths"),
705 // Deny beats allow, so this is allow-all minus the mutating tools.
706 // `--allow-all-paths` is deliberately NOT set: it disables path
707 // verification entirely, which would widen filesystem reach in the one
708 // posture that exists to narrow it.
709 Permission::ReadOnly => a.bare("--deny-tool=shell").bare("--deny-tool=write"),
710 // Edits run; shell stays denied so commands cannot.
711 Permission::Edit => a.bare("--deny-tool=shell"),
712 Permission::Plan => a.pair("--mode", "plan"),
713 };
714
715 a.opt("--model", plan.model.as_ref());
716 // One flag serves both directions: it sets the UUID for a new session and
717 // resumes an existing one by id.
718 match &plan.cont {
719 Continue::NewWith(id) | Continue::Resume(id) => {
720 a.secret("--session-id", id, Sensitivity::SessionId);
721 }
722 // `Fork` is rejected by `Agent::check` before reaching here.
723 Continue::New | Continue::Fork(_) => {}
724 }
725
726 a.pair(
727 "--output-format",
728 if plan.format == Format::Text {
729 "text"
730 } else {
731 // Copilot's `json` is JSONL, so it serves both structured formats.
732 "json"
733 },
734 );
735 a.done()
736}
737
738#[cfg(test)]
739mod tests {
740 use super::*;
741
742 fn plan(bin: &str) -> Plan {
743 Plan {
744 bin: bin.into(),
745 prompt: "hi".into(),
746 system: None,
747 model: None,
748 permission: Permission::ReadOnly,
749 format: Format::Json,
750 cont: Continue::New,
751 stdin_prompt: false,
752 }
753 }
754
755 fn argv(agent: Agent, plan: &Plan) -> Vec<String> {
756 agent.argv(plan).expect("plan is supported")
757 }
758
759 fn pos(a: &[String], needle: &str) -> Option<usize> {
760 a.iter().position(|s| s == needle)
761 }
762
763 #[test]
764 fn claude_builds_print_mode_with_format_and_permission() {
765 let a = argv(Agent::Claude, &plan("claude"));
766 assert_eq!(a[0..3], ["claude", "-p", "hi"]);
767 assert!(pos(&a, "--permission-mode").is_some());
768 assert!(a.contains(&"dontAsk".to_string()));
769 assert_eq!(a[pos(&a, "--output-format").unwrap() + 1], "json");
770 }
771
772 #[test]
773 fn claude_read_only_removes_the_mutating_tools() {
774 let a = argv(Agent::Claude, &plan("claude"));
775 let at = pos(&a, "--disallowedTools").expect("read-only denies tools");
776 assert_eq!(
777 &a[at + 1..at + 5],
778 ["Bash", "Edit", "Write", "NotebookEdit"]
779 );
780 }
781
782 #[test]
783 fn claude_bypass_does_not_deny_tools() {
784 let mut p = plan("claude");
785 p.permission = Permission::Bypass;
786 let a = argv(Agent::Claude, &p);
787 assert!(a.contains(&"bypassPermissions".to_string()));
788 assert!(pos(&a, "--disallowedTools").is_none());
789 }
790
791 #[test]
792 fn claude_stream_format_adds_verbose_but_json_does_not() {
793 let mut p = plan("claude");
794 p.format = Format::Stream;
795 assert!(argv(Agent::Claude, &p).contains(&"--verbose".to_string()));
796 p.format = Format::Json;
797 assert!(!argv(Agent::Claude, &p).contains(&"--verbose".to_string()));
798 }
799
800 #[test]
801 fn claude_mints_an_id_for_a_new_session_and_resumes_an_old_one() {
802 let mut p = plan("claude");
803 p.cont = Continue::NewWith("11111111-2222-3333-4444-555555555555".into());
804 let a = argv(Agent::Claude, &p);
805 assert_eq!(
806 a[pos(&a, "--session-id").unwrap() + 1],
807 "11111111-2222-3333-4444-555555555555"
808 );
809 assert!(pos(&a, "--resume").is_none());
810
811 p.cont = Continue::Resume("sess-1".into());
812 let a = argv(Agent::Claude, &p);
813 assert_eq!(a[pos(&a, "--resume").unwrap() + 1], "sess-1");
814 assert!(!a.contains(&"--fork-session".to_string()));
815 }
816
817 #[test]
818 fn claude_fork_resumes_and_branches() {
819 let mut p = plan("claude");
820 p.cont = Continue::Fork("sess-1".into());
821 let a = argv(Agent::Claude, &p);
822 assert_eq!(a[pos(&a, "--resume").unwrap() + 1], "sess-1");
823 assert!(a.contains(&"--fork-session".to_string()));
824 }
825
826 #[test]
827 fn claude_keeps_the_system_prompt_on_its_own_flag() {
828 let mut p = plan("claude");
829 p.system = Some("be terse".into());
830 let a = argv(Agent::Claude, &p);
831 assert_eq!(
832 a[pos(&a, "--append-system-prompt").unwrap() + 1],
833 "be terse"
834 );
835 // The prompt itself stays clean.
836 assert!(a.contains(&"hi".to_string()));
837 }
838
839 #[test]
840 fn claude_stdin_prompt_leaves_the_argv() {
841 let mut p = plan("claude");
842 p.stdin_prompt = true;
843 let a = argv(Agent::Claude, &p);
844 assert_eq!(a[pos(&a, "--input-format").unwrap() + 1], "text");
845 assert!(!a.contains(&"hi".to_string()), "prompt must not ride argv");
846 }
847
848 #[test]
849 fn codex_resume_is_a_subcommand_and_prompt_is_last() {
850 let mut p = plan("codex");
851 p.cont = Continue::Resume("thread-9".into());
852 let a = argv(Agent::Codex, &p);
853 assert_eq!(a[0..4], ["codex", "exec", "resume", "thread-9"]);
854 assert_eq!(a.last().unwrap(), "hi");
855 }
856
857 /// `Minimal` exists to withhold secrets, so nothing it passes through may
858 /// be a credential carrier. Proxy URLs in particular routinely embed
859 /// `user:pass`, which is why they are offered separately instead.
860 #[test]
861 fn the_minimal_environment_carries_no_proxy_variables() {
862 for agent in Agent::ALL {
863 let essential = agent.essential_env();
864 for name in NETWORK_ENV {
865 assert!(
866 !essential.contains(name),
867 "{agent} would pass {name} through EnvPolicy::Minimal"
868 );
869 }
870 }
871 }
872
873 /// The floor verified live on macOS: with exactly these set, all three CLIs
874 /// authenticate and answer. Claude reports "Not logged in" without `USER`.
875 #[test]
876 fn every_agent_asks_for_the_verified_floor() {
877 for agent in Agent::ALL {
878 let essential = agent.essential_env();
879 for name in ["PATH", "HOME", "USER"] {
880 assert!(essential.contains(&name), "{agent} omits {name}");
881 }
882 }
883 }
884
885 /// Each agent's own credentials, and nobody else's.
886 #[test]
887 fn agents_do_not_request_each_others_credentials() {
888 let claude = Agent::Claude.essential_env();
889 assert!(claude.contains(&"ANTHROPIC_API_KEY"));
890 assert!(!claude.contains(&"OPENAI_API_KEY"));
891 assert!(!claude.contains(&"GH_TOKEN"));
892
893 let codex = Agent::Codex.essential_env();
894 assert!(codex.contains(&"OPENAI_API_KEY"));
895 assert!(!codex.contains(&"ANTHROPIC_API_KEY"));
896 }
897
898 /// The model is the caller's choice on every agent. It is forwarded
899 /// verbatim and never defaulted, normalized, or validated here: a host with
900 /// a model picker owns that list, and an unknown name must surface as the
901 /// agent's own error rather than something this crate guessed at.
902 #[test]
903 fn every_agent_forwards_the_callers_model_verbatim() {
904 for agent in Agent::ALL {
905 let mut p = plan(agent.bin());
906 // Deliberately not a real model id: nothing here may interpret it.
907 p.model = Some("some-model-9".into());
908 let a = argv(agent, &p);
909 let at = pos(&a, "--model").unwrap_or_else(|| panic!("{agent} dropped --model: {a:?}"));
910 assert_eq!(a[at + 1], "some-model-9", "{agent} rewrote the model");
911 }
912 }
913
914 /// No model means the agent picks its own, so a host can offer a "default"
915 /// entry without this crate inventing one.
916 #[test]
917 fn no_model_means_no_model_flag() {
918 for agent in Agent::ALL {
919 let p = plan(agent.bin());
920 assert!(p.model.is_none());
921 let a = argv(agent, &p);
922 assert!(
923 pos(&a, "--model").is_none(),
924 "{agent} invented a model: {a:?}"
925 );
926 }
927 }
928
929 /// `codex exec` aborts outside a git repository. A host embedding this
930 /// crate runs against scratch dirs and review checkouts, so the check is
931 /// waived on every invocation; the sandbox is what actually contains a run.
932 #[test]
933 fn codex_always_waives_the_git_repo_check() {
934 for cont in [Continue::New, Continue::Resume("t-1".into())] {
935 let mut p = plan("codex");
936 p.cont = cont.clone();
937 assert!(
938 argv(Agent::Codex, &p).contains(&"--skip-git-repo-check".to_string()),
939 "{cont:?} must still run outside a repo"
940 );
941 }
942 }
943
944 /// `codex exec resume` rejects `--sandbox` and takes `-c sandbox_mode=`
945 /// instead. Getting this wrong makes every second turn fail with an
946 /// "unexpected argument" error, which only a multi-turn run reveals.
947 #[test]
948 fn codex_sets_the_sandbox_by_flag_when_fresh_and_by_config_when_resuming() {
949 let mut fresh = plan("codex");
950 fresh.permission = Permission::ReadOnly;
951 let a = argv(Agent::Codex, &fresh);
952 assert_eq!(a[pos(&a, "--sandbox").unwrap() + 1], "read-only");
953 assert!(pos(&a, "-c").is_none());
954
955 let mut resumed = fresh.clone();
956 resumed.cont = Continue::Resume("thread-9".into());
957 let a = argv(Agent::Codex, &resumed);
958 assert!(
959 pos(&a, "--sandbox").is_none(),
960 "resume rejects --sandbox: {a:?}"
961 );
962 assert_eq!(a[pos(&a, "-c").unwrap() + 1], "sandbox_mode=read-only");
963 }
964
965 #[test]
966 fn codex_bypass_uses_the_same_flag_on_both_paths() {
967 for cont in [Continue::New, Continue::Resume("t".into())] {
968 let mut p = plan("codex");
969 p.permission = Permission::Bypass;
970 p.cont = cont.clone();
971 let a = argv(Agent::Codex, &p);
972 assert!(a.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string()));
973 assert!(pos(&a, "--sandbox").is_none(), "{cont:?}: {a:?}");
974 }
975 }
976
977 #[test]
978 fn codex_without_a_system_flag_prepends_it_to_the_prompt() {
979 let mut p = plan("codex");
980 p.system = Some("be terse".into());
981 let a = argv(Agent::Codex, &p);
982 assert_eq!(a.last().unwrap(), "be terse\n\nhi");
983 }
984
985 #[test]
986 fn codex_maps_each_posture_to_a_sandbox() {
987 for (perm, expect) in [
988 (Permission::ReadOnly, "read-only"),
989 (Permission::Plan, "read-only"),
990 (Permission::Edit, "workspace-write"),
991 (Permission::Auto, "workspace-write"),
992 ] {
993 let mut p = plan("codex");
994 p.permission = perm;
995 let a = argv(Agent::Codex, &p);
996 assert_eq!(a[pos(&a, "--sandbox").unwrap() + 1], expect, "{perm:?}");
997 }
998 let mut p = plan("codex");
999 p.permission = Permission::Bypass;
1000 let a = argv(Agent::Codex, &p);
1001 assert!(a.contains(&"--dangerously-bypass-approvals-and-sandbox".to_string()));
1002 assert!(pos(&a, "--sandbox").is_none());
1003 }
1004
1005 #[test]
1006 fn copilot_drops_dash_p_when_the_prompt_is_piped() {
1007 let mut p = plan("copilot");
1008 p.stdin_prompt = true;
1009 let a = argv(Agent::Copilot, &p);
1010 assert!(
1011 !a.contains(&"-p".to_string()),
1012 "a -p value shadows the pipe"
1013 );
1014 assert!(!a.contains(&"hi".to_string()));
1015 }
1016
1017 /// Copilot declares its tool filters as `--deny-tool[=tools...]`, an
1018 /// optional value, which binds only with `=`. Passed across a space the
1019 /// value is silently read as a positional instead, so the deny is lost.
1020 #[test]
1021 fn copilot_read_only_denies_shell_and_write_with_the_combined_form() {
1022 let a = argv(Agent::Copilot, &plan("copilot"));
1023 assert!(a.contains(&"--deny-tool=shell".to_string()));
1024 assert!(a.contains(&"--deny-tool=write".to_string()));
1025 assert!(
1026 !a.iter().any(|s| s == "--deny-tool"),
1027 "a bare --deny-tool would drop its value: {a:?}"
1028 );
1029 }
1030
1031 /// A headless Copilot run stalls at the first tool confirmation without it.
1032 #[test]
1033 fn copilot_always_allows_tools_and_silences_the_ask_tool() {
1034 for permission in [Permission::ReadOnly, Permission::Plan, Permission::Bypass] {
1035 let mut p = plan("copilot");
1036 p.permission = permission;
1037 let a = argv(Agent::Copilot, &p);
1038 assert!(
1039 a.contains(&"--allow-all-tools".to_string()),
1040 "{permission:?}"
1041 );
1042 assert!(a.contains(&"--no-ask-user".to_string()), "{permission:?}");
1043 }
1044 }
1045
1046 /// Copilot uses one flag in both directions: it sets the id for a new
1047 /// session and resumes an existing one.
1048 #[test]
1049 fn copilot_uses_session_id_for_both_new_and_resumed_sessions() {
1050 for cont in [
1051 Continue::NewWith("11111111-2222-3333-4444-555555555555".into()),
1052 Continue::Resume("11111111-2222-3333-4444-555555555555".into()),
1053 ] {
1054 let mut p = plan("copilot");
1055 p.cont = cont.clone();
1056 let a = argv(Agent::Copilot, &p);
1057 assert_eq!(
1058 a[pos(&a, "--session-id").unwrap() + 1],
1059 "11111111-2222-3333-4444-555555555555",
1060 "{cont:?}"
1061 );
1062 }
1063 }
1064
1065 #[test]
1066 fn unsupported_capabilities_are_refused_not_downgraded() {
1067 // Forking headlessly is Claude-only.
1068 for agent in [Agent::Codex, Agent::Copilot] {
1069 let mut p = plan(agent.bin());
1070 p.cont = Continue::Fork("s".into());
1071 assert!(
1072 matches!(agent.argv(&p), Err(Error::Unsupported { .. })),
1073 "{agent} must refuse a fork rather than resume linearly"
1074 );
1075 }
1076 // Codex's id is printed, not assigned, so it cannot be chosen up front.
1077 let mut p = plan("codex");
1078 p.cont = Continue::NewWith("id".into());
1079 assert!(matches!(
1080 Agent::Codex.argv(&p),
1081 Err(Error::Unsupported { .. })
1082 ));
1083 }
1084
1085 /// All three expose an id, so all three can back a named session, but only
1086 /// through a format that actually carries one.
1087 #[test]
1088 fn every_agent_has_a_format_that_carries_its_session_id() {
1089 assert_eq!(Agent::Claude.session_format(), Some(Format::Json));
1090 assert_eq!(Agent::Codex.session_format(), Some(Format::Stream));
1091 assert_eq!(Agent::Copilot.session_format(), Some(Format::Stream));
1092 }
1093
1094 /// Claude and Copilot let the caller assign the id, so a run that dies
1095 /// mid-turn still leaves a resumable session.
1096 #[test]
1097 fn the_minting_agents_are_claude_and_copilot() {
1098 let minting: Vec<_> = Agent::ALL
1099 .into_iter()
1100 .filter(|a| a.caps().session == SessionSupport::Minted)
1101 .collect();
1102 assert_eq!(minting, [Agent::Claude, Agent::Copilot]);
1103 }
1104}