LazyBoy2/crates/grokboy-core/src/box_runtime.rs

824 lines
28 KiB
Rust

//! Agent's own Linux computer (Grok Bot box), Docker-backed.
//! User-facing name is always "my computer". Viewer is noVNC on 127.0.0.1:6080.
use crate::computer::{
settle_before, steps_for, validate_bounds, ComputerAction, ComputerStep, ScreenSize,
};
use anyhow::{anyhow, Context, Result};
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use tokio::process::Command;
use tokio::sync::Mutex;
const IMAGE: &str = "grokboy-box:local";
const CONTAINER: &str = "grokboy-box";
const VIEWER_PORT: u16 = 6080;
const BOX_REVISION: &str = "computer-use-2";
const BROWSER_PROFILE: &str = "/home/box/chrome-profile";
/// Per-stream cap on shell output returned to the model; the middle is elided.
const JOB_OUTPUT_HEAD_CHARS: usize = 16_000;
const JOB_OUTPUT_TAIL_CHARS: usize = 32_000;
pub struct BoxHub {
inner: Mutex<BoxState>,
}
struct BoxState {
ready: bool,
}
impl BoxHub {
pub fn new() -> Arc<Self> {
Arc::new(Self {
inner: Mutex::new(BoxState { ready: false }),
})
}
pub fn viewer_url() -> String {
format!("http://127.0.0.1:{VIEWER_PORT}/vnc.html?autoconnect=true&resize=scale")
}
pub async fn ensure_ready(&self) -> Result<Value> {
let mut state = self.inner.lock().await;
if state.ready && docker_running(CONTAINER).await? {
// One cheap probe on the hot path; only a restarted container pays
// for the full readiness wait.
if !desktop_up().await {
wait_desktop().await?;
}
return Ok(json!({
"ready": true,
"viewer_url": Self::viewer_url(),
"browser_surface": "docker",
"workspace": "/workspace",
"browser_profile": BROWSER_PROFILE,
}));
}
docker_info().await?;
// Several processes (CLI, browser helper spawn, tests) each own a
// BoxHub. Without a host-wide lock they build the image twice and
// race each other removing the stale container.
let _provision = ProvisionLock::acquire().await?;
ensure_image().await?;
ensure_container().await?;
wait_desktop().await?;
state.ready = true;
Ok(json!({
"ready": true,
"viewer_url": Self::viewer_url(),
"browser_surface": "docker",
"workspace": "/workspace",
"browser_profile": BROWSER_PROFILE,
"instruction": "This is my computer. Paths here are /workspace and /home/box, not the user's machine.",
}))
}
pub async fn ensure_browser_ready(&self) -> Result<()> {
self.ensure_ready().await?;
let probe = ["python3", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:9222/json/version', timeout=1).close()"];
if docker_exec(&probe).await?.status == 0 { return Ok(()); }
// The desktop autostart may already be launching Chromium; a second
// launcher would race it for the profile. box-chrome serialises
// launches, but skipping the launch entirely is cheaper.
let already_launching = docker_exec(&[
"pgrep", "-f", "--", &format!("--user-data-dir={BROWSER_PROFILE}"),
]).await?.status == 0;
if !already_launching {
docker_exec(&["bash", "-lc", "nohup box-chrome </dev/null >/tmp/grokboy-browser.log 2>&1 &"]).await?;
}
for _ in 0..80 {
if docker_exec(&probe).await?.status == 0 { return Ok(()); }
tokio::time::sleep(Duration::from_millis(250)).await;
}
Err(anyhow!("Box Chromium did not start. Inspect /tmp/grokboy-browser.log on the box; no local browser was opened."))
}
pub async fn shell(&self, cmd: &str, block_until_ms: u64) -> Result<Value> {
self.ensure_ready().await?;
let id = uuid::Uuid::new_v4().to_string();
// All commands run detached in the box. A foreground wait expiring must
// never kill an install or silently move it onto the user's computer.
// The exit file is renamed into place so a poller never sees it empty.
docker_exec(&["bash", "-lc", &format!(
"mkdir -p /tmp/gb-jobs/{id} && {{ nohup bash -lc {q} </dev/null >/tmp/gb-jobs/{id}/stdout 2>/tmp/gb-jobs/{id}/stderr & }}",
q = shell_quote(&format!(
"cd /workspace && bash -lc {}; code=$?; printf '%s\\n' \"$code\" >/tmp/gb-jobs/{id}/exit.tmp && mv -f /tmp/gb-jobs/{id}/exit.tmp /tmp/gb-jobs/{id}/exit",
shell_quote(cmd)
))
)]).await?;
self.wait_job(&id, block_until_ms).await
}
async fn wait_job(&self, id: &str, block_until_ms: u64) -> Result<Value> {
let deadline = tokio::time::Instant::now() + Duration::from_millis(block_until_ms.min(30_000));
loop {
let mut status = self.read_job_status(id).await?;
status["session_id"] = json!(id);
status["surface"] = json!("box");
if status["running"] != true || tokio::time::Instant::now() >= deadline {
if status["running"] == true {
status["instruction"] = json!("Still running on my computer. Use await_shell with this session_id when the result is needed. Do not rerun the command locally.");
}
return Ok(status);
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
}
pub async fn await_job(&self, session_id: &str) -> Result<Value> {
self.ensure_ready().await?;
validate_job_id(session_id)?;
self.wait_job(session_id, 30_000).await
}
async fn read_job_status(&self, session_id: &str) -> Result<Value> {
validate_job_id(session_id)?;
let py = r#"
import json, pathlib, sys
root = pathlib.Path('/tmp/gb-jobs') / sys.argv[1]
head, tail = int(sys.argv[2]), int(sys.argv[3])
def stream(name):
p = root / name
if not p.exists():
return '', False
text = p.read_text(errors='replace')
if len(text) <= head + tail:
return text, False
dropped = len(text) - head - tail
return text[:head] + f'\n... [{dropped} chars elided] ...\n' + text[-tail:], True
if not root.exists():
print(json.dumps({'error': 'unknown session'}))
else:
exitp = root / 'exit'
raw = exitp.read_text(errors='replace').strip() if exitp.exists() else ''
code = int(raw) if raw.lstrip('-').isdigit() else None
out, out_cut = stream('stdout')
err, err_cut = stream('stderr')
status = {'running': code is None, 'exit_code': code, 'stdout': out, 'stderr': err}
if out_cut or err_cut:
status['truncated'] = True
status['full_output'] = {'stdout': str(root / 'stdout'), 'stderr': str(root / 'stderr')}
print(json.dumps(status))
"#;
let out = docker_exec(&[
"python3",
"-c",
py,
session_id,
&JOB_OUTPUT_HEAD_CHARS.to_string(),
&JOB_OUTPUT_TAIL_CHARS.to_string(),
])
.await?;
if out.status != 0 {
return Err(anyhow!("box job status failed: {}", out.stderr.trim()));
}
serde_json::from_str(out.stdout.trim()).context("parse box_await json")
}
pub async fn read(&self, path: &str, offset: Option<i64>, limit: Option<i64>) -> Result<Value> {
self.ensure_ready().await?;
let path = resolve_box_path(path)?;
let offset = offset.unwrap_or(1);
let limit = limit.unwrap_or(400);
let py = format!(
r#"
import json, pathlib, sys
p = pathlib.Path(sys.argv[1])
lines = p.read_text(errors='replace').splitlines()
start = max({offset}-1, 0)
chunk = lines[start:start+{limit}]
print(json.dumps({{'path': str(p), 'offset': start+1, 'lines': len(lines), 'content': chr(10).join(chunk)}}))
"#
);
let out = docker_exec(&["python3", "-c", &py, &path]).await?;
if out.status != 0 {
return Err(anyhow!(out.stderr.trim().to_string()));
}
serde_json::from_str(out.stdout.trim()).context("parse box_read")
}
pub async fn copy_to_box(&self, host_path: &Path, box_path: Option<&str>) -> Result<Value> {
self.ensure_ready().await?;
if !host_path.is_file() {
return Err(anyhow!("source is not a file: {}", host_path.display()));
}
let dest = match box_path {
Some(p) => resolve_box_path(p)?,
None => format!(
"/workspace/uploads/{}",
host_path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("file")
),
};
docker(["exec", CONTAINER, "mkdir", "-p", parent_dir(&dest)]).await?;
docker([
"cp",
&host_path.to_string_lossy(),
&format!("{CONTAINER}:{dest}"),
])
.await?;
Ok(json!({"box_path": dest, "bytes": std::fs::metadata(host_path)?.len()}))
}
pub async fn copy_from_box(&self, box_path: &str, host_path: &Path) -> Result<Value> {
self.ensure_ready().await?;
let src = resolve_box_path(box_path)?;
if let Some(parent) = host_path.parent() {
std::fs::create_dir_all(parent)?;
}
docker([
"cp",
&format!("{CONTAINER}:{src}"),
&host_path.to_string_lossy(),
])
.await?;
let is_dir = host_path.is_dir();
Ok(json!({
"computer_path": host_path,
"is_dir": is_dir,
"bytes": if is_dir { dir_size(host_path) } else { std::fs::metadata(host_path).map(|m| m.len()).unwrap_or(0) },
}))
}
pub async fn screenshot(&self, dest: &Path) -> Result<Value> {
self.ensure_ready().await?;
let shot = ShotFile::new();
self.capture_root_png(&shot).await?;
shot.fetch(dest).await?;
Ok(json!({
"path": dest,
"viewer_url": Self::viewer_url(),
}))
}
pub async fn computer(&self, actions: &[ComputerAction], dest: &Path) -> Result<Value> {
self.ensure_ready().await?;
let screen = self.display_geometry().await?;
validate_bounds(actions, screen)?;
let names: Vec<&str> = actions.iter().map(ComputerAction::name).collect();
let shot = ShotFile::new();
for (index, action) in actions.iter().enumerate() {
let settle = settle_before(actions, index);
if settle > 0 {
tokio::time::sleep(Duration::from_millis(u64::from(settle))).await;
}
self.run_computer_action(action, &shot).await?;
}
if !actions.iter().any(ComputerAction::is_screenshot) {
self.capture_root_png(&shot).await?;
}
shot.fetch(dest).await?;
let cursor = self.cursor_position().await.ok();
Ok(json!({
"ok": true,
"actions": names,
"path": dest,
"viewer_url": Self::viewer_url(),
"cursor_position": cursor,
"screen": {"width": screen.width, "height": screen.height},
"instruction": "Computer action ran on my computer. Read this screenshot before the next action. A batched then sequence returns only this final screen."
}))
}
async fn run_computer_action(&self, action: &ComputerAction, shot: &ShotFile) -> Result<()> {
for step in steps_for(action)? {
match step {
ComputerStep::Screenshot => self.capture_root_png(shot).await?,
ComputerStep::SleepMs(0) => {}
ComputerStep::SleepMs(ms) => {
let secs = format!("{:.3}", f64::from(ms) / 1000.0);
let out = docker_exec(&[
"python3",
"-c",
"import sys,time; time.sleep(float(sys.argv[1]))",
&secs,
])
.await?;
if out.status != 0 {
return Err(anyhow!("computer wait failed: {}", out.stderr.trim()));
}
}
ComputerStep::Xdotool(args) => {
let mut argv: Vec<&str> = vec!["xdotool"];
argv.extend(args.iter().map(String::as_str));
let out = docker_exec_env(&[("DISPLAY", ":1")], &argv).await?;
if out.status != 0 {
let detail = out.stderr.trim();
if detail.contains("xdotool") && detail.contains("not found") {
return Err(anyhow!(
"My computer image is missing xdotool. Rebuild the box image and retry."
));
}
return Err(anyhow!(
"computer {} failed: {}",
action.name(),
if detail.is_empty() {
out.stdout.trim()
} else {
detail
}
));
}
}
}
}
Ok(())
}
async fn display_geometry(&self) -> Result<ScreenSize> {
let out = docker_exec_env(&[("DISPLAY", ":1")], &["xdotool", "getdisplaygeometry"]).await?;
if out.status == 0 {
if let Ok(size) = ScreenSize::parse(&out.stdout) {
return Ok(size);
}
}
let info = docker_exec(&["bash", "-lc", "xdpyinfo -display :1 | awk '/dimensions:/{print $2}'"]).await?;
let raw = info.stdout.trim().replace('x', " ");
ScreenSize::parse(&raw).map_err(|e| {
anyhow!("could not read my computer's display size ({e}). Is the desktop up?")
})
}
async fn cursor_position(&self) -> Result<Value> {
let out = docker_exec_env(&[("DISPLAY", ":1")], &["xdotool", "getmouselocation", "--shell"]).await?;
if out.status != 0 {
return Err(anyhow!(out.stderr.trim().to_string()));
}
let mut x = 0i32;
let mut y = 0i32;
for line in out.stdout.lines() {
if let Some(v) = line.strip_prefix("X=") {
x = v.trim().parse().unwrap_or(0);
}
if let Some(v) = line.strip_prefix("Y=") {
y = v.trim().parse().unwrap_or(0);
}
}
Ok(json!({"x": x, "y": y}))
}
async fn capture_root_png(&self, shot: &ShotFile) -> Result<()> {
let out = docker_exec(&[
"import",
"-display",
":1",
"-window",
"root",
&shot.box_path,
])
.await?;
if out.status != 0 {
return Err(anyhow!(
"Could not capture my computer's screen (desktop not up yet). {}",
out.stderr.trim()
));
}
Ok(())
}
}
/// A per-call screenshot file on the box. Concurrent callers (a parent
/// checking in on a computerUse child) must never read each other's frame.
struct ShotFile {
box_path: String,
}
impl ShotFile {
fn new() -> Self {
Self {
box_path: format!("/tmp/grokboy-shot-{}.png", uuid::Uuid::new_v4()),
}
}
async fn fetch(&self, dest: &Path) -> Result<()> {
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
let copied = docker([
"cp",
&format!("{CONTAINER}:{}", self.box_path),
&dest.to_string_lossy(),
])
.await;
let _ = docker_exec(&["rm", "-f", &self.box_path]).await;
copied.map(|_| ())
}
}
/// Host-wide advisory lock held while the image/container are provisioned.
/// Released when dropped (the fd closes).
struct ProvisionLock {
_file: std::fs::File,
}
impl ProvisionLock {
async fn acquire() -> Result<Self> {
let path = std::env::temp_dir().join("grokboy-box-provision.lock");
let file = tokio::task::spawn_blocking(move || -> Result<std::fs::File> {
let file = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(&path)
.with_context(|| format!("open {}", path.display()))?;
#[cfg(unix)]
{
use std::os::fd::AsRawFd;
if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
return Err(std::io::Error::last_os_error())
.context("lock box provisioning");
}
}
Ok(file)
})
.await
.context("box provisioning lock task")??;
Ok(Self { _file: file })
}
}
fn dir_size(path: &Path) -> u64 {
let Ok(entries) = std::fs::read_dir(path) else {
return 0;
};
entries
.flatten()
.map(|entry| {
let path = entry.path();
if path.is_dir() {
dir_size(&path)
} else {
std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0)
}
})
.sum()
}
pub fn resolve_box_path(path: &str) -> Result<String> {
let path = path.trim();
if path.is_empty() {
return Err(anyhow!("box path is empty"));
}
let abs = if path.starts_with('/') {
path.to_string()
} else {
format!("/workspace/{path}")
};
// Normalise by component so `a//b` and `./x` are accepted while only a
// real `..` segment (not a name like `report..final.txt`) is rejected.
let mut parts: Vec<&str> = Vec::new();
for part in abs.split('/') {
match part {
"" | "." => {}
".." => return Err(anyhow!("box path must not contain ..")),
other => parts.push(other),
}
}
let clean = format!("/{}", parts.join("/"));
let allowed = ["/workspace", "/home/box", "/tmp/gb-jobs"];
if !allowed
.iter()
.any(|root| clean == *root || clean.starts_with(&format!("{root}/")))
{
return Err(anyhow!(
"box paths must be under /workspace or /home/box (got {clean}). This is my computer, not the user's."
));
}
Ok(clean)
}
fn parent_dir(path: &str) -> &str {
path.rsplit_once('/')
.map(|(p, _)| if p.is_empty() { "/" } else { p })
.unwrap_or("/workspace")
}
fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', r#"'"'"'"#))
}
fn validate_job_id(id: &str) -> Result<()> {
if id.chars().all(|c| c.is_ascii_hexdigit() || c == '-') && (8..80).contains(&id.len()) {
Ok(())
} else {
Err(anyhow!("invalid session_id"))
}
}
fn box_context_dir() -> PathBuf {
if let Some(dir) = std::env::var_os("GROKBOY_BOX_DIR").filter(|s| !s.is_empty()) {
return PathBuf::from(dir);
}
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join("box")
}
async fn docker_info() -> Result<()> {
if docker(["info"]).await.is_err() {
return Err(anyhow!(
"My computer needs Docker. Start Docker Desktop (or the docker daemon) and try again. I will not run this on your computer."
));
}
Ok(())
}
async fn image_revision() -> Option<String> {
docker([
"inspect",
"-f",
"{{index .Config.Labels \"grokboy.box.revision\"}}",
IMAGE,
])
.await
.ok()
.map(|out| out.stdout.trim().to_string())
.filter(|s| !s.is_empty() && s != "<no value>")
}
async fn ensure_image() -> Result<()> {
if image_revision().await.as_deref() == Some(BOX_REVISION) {
return Ok(());
}
let ctx = box_context_dir();
if !ctx.join("Dockerfile").is_file() {
return Err(anyhow!(
"Box image {IMAGE} is missing and Dockerfile was not found at {}",
ctx.display()
));
}
// Stage the helper in the build context; never mount the user's workspace.
let helper_dir = ctx.join("playwright");
tokio::fs::create_dir_all(&helper_dir).await?;
for file in ["package.json", "browser_helper.mjs"] {
let src = ctx.join("../tools/playwright").join(file);
tokio::fs::copy(&src, helper_dir.join(file))
.await
.with_context(|| format!("stage browser helper {}", src.display()))?;
}
eprintln!(
"box: building image {IMAGE} (revision {BOX_REVISION}) from {}; this can take several minutes on first run",
ctx.display()
);
let started = std::time::Instant::now();
docker(["build", "-t", IMAGE, &ctx.to_string_lossy()]).await?;
eprintln!("box: image {IMAGE} ready in {:?}", started.elapsed());
Ok(())
}
async fn container_image_id() -> Option<String> {
docker(["inspect", "-f", "{{.Image}}", CONTAINER])
.await
.ok()
.map(|out| out.stdout.trim().to_string())
.filter(|s| !s.is_empty())
}
async fn local_image_id() -> Option<String> {
docker(["inspect", "-f", "{{.Id}}", IMAGE])
.await
.ok()
.map(|out| out.stdout.trim().to_string())
.filter(|s| !s.is_empty())
}
async fn ensure_container() -> Result<()> {
if docker_running(CONTAINER).await?
&& container_image_id().await == local_image_id().await
{
return Ok(());
}
if docker(["container", "inspect", CONTAINER]).await.is_ok()
&& container_image_id().await != local_image_id().await
{
remove_stale_container().await?;
}
if docker_running(CONTAINER).await? {
return Ok(());
}
// The container name is Docker's cross-process creation lock. Losing a
// create race must reuse the winner, never remove its container.
let created = docker([
"create",
"--name",
CONTAINER,
"--shm-size=2g",
"-p",
&format!("127.0.0.1:{VIEWER_PORT}:6080"),
"--stop-timeout=20",
"-v",
"grokboy-box-workspace:/workspace",
"-v",
"grokboy-box-home:/home/box",
IMAGE,
])
.await;
if let Err(error) = created {
// Docker reserves the name before inspect can see the new container.
let mut visible = false;
for _ in 0..50 {
if docker(["container", "inspect", CONTAINER]).await.is_ok() {
visible = true;
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
if !visible {
return Err(error);
}
}
// Starting an existing container also preserves its writable layer.
docker(["start", CONTAINER]).await?;
Ok(())
}
/// X server, window manager, VNC and noVNC all answering. The WM check
/// matters: xdotool against a bare Xvfb has no focus or window stacking.
const DESKTOP_PROBE: &str = "xdpyinfo -display :1 >/dev/null 2>&1 \
&& xprop -display :1 -root _NET_SUPPORTING_WM_CHECK 2>/dev/null | grep -q 'window id' \
&& python3 -c 'import urllib.request, socket; urllib.request.urlopen(\"http://127.0.0.1:6080/vnc.html\", timeout=1).close(); socket.create_connection((\"127.0.0.1\", 5900), timeout=1).close()'";
async fn desktop_up() -> bool {
docker_exec(&["bash", "-c", DESKTOP_PROBE])
.await
.map(|o| o.status == 0)
.unwrap_or(false)
}
/// Stop and remove the container built from an older image. Another process
/// (outside our provisioning lock, e.g. an older binary) may be doing the
/// same; "already in progress" or "no such container" both mean it is gone.
async fn remove_stale_container() -> Result<()> {
if docker_running(CONTAINER).await? {
if let Err(error) = docker(["stop", "-t", "20", CONTAINER]).await {
if !is_concurrent_removal(&error) {
return Err(error);
}
}
}
if let Err(error) = docker(["rm", CONTAINER]).await {
if !is_concurrent_removal(&error) {
return Err(error);
}
}
for _ in 0..100 {
// Gone, or already replaced by a container on the current image.
if docker(["container", "inspect", CONTAINER]).await.is_err()
|| container_image_id().await == local_image_id().await
{
return Ok(());
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
Err(anyhow!(
"stale container {CONTAINER} is still being removed; retry in a moment"
))
}
fn is_concurrent_removal(error: &anyhow::Error) -> bool {
let text = error.to_string();
text.contains("already in progress")
|| text.contains("No such container")
|| text.contains("is already stopped")
}
async fn wait_desktop() -> Result<()> {
for _ in 0..120 {
if desktop_up().await {
return Ok(());
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
Err(anyhow!("My computer did not become ready: desktop or viewer startup timed out. Check the container desktop logs and try again."))
}
async fn docker_running(name: &str) -> Result<bool> {
let out = docker(["inspect", "-f", "{{.State.Running}}", name]).await;
Ok(matches!(out, Ok(s) if s.stdout.trim() == "true"))
}
struct CmdOut {
status: i32,
stdout: String,
stderr: String,
}
async fn docker<I, S>(args: I) -> Result<CmdOut>
where
I: IntoIterator<Item = S>,
S: AsRef<std::ffi::OsStr>,
{
let mut cmd = Command::new("docker");
cmd.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let out = cmd.output().await.context("run docker")?;
let result = CmdOut {
status: out.status.code().unwrap_or(-1),
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
};
if result.status != 0 {
return Err(anyhow!(
"docker failed ({}): {}",
result.status,
result.stderr.trim()
));
}
Ok(result)
}
async fn docker_exec(args: &[&str]) -> Result<CmdOut> {
docker_exec_env(&[], args).await
}
async fn docker_exec_env(env: &[(&str, &str)], args: &[&str]) -> Result<CmdOut> {
let mut all = vec!["exec".to_string()];
for (key, value) in env {
all.push("-e".into());
all.push(format!("{key}={value}"));
}
all.push(CONTAINER.to_string());
all.extend(args.iter().map(|s| (*s).to_string()));
let mut cmd = Command::new("docker");
cmd.args(&all)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let out = cmd.output().await.context("docker exec")?;
Ok(CmdOut {
status: out.status.code().unwrap_or(-1),
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn box_paths_stay_on_my_computer() {
assert_eq!(
resolve_box_path("notes.txt").unwrap(),
"/workspace/notes.txt"
);
assert_eq!(resolve_box_path("/workspace/a").unwrap(), "/workspace/a");
assert!(resolve_box_path("/etc/passwd").is_err());
assert!(resolve_box_path("../secret").is_err());
assert!(resolve_box_path("/workspace/../home").is_err());
assert!(resolve_box_path("/workspace/../../etc/passwd").is_err());
assert!(resolve_box_path("/workspaceX/a").is_err());
assert!(resolve_box_path("/tmp/other").is_err());
}
#[test]
fn box_paths_normalise_without_false_positives() {
assert_eq!(
resolve_box_path("/workspace///a/./b").unwrap(),
"/workspace/a/b"
);
assert_eq!(
resolve_box_path("report..final.txt").unwrap(),
"/workspace/report..final.txt"
);
assert_eq!(resolve_box_path("/home/box/").unwrap(), "/home/box");
assert_eq!(
resolve_box_path("/tmp/gb-jobs/abc/stdout").unwrap(),
"/tmp/gb-jobs/abc/stdout"
);
}
#[test]
fn dir_size_sums_nested_files() {
let dir = std::env::temp_dir().join(format!("gb-dirsize-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(dir.join("inner")).unwrap();
std::fs::write(dir.join("a.bin"), [0u8; 10]).unwrap();
std::fs::write(dir.join("inner/b.bin"), [0u8; 5]).unwrap();
assert_eq!(dir_size(&dir), 15);
std::fs::remove_dir_all(&dir).unwrap();
}
#[tokio::test]
async fn missing_docker_is_explicit() {
if docker(["info"]).await.is_ok() {
return;
}
let hub = BoxHub::new();
let err = hub.ensure_ready().await.unwrap_err().to_string();
assert!(err.contains("Docker"), "{err}");
assert!(
err.contains("your computer") || err.contains("My computer"),
"{err}"
);
}
}