Skip to main content

agentmux_srv/backend/blockcontroller/subprocess/
argv.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Provider-aware construction of per-turn subprocess arguments.
5
6/// Build the argv for one provider turn, adding session continuation when a
7/// prior session id is available.
8///
9/// Existing providers use a trailing flag (`--resume <id>` / `-r <id>`).
10/// Codex is different: its continuation command is
11/// `codex exec resume [OPTIONS] <id> -`, so `resume` must be inserted after
12/// `exec` and the session id must remain before the stdin prompt marker.
13pub(super) fn build_turn_argv(
14    base: &[String],
15    resume_strategy: &str,
16    resume_flag: &str,
17    session_id: Option<&str>,
18) -> Result<Vec<String>, String> {
19    let session_id = session_id.filter(|sid| !sid.trim().is_empty());
20
21    match resume_strategy {
22        "codex-exec" => build_codex_argv(base, session_id),
23        _ if session_id.is_none() => Ok(base.to_vec()),
24        "" => build_legacy_argv(base, resume_flag, session_id.unwrap()),
25        "none" => Ok(base.to_vec()),
26        "flag" => {
27            if resume_flag.is_empty() {
28                return Err("resume strategy 'flag' requires a non-empty resume flag".to_string());
29            }
30            let mut argv = base.to_vec();
31            argv.push(resume_flag.to_string());
32            argv.push(session_id.unwrap().to_string());
33            Ok(argv)
34        }
35        other => Err(format!("unsupported resume strategy '{other}'")),
36    }
37}
38
39/// The longest prompt [`append_prompt_arg`] passes on the command line.
40/// Windows caps a whole command line at 32,767 UTF-16 units; this leaves room
41/// for the executable path and the provider's own flags.
42pub(super) const MAX_PROMPT_ARG_UNITS: usize = 30_000;
43
44/// For a provider that takes its prompt as an argument rather than on stdin
45/// (Antigravity: `agy -p <prompt>`), append `<flag> <prompt>` last, after
46/// any resume flag. A prompt too long for the command line is refused, never
47/// truncated: a cut-off prompt would run as something the user didn't send.
48pub(super) fn append_prompt_arg(args: &mut Vec<String>, flag: &str, prompt: &str) -> Result<(), String> {
49    let units = prompt.encode_utf16().count();
50    if units > MAX_PROMPT_ARG_UNITS {
51        return Err(format!(
52            "This message is too long for this agent's CLI, which takes it on the command line: {units} characters, over the {MAX_PROMPT_ARG_UNITS} limit. Shorten it, or attach the long part as a file."
53        ));
54    }
55    args.push(flag.to_string());
56    args.push(prompt.to_string());
57    Ok(())
58}
59
60/// `args` as they may be logged: the prompt [`append_prompt_arg`] put last is
61/// replaced by its length. On stdin the prompt never reached a log; on the
62/// command line it must not either (#4407).
63pub(super) fn args_for_log(args: &[String], prompt_in_argv: bool) -> Vec<String> {
64    let mut out = args.to_vec();
65    if prompt_in_argv {
66        if let Some(prompt) = out.last_mut() {
67            *prompt = format!("<prompt: {} chars>", prompt.chars().count());
68        }
69    }
70    out
71}
72
73fn build_legacy_argv(
74    base: &[String],
75    resume_flag: &str,
76    session_id: &str,
77) -> Result<Vec<String>, String> {
78    if resume_flag.is_empty() {
79        return Ok(base.to_vec());
80    }
81    let mut argv = base.to_vec();
82    argv.push(resume_flag.to_string());
83    argv.push(session_id.to_string());
84    Ok(argv)
85}
86
87/// Build the argv for a `/btw` side-question one-shot turn
88/// (`server/agent_handlers/side_question.rs`) from `base_one_shot_args` —
89/// the same one-shot `cli_args` a normal turn on this block would use.
90///
91/// Deliberately does NOT touch resume/session-id handling: the caller
92/// always passes `SubprocessSpawnConfig::session_id: None` for this turn
93/// (a `/btw` never resumes a session — context comes from a text prefix in
94/// the prompt instead, per the module's design), so `build_turn_argv`'s own
95/// `session_id.is_none()` branch already returns `base` unchanged with no
96/// `--resume` appended — nothing here needs to duplicate that guarantee.
97///
98/// Appends two flags, confirmed against
99/// `code.claude.com/docs/en/cli-reference` (checked 2026-09-19):
100///   - `--disallowedTools "*"` — the docs' own words are "a bare tool name
101///     removes the matching tools... `\"*\"` removes every tool", making
102///     this turn fully tool-less. The doc's other caveat — "a rule naming
103///     `EndConversation` can't remove it while any other tool remains" — is
104///     about a rule that NAMES `EndConversation` specifically; a wildcard
105///     that merely happens to cover it too is a different case and isn't
106///     described as failing.
107///   - `--max-turns 1` — print mode's own hard cap on agentic turns, a
108///     backstop against looping that holds even if the no-tools flag were
109///     somehow bypassed.
110///
111/// `--output-format stream-json` is NOT added here — every subprocess-mode
112/// provider's base one-shot `launch_args` already carries it (see
113/// `providers.rs`), so `base_one_shot_args` already has it.
114pub(crate) fn build_side_question_argv(base_one_shot_args: &[String]) -> Vec<String> {
115    let mut argv = base_one_shot_args.to_vec();
116    argv.push("--disallowedTools".to_string());
117    argv.push("*".to_string());
118    argv.push("--max-turns".to_string());
119    argv.push("1".to_string());
120    argv
121}
122
123/// Codex features that each add a tool to the model's request. `/btw`
124/// disables every one of them. Checked against the pinned CLI (0.154.0,
125/// `codex features list`) by capturing the request it sends: with these
126/// off, plus `--ignore-user-config` and `web_search="disabled"`, the only
127/// tool left is `request_user_input`, which `exec` mode refuses to run
128/// ("request_user_input is not supported in exec mode").
129const CODEX_TOOL_FEATURES: &[&str] = &[
130    "shell_tool",
131    "unified_exec",
132    "view_image",
133    "multi_agent",
134    "goals",
135    "apps",
136    "plugins",
137    "browser_use",
138    "computer_use",
139    "image_generation",
140    "sleep_tool",
141    "tool_suggest",
142];
143
144/// Build the argv for a `/btw` side question on a Codex pane: a tool-less
145/// `codex exec` turn, built from scratch rather than from the pane's own
146/// argv, which may be the app-server's and always carries
147/// `--dangerously-bypass-approvals-and-sandbox`.
148///
149/// - `--ignore-user-config`: `$CODEX_HOME/config.toml` is where MCP servers
150///   (and so the agentmux tools) come from; an empty `-c mcp_servers={}`
151///   override does NOT remove them. Auth still reads `CODEX_HOME`.
152/// - `--sandbox read-only`, the tool features off, `web_search="disabled"`:
153///   no tool that can act.
154/// - `--ephemeral`: the side question leaves no session file behind.
155/// - `--skip-git-repo-check`: the pane's bypass flag implied it; a
156///   tool-less turn has nothing to protect with it.
157///
158/// `model` is the pane's `-m`/`--model`, if it had one.
159pub(crate) fn build_codex_side_question_argv(model: Option<&str>) -> Vec<String> {
160    let mut argv: Vec<String> = [
161        "exec",
162        "--json",
163        "--ignore-user-config",
164        "--ephemeral",
165        "--skip-git-repo-check",
166        "--sandbox",
167        "read-only",
168    ]
169    .iter()
170    .map(|s| s.to_string())
171    .collect();
172    for feature in CODEX_TOOL_FEATURES {
173        argv.push("--disable".to_string());
174        argv.push(feature.to_string());
175    }
176    argv.push("-c".to_string());
177    argv.push("web_search=\"disabled\"".to_string());
178    if let Some(model) = model {
179        argv.push("-m".to_string());
180        argv.push(model.to_string());
181    }
182    // The prompt comes from stdin; `-` stays last.
183    argv.push("-".to_string());
184    argv
185}
186
187/// The Gemini policy that denies every tool, built-in and MCP. Written to
188/// disk for `--policy`. Checked against the pinned CLI (0.60.0) by
189/// capturing its request: with this policy the request declares no tools
190/// at all, even with `--yolo` on.
191pub(crate) const GEMINI_DENY_ALL_TOOLS_POLICY: &str = "\
192[[rule]]
193toolName = \"*\"
194decision = \"deny\"
195priority = 999
196
197[[rule]]
198toolName = \"*\"
199mcpName = \"*\"
200decision = \"deny\"
201priority = 999
202";
203
204/// Build the argv for a `/btw` side question on a Gemini pane: headless
205/// stream-json with `GEMINI_DENY_ALL_TOOLS_POLICY` at `policy_path`, and
206/// approval mode `default` instead of the pane's `--yolo`. No
207/// `--skip-trust`: trusting the folder would load the project's own Gemini
208/// settings, hooks included, so `/btw` gets the same trust as the pane.
209pub(crate) fn build_gemini_side_question_argv(policy_path: &str, model: Option<&str>) -> Vec<String> {
210    let mut argv: Vec<String> = [
211        "--output-format",
212        "stream-json",
213        "--approval-mode",
214        "default",
215        "--policy",
216        policy_path,
217    ]
218    .iter()
219    .map(|s| s.to_string())
220    .collect();
221    if let Some(model) = model {
222        argv.push("-m".to_string());
223        argv.push(model.to_string());
224    }
225    // Headless; the prompt comes from stdin.
226    argv.push("-p".to_string());
227    argv.push(String::new());
228    argv
229}
230
231/// The value of `-m` / `--model` (or `--model=<v>`) in `args`, if any.
232pub(crate) fn model_flag_value(args: &[String]) -> Option<String> {
233    let mut it = args.iter();
234    while let Some(arg) = it.next() {
235        if arg == "-m" || arg == "--model" {
236            return it.next().filter(|v| !v.is_empty() && !v.starts_with('-')).cloned();
237        }
238        if let Some(v) = arg.strip_prefix("--model=") {
239            return Some(v.to_string()).filter(|v| !v.is_empty());
240        }
241    }
242    None
243}
244
245fn build_codex_argv(base: &[String], session_id: Option<&str>) -> Result<Vec<String>, String> {
246    let exec_index = base
247        .iter()
248        .position(|arg| arg == "exec")
249        .ok_or_else(|| "codex resume argv is missing the 'exec' subcommand".to_string())?;
250    let prompt_index = base
251        .iter()
252        .rposition(|arg| arg == "-")
253        .ok_or_else(|| "codex resume argv is missing the stdin prompt marker '-'".to_string())?;
254    if prompt_index <= exec_index {
255        return Err("codex resume argv has the stdin marker before 'exec'".to_string());
256    }
257
258    // Provider flags are appended to launch_args elsewhere, which can put
259    // `--model` and friends after the stdin marker. Normalize the marker to
260    // the end for both fresh and resumed Codex turns.
261    let mut argv = base.to_vec();
262    argv.remove(prompt_index);
263    if let Some(session_id) = session_id {
264        argv.insert(exec_index + 1, "resume".to_string());
265        argv.push(session_id.to_string());
266    }
267    argv.push("-".to_string());
268    Ok(argv)
269}
270
271#[cfg(test)]
272mod prompt_arg_tests {
273    use super::{append_prompt_arg, args_for_log, build_turn_argv, MAX_PROMPT_ARG_UNITS};
274
275    #[test]
276    fn the_logged_args_never_carry_the_prompt() {
277        let mut args = strings(&["--output-format", "stream-json"]);
278        append_prompt_arg(&mut args, "-p", "my token is abc123").unwrap();
279        let logged = args_for_log(&args, true);
280        assert_eq!(logged, strings(&["--output-format", "stream-json", "-p", "<prompt: 18 chars>"]));
281        assert!(!logged.iter().any(|a| a.contains("abc123")));
282        // A stdin provider's args hold no prompt and are logged as they are.
283        assert_eq!(args_for_log(&strings(&["-p", "--verbose"]), false), strings(&["-p", "--verbose"]));
284    }
285
286    fn strings(v: &[&str]) -> Vec<String> {
287        v.iter().map(|s| s.to_string()).collect()
288    }
289
290    #[test]
291    fn the_prompt_goes_last_after_the_resume_flag() {
292        let base = strings(&["--output-format", "stream-json"]);
293        let mut args = build_turn_argv(&base, "flag", "--conversation", Some("c1")).unwrap();
294        append_prompt_arg(&mut args, "-p", "hello & \"bye\"").unwrap();
295        assert_eq!(
296            args,
297            strings(&["--output-format", "stream-json", "--conversation", "c1", "-p", "hello & \"bye\""])
298        );
299    }
300
301    #[test]
302    fn a_prompt_too_long_for_the_command_line_is_refused_not_cut() {
303        let mut args = Vec::new();
304        assert!(append_prompt_arg(&mut args, "-p", &"x".repeat(MAX_PROMPT_ARG_UNITS)).is_ok());
305        let mut args = Vec::new();
306        let err = append_prompt_arg(&mut args, "-p", &"x".repeat(MAX_PROMPT_ARG_UNITS + 1)).unwrap_err();
307        assert!(err.contains("too long"), "{err}");
308        assert!(args.is_empty(), "nothing is appended for a refused prompt");
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::{
315        build_codex_side_question_argv, build_gemini_side_question_argv, build_side_question_argv,
316        build_turn_argv, model_flag_value, CODEX_TOOL_FEATURES,
317    };
318
319    fn strings(values: &[&str]) -> Vec<String> {
320        values.iter().map(|value| (*value).to_string()).collect()
321    }
322
323    #[test]
324    fn first_codex_turn_keeps_exec_argv_unchanged() {
325        let base = strings(&[
326            "exec",
327            "--json",
328            "--dangerously-bypass-approvals-and-sandbox",
329            "-",
330        ]);
331        assert_eq!(
332            build_turn_argv(&base, "codex-exec", "", None).unwrap(),
333            base,
334        );
335    }
336
337    #[test]
338    fn first_codex_turn_moves_appended_provider_flags_before_stdin() {
339        let base = strings(&["exec", "--json", "-", "--model", "gpt-5.4"]);
340        assert_eq!(
341            build_turn_argv(&base, "codex-exec", "", None).unwrap(),
342            strings(&["exec", "--json", "--model", "gpt-5.4", "-"]),
343        );
344    }
345
346    #[test]
347    fn resumed_codex_turn_inserts_subcommand_and_session_before_stdin() {
348        let base = strings(&[
349            "exec",
350            "--json",
351            "--dangerously-bypass-approvals-and-sandbox",
352            "-",
353        ]);
354        assert_eq!(
355            build_turn_argv(
356                &base,
357                "codex-exec",
358                "",
359                Some("00000000-0000-0000-0000-000000000005")
360            )
361            .unwrap(),
362            strings(&[
363                "exec",
364                "resume",
365                "--json",
366                "--dangerously-bypass-approvals-and-sandbox",
367                "00000000-0000-0000-0000-000000000005",
368                "-",
369            ]),
370        );
371    }
372
373    #[test]
374    fn container_codex_argv_preserves_executable_prefix() {
375        let base = strings(&["codex", "exec", "--json", "-"]);
376        assert_eq!(
377            build_turn_argv(&base, "codex-exec", "", Some("thread-name")).unwrap(),
378            strings(&["codex", "exec", "resume", "--json", "thread-name", "-"]),
379        );
380    }
381
382    #[test]
383    fn flag_resume_remains_backward_compatible() {
384        let base = strings(&["-p", "--output-format", "stream-json"]);
385        assert_eq!(
386            build_turn_argv(&base, "flag", "--resume", Some("claude-session")).unwrap(),
387            strings(&[
388                "-p",
389                "--output-format",
390                "stream-json",
391                "--resume",
392                "claude-session"
393            ]),
394        );
395    }
396
397    #[test]
398    fn malformed_codex_argv_fails_instead_of_starting_a_fresh_turn() {
399        let base = strings(&["exec", "--json"]);
400        assert!(build_turn_argv(&base, "codex-exec", "", Some("thread-id"))
401            .unwrap_err()
402            .contains("stdin prompt marker"));
403    }
404
405    // ── build_side_question_argv (/btw) ─────────────────────────────────
406
407    #[test]
408    fn side_question_argv_disables_every_tool_and_caps_turns() {
409        let base = strings(&["-p", "--output-format", "stream-json", "--verbose"]);
410        let got = build_side_question_argv(&base);
411        assert_eq!(
412            got,
413            strings(&[
414                "-p",
415                "--output-format",
416                "stream-json",
417                "--verbose",
418                "--disallowedTools",
419                "*",
420                "--max-turns",
421                "1",
422            ]),
423        );
424    }
425
426    #[test]
427    fn side_question_argv_never_contains_resume() {
428        // No session id is ever plumbed into this path (see the function's
429        // own doc comment) — assert the negative directly so a future edit
430        // that accidentally threads one through gets caught here, not just
431        // by omission.
432        let base = strings(&["-p", "--output-format", "stream-json"]);
433        let got = build_side_question_argv(&base);
434        assert!(!got.iter().any(|a| a == "--resume"));
435    }
436
437    #[test]
438    fn side_question_argv_preserves_base_args_verbatim_and_in_order() {
439        let base = strings(&["-p", "--model", "opus", "--my-custom-flag", "42"]);
440        let got = build_side_question_argv(&base);
441        assert_eq!(&got[..base.len()], base.as_slice());
442    }
443
444    #[test]
445    fn side_question_argv_composes_with_build_turn_argv_resume_free() {
446        // The real call path: build_side_question_argv's output becomes
447        // SubprocessSpawnConfig::cli_args, which spawn_turn then feeds
448        // through build_turn_argv with session_id: None (a `/btw` turn
449        // never resumes). Confirm that composition still ends up
450        // --resume-free and still carries both added flags, exactly as it
451        // would in the real spawn.
452        let base = strings(&["-p", "--output-format", "stream-json"]);
453        let augmented = build_side_question_argv(&base);
454        let final_argv = build_turn_argv(&augmented, "flag", "--resume", None).unwrap();
455        assert_eq!(final_argv, augmented);
456        assert!(!final_argv.iter().any(|a| a == "--resume"));
457        assert!(final_argv.iter().any(|a| a == "--disallowedTools"));
458        assert!(final_argv.iter().any(|a| a == "--max-turns"));
459    }
460
461    // ── /btw on Codex and Gemini ─────────────────────────────────────────
462
463    #[test]
464    fn the_codex_side_question_is_tool_less_and_keeps_stdin_last() {
465        let got = build_codex_side_question_argv(Some("gpt-5"));
466        assert_eq!(&got[..2], &strings(&["exec", "--json"])[..]);
467        for flag in ["--ignore-user-config", "--ephemeral", "--skip-git-repo-check"] {
468            assert!(got.iter().any(|a| a == flag), "{flag}");
469        }
470        assert!(got.windows(2).any(|w| w == strings(&["--sandbox", "read-only"])));
471        for feature in CODEX_TOOL_FEATURES {
472            assert!(got.windows(2).any(|w| w[0] == "--disable" && w[1] == *feature), "{feature}");
473        }
474        assert!(got.windows(2).any(|w| w == strings(&["-c", "web_search=\"disabled\""])));
475        assert!(got.windows(2).any(|w| w == strings(&["-m", "gpt-5"])));
476        assert!(!got.iter().any(|a| a.contains("dangerously")));
477        assert_eq!(got.last().map(String::as_str), Some("-"));
478        assert!(!build_codex_side_question_argv(None).iter().any(|a| a == "-m"));
479    }
480
481    #[test]
482    fn the_gemini_side_question_denies_every_tool_without_yolo() {
483        let got = build_gemini_side_question_argv("/data/btw/gemini.toml", Some("gemini-3-pro"));
484        assert!(got.windows(2).any(|w| w == strings(&["--policy", "/data/btw/gemini.toml"])));
485        assert!(got.windows(2).any(|w| w == strings(&["--approval-mode", "default"])));
486        assert!(got.windows(2).any(|w| w == strings(&["--output-format", "stream-json"])));
487        assert!(got.windows(2).any(|w| w == strings(&["-m", "gemini-3-pro"])));
488        for flag in ["--yolo", "-y", "--skip-trust"] {
489            assert!(!got.iter().any(|a| a == flag), "{flag}");
490        }
491        assert_eq!(&got[got.len() - 2..], &strings(&["-p", ""])[..]);
492    }
493
494    #[test]
495    fn the_pane_model_is_read_from_either_flag_spelling() {
496        assert_eq!(model_flag_value(&strings(&["exec", "-m", "o3", "-"])), Some("o3".into()));
497        assert_eq!(model_flag_value(&strings(&["--model", "x"])), Some("x".into()));
498        assert_eq!(model_flag_value(&strings(&["--model=y", "-p", ""])), Some("y".into()));
499        assert_eq!(model_flag_value(&strings(&["-p", "", "--yolo"])), None);
500        assert_eq!(model_flag_value(&strings(&["-m"])), None);
501    }
502}