From ced123659bbd087bf30288c607b286b83d3f1fd9 Mon Sep 17 00:00:00 2001 From: srnafi Date: Wed, 14 Oct 2026 17:06:43 +0600 Subject: [PATCH 1/2] fix: Windows support for opencode.exe resolution, health check, WRAP_CWD --- src/bootstrap.rs | 42 +++++++++++++++++++++++++++++++++--------- src/config.rs | 11 ++++++++++- 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/src/bootstrap.rs b/src/bootstrap.rs index eb5bf02..6720472 100644 --- a/src/bootstrap.rs +++ b/src/bootstrap.rs @@ -14,7 +14,10 @@ pub struct SpawnState { pub async fn ensure_opencode(client: &OcoClient, spawn: &mut SpawnState) -> Result<(), String> { if let Ok(h) = oco(client, "/global/health", "GET", None, None).await { - if h.get("healthy").and_then(|v| v.as_bool()) == Some(true) { + if h.get("healthy").and_then(|v| v.as_bool()) == Some(true) + || h.as_str().is_some() + || h.is_null() + { tracing::info!( "[wrap] using existing opencode serve at {}", client.config.opencode_base @@ -28,7 +31,11 @@ pub async fn ensure_opencode(client: &OcoClient, spawn: &mut SpawnState) -> Resu client.config.opencode_port, client.config.wrap_cwd ); - let child = Command::new("opencode") + #[cfg(windows)] + let mut cmd = Command::new("opencode.exe"); + #[cfg(not(windows))] + let cmd = Command::new("opencode"); + let child = cmd .args([ "serve", "--port", @@ -53,7 +60,10 @@ pub async fn ensure_opencode(client: &OcoClient, spawn: &mut SpawnState) -> Resu let deadline = tokio::time::Instant::now() + Duration::from_secs(20); while tokio::time::Instant::now() < deadline { if let Ok(h) = oco(client, "/global/health", "GET", None, None).await { - if h.get("healthy").and_then(|v| v.as_bool()) == Some(true) { + if h.get("healthy").and_then(|v| v.as_bool()) == Some(true) + || h.as_str().is_some() + || h.is_null() + { tracing::info!("[wrap] opencode serve ready"); return Ok(()); } @@ -64,7 +74,11 @@ pub async fn ensure_opencode(client: &OcoClient, spawn: &mut SpawnState) -> Resu } pub fn require_opencode_installed() -> Result<(), String> { - let r = std::process::Command::new("opencode") + #[cfg(windows)] + let mut cmd = std::process::Command::new("opencode.exe"); + #[cfg(not(windows))] + let cmd = std::process::Command::new("opencode"); + let r = cmd .arg("--version") .stdout(Stdio::null()) .stderr(Stdio::null()) @@ -72,11 +86,21 @@ pub fn require_opencode_installed() -> Result<(), String> { match r { Ok(s) if s.success() => Ok(()), _ => { - tracing::error!("[wrap] opencode CLI not found or not runnable."); - tracing::error!( - "[wrap] Install it first (see https://opencode.ai/docs), then run `opencode auth login` and restart this server." - ); - Err("opencode CLI missing".into()) + let r2 = std::process::Command::new("opencode") + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + match r2 { + Ok(s) if s.success() => Ok(()), + _ => { + tracing::error!("[wrap] opencode CLI not found or not runnable."); + tracing::error!( + "[wrap] Install it first (see https://opencode.ai/docs), then run `opencode auth login` and restart this server." + ); + Err("opencode CLI missing".into()) + } + } } } } diff --git a/src/config.rs b/src/config.rs index 27e4189..0b50084 100644 --- a/src/config.rs +++ b/src/config.rs @@ -39,7 +39,16 @@ impl Config { default_model: env::var("WRAP_MODEL") .unwrap_or_else(|_| "muse-spark-1.3-contributor-free".into()), default_provider: env::var("WRAP_PROVIDER").unwrap_or_else(|_| "opencode".into()), - wrap_cwd: env::var("WRAP_CWD").unwrap_or_else(|_| "/tmp".into()), + wrap_cwd: env::var("WRAP_CWD").unwrap_or_else(|_| { + #[cfg(windows)] + { + env::var("TEMP").unwrap_or_else(|_| "C:\\temp".into()) + } + #[cfg(not(windows))] + { + "/tmp".into() + } + }), oco_timeout_ms: num_env("WRAP_OCO_TIMEOUT_MS", 180_000, 100, 1_800_000)?, wrap_max_body_bytes: num_env( "WRAP_MAX_BODY_BYTES", From ccdd56440aa1f13d1f5094fca1e82746a7edc417 Mon Sep 17 00:00:00 2001 From: vishal veerareddy Date: Thu, 15 Oct 2026 10:24:02 -0700 Subject: [PATCH 2/2] Windows support on the OpenCode 2 bootstrap Re-applies the intent of the original Windows commit on top of the V2 code: - Resolve the CLI on Windows by probing PATH for opencode.exe, then the npm opencode.cmd / opencode.bat shims. Rust's Command already appends .exe but does not consult PATHEXT, so bare "opencode" misses npm installs. .cmd shims are run through cmd.exe by std; a warning notes that only the shim is killed on shutdown. - Default WRAP_CWD to the system temp directory on Windows (always exists) instead of /tmp, which fails the directory check there. - Fall back to USERPROFILE for the home-directory warning. - Document the Windows default and the npm-shim caveat in the README. The original health-check relaxation (accept any HTTP 200) is dropped: main now probes /api/info and checks the JSON version, and accepting HTML would mark an OpenCode 2 UI response as healthy. Co-authored-by: srnafi Co-Authored-By: Claude Fable 5.1 --- README.md | 10 +++++++- src/bootstrap.rs | 67 ++++++++++++++++++++++++++++++++++++++++++++++-- src/config.rs | 20 +++++++++++++-- 3 files changed, 92 insertions(+), 5 deletions(-) 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();