From 5eab4b37de0ac61c34eeb275d74d190cb87a8553 Mon Sep 17 00:00:00 2001 From: viwe-monai Date: Sun, 25 Oct 2026 13:34:22 +0800 Subject: [PATCH] fix(cli): make the sandbox download path probe work with busybox realpath MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sandbox download` resolved the source path with `pwd -P && realpath -e -- `. busybox realpath has no `-e` flag and treats `-e` and `--` as filenames, so on Alpine-based images the probe printed errors and exited 1 ('failed to resolve sandbox source path ... ssh probe exited with status 1') even for paths that exist — breaking the documented upload -> run -> download workflow on any busybox image, including ghcr.io/anomalyco/opencode:latest. Replace the GNU-only invocation with the POSIX form `test -e && realpath `, keeping the two-line output contract (workspace root, resolved path) and the non-zero-exit behavior for missing paths. Since `--` is gone, relative paths are prefixed with ./ so a leading-dash name cannot be parsed as an option. Factor the command into sandbox_source_resolve_command so it can be tested, and cover symlink resolution, leading-dash names, names with spaces, and the missing-path failure under /bin/sh. --- crates/openshell-cli/src/ssh.rs | 77 +++++++++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 4 deletions(-) diff --git a/crates/openshell-cli/src/ssh.rs b/crates/openshell-cli/src/ssh.rs index a576d5a788..21e0691934 100644 --- a/crates/openshell-cli/src/ssh.rs +++ b/crates/openshell-cli/src/ssh.rs @@ -1406,6 +1406,23 @@ fn validate_sandbox_source_path(workspace_root: &str, path: &str) -> Result String { + format!( + "p={path} && case \"$p\" in /*) ;; *) p=\"./$p\" ;; esac; \ + pwd -P && test -e \"$p\" && realpath \"$p\"", + path = shell_escape(path) + ) +} + /// Discover the workspace root and resolve every symlink in `sandbox_path` in /// one SSH probe, then refuse the result if it lands outside the workspace. /// @@ -1420,10 +1437,7 @@ async fn resolve_sandbox_source_path( session: &SshSessionConfig, sandbox_path: &str, ) -> Result { - let resolve_cmd = format!( - "pwd -P && realpath -e -- {path}", - path = shell_escape(sandbox_path) - ); + let resolve_cmd = sandbox_source_resolve_command(sandbox_path); let output = ssh_run_capture_stdout(session, &resolve_cmd) .await .wrap_err_with(|| format!("failed to resolve sandbox source path '{sandbox_path}'"))?; @@ -3883,4 +3897,59 @@ mod tests { // No directory component at all assert_eq!(split_sandbox_path("file.txt"), (".", "file.txt")); } + + #[test] + fn sandbox_source_resolve_probe_is_posix_and_resolves_symlinks() { + // The probe must work with a POSIX /bin/sh and a minimal `realpath` + // (busybox treats `realpath -e --` as filenames and exits 1, which + // broke `sandbox download` on Alpine images), so run the exact command + // under sh and assert its two-line output contract. + let dir = tempfile::tempdir().unwrap(); + fs::create_dir(dir.path().join("real")).unwrap(); + fs::write(dir.path().join("real/probe.txt"), b"hello").unwrap(); + std::os::unix::fs::symlink(dir.path().join("real"), dir.path().join("link")).unwrap(); + fs::write(dir.path().join("-dash.txt"), b"dash").unwrap(); + fs::write(dir.path().join("with space.txt"), b"space").unwrap(); + + let run = |path: &str| { + let output = std::process::Command::new("sh") + .arg("-c") + .arg(sandbox_source_resolve_command(path)) + .current_dir(dir.path()) + .output() + .expect("probe should run under sh"); + ( + output.status.success(), + String::from_utf8(output.stdout).expect("probe output is UTF-8"), + ) + }; + + let root = fs::canonicalize(dir.path()).unwrap(); + let root = root.to_str().unwrap(); + + // Absolute and relative paths through a symlink resolve every hop. + let expected = format!("{root}\n{root}/real/probe.txt\n"); + let (ok, out) = run(&format!("{root}/link/probe.txt")); + assert!(ok); + assert_eq!(out, expected); + let (ok, out) = run("link/probe.txt"); + assert!(ok); + assert_eq!(out, expected); + + // Without `--`, a relative path starting with '-' must not be parsed + // as an option. + let (ok, out) = run("-dash.txt"); + assert!(ok); + assert_eq!(out, format!("{root}\n{root}/-dash.txt\n")); + + // Names with spaces survive quoting. + let (ok, out) = run("with space.txt"); + assert!(ok); + assert_eq!(out, format!("{root}\n{root}/with space.txt\n")); + + // Missing path keeps the historical failure: non-zero exit, root only. + let (ok, out) = run("missing.txt"); + assert!(!ok); + assert_eq!(out, format!("{root}\n")); + } }