use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Instant;
use agent_client_protocol::schema::ProtocolVersion;
use agent_client_protocol::schema::v1::{
CancelNotification, ConfigOptionUpdate, ContentBlock, CreateTerminalRequest, EmbeddedResource,
EmbeddedResourceResource, ImageContent, InitializeRequest, KillTerminalRequest,
LoadSessionRequest, NewSessionRequest, PromptRequest, PromptResponse, ReadTextFileRequest,
ReadTextFileResponse, ReleaseTerminalRequest, RequestPermissionRequest, SessionConfigId,
SessionConfigKind, SessionConfigOption, SessionConfigOptionValue, SessionId,
SessionNotification, SessionUpdate, SetSessionConfigOptionRequest, TextContent,
TextResourceContents, WaitForTerminalExitRequest, WriteTextFileRequest, WriteTextFileResponse,
};
use agent_client_protocol::{AcpAgent, Agent as AcpAgentRole, ConnectionTo, Error as AcpError};
use serde::Deserialize;
use tokio::sync::{broadcast, oneshot};
use tokio::task::JoinHandle;
use crate::acp::handler::{self, SeqNotification};
use crate::acp::permission::{PermissionManager, PermissionRequestEvent};
use crate::acp::terminal::{AcpTerminalManager, TerminalActivity};
use crate::acp::turn_accumulator::{TurnAccumulator, TurnSnapshot};
use crate::models::agent::Agent;
const SESSION_UPDATE_CHANNEL_CAPACITY: usize = 4096;
#[derive(Debug, Deserialize)]
pub struct ImageInput {
pub data: String,
pub mime_type: String,
}
#[derive(Debug)]
pub struct ResourceInput {
pub uri: String,
pub label: String,
pub text: String,
}
struct ActivityState {
active_prompt: bool,
last_activity: Instant,
}
impl ActivityState {
fn new() -> Self {
Self { active_prompt: false, last_activity: Instant::now() }
}
}
pub struct AcpClient {
connection: ConnectionTo<AcpAgentRole>,
session_id: SessionId,
session_update_tx: broadcast::Sender<SeqNotification>,
_shutdown_tx: Mutex<Option<oneshot::Sender<()>>>,
crash_tx: broadcast::Sender<String>,
terminal_event_tx: broadcast::Sender<TerminalActivity>,
terminal_manager: Arc<AcpTerminalManager>,
permission_manager: Arc<PermissionManager>,
supports_load_session: bool,
supports_image: bool,
supports_embedded_context: bool,
initial_config_options: Arc<Mutex<Vec<SessionConfigOption>>>,
available_commands_notif: Arc<Mutex<Option<SessionNotification>>>,
activity: Arc<Mutex<ActivityState>>,
accumulator: Arc<TurnAccumulator>,
}
fn spawn_crash_watcher(
connection_task: JoinHandle<Result<(), AcpError>>,
crash_tx: broadcast::Sender<String>,
accumulator: Arc<TurnAccumulator>,
) {
tokio::spawn(async move {
if let Err(e) = connection_task.await {
accumulator.finalize_turn();
let _ = crash_tx.send(format!("{}", e));
}
});
}
fn resolve_fs_path(base: &Path, requested: &Path) -> Result<PathBuf, String> {
let candidate =
if requested.is_absolute() { requested.to_path_buf() } else { base.join(requested) };
let canon_base =
base.canonicalize().map_err(|e| format!("workspace root unresolvable: {}", e))?;
let canon = if candidate.exists() {
candidate.canonicalize().map_err(|e| format!("path resolution failed: {}", e))?
} else if let Some(parent) = candidate.parent() {
let canon_parent =
parent.canonicalize().map_err(|e| format!("parent dir unresolvable: {}", e))?;
canon_parent.join(candidate.file_name().unwrap_or_default())
} else {
candidate
};
if !canon.starts_with(&canon_base) {
return Err("access denied: path escapes workspace root".to_string());
}
Ok(canon)
}
#[cfg(unix)]
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)]
fn wrap_agent_with_cwd(agent_cmd: &str, agent_args: &[String], workspace: &Path) -> Vec<String> {
let cd_cmd =
format!("cd {} && exec {}", sh_quote(&workspace.to_string_lossy()), sh_quote(agent_cmd));
let shell_script = agent_args.iter().fold(cd_cmd, |acc, arg| acc + " " + &sh_quote(arg));
vec!["-c".to_string(), shell_script]
}
impl AcpClient {
pub async fn spawn_and_connect(agent: Agent, cwd: PathBuf) -> Result<Self, AcpError> {
let mut all_args: Vec<String> = Vec::new();
for env_var in &agent.env {
all_args.push(format!("{}={}", env_var.key, env_var.value));
}
#[cfg(unix)]
let (cmd, args) =
("/bin/sh".to_string(), wrap_agent_with_cwd(&agent.command, &agent.args, &cwd));
#[cfg(not(unix))]
let (cmd, args) = (agent.command.clone(), agent.args.clone());
all_args.push(cmd);
all_args.extend(args);
let transport = AcpAgent::from_args(all_args)?;
let (session_update_tx, _) = broadcast::channel(SESSION_UPDATE_CHANNEL_CAPACITY);
let (crash_tx, _) = broadcast::channel::<String>(16);
let (terminal_event_tx, _) = broadcast::channel::<TerminalActivity>(64);
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
let (conn_tx, conn_rx) = oneshot::channel::<(
ConnectionTo<AcpAgentRole>,
SessionId,
bool,
bool,
bool,
Vec<SessionConfigOption>,
)>();
let notif_tx = session_update_tx.clone();
let terminal_manager = Arc::new(AcpTerminalManager::new(terminal_event_tx.clone()));
let tm = terminal_manager.clone();
let permission_manager = Arc::new(PermissionManager::new());
let pm = permission_manager.clone();
let activity = Arc::new(Mutex::new(ActivityState::new()));
let commands_notif: Arc<Mutex<Option<SessionNotification>>> = Arc::new(Mutex::new(None));
let accumulator = Arc::new(TurnAccumulator::new());
let builder = agent_client_protocol::Client
.builder()
.name("omniterm")
.on_receive_notification(
{
let tx = notif_tx.clone();
let activity = activity.clone();
let commands_notif = commands_notif.clone();
let accumulator = accumulator.clone();
async move |notification: SessionNotification, _cx| {
if let Ok(mut st) = activity.lock() {
st.last_activity = Instant::now();
}
let seq = accumulator.fold(¬ification);
if matches!(
notification.update,
SessionUpdate::AvailableCommandsUpdate(_)
)
&& let Ok(mut guard) = commands_notif.lock() {
*guard = Some(notification.clone());
}
handler::handle_session_update(&tx, SeqNotification { seq, notification })
}
},
agent_client_protocol::on_receive_notification!(),
)
.on_receive_request(
{
let pm = pm.clone();
async move |request: RequestPermissionRequest, responder, _cx| {
pm.handle_request(request, responder).await
}
},
agent_client_protocol::on_receive_request!(),
)
.on_receive_request(
{
let read_cwd = cwd.clone();
async move |request: ReadTextFileRequest, responder, _cx| {
let path = match resolve_fs_path(&read_cwd, &request.path) {
Ok(p) => p,
Err(e) => {
let _ = responder.respond_with_internal_error(e);
return Ok(());
}
};
match tokio::fs::read_to_string(&path).await {
Ok(content) => {
let _ = responder.respond(ReadTextFileResponse::new(content));
}
Err(e) => {
let _ = responder
.respond_with_internal_error(format!("read failed: {}", e));
}
}
Ok(())
}
},
agent_client_protocol::on_receive_request!(),
)
.on_receive_request(
{
let write_cwd = cwd.clone();
async move |request: WriteTextFileRequest, responder, _cx| {
let path = match resolve_fs_path(&write_cwd, &request.path) {
Ok(p) => p,
Err(e) => {
let _ = responder.respond_with_internal_error(e);
return Ok(());
}
};
if let Some(parent) = path.parent() {
let _ = tokio::fs::create_dir_all(parent).await;
}
match tokio::fs::write(&path, &request.content).await {
Ok(()) => {
let _ = responder.respond(WriteTextFileResponse::new());
}
Err(e) => {
let _ = responder
.respond_with_internal_error(format!("write failed: {}", e));
}
}
Ok(())
}
},
agent_client_protocol::on_receive_request!(),
)
.on_receive_request(
{
let tm = tm.clone();
async move |request: CreateTerminalRequest, responder, _cx| {
tm.handle_create(request, responder).await
}
},
agent_client_protocol::on_receive_request!(),
)
.on_receive_request(
{
let tm = tm.clone();
async move |request: agent_client_protocol::schema::v1::TerminalOutputRequest, responder, _cx| {
tm.handle_output(request, responder).await
}
},
agent_client_protocol::on_receive_request!(),
)
.on_receive_request(
{
let tm = tm.clone();
async move |request: KillTerminalRequest, responder, _cx| {
tm.handle_kill(request, responder).await
}
},
agent_client_protocol::on_receive_request!(),
)
.on_receive_request(
{
let tm = tm.clone();
async move |request: ReleaseTerminalRequest, responder, _cx| {
tm.handle_release(request, responder).await
}
},
agent_client_protocol::on_receive_request!(),
)
.on_receive_request(
{
let tm = tm.clone();
async move |request: WaitForTerminalExitRequest, responder, _cx| {
tm.handle_wait_for_exit(request, responder).await
}
},
agent_client_protocol::on_receive_request!(),
);
let connection_task = tokio::spawn(async move {
builder
.connect_with(transport, move |cx: ConnectionTo<AcpAgentRole>| async move {
let init_resp = cx
.send_request(InitializeRequest::new(ProtocolVersion::V1))
.block_task()
.await?;
let supports_load = init_resp.agent_capabilities.load_session;
let supports_image = init_resp.agent_capabilities.prompt_capabilities.image;
let supports_embedded =
init_resp.agent_capabilities.prompt_capabilities.embedded_context;
let session_resp =
cx.send_request(NewSessionRequest::new(cwd)).block_task().await?;
let config_options = session_resp.config_options.clone().unwrap_or_default();
let session_id = session_resp.session_id;
let _ = conn_tx.send((
cx.clone(),
session_id,
supports_load,
supports_image,
supports_embedded,
config_options,
));
let _ = shutdown_rx.await;
Ok(())
})
.await
});
spawn_crash_watcher(connection_task, crash_tx.clone(), accumulator.clone());
let (
connection,
session_id,
supports_load_session,
supports_image,
supports_embedded_context,
initial_config_options,
) = conn_rx.await.map_err(|_| AcpError::internal_error())?;
Ok(AcpClient {
connection,
session_id,
session_update_tx,
_shutdown_tx: Mutex::new(Some(shutdown_tx)),
crash_tx,
terminal_event_tx,
terminal_manager,
permission_manager,
supports_load_session,
supports_image,
supports_embedded_context,
initial_config_options: Arc::new(Mutex::new(initial_config_options)),
available_commands_notif: commands_notif,
activity,
accumulator,
})
}
pub fn session_update_subscribe(&self) -> broadcast::Receiver<SeqNotification> {
self.session_update_tx.subscribe()
}
pub fn crash_subscribe(&self) -> broadcast::Receiver<String> {
self.crash_tx.subscribe()
}
pub fn terminal_event_subscribe(&self) -> broadcast::Receiver<TerminalActivity> {
self.terminal_event_tx.subscribe()
}
pub fn permission_subscribe(&self) -> broadcast::Receiver<PermissionRequestEvent> {
self.permission_manager.subscribe()
}
pub async fn resolve_permission(&self, id: &str, option_id: &str) -> bool {
self.permission_manager.resolve(id, option_id).await
}
pub async fn set_config_option(&self, config_id: &str, value: &str) -> Result<(), AcpError> {
let config_id: Arc<str> = config_id.into();
let value: Arc<str> = value.into();
let is_boolean = self
.initial_config_options
.lock()
.ok()
.map(|opts| {
opts.iter()
.any(|o| o.id.0 == config_id && matches!(o.kind, SessionConfigKind::Boolean(_)))
})
.unwrap_or(false);
let option_value = if is_boolean {
SessionConfigOptionValue::boolean(value.as_ref() == "true")
} else {
SessionConfigOptionValue::from(value.as_ref())
};
let resp = self
.connection
.send_request(SetSessionConfigOptionRequest::new(
self.session_id.clone(),
SessionConfigId::new(config_id),
option_value,
))
.block_task()
.await?;
if !resp.config_options.is_empty() {
if let Ok(mut guard) = self.initial_config_options.lock() {
*guard = resp.config_options.clone();
}
let notification = SessionNotification::new(
self.session_id.clone(),
SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(resp.config_options)),
);
let _ = self.session_update_tx.send(SeqNotification { seq: None, notification });
}
Ok(())
}
pub fn session_id(&self) -> &SessionId {
&self.session_id
}
pub async fn send_prompt(
&self,
text: &str,
images: Vec<ImageInput>,
resources: Vec<ResourceInput>,
) -> Result<PromptResponse, AcpError> {
let inline_resources = !self.supports_embedded_context && !resources.is_empty();
let text = if inline_resources {
let mut t = text.to_string();
for r in &resources {
t.push_str(&format!("\n\n--- @{} ---\n```\n{}\n```", r.label, r.text));
}
t
} else {
text.to_string()
};
let mut blocks = Vec::new();
if !text.is_empty() || images.is_empty() {
blocks.push(ContentBlock::Text(TextContent::new(text)));
}
for img in images {
blocks.push(ContentBlock::Image(ImageContent::new(img.data, img.mime_type)));
}
if !inline_resources {
for r in resources {
blocks.push(ContentBlock::Resource(EmbeddedResource::new(
EmbeddedResourceResource::TextResourceContents(TextResourceContents::new(
r.text, r.uri,
)),
)));
}
}
self.connection
.send_request(PromptRequest::new(self.session_id.clone(), blocks))
.block_task()
.await
}
pub fn cancel(&self) -> Result<(), AcpError> {
self.connection.send_notification(CancelNotification::new(self.session_id.clone()))?;
let pm = self.permission_manager.clone();
tokio::spawn(async move { pm.cancel_all().await });
let tm = self.terminal_manager.clone();
tokio::spawn(async move { tm.kill_all().await });
Ok(())
}
pub fn supports_load_session(&self) -> bool {
self.supports_load_session
}
pub fn supports_image(&self) -> bool {
self.supports_image
}
pub fn mark_activity(&self) {
if let Ok(mut st) = self.activity.lock() {
st.last_activity = Instant::now();
}
}
pub fn mark_prompt_active(&self) {
if let Ok(mut st) = self.activity.lock() {
st.active_prompt = true;
st.last_activity = Instant::now();
}
self.accumulator.begin_turn();
}
pub fn mark_prompt_idle(&self) {
if let Ok(mut st) = self.activity.lock() {
st.active_prompt = false;
}
self.accumulator.finalize_turn();
}
pub fn attach_persistence(&self, db: sqlx::SqlitePool, db_session_id: String) {
self.accumulator.attach_persistence(db, db_session_id);
}
pub fn finalize_turn(&self) {
self.accumulator.finalize_turn();
}
pub fn turn_snapshot(&self) -> TurnSnapshot {
self.accumulator.turn_snapshot()
}
pub async fn pending_permissions(&self) -> usize {
self.permission_manager.pending_count().await
}
pub async fn pending_permission_events(&self) -> Vec<PermissionRequestEvent> {
self.permission_manager.pending_events().await
}
pub async fn is_idle_stale(&self, idle_secs: u64) -> bool {
let (active_prompt, last_activity) = {
let st = self.activity.lock().unwrap();
(st.active_prompt, st.last_activity)
};
let pending = self.permission_manager.pending_count().await;
!active_prompt && pending == 0 && last_activity.elapsed().as_secs() >= idle_secs
}
pub async fn is_permission_stale(&self, perm_secs: u64) -> bool {
let last_activity = {
let st = self.activity.lock().unwrap();
st.last_activity
};
let pending = self.permission_manager.pending_count().await;
pending > 0 && last_activity.elapsed().as_secs() >= perm_secs
}
pub async fn load_session(&self, acp_session_id: &str, cwd: PathBuf) -> Result<(), AcpError> {
let resp = self
.connection
.send_request(LoadSessionRequest::new(SessionId::new(acp_session_id), cwd))
.block_task()
.await?;
let opts: Option<Vec<SessionConfigOption>> =
resp.config_options.filter(|o| !o.is_empty()).or_else(|| {
self.initial_config_options.lock().ok().map(|g| g.clone()).filter(|g| !g.is_empty())
});
if let Some(opts) = opts {
if let Ok(mut guard) = self.initial_config_options.lock() {
*guard = opts.clone();
}
let notification = SessionNotification::new(
self.session_id.clone(),
SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(opts)),
);
let _ = self.session_update_tx.send(SeqNotification { seq: None, notification });
}
Ok(())
}
pub fn initial_config_notification(&self) -> Option<SessionNotification> {
let opts = self.initial_config_options.lock().ok()?.clone();
if opts.is_empty() {
return None;
}
Some(SessionNotification::new(
self.session_id.clone(),
SessionUpdate::ConfigOptionUpdate(ConfigOptionUpdate::new(opts)),
))
}
pub fn initial_commands_notification(&self) -> Option<SessionNotification> {
self.available_commands_notif.lock().ok()?.clone()
}
pub async fn spawn_and_load(
agent: Agent,
cwd: PathBuf,
acp_session_id: String,
) -> Result<Self, AcpError> {
let mut all_args: Vec<String> = Vec::new();
for env_var in &agent.env {
all_args.push(format!("{}={}", env_var.key, env_var.value));
}
#[cfg(unix)]
let (cmd, args) =
("/bin/sh".to_string(), wrap_agent_with_cwd(&agent.command, &agent.args, &cwd));
#[cfg(not(unix))]
let (cmd, args) = (agent.command.clone(), agent.args.clone());
all_args.push(cmd);
all_args.extend(args);
let transport = AcpAgent::from_args(all_args)?;
let (session_update_tx, _) = broadcast::channel(SESSION_UPDATE_CHANNEL_CAPACITY);
let (crash_tx, _) = broadcast::channel::<String>(16);
let (terminal_event_tx, _) = broadcast::channel::<TerminalActivity>(64);
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
let (conn_tx, conn_rx) = oneshot::channel::<(
ConnectionTo<AcpAgentRole>,
SessionId,
bool,
bool,
bool,
Vec<SessionConfigOption>,
)>();
let notif_tx = session_update_tx.clone();
let terminal_manager = Arc::new(AcpTerminalManager::new(terminal_event_tx.clone()));
let tm = terminal_manager.clone();
let permission_manager = Arc::new(PermissionManager::new());
let pm = permission_manager.clone();
let activity = Arc::new(Mutex::new(ActivityState::new()));
let commands_notif: Arc<Mutex<Option<SessionNotification>>> = Arc::new(Mutex::new(None));
let accumulator = Arc::new(TurnAccumulator::new());
let builder = agent_client_protocol::Client
.builder()
.name("omniterm")
.on_receive_notification(
{
let tx = notif_tx.clone();
let activity = activity.clone();
let commands_notif = commands_notif.clone();
let accumulator = accumulator.clone();
async move |notification: SessionNotification, _cx| {
if let Ok(mut st) = activity.lock() {
st.last_activity = Instant::now();
}
let seq = accumulator.fold(¬ification);
if matches!(
notification.update,
SessionUpdate::AvailableCommandsUpdate(_)
)
&& let Ok(mut guard) = commands_notif.lock() {
*guard = Some(notification.clone());
}
handler::handle_session_update(&tx, SeqNotification { seq, notification })
}
},
agent_client_protocol::on_receive_notification!(),
)
.on_receive_request(
{
let pm = pm.clone();
async move |request: RequestPermissionRequest, responder, _cx| {
pm.handle_request(request, responder).await
}
},
agent_client_protocol::on_receive_request!(),
)
.on_receive_request(
{
let read_cwd = cwd.clone();
async move |request: ReadTextFileRequest, responder, _cx| {
let path = match resolve_fs_path(&read_cwd, &request.path) {
Ok(p) => p,
Err(e) => {
let _ = responder.respond_with_internal_error(e);
return Ok(());
}
};
match tokio::fs::read_to_string(&path).await {
Ok(content) => {
let _ = responder.respond(ReadTextFileResponse::new(content));
}
Err(e) => {
let _ = responder
.respond_with_internal_error(format!("read failed: {}", e));
}
}
Ok(())
}
},
agent_client_protocol::on_receive_request!(),
)
.on_receive_request(
{
let write_cwd = cwd.clone();
async move |request: WriteTextFileRequest, responder, _cx| {
let path = match resolve_fs_path(&write_cwd, &request.path) {
Ok(p) => p,
Err(e) => {
let _ = responder.respond_with_internal_error(e);
return Ok(());
}
};
if let Some(parent) = path.parent() {
let _ = tokio::fs::create_dir_all(parent).await;
}
match tokio::fs::write(&path, &request.content).await {
Ok(()) => {
let _ = responder.respond(WriteTextFileResponse::new());
}
Err(e) => {
let _ = responder
.respond_with_internal_error(format!("write failed: {}", e));
}
}
Ok(())
}
},
agent_client_protocol::on_receive_request!(),
)
.on_receive_request(
{
let tm = tm.clone();
async move |request: CreateTerminalRequest, responder, _cx| {
tm.handle_create(request, responder).await
}
},
agent_client_protocol::on_receive_request!(),
)
.on_receive_request(
{
let tm = tm.clone();
async move |request: agent_client_protocol::schema::v1::TerminalOutputRequest, responder, _cx| {
tm.handle_output(request, responder).await
}
},
agent_client_protocol::on_receive_request!(),
)
.on_receive_request(
{
let tm = tm.clone();
async move |request: KillTerminalRequest, responder, _cx| {
tm.handle_kill(request, responder).await
}
},
agent_client_protocol::on_receive_request!(),
)
.on_receive_request(
{
let tm = tm.clone();
async move |request: ReleaseTerminalRequest, responder, _cx| {
tm.handle_release(request, responder).await
}
},
agent_client_protocol::on_receive_request!(),
)
.on_receive_request(
{
let tm = tm.clone();
async move |request: WaitForTerminalExitRequest, responder, _cx| {
tm.handle_wait_for_exit(request, responder).await
}
},
agent_client_protocol::on_receive_request!(),
);
let connection_task = tokio::spawn(async move {
builder
.connect_with(transport, move |cx: ConnectionTo<AcpAgentRole>| async move {
let init_resp = cx
.send_request(InitializeRequest::new(ProtocolVersion::V1))
.block_task()
.await?;
let supports_load = init_resp.agent_capabilities.load_session;
let supports_image = init_resp.agent_capabilities.prompt_capabilities.image;
let supports_embedded =
init_resp.agent_capabilities.prompt_capabilities.embedded_context;
let session_id = SessionId::new(acp_session_id.as_str());
let _ = conn_tx.send((
cx.clone(),
session_id,
supports_load,
supports_image,
supports_embedded,
Vec::new(),
));
let _ = shutdown_rx.await;
Ok(())
})
.await
});
spawn_crash_watcher(connection_task, crash_tx.clone(), accumulator.clone());
let (
connection,
session_id,
supports_load_session,
supports_image,
supports_embedded_context,
initial_config_options,
) = conn_rx.await.map_err(|_| AcpError::internal_error())?;
Ok(AcpClient {
connection,
session_id,
session_update_tx,
_shutdown_tx: Mutex::new(Some(shutdown_tx)),
crash_tx,
terminal_event_tx,
terminal_manager,
permission_manager,
supports_load_session,
supports_image,
supports_embedded_context,
initial_config_options: Arc::new(Mutex::new(initial_config_options)),
available_commands_notif: commands_notif,
activity,
accumulator,
})
}
pub async fn shutdown(&self) {
self.terminal_manager.kill_all().await;
let _ = self._shutdown_tx.lock().unwrap().take();
}
pub async fn disconnect(self) {
self.terminal_manager.kill_all().await;
if let Ok(mut guard) = self._shutdown_tx.try_lock() {
let _ = guard.take();
}
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::process::Stdio;
#[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"));
assert_eq!(args.len(), 2);
assert_eq!(args[0], "-c");
assert_eq!(args[1], "cd '/home/user/project' && 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);
assert!(args[1].contains("'/home/user/it'\\''s a '\\''project'\\'''"));
}
#[test]
fn wrap_with_no_args_emits_cd_exec_only() {
let args = wrap_agent_with_cwd("/usr/bin/myagent", &[], Path::new("/tmp"));
assert_eq!(args[1], "cd '/tmp' && exec '/usr/bin/myagent'");
}
#[tokio::test]
async fn wrapped_subprocess_has_session_workspace_as_cwd() {
let workspace = std::env::temp_dir().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");
let workspace_str = workspace.to_string_lossy().to_string();
let wrapped = wrap_agent_with_cwd("pwd", &[], &workspace);
let output = std::process::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_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);
let output = std::process::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);
let output =
std::process::Command::new("/bin/sh").args(&wrapped).output().expect("spawn sh");
assert!(output.status.success(), "wrapped exit != 0");
let _ = std::fs::remove_dir_all(&workspace);
}
}