use crate::agents::Agent;
use std::io;
use std::process::{Command, ExitStatus, Stdio};
pub const WIZARD_SESSION_ENV: &str = "JARVY_WIZARD_SESSION";
pub const WIZARD_SESSION_ID_ENV: &str = "JARVY_WIZARD_SESSION_ID";
pub fn new_session_id() -> String {
uuid::Uuid::now_v7().to_string()
}
#[derive(Debug, thiserror::Error)]
pub enum HeadlessError {
#[error("`{0}` CLI not found on PATH")]
CliMissing(String),
#[error("agent `{0}` has no headless CLI mode")]
NotHeadlessCapable(String),
#[error("spawn `{cmd}`: {source}")]
Spawn {
cmd: String,
#[source]
source: io::Error,
},
#[error("wait `{cmd}`: {source}")]
Wait {
cmd: String,
#[source]
source: io::Error,
},
#[error("stdin write `{cmd}`: {source}")]
StdinWrite {
cmd: String,
#[source]
source: io::Error,
},
}
pub fn spawn_args(agent: Agent) -> Result<(&'static str, Vec<&'static str>), HeadlessError> {
match agent {
Agent::ClaudeCode => Ok(("claude", vec!["-p", "--allowedTools", "mcp__jarvy"])),
Agent::Codex => Ok(("codex", vec!["exec", "--"])),
a => Err(HeadlessError::NotHeadlessCapable(a.slug().to_string())),
}
}
pub fn build_command(agent: Agent, session_id: &str) -> Result<Command, HeadlessError> {
let (cmd, args) = spawn_args(agent)?;
let mut command = Command::new(cmd);
command
.args(&args)
.env(WIZARD_SESSION_ENV, "1")
.env(WIZARD_SESSION_ID_ENV, session_id)
.stdin(Stdio::piped())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit());
Ok(command)
}
pub fn run(agent: Agent, prompt: &str, session_id: &str) -> Result<ExitStatus, HeadlessError> {
let (cmd, _args) = spawn_args(agent)?;
let mut child = build_command(agent, session_id)?
.spawn()
.map_err(|e| match e.kind() {
io::ErrorKind::NotFound => HeadlessError::CliMissing(cmd.to_string()),
_ => HeadlessError::Spawn {
cmd: cmd.to_string(),
source: e,
},
})?;
send_prompt_to_child_stdin(&mut child, prompt, cmd)?;
child.wait().map_err(|e| HeadlessError::Wait {
cmd: cmd.to_string(),
source: e,
})
}
fn send_prompt_to_child_stdin(
child: &mut std::process::Child,
prompt: &str,
cmd_label: &str,
) -> Result<(), HeadlessError> {
if let Some(stdin) = child.stdin.take() {
use std::io::Write;
let mut buf = std::io::BufWriter::with_capacity(prompt.len().clamp(4096, 64 * 1024), stdin);
buf.write_all(prompt.as_bytes())
.map_err(|e| HeadlessError::StdinWrite {
cmd: cmd_label.to_string(),
source: e,
})?;
buf.flush().map_err(|e| HeadlessError::StdinWrite {
cmd: cmd_label.to_string(),
source: e,
})?;
drop(buf);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn spawn_args_for_claude_uses_p_flag() {
let (cmd, args) = spawn_args(Agent::ClaudeCode).unwrap();
assert_eq!(cmd, "claude");
assert!(
args.contains(&"-p"),
"claude must run in print/non-interactive mode"
);
}
#[test]
fn spawn_args_for_claude_preapproves_jarvy_mcp() {
let (cmd, args) = spawn_args(Agent::ClaudeCode).unwrap();
assert_eq!(cmd, "claude");
assert_eq!(
args,
vec!["-p", "--allowedTools", "mcp__jarvy"],
"argv order for claude must be [-p, --allowedTools, mcp__jarvy] \
— Claude Code's flag parser is order-sensitive"
);
}
#[test]
fn allowed_tools_scope_pins_exact_jarvy_server_prefix() {
let (_, args) = spawn_args(Agent::ClaudeCode).unwrap();
let value = args
.iter()
.position(|a| *a == "--allowedTools")
.and_then(|i| args.get(i + 1))
.expect("--allowedTools has a value");
assert!(
value.starts_with("mcp__"),
"allowedTools must use the `mcp__<server>` grammar; got: {value}"
);
assert!(
value.contains("__jarvy"),
"scope must reference the `jarvy` server explicitly, not \
a naked prefix that could match `jarvy-experimental` or \
`jarvy-labs` — got: {value}"
);
assert_eq!(
*value, "mcp__jarvy",
"scope must be EXACTLY `mcp__jarvy` — no whitespace, no \
trailing wildcard, no other suffix. Any deviation risks \
silent scope broadening in future Claude Code releases."
);
}
#[test]
fn build_command_for_claude_sets_wizard_session_env() {
let session_id = "test-session-uuid";
let cmd = build_command(Agent::ClaudeCode, session_id).unwrap();
let envs: std::collections::HashMap<_, _> = cmd
.get_envs()
.filter_map(|(k, v)| v.map(|vv| (k.to_owned(), vv.to_owned())))
.collect();
assert_eq!(
envs.get(std::ffi::OsStr::new(WIZARD_SESSION_ENV)),
Some(&std::ffi::OsString::from("1")),
"JARVY_WIZARD_SESSION=1 MUST be set on the spawn — this \
is what marks descendant MCP-server processes as \
wizard-driven and bypasses the mutation-confirmation TTY \
prompt. Reverting the .env() line silently reverts the \
fix; this test refuses to let that happen."
);
assert_eq!(
envs.get(std::ffi::OsStr::new(WIZARD_SESSION_ID_ENV)),
Some(&std::ffi::OsString::from(session_id)),
"JARVY_WIZARD_SESSION_ID must be threaded so telemetry \
events emitted from descendants can correlate to the \
enclosing wizard invocation"
);
}
#[test]
fn build_command_for_codex_sets_wizard_session_env() {
let cmd = build_command(Agent::Codex, "codex-session").unwrap();
let envs: std::collections::HashMap<_, _> = cmd
.get_envs()
.filter_map(|(k, v)| v.map(|vv| (k.to_owned(), vv.to_owned())))
.collect();
assert_eq!(
envs.get(std::ffi::OsStr::new(WIZARD_SESSION_ENV)),
Some(&std::ffi::OsString::from("1"))
);
}
#[cfg(unix)]
#[test]
fn send_prompt_delivers_prompt_exceeding_pipe_buffer() {
use std::io::Read;
use std::process::{Command, Stdio};
let big: String = "x".repeat(128 * 1024);
let mut child = Command::new("cat")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("cat must spawn on Unix");
send_prompt_to_child_stdin(&mut child, &big, "cat")
.expect("large-buffer write must succeed");
let mut got = String::new();
child
.stdout
.take()
.unwrap()
.read_to_string(&mut got)
.unwrap();
let status = child.wait().unwrap();
assert!(status.success());
assert_eq!(got.len(), big.len(), "large prompt byte count must match");
assert_eq!(got, big, "large prompt content must match");
}
#[cfg(unix)]
#[test]
fn send_prompt_handles_empty_prompt_without_hang() {
use std::process::{Command, Stdio};
let mut child = Command::new("cat")
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("cat must spawn on Unix");
send_prompt_to_child_stdin(&mut child, "", "cat").expect("empty prompt must succeed");
let status = child.wait().expect("cat must exit on EOF");
assert!(status.success(), "cat must exit cleanly on empty stdin");
}
#[cfg(unix)]
#[test]
fn send_prompt_to_child_stdin_delivers_exact_bytes() {
use std::io::Read;
use std::process::{Command, Stdio};
let expected = "wizard prompt body — line one\nline two\nline three with unicode: αβγ\n";
let mut child = Command::new("cat")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("cat must spawn on Unix — required for this test");
send_prompt_to_child_stdin(&mut child, expected, "cat")
.expect("write must succeed against a piped-stdin child");
let mut received = String::new();
child
.stdout
.take()
.expect("cat stdout was piped")
.read_to_string(&mut received)
.expect("read from cat stdout");
let status = child.wait().expect("cat must exit cleanly on EOF");
assert!(
status.success(),
"cat must exit success after receiving EOF; got: {status:?}"
);
assert_eq!(
received, expected,
"bytes read from child stdout must equal bytes written to \
child stdin — the write path lost or corrupted data"
);
}
#[test]
fn new_session_id_is_unique_across_calls() {
let a = new_session_id();
let b = new_session_id();
assert_ne!(a, b, "session ids must be per-invocation unique");
assert_eq!(a.len(), 36, "UUID stringified length is 36");
}
#[test]
fn spawn_args_for_codex_uses_exec_subcommand() {
let (cmd, args) = spawn_args(Agent::Codex).unwrap();
assert_eq!(cmd, "codex");
assert!(args.contains(&"exec"));
}
#[test]
fn spawn_args_rejects_gui_agents() {
for &agent in &[
Agent::Cursor,
Agent::Windsurf,
Agent::Cline,
Agent::Continue,
] {
assert!(
spawn_args(agent).is_err(),
"GUI agent `{}` must not have a headless mapping",
agent.slug()
);
}
}
}