1use std::ffi::OsString;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4
5use anyhow::{anyhow, Context, Result};
6use portable_pty::CommandBuilder as PtyCommandBuilder;
7
8mod podman;
9
10pub const DEFAULT_SANDBOX_IMAGE: &str = "python:3.13-bookworm";
11pub const DEFAULT_SANDBOX_WORKDIR: &str = "/workspace";
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct MountSpec {
15 pub host: PathBuf,
16 pub guest: PathBuf,
17 pub read_only: bool,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct SandboxSpec {
22 pub image: String,
23 pub mounts: Vec<MountSpec>,
24 pub workdir: PathBuf,
25 pub gpu_devices: Vec<String>,
26 pub shm_size: Option<String>,
27}
28
29#[derive(Clone)]
30pub struct SandboxSession {
31 inner: Arc<podman::PodmanSession>,
32}
33
34impl SandboxSession {
35 pub async fn create(spec: SandboxSpec, session_key: String, owner: bool) -> Result<Self> {
36 let inner = Arc::new(podman::PodmanSession::new(spec, session_key, owner));
37 inner.ensure_ready().await?;
38 Ok(Self { inner })
39 }
40
41 pub fn container_name(&self) -> &str {
42 self.inner.container_name()
43 }
44
45 pub fn workdir_display(&self) -> String {
46 self.inner.spec().workdir.display().to_string()
47 }
48
49 pub fn host_workdir(&self) -> Option<PathBuf> {
50 let spec = self.inner.spec();
51 for mount in &spec.mounts {
52 if spec.workdir.starts_with(&mount.guest) {
53 let suffix = spec
54 .workdir
55 .strip_prefix(&mount.guest)
56 .unwrap_or_else(|_| Path::new(""));
57 return Some(join_host_path(&mount.host, suffix));
58 }
59 }
60 None
61 }
62
63 pub fn image(&self) -> &str {
64 &self.inner.spec().image
65 }
66
67 pub fn spec(&self) -> &SandboxSpec {
68 self.inner.spec()
69 }
70
71 pub fn status_text(&self) -> String {
72 format!("on (podman, image={})", self.image())
73 }
74
75 pub fn worker_cli_args(&self) -> Vec<OsString> {
76 self.inner.worker_cli_args()
77 }
78
79 pub fn resolve_path(&self, path: &str) -> Result<PathBuf> {
80 let requested = PathBuf::from(path);
81 let spec = self.inner.spec();
82
83 if requested.is_relative() {
84 return Ok(spec.workdir.join(requested));
85 }
86
87 for mount in &spec.mounts {
88 if requested.starts_with(&mount.host) {
89 let suffix = requested
90 .strip_prefix(&mount.host)
91 .unwrap_or_else(|_| Path::new(""));
92 return Ok(join_guest_path(&mount.guest, suffix));
93 }
94 }
95
96 for mount in &spec.mounts {
97 if requested.starts_with(&mount.guest) {
98 return Ok(requested);
99 }
100 }
101
102 if requested.starts_with(&spec.workdir) {
103 return Ok(requested);
104 }
105
106 if requested.exists() {
107 return Err(anyhow!(
108 "Path '{}' is not mounted into the sandbox. Use /workspace or an explicitly mounted guest path.",
109 path
110 ));
111 }
112
113 Ok(requested)
114 }
115
116 pub async fn exec(
117 &self,
118 program: &str,
119 args: &[String],
120 stdin: Option<Vec<u8>>,
121 ) -> Result<std::process::Output> {
122 self.inner.exec(program, args, stdin).await
123 }
124
125 pub fn child_process_command(
126 &self,
127 program: &str,
128 args: &[String],
129 envs: &[(String, String)],
130 ) -> tokio::process::Command {
131 self.inner.child_process_command(program, args, envs)
132 }
133
134 pub fn terminal_pty_command(
135 &self,
136 cwd: Option<&Path>,
137 envs: &[(String, String)],
138 ) -> (PtyCommandBuilder, String) {
139 self.inner.terminal_pty_command(cwd, envs)
140 }
141
142 pub fn terminal_pipe_command(
143 &self,
144 cmd: &str,
145 cwd: Option<&Path>,
146 envs: &[(String, String)],
147 ) -> (tokio::process::Command, String) {
148 self.inner.terminal_pipe_command(cmd, cwd, envs)
149 }
150
151 pub async fn terminal_pipe_kill(&self, pidfile: &str) -> Result<()> {
152 self.inner.terminal_pipe_kill(pidfile).await
153 }
154
155 #[cfg(test)]
156 pub(crate) fn new_for_test(spec: SandboxSpec) -> Self {
157 Self {
158 inner: Arc::new(podman::PodmanSession::new(
159 spec,
160 "test-session".to_string(),
161 false,
162 )),
163 }
164 }
165}
166
167pub fn parse_mount_spec(raw: &str, read_only: bool, cwd: &Path) -> Result<MountSpec> {
168 let (host_raw, guest_raw) = raw
169 .split_once(':')
170 .ok_or_else(|| anyhow!("invalid mount '{}': expected HOST:GUEST", raw))?;
171
172 if host_raw.is_empty() || guest_raw.is_empty() {
173 return Err(anyhow!("invalid mount '{}': expected HOST:GUEST", raw));
174 }
175
176 let host = absolutize_host_path(host_raw, cwd)
177 .with_context(|| format!("invalid host path in mount '{}'", raw))?;
178 if !host.exists() {
179 return Err(anyhow!("mount source '{}' does not exist", host.display()));
180 }
181
182 let guest = PathBuf::from(guest_raw);
183 if !guest.is_absolute() {
184 return Err(anyhow!(
185 "mount target '{}' must be an absolute path inside the sandbox",
186 guest.display()
187 ));
188 }
189
190 Ok(MountSpec {
191 host,
192 guest,
193 read_only,
194 })
195}
196
197pub fn build_sandbox_spec(
198 image: String,
199 workdir: String,
200 mounts: Vec<MountSpec>,
201 gpu_devices: Vec<String>,
202 shm_size: Option<String>,
203) -> Result<SandboxSpec> {
204 let workdir = PathBuf::from(workdir);
205 if !workdir.is_absolute() {
206 return Err(anyhow!(
207 "sandbox workdir '{}' must be an absolute path",
208 workdir.display()
209 ));
210 }
211
212 Ok(SandboxSpec {
213 image,
214 mounts,
215 workdir,
216 gpu_devices,
217 shm_size,
218 })
219}
220
221fn absolutize_host_path(raw: &str, cwd: &Path) -> Result<PathBuf> {
222 let path = PathBuf::from(raw);
223 let joined = if path.is_absolute() {
224 path
225 } else {
226 cwd.join(path)
227 };
228 joined
229 .canonicalize()
230 .with_context(|| format!("failed to canonicalize '{}'", joined.display()))
231}
232
233fn join_guest_path(base: &Path, suffix: &Path) -> PathBuf {
234 join_path(base, suffix)
235}
236
237fn join_host_path(base: &Path, suffix: &Path) -> PathBuf {
238 join_path(base, suffix)
239}
240
241fn join_path(base: &Path, suffix: &Path) -> PathBuf {
242 if suffix.as_os_str().is_empty() {
243 return base.to_path_buf();
244 }
245 let mut out = base.to_path_buf();
246 for component in suffix.components() {
247 if let std::path::Component::Normal(part) = component {
248 out.push(part);
249 }
250 }
251 out
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257
258 #[test]
259 fn parse_mount_spec_normalizes_relative_host_path() {
260 let cwd = std::env::current_dir().unwrap();
261 let mount = parse_mount_spec(".:/sandbox/crates", true, &cwd).unwrap();
262 assert!(mount.host.is_absolute());
263 assert_eq!(mount.guest, PathBuf::from("/sandbox/crates"));
264 assert!(mount.read_only);
265 }
266
267 #[test]
268 fn resolve_relative_and_host_absolute_paths() {
269 let cwd = std::env::current_dir().unwrap();
270 let mount = MountSpec {
271 host: cwd.clone(),
272 guest: PathBuf::from(DEFAULT_SANDBOX_WORKDIR),
273 read_only: false,
274 };
275 let session = SandboxSession {
276 inner: Arc::new(podman::PodmanSession::new(
277 SandboxSpec {
278 image: DEFAULT_SANDBOX_IMAGE.to_string(),
279 mounts: vec![mount],
280 workdir: PathBuf::from(DEFAULT_SANDBOX_WORKDIR),
281 gpu_devices: Vec::new(),
282 shm_size: Some("0".to_string()),
283 },
284 "test-session".to_string(),
285 false,
286 )),
287 };
288
289 assert_eq!(session.host_workdir().unwrap(), cwd);
290
291 assert_eq!(
292 session.resolve_path("Cargo.toml").unwrap(),
293 PathBuf::from("/workspace/Cargo.toml")
294 );
295 assert_eq!(
296 session
297 .resolve_path(&cwd.join("Cargo.toml").display().to_string())
298 .unwrap(),
299 PathBuf::from("/workspace/Cargo.toml")
300 );
301 }
302}