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