#[cfg(unix)]
use std::collections::HashSet;
#[cfg(unix)]
use std::io::Read;
#[cfg(unix)]
use std::path::{Path, PathBuf};
#[cfg(unix)]
use crate::engine::pty_io::SESSION_LEAK_ENV_VARS;
#[cfg(unix)]
use uuid::Uuid;
#[cfg(unix)]
pub fn sh_quote(s: &str) -> String {
if s.is_empty() {
return "''".to_string();
}
if !s.contains('\'') {
return format!("'{s}'");
}
let mut quoted = String::new();
quoted.push('\'');
for ch in s.chars() {
if ch == '\'' {
quoted.push_str("'\\''");
} else {
quoted.push(ch);
}
}
quoted.push('\'');
quoted
}
#[cfg(unix)]
pub fn new_pid_file() -> PathBuf {
std::env::temp_dir().join(format!("omniterm-acp-{}.pid", Uuid::new_v4()))
}
#[cfg(unix)]
pub fn remove_pid_file(path: &Path) {
if let Err(e) = std::fs::remove_file(path)
&& e.kind() != std::io::ErrorKind::NotFound
{
tracing::debug!(path = %path.display(), error = %e, "清理 ACP pid 自报文件失败");
}
}
#[cfg(unix)]
pub(crate) struct PidFileCleanup(PathBuf);
#[cfg(unix)]
impl PidFileCleanup {
pub(crate) fn new(path: PathBuf) -> Self {
Self(path)
}
}
#[cfg(unix)]
impl Drop for PidFileCleanup {
fn drop(&mut self) {
remove_pid_file(&self.0);
}
}
#[cfg(unix)]
pub fn read_and_clear_pid_file(path: &Path) -> Option<u32> {
let parsed = match std::fs::File::open(path) {
Ok(mut f) => {
let mut buf = [0u8; 32];
match f.read(&mut buf) {
Ok(n) => {
std::str::from_utf8(&buf[..n]).ok().and_then(|s| s.trim().parse::<u32>().ok())
}
Err(_) => None,
}
}
Err(_) => None,
};
remove_pid_file(path);
parsed
}
#[cfg(unix)]
pub fn wrap_agent_with_cwd(
agent_cmd: &str,
agent_args: &[String],
workspace: &Path,
pid_file: Option<&Path>,
) -> Vec<String> {
let mut script = format!("cd {} && ", sh_quote(&workspace.to_string_lossy()));
if let Some(f) = pid_file {
script.push_str(&format!("echo $$ > {} && ", sh_quote(&f.to_string_lossy())));
}
script.push_str(&format!("unset {} && ", SESSION_LEAK_ENV_VARS.join(" ")));
script.push_str(&format!("exec {}", sh_quote(agent_cmd)));
let shell_script = agent_args.iter().fold(script, |acc, arg| acc + " " + &sh_quote(arg));
vec!["-c".to_string(), shell_script]
}
#[cfg(all(unix, target_os = "linux"))]
pub fn snapshot_direct_children() -> HashSet<u32> {
let mut set = HashSet::new();
let Ok(entries) = std::fs::read_dir("/proc/self/task") else {
return set;
};
for entry in entries.flatten() {
if let Ok(content) = std::fs::read_to_string(entry.path().join("children")) {
for pid in content.split_whitespace() {
if let Ok(pid) = pid.parse::<u32>() {
set.insert(pid);
}
}
}
}
set
}
#[cfg(unix)]
pub fn capture_agent_pid(
pid_file: &Path,
children_before: &HashSet<u32>,
workspace: &Path,
) -> Option<u32> {
if let Some(pid) = read_and_clear_pid_file(pid_file) {
return Some(pid);
}
tracing::debug!("ACP pid 自报文件缺失或非法,回退直接子进程扫描");
#[cfg(target_os = "linux")]
let fallback = resolve_child_pid(children_before, workspace);
#[cfg(not(target_os = "linux"))]
let fallback = {
let _ = (children_before, workspace);
tracing::warn!("非 Linux Unix 无 /proc 直接子进程扫描且 pid 自报缺失,放弃 pid 捕获");
None
};
fallback
}
#[cfg(all(unix, target_os = "linux"))]
pub fn resolve_child_pid(children_before: &HashSet<u32>, workspace: &Path) -> Option<u32> {
let new_pids: Vec<u32> =
snapshot_direct_children().difference(children_before).copied().collect();
select_new_child_pid(&new_pids, workspace)
}
#[cfg(all(unix, target_os = "linux"))]
fn select_new_child_pid(new_pids: &[u32], workspace: &Path) -> Option<u32> {
if new_pids.is_empty() {
tracing::warn!("spawn 前后直接子进程 diff 为空,agent pid 捕获失败");
return None;
}
if new_pids.len() == 1 {
return Some(new_pids[0]);
}
let canon_ws = workspace.canonicalize().ok();
let matched: Vec<u32> = new_pids
.iter()
.copied()
.filter(|pid| {
let Ok(cwd) = std::fs::read_link(format!("/proc/{pid}/cwd")) else {
return false;
};
canon_ws.as_ref() == Some(&cwd) || *workspace == cwd
})
.collect();
match matched.as_slice() {
[pid] => Some(*pid),
[] => {
tracing::warn!(
?new_pids,
"多个新直接子进程且无一 cwd 匹配 workspace,pid 归属不确定,放弃捕获"
);
None
}
_ => {
tracing::warn!(
?matched,
"多个新直接子进程 cwd 均匹配 workspace,pid 归属不确定,放弃捕获"
);
None
}
}
}
#[cfg(all(unix, target_os = "linux"))]
fn is_direct_child(pid: u32) -> bool {
crate::process_identity::process_identity(pid)
.is_some_and(|ident| ident.ppid == std::process::id())
}
#[cfg(all(unix, target_os = "linux"))]
fn pid_alive(pid: u32) -> bool {
crate::process_identity::pid_alive(pid)
}
#[cfg(all(unix, target_os = "linux"))]
fn process_group_alive(pgid: u32) -> bool {
let r = unsafe { libc::kill(-(pgid as i32), 0) };
r == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}
#[cfg(all(unix, target_os = "linux"))]
fn should_kill_group(pid: u32) -> bool {
is_direct_child(pid) || (!pid_alive(pid) && process_group_alive(pid))
}
pub fn kill_agent_process_group(pid: Option<u32>) {
#[cfg(unix)]
{
let Some(pid) = pid else {
tracing::warn!(
"ACP agent pid 未捕获(D1 降级路径):仅发送优雅关闭信号,agent 进程组可能残留"
);
return;
};
#[cfg(target_os = "linux")]
if !should_kill_group(pid) {
tracing::warn!(
pid,
"agent pid 归属校验未通过(已退出且组空 / pid 可能被复用),跳过进程组击杀"
);
return;
}
let r = unsafe { libc::kill(-(pid as i32), libc::SIGKILL) };
if r != 0 {
let err = std::io::Error::last_os_error();
if err.raw_os_error() != Some(libc::ESRCH) {
tracing::warn!(pid, error = %err, "killpg agent 进程组失败");
}
}
}
#[cfg(not(unix))]
{
let _ = pid;
tracing::warn!(
"非 Unix 平台无进程组语义:agent 进程组击杀不可用,仅发送优雅关闭信号(进程可能残留)"
);
}
}
#[cfg(all(test, target_os = "linux"))]
static SPAWN_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
#[cfg(all(test, target_os = "linux"))]
pub(crate) fn spawn_test_lock() -> tokio::sync::MutexGuard<'static, ()> {
SPAWN_TEST_LOCK.blocking_lock()
}
#[cfg(all(test, target_os = "linux"))]
pub(crate) async fn spawn_test_lock_async() -> tokio::sync::MutexGuard<'static, ()> {
SPAWN_TEST_LOCK.lock().await
}
#[cfg(all(test, unix))]
mod tests {
use super::spawn_test_lock;
use super::*;
use std::process::{Command, Stdio};
use std::time::Duration;
fn unique_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"omniterm-agent-proc-{tag}-{}-{}",
std::process::id(),
chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)
));
std::fs::create_dir_all(&dir).expect("create unique test dir");
dir
}
#[test]
fn sh_quote_empty_string() {
assert_eq!(sh_quote(""), "''");
}
#[test]
fn sh_quote_plain_path() {
assert_eq!(sh_quote("/home/user/project"), "'/home/user/project'");
}
#[test]
fn sh_quote_no_special_chars_passes_through() {
assert_eq!(sh_quote("hello world"), "'hello world'");
assert_eq!(sh_quote("--acp"), "'--acp'");
}
#[test]
fn sh_quote_with_single_quote_splits_segments() {
assert_eq!(sh_quote("foo'bar"), "'foo'\\''bar'");
}
#[test]
fn sh_quote_only_single_quote() {
assert_eq!(sh_quote("'"), "''\\'''");
}
#[test]
fn sh_quote_does_not_inject_shell_metacharacters() {
let dangerous = "a; rm -rf /; $(echo bad); `id`";
let quoted = sh_quote(dangerous);
assert_eq!(quoted, format!("'{dangerous}'"));
}
#[test]
fn wrap_returns_cd_then_exec_form() {
let args = wrap_agent_with_cwd(
"codebuddy",
&["--acp".into()],
Path::new("/home/user/project"),
None,
);
assert_eq!(args.len(), 2);
assert_eq!(args[0], "-c");
let unset = format!("unset {}", SESSION_LEAK_ENV_VARS.join(" "));
assert_eq!(
args[1],
format!("cd '/home/user/project' && {unset} && exec 'codebuddy' '--acp'")
);
}
#[test]
fn wrap_escapes_workspace_with_spaces_and_quotes() {
let workspace = Path::new("/home/user/it's a 'project'");
let args = wrap_agent_with_cwd("agent", &[], workspace, None);
assert!(args[1].contains("'/home/user/it'\\''s a '\\''project'\\'''"));
}
#[test]
fn wrap_with_no_args_emits_cd_unset_exec_only() {
let args = wrap_agent_with_cwd("/usr/bin/myagent", &[], Path::new("/tmp"), None);
let unset = format!("unset {}", SESSION_LEAK_ENV_VARS.join(" "));
assert_eq!(args[1], format!("cd '/tmp' && {unset} && exec '/usr/bin/myagent'"));
}
#[test]
fn wrap_with_pid_file_inserts_echo_before_exec() {
let args = wrap_agent_with_cwd(
"codebuddy",
&["--acp".into()],
Path::new("/home/user/project"),
Some(Path::new("/tmp/omniterm-acp-x.pid")),
);
let unset = format!("unset {}", SESSION_LEAK_ENV_VARS.join(" "));
assert_eq!(
args[1],
format!(
"cd '/home/user/project' && echo $$ > '/tmp/omniterm-acp-x.pid' && {unset} && exec 'codebuddy' '--acp'"
)
);
}
#[test]
fn pid_file_roundtrip_reads_and_removes() {
let dir = unique_dir("pidfile");
let path = dir.join("agent.pid");
std::fs::write(&path, "123456\n").expect("write pid file");
assert_eq!(read_and_clear_pid_file(&path), Some(123456));
assert!(!path.exists(), "读取后必须删除,否则 /tmp 累积");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn pid_file_garbage_or_missing_yields_none() {
let dir = unique_dir("pidfile-bad");
let path = dir.join("agent.pid");
std::fs::write(&path, "not-a-pid").expect("write garbage");
assert_eq!(read_and_clear_pid_file(&path), None);
assert!(!path.exists(), "内容非法也要删除");
assert_eq!(read_and_clear_pid_file(&path), None, "文件已不存在 → None");
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn wrapped_subprocess_reports_own_pid_via_file() {
let dir = unique_dir("pidreport");
let workspace = dir.join("ws");
std::fs::create_dir_all(&workspace).expect("create workspace");
let pid_file = dir.join("agent.pid");
let wrapped = wrap_agent_with_cwd("pwd", &[], &workspace, Some(&pid_file));
let mut child = Command::new("/bin/sh")
.args(&wrapped)
.stdout(Stdio::piped())
.spawn()
.expect("spawn wrapped sh");
let status = child.wait().expect("wait wrapped sh");
assert!(status.success(), "wrapped sh 应正常退出: {status:?}");
let reported = read_and_clear_pid_file(&pid_file).expect("wrapper 应自报 pid");
assert_eq!(reported, child.id(), "自报 pid 必须等于 wrapper(= exec 后 agent)pid");
assert!(reported > 1 && reported != std::process::id());
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn wrapped_subprocess_has_session_workspace_as_cwd() {
let workspace = unique_dir("cwd");
let workspace_str = workspace.to_string_lossy().to_string();
let wrapped = wrap_agent_with_cwd("pwd", &[], &workspace, None);
let output = Command::new("/bin/sh")
.args(&wrapped)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.expect("spawn sh");
assert!(
output.status.success(),
"sh exited with {}: stderr={}",
output.status,
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert_eq!(
stdout, workspace_str,
"subprocess cwd was {stdout:?}, expected {workspace_str:?} — wrap_agent_with_cwd \
is not actually changing the OS cwd. This is the regression \
the fix targets: agent-client-protocol's AcpAgent::spawn_process \
does NOT call Command::current_dir, so the spawned agent runs \
in the backend's cwd rather than the session's workspace_path."
);
let _ = std::fs::remove_dir_all(&workspace);
}
#[tokio::test]
async fn wrapped_subprocess_strips_session_leak_env() {
let workspace = unique_dir("envstrip");
let wrapped = wrap_agent_with_cwd("env", &[], &workspace, None);
let mut cmd = Command::new("/bin/sh");
cmd.args(&wrapped);
for var in SESSION_LEAK_ENV_VARS {
cmd.env(var, "leak");
}
cmd.env("OMNITERM_ACP_ENV_KEEP", "1");
let output = cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).output().expect("spawn sh");
assert!(
output.status.success(),
"sh exited with {}: stderr={}",
output.status,
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
for leaked in SESSION_LEAK_ENV_VARS {
assert!(
!stdout.lines().any(|l| l.starts_with(&format!("{leaked}="))),
"{leaked} 必须被 wrapper 清除,实际仍在子进程环境中:\n{stdout}"
);
}
assert!(
stdout.lines().any(|l| l == "OMNITERM_ACP_ENV_KEEP=1"),
"无关变量不得被误清:\n{stdout}"
);
let _ = std::fs::remove_dir_all(&workspace);
}
#[tokio::test]
async fn wrapped_subprocess_workspaces_with_spaces() {
let parent = std::env::temp_dir();
let workspace = parent.join(format!(
"omniterm cwd test {}-{}",
std::process::id(),
chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)
));
std::fs::create_dir_all(&workspace).expect("create temp workspace with space");
let workspace_str = workspace.to_string_lossy().to_string();
assert!(
workspace_str.contains(' '),
"test setup must use a workspace with spaces; got {workspace_str}"
);
let wrapped = wrap_agent_with_cwd("pwd", &[], &workspace, None);
let output = Command::new("/bin/sh")
.args(&wrapped)
.stdout(Stdio::piped())
.output()
.expect("spawn sh");
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert_eq!(
stdout, workspace_str,
"subprocess cwd with spaces-in-path didn't survive sh -c wrap"
);
let _ = std::fs::remove_dir_all(&workspace);
}
#[tokio::test]
async fn wrapped_subprocess_exits_normally() {
let workspace = std::env::temp_dir().join("omniterm-exec-test");
let _ = std::fs::create_dir_all(&workspace);
let wrapped = wrap_agent_with_cwd("true", &[], &workspace, None);
let output = Command::new("/bin/sh").args(&wrapped).output().expect("spawn sh");
assert!(output.status.success(), "wrapped exit != 0");
let _ = std::fs::remove_dir_all(&workspace);
}
#[cfg(target_os = "linux")]
fn wait_until_dead(pid: u32, timeout: Duration) -> bool {
let deadline = std::time::Instant::now() + timeout;
while std::time::Instant::now() < deadline {
if !pid_alive(pid) {
return true;
}
std::thread::sleep(Duration::from_millis(20));
}
!pid_alive(pid)
}
#[cfg(target_os = "linux")]
fn wait_until_registered(pid: u32) -> bool {
for _ in 0..100 {
if snapshot_direct_children().contains(&pid) {
return true;
}
std::thread::sleep(Duration::from_millis(20));
}
false
}
#[cfg(target_os = "linux")]
#[test]
fn snapshot_direct_children_includes_spawned_child() {
let _guard = spawn_test_lock();
let before = snapshot_direct_children();
let mut child = Command::new("sleep").arg("5").spawn().expect("spawn sleep");
assert!(
wait_until_registered(child.id()),
"spawn 出的子进程应出现在 /proc/self/task/*/children"
);
assert!(!before.contains(&child.id()));
let _ = child.kill();
let _ = child.wait();
}
#[cfg(target_os = "linux")]
#[test]
fn select_single_new_pid_fast_path() {
assert_eq!(select_new_child_pid(&[424242], &std::env::temp_dir()), Some(424242));
}
#[cfg(target_os = "linux")]
#[test]
fn select_empty_new_pids_yields_none() {
assert_eq!(select_new_child_pid(&[], &std::env::temp_dir()), None);
}
#[cfg(target_os = "linux")]
#[test]
fn resolve_child_pid_finds_spawned_child_among_strangers() {
let dir = unique_dir("resolve-single");
let before = snapshot_direct_children();
let mut child =
Command::new("sleep").arg("5").current_dir(&dir).spawn().expect("spawn sleep");
assert!(wait_until_registered(child.id()));
let resolved = resolve_child_pid(&before, &dir).expect("应归属到本子进程");
assert_eq!(resolved, child.id());
let _ = child.kill();
let _ = child.wait();
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(target_os = "linux")]
#[test]
fn resolve_child_pid_disambiguates_concurrent_spawns_by_cwd() {
let _guard = spawn_test_lock();
let dir_a = unique_dir("cwd-a");
let dir_b = unique_dir("cwd-b");
let before = snapshot_direct_children();
let mut a = Command::new("sleep").arg("5").current_dir(&dir_a).spawn().expect("spawn a");
let mut b = Command::new("sleep").arg("5").current_dir(&dir_b).spawn().expect("spawn b");
assert!(wait_until_registered(a.id()) && wait_until_registered(b.id()));
let resolved =
resolve_child_pid(&before, &dir_a).expect("应按 cwd 匹配到 a,而非放弃或误取 b");
assert_eq!(resolved, a.id());
for c in [&mut a, &mut b] {
let _ = c.kill();
let _ = c.wait();
}
let _ = std::fs::remove_dir_all(&dir_a);
let _ = std::fs::remove_dir_all(&dir_b);
}
#[cfg(target_os = "linux")]
#[test]
fn kill_agent_process_group_kills_child_and_is_idempotent() {
let _guard = spawn_test_lock();
use std::os::unix::process::CommandExt;
let mut child = Command::new("sleep")
.arg("30")
.process_group(0) .spawn()
.expect("spawn sleep");
kill_agent_process_group(Some(child.id()));
let mut reaped = false;
let deadline = std::time::Instant::now() + Duration::from_secs(2);
while std::time::Instant::now() < deadline {
if child.try_wait().expect("try_wait").is_some() {
reaped = true;
break;
}
std::thread::sleep(Duration::from_millis(20));
}
assert!(reaped, "killpg 应击杀直接子进程");
kill_agent_process_group(Some(child.id()));
let _ = child.wait();
}
#[cfg(target_os = "linux")]
#[test]
fn kill_agent_process_group_reaches_grandchild_after_leader_exit() {
let _guard = spawn_test_lock();
use std::os::unix::process::CommandExt;
let dir = unique_dir("grandchild");
let pid_file = dir.join("grandchild.pid");
let script =
format!("sleep 30 & echo $! > {} ; exit 0", sh_quote(&pid_file.to_string_lossy()));
let mut sh = Command::new("/bin/sh")
.arg("-c")
.arg(&script)
.process_group(0)
.spawn()
.expect("spawn wrapper sh");
let leader = sh.id();
let status = sh.wait().expect("wait wrapper sh");
assert!(status.success(), "wrapper 应正常退出: {status:?}");
let grandchild: u32 = std::fs::read_to_string(&pid_file)
.expect("wrapper 应留下孙进程 pid")
.trim()
.parse()
.expect("孙进程 pid 应可解析");
assert_ne!(grandchild, leader);
assert!(pid_alive(grandchild), "孙进程应仍在(同进程组,未单独杀)");
assert!(!is_direct_child(leader), "leader 已被 reap,不再是直接子进程");
assert!(should_kill_group(leader), "leader 退但组非空 → 应 killpg");
kill_agent_process_group(Some(leader));
assert!(
wait_until_dead(grandchild, Duration::from_secs(2)),
"killpg 必须带走 leader 退出后残留的孙进程"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(target_os = "linux")]
#[test]
fn should_kill_group_rejects_dead_reaped_pid() {
let _guard = spawn_test_lock();
use std::os::unix::process::CommandExt;
let mut child = Command::new("true").process_group(0).spawn().expect("spawn true");
let dead = child.id();
child.wait().expect("reap true"); assert!(!pid_alive(dead));
assert!(!should_kill_group(dead), "组已空的死 pid 不应杀(pid 复用防护)");
kill_agent_process_group(Some(dead)); }
#[test]
fn kill_agent_process_group_none_pid_is_noop() {
kill_agent_process_group(None);
}
}