use std::path::{Path, PathBuf};
use std::process::Stdio;
use anyhow::{Context, Result};
use async_trait::async_trait;
use chrono::Utc;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tracing::{debug, warn};
use crate::control::event::{SessionEvent, StreamJsonEvent};
use crate::control::id::ControlSessionId;
use crate::core::spawn_disclaim::{
ChildHandle, DynChildStdin, DynChildStdout, disclaimed_piped_spawn,
};
use super::{SessionBackend, SessionInput};
pub const ANTHROPIC_API_KEY_VAR: &str = "ANTHROPIC_API_KEY";
pub fn build_claude_command(workdir: &Path, prompt_file: Option<&Path>) -> std::process::Command {
let mut cmd = std::process::Command::new("claude");
cmd.env_remove(ANTHROPIC_API_KEY_VAR);
crate::core::claude_env_scrub::scrub_command(&mut cmd);
cmd.arg("-p");
cmd.arg("--output-format").arg("stream-json");
if let Some(pf) = prompt_file {
cmd.arg("--append-system-prompt-file").arg(pf);
}
cmd.arg("--workdir").arg(workdir);
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.current_dir(workdir);
cmd
}
pub struct StreamJsonBackend {
session_id: ControlSessionId,
child: ChildHandle,
stdin: DynChildStdin,
stdout: BufReader<DynChildStdout>,
}
impl StreamJsonBackend {
pub fn spawn(
session_id: ControlSessionId,
workdir: PathBuf,
prompt_file: Option<PathBuf>,
) -> Result<Self> {
let std_cmd = build_claude_command(&workdir, prompt_file.as_deref());
let spawned = disclaimed_piped_spawn(std_cmd).with_context(|| {
format!(
"failed to spawn 'claude' for session {} in {}",
session_id,
workdir.display()
)
})?;
Ok(Self {
session_id,
child: spawned.handle,
stdin: spawned.stdin,
stdout: BufReader::new(spawned.stdout),
})
}
}
#[async_trait]
impl SessionBackend for StreamJsonBackend {
async fn send(&mut self, msg: SessionInput) -> Result<()> {
let mut text = msg.text;
if !text.ends_with('\n') {
text.push('\n');
}
self.stdin
.write_all(text.as_bytes())
.await
.context("write to claude stdin failed")?;
self.stdin
.flush()
.await
.context("flush claude stdin failed")
}
async fn recv(&mut self) -> Option<Result<Vec<SessionEvent>>> {
let mut line = String::new();
loop {
line.clear();
let n = match self.stdout.read_line(&mut line).await {
Ok(n) => n,
Err(e) => return Some(Err(e.into())),
};
if n == 0 {
debug!(session_id = %self.session_id, "stream-json stdout EOF");
return None;
}
let trimmed = line.trim();
if trimmed.is_empty() {
continue; }
let structured = match serde_json::from_str::<serde_json::Value>(trimmed) {
Ok(val) => {
let event_type = val
.get("type")
.and_then(|t| t.as_str())
.unwrap_or("unknown")
.to_owned();
Some(StreamJsonEvent {
event_type,
payload: val,
})
}
Err(e) => {
warn!(
session_id = %self.session_id,
"failed to parse stream-json line as JSON: {e}: {trimmed}"
);
None
}
};
let event = SessionEvent::Output {
session_id: self.session_id.clone(),
raw: trimmed.to_owned(),
structured,
ts: Utc::now(),
};
return Some(Ok(vec![event]));
}
}
async fn stop(mut self) -> Result<()> {
let _ = self.child.start_kill(); let _ = self.child.wait().await; Ok(())
}
}
impl Drop for StreamJsonBackend {
fn drop(&mut self) {
let _ = self.child.start_kill();
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsStr;
#[test]
fn session_input_newtype() {
let input = SessionInput::new("hello");
assert_eq!(input.text, "hello");
}
fn env_mutations(cmd: &std::process::Command) -> Vec<(String, Option<String>)> {
cmd.get_envs()
.map(|(k, v)| {
(
k.to_string_lossy().into_owned(),
v.map(|v| v.to_string_lossy().into_owned()),
)
})
.collect()
}
#[test]
fn build_command_removes_api_key() {
let cmd = build_claude_command(Path::new("/tmp/wd"), None);
let muts = env_mutations(&cmd);
let removed = muts
.iter()
.find(|(k, _)| k == ANTHROPIC_API_KEY_VAR)
.expect("ANTHROPIC_API_KEY must appear as an explicit env mutation");
assert_eq!(
removed.1, None,
"ANTHROPIC_API_KEY must be REMOVED (None), never set, in the child env"
);
}
#[test]
fn build_command_scrubs_inherited_session_markers() {
let cmd = build_claude_command(Path::new("/tmp/wd"), None);
let muts = env_mutations(&cmd);
let removed = muts
.iter()
.find(|(k, _)| k == "CLAUDE_CODE_CHILD_SESSION")
.expect("CLAUDE_CODE_CHILD_SESSION must appear as an explicit env mutation");
assert_eq!(
removed.1, None,
"CLAUDE_CODE_CHILD_SESSION must be REMOVED (None) from the child env"
);
assert!(
!muts.iter().any(|(k, _)| k == "CLAUDE_CONFIG_DIR"),
"CLAUDE_CONFIG_DIR must not be touched by the scrub (#4455): {muts:?}"
);
}
#[test]
fn build_command_never_passes_on_the_profile_stamp() {
let cmd = build_claude_command(Path::new("/tmp/wd"), None);
assert!(
env_mutations(&cmd).contains(&("TRUSTY_MPM_SESSION_PROFILE".to_owned(), None)),
"the profile stamp must be REMOVED from the headless child"
);
}
#[test]
fn build_command_program_is_claude() {
let cmd = build_claude_command(Path::new("/tmp/wd"), None);
assert_eq!(cmd.get_program(), OsStr::new("claude"));
}
#[test]
fn build_command_has_stream_json_args() {
let cmd = build_claude_command(Path::new("/tmp/wd"), None);
let args: Vec<String> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert!(args.contains(&"-p".to_string()));
assert!(args.contains(&"--output-format".to_string()));
assert!(args.contains(&"stream-json".to_string()));
assert!(args.contains(&"--workdir".to_string()));
assert!(!args.contains(&"--append-system-prompt-file".to_string()));
}
#[test]
fn build_command_includes_prompt_file_when_present() {
let pf = PathBuf::from("/tmp/prompt.md");
let cmd = build_claude_command(Path::new("/tmp/wd"), Some(&pf));
let args: Vec<String> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert!(args.contains(&"--append-system-prompt-file".to_string()));
assert!(args.contains(&"/tmp/prompt.md".to_string()));
}
#[test]
#[ignore = "requires claude binary absent from PATH"]
fn stream_json_spawn_fails_gracefully_without_claude() {
}
}