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}