1use std::{
9 ffi::OsString,
10 io,
11 path::{Path, PathBuf},
12 process::{Output, Stdio},
13 time::Duration,
14};
15
16use serde::Serialize;
17use soma_provider_core::{EnvRequirement, ProviderCall, ProviderError, ProviderSurface};
18use tokio::{
19 io::{AsyncRead, AsyncReadExt, AsyncWriteExt},
20 process::Command,
21 time::timeout,
22};
23
24use crate::error::SidecarError;
25
26#[derive(Debug, Serialize)]
32pub struct ExecutionEnvelope<'a> {
33 pub schema_version: u32,
34 pub provider: &'a str,
35 pub action: &'a str,
36 pub params: &'a serde_json::Value,
37 pub surface: ProviderSurface,
38 pub snapshot_id: &'a str,
39}
40
41impl<'a> ExecutionEnvelope<'a> {
42 pub fn new(call: &'a ProviderCall) -> Self {
43 Self {
44 schema_version: 1,
45 provider: &call.provider,
46 action: &call.action,
47 params: &call.params,
48 surface: call.surface,
49 snapshot_id: &call.snapshot_id,
50 }
51 }
52}
53
54pub fn execution_payload(call: &ProviderCall) -> Result<Vec<u8>, serde_json::Error> {
56 serde_json::to_vec(&ExecutionEnvelope::new(call))
57}
58
59pub struct BoundedOutput {
60 pub output: Output,
61 pub stdout_exceeded: bool,
62 pub stderr_exceeded: bool,
63}
64
65pub async fn run_bounded_sidecar(
66 command: &str,
67 args: &[&str],
68 env: Vec<(String, String)>,
69 input: &[u8],
70 timeout_ms: u64,
71 max_output_bytes: usize,
72) -> Result<BoundedOutput, SidecarError> {
73 let resolved_command = resolve_sidecar_command(command);
74 let mut command = Command::new(resolved_command);
75 command
76 .args(args)
77 .kill_on_drop(true)
78 .env_clear()
79 .stdin(Stdio::piped())
80 .stdout(Stdio::piped())
81 .stderr(Stdio::piped());
82 apply_sidecar_base_env(&mut command);
83 command.envs(env);
84
85 let mut child = command.spawn().map_err(SidecarError::Io)?;
86
87 let stdout = child
88 .stdout
89 .take()
90 .ok_or_else(|| io::Error::other("sidecar stdout pipe was not captured"))
91 .map_err(SidecarError::Io)?;
92 let stderr = child
93 .stderr
94 .take()
95 .ok_or_else(|| io::Error::other("sidecar stderr pipe was not captured"))
96 .map_err(SidecarError::Io)?;
97 let stdout_task = tokio::spawn(read_bounded(stdout, max_output_bytes));
98 let stderr_task = tokio::spawn(read_bounded(stderr, max_output_bytes));
99
100 if let Some(mut stdin) = child.stdin.take() {
101 stdin.write_all(input).await.map_err(SidecarError::Io)?;
102 }
103
104 let status = match timeout(Duration::from_millis(timeout_ms), child.wait()).await {
105 Ok(status) => status.map_err(SidecarError::Io)?,
106 Err(_) => {
107 let _ = child.kill().await;
108 let _ = child.wait().await;
109 stdout_task.abort();
110 stderr_task.abort();
111 return Err(SidecarError::Timeout);
112 }
113 };
114
115 let (stdout, stdout_exceeded) = stdout_task
116 .await
117 .map_err(SidecarError::Join)?
118 .map_err(SidecarError::Io)?;
119 let (stderr, stderr_exceeded) = stderr_task
120 .await
121 .map_err(SidecarError::Join)?
122 .map_err(SidecarError::Io)?;
123
124 Ok(BoundedOutput {
125 output: Output {
126 status,
127 stdout,
128 stderr,
129 },
130 stdout_exceeded,
131 stderr_exceeded,
132 })
133}
134
135fn apply_sidecar_base_env(command: &mut Command) {
136 for (key, value) in sidecar_base_env() {
137 command.env(key, value);
138 }
139}
140
141#[cfg(windows)]
142pub fn sidecar_base_env() -> Vec<(OsString, OsString)> {
143 let mut env = Vec::new();
144 for key in ["SystemRoot", "WINDIR", "COMSPEC", "PATHEXT", "TEMP", "TMP"] {
145 if let Some(value) = std::env::var_os(key) {
146 env.push((OsString::from(key), value));
147 }
148 }
149 env
150}
151
152#[cfg(not(windows))]
153pub fn sidecar_base_env() -> Vec<(OsString, OsString)> {
154 let mut env = Vec::new();
155 for key in ["HOME", "TMPDIR", "TEMP", "TMP"] {
156 if let Some(value) = std::env::var_os(key) {
157 env.push((OsString::from(key), value));
158 }
159 }
160 env
161}
162
163pub fn resolve_sidecar_command(command: &str) -> PathBuf {
164 resolve_sidecar_command_with_env(
165 command,
166 std::env::var_os("PATH"),
167 std::env::var_os("PATHEXT"),
168 )
169}
170
171fn resolve_sidecar_command_with_env(
172 command: &str,
173 path_env: Option<OsString>,
174 pathext_env: Option<OsString>,
175) -> PathBuf {
176 let command_path = Path::new(command);
177 if command_path.components().count() > 1 || command_path.is_absolute() {
178 return command_path.to_path_buf();
179 }
180
181 let Some(path_env) = path_env else {
182 return command_path.to_path_buf();
183 };
184 for dir in std::env::split_paths(&path_env) {
185 if command_path.extension().is_some() {
186 let candidate = dir.join(command_path);
187 if candidate.is_file() {
188 return resolve_runtime_shim(command, candidate);
189 }
190 continue;
191 }
192 let direct_candidate = dir.join(command_path);
193 if direct_candidate.is_file() {
194 return resolve_runtime_shim(command, direct_candidate);
195 }
196 #[cfg(windows)]
197 for extension in windows_path_extensions(pathext_env.as_ref()) {
198 let candidate = dir.join(format!("{command}{extension}"));
199 if candidate.is_file() {
200 return resolve_runtime_shim(command, candidate);
201 }
202 }
203 }
204 #[cfg(not(windows))]
205 let _ = pathext_env;
206 command_path.to_path_buf()
207}
208
209fn resolve_runtime_shim(command: &str, candidate: PathBuf) -> PathBuf {
210 resolve_mise_shim(command, &candidate).unwrap_or(candidate)
211}
212
213fn resolve_mise_shim(command: &str, candidate: &Path) -> Option<PathBuf> {
214 let canonical = candidate.canonicalize().ok()?;
215 if canonical.file_stem()?.to_string_lossy() != "mise" {
216 return None;
217 }
218 let output = std::process::Command::new(&canonical)
219 .args(["which", command])
220 .output()
221 .ok()?;
222 if !output.status.success() {
223 return None;
224 }
225 let resolved = PathBuf::from(String::from_utf8(output.stdout).ok()?.trim());
226 resolved.is_file().then_some(resolved)
227}
228
229#[cfg(windows)]
230fn windows_path_extensions(pathext_env: Option<&OsString>) -> Vec<String> {
231 pathext_env
232 .and_then(|value| value.to_str().map(ToOwned::to_owned))
233 .unwrap_or_else(|| ".COM;.EXE;.BAT;.CMD".to_owned())
234 .split(';')
235 .filter(|extension| !extension.is_empty())
236 .map(|extension| {
237 if extension.starts_with('.') {
238 extension.to_owned()
239 } else {
240 format!(".{extension}")
241 }
242 })
243 .collect()
244}
245
246pub fn output_exceeded_message(stream: &str, max_output_bytes: usize) -> String {
247 format!("sidecar {stream} output exceeds {max_output_bytes} bytes")
248}
249
250pub fn collect_provider_env(
255 provider_requirements: &[EnvRequirement],
256 tool_requirements: &[EnvRequirement],
257 prefix: &str,
258 provider: &str,
259 action: &str,
260) -> Result<Vec<(String, String)>, ProviderError> {
261 let mut env = Vec::new();
262 for requirement in provider_requirements.iter().chain(tool_requirements) {
263 let name = requirement.runtime_name(prefix);
264 let value = std::env::var(&name)
265 .ok()
266 .or_else(|| {
267 requirement
268 .allow_unprefixed
269 .then(|| std::env::var(&requirement.name).ok())
270 .flatten()
271 })
272 .or_else(|| {
273 requirement
274 .default
275 .as_ref()
276 .and_then(serde_json::Value::as_str)
277 .map(ToOwned::to_owned)
278 });
279 match value {
280 Some(value) => env.push((name, value)),
281 None if requirement.required => {
282 return Err(ProviderError::validation(
283 provider,
284 action,
285 "missing_provider_env",
286 format!("missing required provider env `{name}`"),
287 ));
288 }
289 None => {}
290 }
291 }
292 Ok(env)
293}
294
295async fn read_bounded<R>(mut reader: R, max_output_bytes: usize) -> io::Result<(Vec<u8>, bool)>
296where
297 R: AsyncRead + Unpin,
298{
299 let mut bytes = Vec::new();
300 let mut exceeded = false;
301 let mut chunk = [0u8; 8192];
302 loop {
303 let read = reader.read(&mut chunk).await?;
304 if read == 0 {
305 return Ok((bytes, exceeded));
306 }
307 let remaining = max_output_bytes.saturating_sub(bytes.len());
308 if remaining >= read && !exceeded {
309 bytes.extend_from_slice(&chunk[..read]);
310 } else {
311 exceeded = true;
312 if remaining > 0 {
313 bytes.extend_from_slice(&chunk[..remaining]);
314 }
315 }
316 }
317}
318
319#[cfg(test)]
320#[path = "sidecar_tests.rs"]
321mod tests;