use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use agent_client_protocol as acp;
use schemars::JsonSchema;
use serde::Deserialize;
use tokio::sync::{mpsc, oneshot};
use tokio_util::sync::CancellationToken;
use zeph_tools::{
ToolCall, ToolError, ToolOutput,
executor::deserialize_params,
registry::{InvocationHint, ToolDef},
};
use crate::{error::AcpError, permission::AcpPermissionGate};
const KILL_GRACE_TIMEOUT: Duration = Duration::from_secs(5);
const MAX_STDIN_BYTES: usize = 65_536;
const STDIN_CHANNEL_CAPACITY: usize = 16;
const TERMINAL_CHANNEL_CAPACITY: usize = 64;
const STDIN_RATE_INTERVAL: Duration = Duration::from_millis(10);
const SHELL_INTERPRETERS: &[&str] = &["bash", "sh", "zsh", "fish", "dash"];
const TRANSPARENT_PREFIXES: &[&str] = &["env", "command", "exec", "nice", "nohup", "time"];
fn extract_command_binary(command: &str) -> &str {
let mut tokens = command.split_whitespace().peekable();
loop {
match tokens.peek() {
None => return "bash",
Some(tok) => {
if tok.contains('=') {
tokens.next();
continue;
}
let base = tok.rsplit('/').next().unwrap_or(tok);
if TRANSPARENT_PREFIXES.contains(&base) {
tokens.next();
continue;
}
let binary = tok.rsplit('/').next().unwrap_or(tok);
return binary;
}
}
}
}
struct ShellResult {
output: String,
exit_code: Option<u32>,
terminal_id: String,
}
struct TerminalRequest {
session_id: acp::schema::v1::SessionId,
command: String,
args: Vec<String>,
cwd: Option<PathBuf>,
timeout: Duration,
reply: oneshot::Sender<Result<ShellResult, AcpError>>,
stream_tx: Option<(mpsc::Sender<acp::schema::v1::SessionNotification>, String)>,
}
struct TerminalReleaseRequest {
session_id: acp::schema::v1::SessionId,
terminal_id: String,
}
struct StdinWriteRequest {
session_id: acp::schema::v1::SessionId,
terminal_id: acp::schema::v1::TerminalId,
data: Vec<u8>,
reply: oneshot::Sender<Result<(), AcpError>>,
}
enum TerminalMessage {
Execute(TerminalRequest),
Release(TerminalReleaseRequest),
WriteStdin(StdinWriteRequest),
}
#[derive(Clone)]
pub struct AcpShellExecutor {
session_id: acp::schema::v1::SessionId,
request_tx: mpsc::Sender<TerminalMessage>,
permission_gate: Option<AcpPermissionGate>,
timeout: Duration,
}
impl AcpShellExecutor {
pub fn new(
conn: Arc<acp::ConnectionTo<acp::Client>>,
session_id: acp::schema::v1::SessionId,
permission_gate: Option<AcpPermissionGate>,
timeout_secs: u64,
) -> (Self, impl std::future::Future<Output = ()>) {
Self::with_timeout(
conn,
session_id,
permission_gate,
Duration::from_secs(timeout_secs),
)
}
pub fn with_timeout(
conn: Arc<acp::ConnectionTo<acp::Client>>,
session_id: acp::schema::v1::SessionId,
permission_gate: Option<AcpPermissionGate>,
timeout: Duration,
) -> (Self, impl std::future::Future<Output = ()>) {
let (tx, rx) = mpsc::channel::<TerminalMessage>(TERMINAL_CHANNEL_CAPACITY);
let handler = async move { run_terminal_handler(conn, rx).await };
(
Self {
session_id,
request_tx: tx,
permission_gate,
timeout,
},
handler,
)
}
pub fn release_terminal(&self, terminal_id: String) {
if let Err(e) = self
.request_tx
.try_send(TerminalMessage::Release(TerminalReleaseRequest {
session_id: self.session_id.clone(),
terminal_id,
}))
{
tracing::warn!(error = %e, "terminal release dropped: handler channel full or closed");
}
}
async fn handle_bash_stdin(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
let gate = self
.permission_gate
.as_ref()
.ok_or_else(|| ToolError::Blocked {
command: "bash_stdin: permission gate required".into(),
})?;
let params: BashStdinParams = deserialize_params(&call.params)?;
if params.data.len() > MAX_STDIN_BYTES {
return Err(ToolError::InvalidParams {
message: AcpError::StdinTooLarge {
size: params.data.len(),
}
.to_string(),
});
}
let data = params.data.as_bytes().to_vec();
let is_shell = SHELL_INTERPRETERS
.iter()
.any(|s| params.terminal_id.contains(s));
let title = if is_shell {
"bash_stdin [WARNING: stdin to shell interpreter — data will be executed as commands]"
.to_string()
} else {
"bash_stdin".to_owned()
};
let fields = acp::schema::v1::ToolCallUpdateFields::new()
.title(title)
.raw_input(serde_json::json!({
"terminal_id": params.terminal_id,
"data_length": params.data.len(),
}));
let tool_call = acp::schema::v1::ToolCallUpdate::new("bash_stdin".to_owned(), fields);
let allowed = gate
.check_permission(self.session_id.clone(), tool_call)
.await
.map_err(|e| ToolError::InvalidParams {
message: e.to_string(),
})?;
if !allowed {
return Err(ToolError::Blocked {
command: "bash_stdin: permission denied".into(),
});
}
let terminal_id: acp::schema::v1::TerminalId = params.terminal_id.clone().into();
let (reply_tx, reply_rx) = oneshot::channel();
self.request_tx
.send(TerminalMessage::WriteStdin(StdinWriteRequest {
session_id: self.session_id.clone(),
terminal_id,
data,
reply: reply_tx,
}))
.await
.map_err(|_| ToolError::InvalidParams {
message: "terminal handler closed".into(),
})?;
reply_rx
.await
.map_err(|_| ToolError::InvalidParams {
message: "terminal handler closed".into(),
})?
.map_err(|e| ToolError::InvalidParams {
message: e.to_string(),
})?;
Ok(Some(ToolOutput {
tool_name: zeph_tools::ToolName::new("bash_stdin"),
summary: format!(
"wrote {} bytes to stdin of {}",
params.data.len(),
params.terminal_id
),
blocks_executed: 1,
filter_stats: None,
diff: None,
streamed: false,
terminal_id: Some(params.terminal_id),
locations: None,
raw_response: None,
claim_source: Some(zeph_tools::ClaimSource::Shell),
..Default::default()
}))
}
async fn execute_shell(
&self,
command: String,
args: Vec<String>,
cwd: Option<PathBuf>,
stream_tx: Option<(mpsc::Sender<acp::schema::v1::SessionNotification>, String)>,
) -> Result<ShellResult, AcpError> {
let (reply_tx, reply_rx) = oneshot::channel();
self.request_tx
.send(TerminalMessage::Execute(TerminalRequest {
session_id: self.session_id.clone(),
command,
args,
cwd,
timeout: self.timeout,
reply: reply_tx,
stream_tx,
}))
.await
.map_err(|_| AcpError::ChannelClosed)?;
reply_rx.await.map_err(|_| AcpError::ChannelClosed)?
}
}
#[derive(Deserialize, JsonSchema)]
struct BashParams {
command: String,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
cwd: Option<String>,
}
#[derive(Deserialize, JsonSchema)]
struct BashStdinParams {
terminal_id: String,
data: String,
}
impl zeph_tools::ToolExecutor for AcpShellExecutor {
async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
Ok(None)
}
fn tool_definitions(&self) -> Vec<ToolDef> {
let mut defs = vec![ToolDef {
id: "bash".into(),
description: "Execute a shell command in the IDE terminal.\n\nParameters: command (string, required) - shell command to run\nReturns: stdout/stderr combined with exit code\nErrors: Timeout; permission denied by IDE; command blocked by policy\nExample: {\"command\": \"cargo build\"}".into(),
schema: schemars::schema_for!(BashParams),
invocation: InvocationHint::ToolCall,
output_schema: None,
server_id: None,
}];
if self.permission_gate.is_some() {
defs.push(ToolDef {
id: "bash_stdin".into(),
description: "Write data to stdin of a running terminal process.\n\nParameters: terminal_id (string, required) - terminal to write to; data (string, required) - stdin data\nReturns: confirmation\nErrors: terminal not found; terminal process exited\nExample: {\"terminal_id\": \"term-1\", \"data\": \"yes\\n\"}".into(),
schema: schemars::schema_for!(BashStdinParams),
invocation: InvocationHint::ToolCall,
output_schema: None,
server_id: None,
});
}
defs
}
async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
if call.tool_id == "bash_stdin" {
return self.handle_bash_stdin(call).await;
}
if call.tool_id != "bash" {
return Ok(None);
}
let params: BashParams = deserialize_params(&call.params)?;
let cwd = params.cwd.map(PathBuf::from);
let blocklist: Vec<String> = zeph_tools::DEFAULT_BLOCKED_COMMANDS
.iter()
.map(|s| (*s).to_owned())
.collect();
if let Some(pattern) = zeph_tools::check_blocklist(¶ms.command, &blocklist) {
return Err(ToolError::Blocked { command: pattern });
}
if let Some(script) = zeph_tools::effective_shell_command(¶ms.command, ¶ms.args)
&& let Some(pattern) = zeph_tools::check_blocklist(script, &blocklist)
{
return Err(ToolError::Blocked { command: pattern });
}
if self.permission_gate.is_none() {
tracing::warn!(
"AcpShellExecutor has no permission gate — only blocklist applies. \
Do not use in production without a permission gate."
);
}
if let Some(gate) = &self.permission_gate {
let cmd_binary = extract_command_binary(¶ms.command);
let fields = acp::schema::v1::ToolCallUpdateFields::new()
.title(cmd_binary.to_owned())
.raw_input(serde_json::json!({ "command": params.command }));
let tool_call = acp::schema::v1::ToolCallUpdate::new(cmd_binary.to_owned(), fields);
let allowed = gate
.check_permission(self.session_id.clone(), tool_call)
.await
.map_err(|e| ToolError::InvalidParams {
message: e.to_string(),
})?;
if !allowed {
return Err(ToolError::Blocked {
command: params.command,
});
}
}
let result = self
.execute_shell(params.command, params.args, cwd, None)
.await
.map_err(|e| ToolError::InvalidParams {
message: e.to_string(),
})?;
let is_error = !matches!(result.exit_code, Some(0) | None);
let summary = if is_error {
format!(
"[exit {}]\n{}",
result.exit_code.unwrap_or(1),
result.output
)
} else {
result.output.clone()
};
let raw_response = Some(serde_json::json!({
"stdout": result.output,
"stderr": "",
"interrupted": false,
"isImage": false,
"noOutputExpected": false
}));
Ok(Some(ToolOutput {
tool_name: zeph_tools::ToolName::new("bash"),
summary,
blocks_executed: 1,
filter_stats: None,
diff: None,
streamed: false,
terminal_id: Some(result.terminal_id),
locations: None,
raw_response,
claim_source: Some(zeph_tools::ClaimSource::Shell),
..Default::default()
}))
}
zeph_tools::tool_executor_no_inner_defaults!();
}
async fn forward_stdin_via_ext(
conn: &Arc<acp::ConnectionTo<acp::Client>>,
session_id: &acp::schema::v1::SessionId,
terminal_id: &acp::schema::v1::TerminalId,
data: Vec<u8>,
) -> Result<(), AcpError> {
use base64::Engine as _;
let encoded = base64::engine::general_purpose::STANDARD.encode(&data);
let params_json = serde_json::json!({
"session_id": session_id.to_string(),
"terminal_id": terminal_id.to_string(),
"data": encoded,
});
let req = acp::UntypedMessage::new("terminal/write_stdin", params_json)
.map_err(|e| AcpError::ClientError(e.to_string()))?;
conn.send_request(req)
.block_task()
.await
.map(|_| ())
.map_err(|e| AcpError::ClientError(e.to_string()))
}
async fn run_stdin_pump(
conn: Arc<acp::ConnectionTo<acp::Client>>,
session_id: acp::schema::v1::SessionId,
terminal_id: acp::schema::v1::TerminalId,
mut data_rx: mpsc::Receiver<Vec<u8>>,
cancel: CancellationToken,
) {
let mut interval = tokio::time::interval(STDIN_RATE_INTERVAL);
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
let data = tokio::select! {
() = cancel.cancelled() => break,
msg = data_rx.recv() => match msg {
Some(d) => d,
None => break,
},
};
tokio::select! {
() = cancel.cancelled() => break,
_ = interval.tick() => {}
}
if let Err(e) = forward_stdin_via_ext(&conn, &session_id, &terminal_id, data).await {
tracing::warn!(%terminal_id, error = %e, "stdin pump error — cancelling");
cancel.cancel();
break;
}
}
}
async fn run_terminal_handler(
conn: Arc<acp::ConnectionTo<acp::Client>>,
mut rx: mpsc::Receiver<TerminalMessage>,
) {
let mut stdin_pumps: std::collections::HashMap<
String,
(mpsc::Sender<Vec<u8>>, CancellationToken),
> = std::collections::HashMap::new();
while let Some(msg) = rx.recv().await {
match msg {
TerminalMessage::Execute(req) => {
let result = execute_in_terminal(
&conn,
req.session_id,
req.command,
req.args,
req.cwd,
req.timeout,
req.stream_tx,
)
.await;
if let Ok(ref shell_result) = result
&& let Some((_, token)) = stdin_pumps.remove(&shell_result.terminal_id)
{
token.cancel();
}
req.reply.send(result).ok();
}
TerminalMessage::Release(req) => {
if let Some((_, token)) = stdin_pumps.remove(&req.terminal_id) {
token.cancel();
}
let tid = req.terminal_id.clone();
let release_req =
acp::schema::v1::ReleaseTerminalRequest::new(req.session_id, req.terminal_id);
if let Err(e) = conn.send_request(release_req).block_task().await {
tracing::warn!(
terminal_id = %tid,
error = %e,
"failed to release terminal"
);
}
}
TerminalMessage::WriteStdin(req) => {
let tid_str = req.terminal_id.to_string();
let (data_tx, cancel) = stdin_pumps.entry(tid_str).or_insert_with(|| {
let (tx, rx) = mpsc::channel::<Vec<u8>>(STDIN_CHANNEL_CAPACITY);
let token = CancellationToken::new();
tokio::spawn(run_stdin_pump(
conn.clone(),
req.session_id.clone(),
req.terminal_id.clone(),
rx,
token.clone(),
));
(tx, token)
});
let result = if cancel.is_cancelled() {
Err(AcpError::BrokenPipe)
} else {
data_tx.try_send(req.data).map_err(|_| AcpError::BrokenPipe)
};
req.reply.send(result).ok();
}
}
}
}
const STREAM_POLL_INTERVAL: Duration = Duration::from_millis(200);
async fn kill_terminal(
conn: &Arc<acp::ConnectionTo<acp::Client>>,
session_id: &acp::schema::v1::SessionId,
terminal_id: &acp::schema::v1::TerminalId,
) -> Result<(), AcpError> {
tracing::warn!(%terminal_id, "terminal command timed out — sending kill");
let kill_req =
acp::schema::v1::KillTerminalRequest::new(session_id.clone(), terminal_id.clone());
conn.send_request(kill_req)
.block_task()
.await
.map_err(|e| AcpError::ClientError(e.to_string()))?;
let wait_again =
acp::schema::v1::WaitForTerminalExitRequest::new(session_id.clone(), terminal_id.clone());
let _ = tokio::time::timeout(
KILL_GRACE_TIMEOUT,
conn.send_request(wait_again).block_task(),
)
.await;
Ok(())
}
async fn stream_until_exit(
conn: &Arc<acp::ConnectionTo<acp::Client>>,
session_id: &acp::schema::v1::SessionId,
terminal_id: &acp::schema::v1::TerminalId,
timeout: Duration,
notify_tx: &mpsc::Sender<acp::schema::v1::SessionNotification>,
tool_call_id: &str,
) -> Result<Option<u32>, AcpError> {
let wait_req =
acp::schema::v1::WaitForTerminalExitRequest::new(session_id.clone(), terminal_id.clone());
let exit_future = conn.send_request(wait_req).block_task();
tokio::pin!(exit_future);
let deadline = tokio::time::Instant::now() + timeout;
let mut last_output_len = 0usize;
loop {
tokio::select! {
result = &mut exit_future => {
return match result {
Ok(resp) => Ok(resp.exit_status.exit_code),
Err(e) => Err(AcpError::ClientError(e.to_string())),
};
}
() = tokio::time::sleep(STREAM_POLL_INTERVAL) => {
if tokio::time::Instant::now() >= deadline {
kill_terminal(conn, session_id, terminal_id).await?;
return Ok(Some(124u32));
}
let output_req =
acp::schema::v1::TerminalOutputRequest::new(session_id.clone(), terminal_id.clone());
if let Ok(resp) = conn.send_request(output_req).block_task().await {
let new_data = resp.output.get(last_output_len..).unwrap_or("");
if !new_data.is_empty() {
last_output_len = resp.output.len();
let mut meta = serde_json::Map::new();
meta.insert(
"terminal_output".to_owned(),
serde_json::json!({
"terminal_id": terminal_id.to_string(),
"data": new_data,
}),
);
let update = acp::schema::v1::ToolCallUpdate::new(
tool_call_id.to_owned(),
acp::schema::v1::ToolCallUpdateFields::new(),
)
.meta(meta);
let notif = acp::schema::v1::SessionNotification::new(
session_id.clone(),
acp::schema::v1::SessionUpdate::ToolCallUpdate(update),
);
let _ = notify_tx.try_send(notif);
}
}
}
}
}
}
async fn execute_in_terminal(
conn: &Arc<acp::ConnectionTo<acp::Client>>,
session_id: acp::schema::v1::SessionId,
command: String,
args: Vec<String>,
cwd: Option<PathBuf>,
timeout: Duration,
stream_tx: Option<(mpsc::Sender<acp::schema::v1::SessionNotification>, String)>,
) -> Result<ShellResult, AcpError> {
let create_req = acp::schema::v1::CreateTerminalRequest::new(session_id.clone(), command)
.args(args)
.cwd(cwd);
let create_resp = conn
.send_request(create_req)
.block_task()
.await
.map_err(|e| AcpError::ClientError(e.to_string()))?;
let terminal_id = create_resp.terminal_id;
let exit_code = if let Some((ref notify_tx, ref tool_call_id)) = stream_tx {
stream_until_exit(
conn,
&session_id,
&terminal_id,
timeout,
notify_tx,
tool_call_id,
)
.await?
} else {
let wait_req = acp::schema::v1::WaitForTerminalExitRequest::new(
session_id.clone(),
terminal_id.clone(),
);
match tokio::time::timeout(timeout, conn.send_request(wait_req).block_task()).await {
Ok(Ok(resp)) => resp.exit_status.exit_code,
Ok(Err(e)) => return Err(AcpError::ClientError(e.to_string())),
Err(_) => {
kill_terminal(conn, &session_id, &terminal_id).await?;
Some(124u32)
}
}
};
let output_req =
acp::schema::v1::TerminalOutputRequest::new(session_id.clone(), terminal_id.clone());
let output_resp = conn
.send_request(output_req)
.block_task()
.await
.map_err(|e| AcpError::ClientError(e.to_string()))?;
if let Some((ref notify_tx, ref tool_call_id)) = stream_tx {
let mut meta = serde_json::Map::new();
meta.insert(
"terminal_exit".to_owned(),
serde_json::json!({ "terminal_id": terminal_id.to_string(), "exit_code": exit_code }),
);
let update = acp::schema::v1::ToolCallUpdate::new(
tool_call_id.clone(),
acp::schema::v1::ToolCallUpdateFields::new(),
)
.meta(meta);
let notif = acp::schema::v1::SessionNotification::new(
session_id.clone(),
acp::schema::v1::SessionUpdate::ToolCallUpdate(update),
);
let _ = notify_tx.try_send(notif);
}
Ok(ShellResult {
output: output_resp.output,
exit_code,
terminal_id: terminal_id.to_string(),
})
}