Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,14 +115,22 @@ 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 |
| `WRAP_API_KEY` | unset | When set, `/v1/*` routes require `Authorization: Bearer <key>` (health stays open) |
| `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
Expand Down
67 changes: 65 additions & 2 deletions src/bootstrap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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<Child>,
pub spawned: Arc<AtomicBool>,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -185,7 +227,7 @@ pub(crate) fn parse_major_version(text: &str) -> Option<u64> {
}

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();
Expand Down Expand Up @@ -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));
Expand Down
20 changes: 18 additions & 2 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down Expand Up @@ -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<String> {
env::var(name)
.ok()
Expand Down Expand Up @@ -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();
Expand Down
Loading