Skip to main content

defect_agent/
shell.rs

1//! Shell execution backend abstraction.
2//!
3//! [`ShellBackend`] is the trait boundary between the `bash` tool and the underlying
4//! process management. Two implementations:
5//! - `defect_tools::shell::LocalShellBackend`: spawns child processes directly
6//! - `defect_acp::shell::AcpShellBackend`: delegates to the client via ACP `terminal/*`
7//!   reverse requests
8//!
9//! Assembly is handled in the `defect-acp` `session/new` handler — it selects the backend
10//! based on the client's [`ClientCapabilities::terminal`] negotiation result and injects
11//! it into [`crate::session::AgentCore::create_session`].
12//!
13//! [`ClientCapabilities::terminal`]: agent_client_protocol_schema::ClientCapabilities
14
15use std::path::PathBuf;
16
17use futures::future::BoxFuture;
18use thiserror::Error;
19
20use crate::error::BoxError;
21
22/// A terminal handle. Internally, in the backend, it maps to a PID + monotonic counter
23/// (local) or an ACP schema's `TerminalId` (acp).
24///
25/// A newtype rather than a bare `String`: callers see a "terminal handle" at trait
26/// boundaries, not a plain string.
27#[derive(Debug, Clone, PartialEq, Eq, Hash)]
28pub struct TerminalId(String);
29
30impl TerminalId {
31    pub fn new(id: impl Into<String>) -> Self {
32        Self(id.into())
33    }
34
35    pub fn as_str(&self) -> &str {
36        &self.0
37    }
38}
39
40impl From<TerminalId> for String {
41    fn from(value: TerminalId) -> Self {
42        value.0
43    }
44}
45
46/// A snapshot result of [`ShellBackend::output`].
47#[derive(Debug, Clone)]
48pub struct ShellOutput {
49    /// Accumulated combined stdout/stderr text up to this call. The backend guarantees
50    /// valid UTF-8.
51    pub text: String,
52    /// Whether the output was truncated by the backend due to a byte limit.
53    pub truncated: bool,
54    /// Set to the actual exit status when the process has exited, or `None` if it is
55    /// still running.
56    pub exit_status: Option<TerminalExitStatus>,
57}
58
59/// Exit status of a terminal process.
60#[derive(Debug, Clone)]
61pub struct TerminalExitStatus {
62    /// Exit code of the process. `None` if killed by a signal; see `signal`.
63    ///
64    /// Internally uses `i32` to match `BashOutput.exit_code`. When `AcpShellBackend`
65    /// receives
66    /// `Option<u32>` from the schema, it uses `i32::try_from`; values exceeding
67    /// `i32::MAX` degrade to
68    /// `-1` (the actual exit code range is 0..=255, so this never overflows).
69    pub exit_code: Option<i32>,
70    /// Signal name (e.g. `SIGKILL`). The local backend obtains it from
71    /// `signal_name(sig)`; the ACP backend passes through the schema's `signal:
72    /// Option<String>`.
73    pub signal: Option<String>,
74}
75
76/// Shell backend trait.
77///
78/// Current semantics: each command gets an independent terminal — `create` → run →
79/// `wait_for_exit` for the exit status → `output` for the full output → `release` to free
80/// resources. Persistent terminals reused across turns are not exposed; interactive
81/// terminal tooling is left for future evolution.
82///
83/// Parameters use owned `String` / `PathBuf` to confine the future's lifetime to `&'_
84/// self`, avoiding explicit lifetime parameters — the same trade-off as
85/// [`crate::fs::FsBackend`].
86pub trait ShellBackend: Send + Sync {
87    /// Creates a terminal and starts the command.
88    ///
89    /// `command` is a full shell command line (currently run via `sh -c` on the backend).
90    /// `cwd` must be an absolute path already validated to be inside the workspace — the
91    /// agent tool layer enforces this boundary; the backend does not perform business
92    /// validation.
93    fn create(
94        &self,
95        command: String,
96        cwd: PathBuf,
97    ) -> BoxFuture<'_, Result<TerminalId, ShellError>>;
98
99    /// Take a snapshot of the terminal's current accumulated output.
100    ///
101    /// **Idempotent and safe to call repeatedly** — the backend does not drain the buffer
102    /// here. `exit_status = Some(_)` indicates the process has exited, but `output`
103    /// itself does not block waiting for exit (use [`ShellBackend::wait_for_exit`] for
104    /// blocking).
105    fn output(&self, id: &TerminalId) -> BoxFuture<'_, Result<ShellOutput, ShellError>>;
106
107    /// Blocks until the terminal process exits.
108    fn wait_for_exit(
109        &self,
110        id: &TerminalId,
111    ) -> BoxFuture<'_, Result<TerminalExitStatus, ShellError>>;
112
113    /// Release terminal resources (close file descriptors / remove internal bookkeeping).
114    ///
115    /// Idempotent: releasing the same `id` multiple times does not return an error
116    /// (silently succeeds if already released).
117    fn release(&self, id: &TerminalId) -> BoxFuture<'_, Result<(), ShellError>>;
118
119    /// Forcefully kill the terminal process. Does **not** release resources — subsequent
120    /// calls to [`ShellBackend::output`] / [`ShellBackend::wait_for_exit`] are still
121    /// valid; releasing is handled by [`ShellBackend::release`].
122    fn kill(&self, id: &TerminalId) -> BoxFuture<'_, Result<(), ShellError>>;
123}
124
125/// Errors from the shell backend.
126#[non_exhaustive]
127#[derive(Debug, Error)]
128pub enum ShellError {
129    /// The terminal ID refers to a non-existent or already-released terminal.
130    #[error("terminal not found: {0:?}")]
131    NotFound(TerminalId),
132
133    /// Backend failed to spawn a child process or communicate with the client.
134    #[error("shell backend failure: {0}")]
135    Backend(#[source] BoxError),
136
137    /// Operation not permitted: cwd out of bounds, client denied, insufficient
138    /// permissions, etc.
139    #[error("operation not permitted: {0}")]
140    NotPermitted(String),
141}
142
143/// A no-op shell backend for testing only. All methods return
144/// [`ShellError::NotPermitted`],
145/// allowing test scenarios that require an `Arc<dyn ShellBackend>` (without actually
146/// running
147/// a shell tool) to skip setup.
148///
149/// For real use, use `defect_tools::shell::LocalShellBackend` or
150/// `defect_acp::shell::AcpShellBackend`.
151pub struct NoopShellBackend;
152
153impl ShellBackend for NoopShellBackend {
154    fn create(
155        &self,
156        _command: String,
157        _cwd: PathBuf,
158    ) -> BoxFuture<'_, Result<TerminalId, ShellError>> {
159        Box::pin(async {
160            Err(ShellError::NotPermitted(
161                "NoopShellBackend cannot spawn".to_string(),
162            ))
163        })
164    }
165
166    fn output(&self, id: &TerminalId) -> BoxFuture<'_, Result<ShellOutput, ShellError>> {
167        let id = id.clone();
168        Box::pin(async move { Err(ShellError::NotFound(id)) })
169    }
170
171    fn wait_for_exit(
172        &self,
173        id: &TerminalId,
174    ) -> BoxFuture<'_, Result<TerminalExitStatus, ShellError>> {
175        let id = id.clone();
176        Box::pin(async move { Err(ShellError::NotFound(id)) })
177    }
178
179    fn release(&self, _id: &TerminalId) -> BoxFuture<'_, Result<(), ShellError>> {
180        // Release is idempotent — the no-op backend never holds resources, so it always
181        // succeeds.
182        Box::pin(async { Ok(()) })
183    }
184
185    fn kill(&self, id: &TerminalId) -> BoxFuture<'_, Result<(), ShellError>> {
186        let id = id.clone();
187        Box::pin(async move { Err(ShellError::NotFound(id)) })
188    }
189}