diff --git a/README.md b/README.md index 3c83756..158e762 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ before starting the wrapper. | `WRAP_MODEL` | `muse-spark-1.3-contributor-free` | Backend model | | `WRAP_PROVIDER` | `opencode` | Backend provider | | `WRAP_AGENT` | `build` | OpenCode agent each per-request session runs under | -| `WRAP_CWD` | `/tmp` | Existing directory for spawned serve. Native bash tools run here — use an empty sandbox, not your home directory | +| `WRAP_CWD` | `/tmp` (Unix) / `%TEMP%` (Windows) | Existing directory for spawned serve. Native bash tools run here — use an empty sandbox, not your home directory | | `WRAP_OCO_TIMEOUT_MS` | `180000` | Per-backend-request timeout | | `WRAP_MAX_BODY_BYTES` | `8388608` | Max chat body. Oversize requests return OpenAI-shaped `413` | | `WRAP_ZEN_MODELS_URL` | `https://opencode.ai/zen/v1/models` | Zen model allowlist | @@ -123,6 +123,14 @@ before starting the wrapper. | `WRAP_CORS_ORIGINS` | unset (any) | Comma-separated allowed CORS origins | | `WRAP_MAX_IN_FLIGHT` | `8` | Concurrent chat requests before replying `429` | +## Windows + +Works with a native `opencode.exe` on `PATH` (official installer, scoop, +etc.). npm installs expose OpenCode as an `opencode.cmd` shim; the wrapper +finds that too, but then only the shim is killed on shutdown, so the spawned +`opencode serve` can outlive the wrapper — prefer the native install, or run +`opencode serve` yourself and point `OPENCODE_BASE` at it. + ## Tests ```bash diff --git a/src/bootstrap.rs b/src/bootstrap.rs index c8c1ea5..4bc6f3f 100644 --- a/src/bootstrap.rs +++ b/src/bootstrap.rs @@ -14,6 +14,42 @@ const SPAWN_READY_SECS: u64 = 20; /// The wrapper speaks the OpenCode 2 `/api/*` contract only. pub const MIN_OPENCODE_MAJOR: u64 = 2; +/// Program name to launch the OpenCode CLI with. +/// +/// On Windows, `Command` does not consult `PATHEXT`, and npm installs expose +/// OpenCode as an `opencode.cmd` shim rather than an `.exe`, so probe `PATH` +/// for the concrete file. Rust's `Command` runs `.cmd`/`.bat` through +/// `cmd.exe` itself, so returning the shim's file name is enough. +fn opencode_program() -> String { + if cfg!(windows) { + if let Some(found) = find_on_path( + &["opencode.exe", "opencode.cmd", "opencode.bat"], + std::env::var_os("PATH").as_deref(), + ) { + return found; + } + } + "opencode".into() +} + +/// First of `candidates` that exists as a file in any `PATH` entry. +pub(crate) fn find_on_path(candidates: &[&str], path: Option<&std::ffi::OsStr>) -> Option { + let path = path?; + for dir in std::env::split_paths(path) { + for name in candidates { + if dir.join(name).is_file() { + return Some((*name).to_string()); + } + } + } + None +} + +fn is_batch_shim(program: &str) -> bool { + let lower = program.to_ascii_lowercase(); + lower.ends_with(".cmd") || lower.ends_with(".bat") +} + pub struct SpawnState { pub child: Option, pub spawned: Arc, @@ -118,7 +154,13 @@ pub async fn ensure_opencode(client: &OcoClient, spawn: &mut SpawnState) -> Resu client.config.wrap_cwd ); let (stdout, stderr) = serve_log_stdio(&client.config.wrap_cwd)?; - let child = Command::new("opencode") + let program = opencode_program(); + if is_batch_shim(&program) { + tracing::warn!( + "[wrap] opencode resolved to the {program} shim; the spawned server may outlive this process because only the shim is killed on shutdown. Prefer a native opencode.exe install." + ); + } + let child = Command::new(&program) .args([ "serve", "--port", @@ -185,7 +227,7 @@ pub(crate) fn parse_major_version(text: &str) -> Option { } pub fn require_opencode_installed() -> Result<(), String> { - let out = std::process::Command::new("opencode") + let out = std::process::Command::new(opencode_program()) .arg("--version") .stderr(Stdio::null()) .output(); @@ -222,6 +264,27 @@ pub fn require_opencode_installed() -> Result<(), String> { mod tests { use super::*; + #[test] + fn find_on_path_picks_first_existing_candidate() { + let dir = std::env::temp_dir().join(format!("ocw-path-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("opencode.cmd"), "@echo off").unwrap(); + let path = + std::env::join_paths([std::path::Path::new("/nonexistent"), dir.as_path()]).unwrap(); + assert_eq!( + find_on_path(&["opencode.exe", "opencode.cmd"], Some(path.as_os_str())), + Some("opencode.cmd".into()) + ); + assert_eq!( + find_on_path(&["opencode.exe"], Some(path.as_os_str())), + None + ); + assert_eq!(find_on_path(&["opencode.exe"], None), None); + std::fs::remove_dir_all(&dir).unwrap(); + assert!(is_batch_shim("opencode.CMD")); + assert!(!is_batch_shim("opencode.exe")); + } + #[test] fn parses_major_version_from_cli_output() { assert_eq!(parse_major_version("opencode v2.0.28"), Some(2)); diff --git a/src/config.rs b/src/config.rs index 7b7e653..568dc4c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -43,8 +43,8 @@ impl Config { "config_error", )); } - let wrap_cwd = require_dir(&env::var("WRAP_CWD").unwrap_or_else(|_| "/tmp".into()))?; - if let Ok(home) = env::var("HOME") { + let wrap_cwd = require_dir(&env::var("WRAP_CWD").unwrap_or_else(|_| default_wrap_cwd()))?; + if let Ok(home) = env::var("HOME").or_else(|_| env::var("USERPROFILE")) { if paths_equal(&wrap_cwd, &home) { tracing::warn!( "[wrap] WRAP_CWD is the home directory; opencode tools run there. Point WRAP_CWD at an empty sandbox directory." @@ -108,6 +108,16 @@ impl Config { } } +/// `/tmp` on Unix; the user's temp directory (`%TEMP%`, always present) on +/// Windows, where `/tmp` does not exist and would fail `require_dir`. +fn default_wrap_cwd() -> String { + if cfg!(windows) { + env::temp_dir().to_string_lossy().into_owned() + } else { + "/tmp".into() + } +} + fn non_empty_env(name: &str) -> Option { env::var(name) .ok() @@ -231,6 +241,12 @@ mod tests { ); } + #[test] + fn default_wrap_cwd_exists() { + let d = default_wrap_cwd(); + assert!(std::path::Path::new(&d).is_dir(), "{d} should exist"); + } + #[test] fn random_password_is_hex_and_unique() { let a = random_password();