Skip to main content

sac/sandbox/
mod.rs

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}