diff --git a/desktop/app/loading.html b/desktop/app/loading.html index 58f6a35b..c4825d15 100644 --- a/desktop/app/loading.html +++ b/desktop/app/loading.html @@ -61,21 +61,25 @@ diff --git a/desktop/app/src-tauri/src/bin/orcabot.rs b/desktop/app/src-tauri/src/bin/orcabot.rs index d4149ad3..c13991c5 100644 --- a/desktop/app/src-tauri/src/bin/orcabot.rs +++ b/desktop/app/src-tauri/src/bin/orcabot.rs @@ -1,4 +1,4 @@ -// REVISION: orcabot-cli-v21-pull-openat-walk +// REVISION: orcabot-cli-v22-dynamic-ports // // `orcabot` — command-line control for the Orcabot desktop stack. // @@ -7,8 +7,10 @@ // check status, and (incrementally) initiate agents and connections — i.e. the // same things the in-app chat does, from the outside. // -// Transport: the desktop app exposes the control plane on 127.0.0.1:8787 and the -// sandbox on 127.0.0.1:8080 (host loopback). `exec` uses the sandbox /debug/exec +// Transport: the desktop app exposes the control plane and sandbox on host +// loopback. Ports default to 8787 / 8080 / 8788 but may be dynamic if a default +// was busy at boot — the backend records the bound ports in `/ports` and +// this CLI reads them (see resolved_port). `exec` uses the sandbox /debug/exec // endpoint, authenticated with the per-boot token the VM writes to its console. // // PLATFORM: this CLI is inherently unix (POSIX signals, setsid/pre_exec, vsock, @@ -33,6 +35,7 @@ mod unix_cli { use std::collections::HashMap; use std::fs::{self, File}; use std::io::{Read, Write}; +use std::os::unix::io::AsRawFd; use std::os::unix::process::CommandExt; use std::path::PathBuf; use std::process::{Command, Stdio}; @@ -50,11 +53,11 @@ use ratatui::{DefaultTerminal, Frame}; use tungstenite::Message; -const CONTROLPLANE_PORT: u16 = 8787; -const SANDBOX_PORT: u16 = 8080; -const FRONTEND_PORT: u16 = 8788; +const DEFAULT_CONTROLPLANE_PORT: u16 = 8787; +const DEFAULT_SANDBOX_PORT: u16 = 8080; +const DEFAULT_FRONTEND_PORT: u16 = 8788; const VZ_CONSOLE_LOG: &str = "/tmp/vz-console.log"; -const REVISION: &str = "orcabot-cli-v21-pull-openat-walk"; +const REVISION: &str = "orcabot-cli-v22-dynamic-ports"; pub fn run() { let args: Vec = std::env::args().collect(); @@ -129,11 +132,23 @@ fn print_help() { // ---- paths / state ------------------------------------------------------- fn data_dir() -> PathBuf { - let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); + // Must match the backend's Tauri `app_data_dir()` exactly, else the CLI reads + // the wrong `/ports` (and pid/token) files. macOS: ~/Library/Application + // Support/. Linux: XDG data dir (honors an ABSOLUTE $XDG_DATA_HOME, same + // rule the `dirs` crate Tauri uses applies) then /. let p = if cfg!(target_os = "macos") { + let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); PathBuf::from(home).join("Library/Application Support/com.orcabot.desktop") } else { - PathBuf::from(home).join(".local/share/com.orcabot.desktop") + let base = std::env::var("XDG_DATA_HOME") + .ok() + .filter(|s| std::path::Path::new(s).is_absolute()) + .map(PathBuf::from) + .unwrap_or_else(|| { + let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); + PathBuf::from(home).join(".local/share") + }); + base.join("com.orcabot.desktop") }; let _ = fs::create_dir_all(&p); p @@ -147,6 +162,39 @@ fn headless_log() -> PathBuf { data_dir().join("headless.log") } +// ---- resolved ports ------------------------------------------------------ +// The backend writes the ports it actually bound to (some may be dynamic when a +// default was busy) to `/ports`. Read it FRESH each call — not cached — +// because `up` spawns the backend and then polls: an early read (file absent) +// must fall back to the default, and a later read (file written) must pick up +// the real port so the poll converges. Missing file / key = the default. +fn resolved_port(key: &str, default: u16) -> u16 { + let contents = match fs::read_to_string(data_dir().join("ports")) { + Ok(s) => s, + Err(_) => return default, + }; + for line in contents.lines() { + if let Some((k, v)) = line.split_once('=') { + if k.trim() == key { + if let Ok(p) = v.trim().parse::() { + return p; + } + } + } + } + default +} + +fn cp_port() -> u16 { + resolved_port("controlplane", DEFAULT_CONTROLPLANE_PORT) +} +fn sb_port() -> u16 { + resolved_port("sandbox", DEFAULT_SANDBOX_PORT) +} +fn fe_port() -> u16 { + resolved_port("frontend", DEFAULT_FRONTEND_PORT) +} + /// Resolve the desktop binary, which sits next to this CLI binary. fn desktop_binary() -> Option { let exe = std::env::current_exe().ok()?; @@ -176,10 +224,36 @@ fn http_get(url: &str, timeout: Duration) -> Option<(u16, String)> { } } +/// Best-effort cross-process lock serializing the launch decision, so two +/// simultaneous first-launches don't both spawn a backend (the app_pid() check +/// and the spawn aren't otherwise atomic). Returns a guard whose flock releases +/// when it drops (including on process exit — the kernel drops flocks on close). +/// None if locking is unavailable; callers then just proceed as before. +fn acquire_launch_lock() -> Option { + let f = File::create(data_dir().join("up.lock")).ok()?; + // Blocking exclusive advisory lock. The critical section (app_pid check + + // spawn + pid-file write) is fast, so a concurrent `up` waits only briefly + // before it acquires and finds the now-present backend to attach to. + if unsafe { libc::flock(f.as_raw_fd(), libc::LOCK_EX) } != 0 { + return None; + } + Some(f) +} + +/// True only when OUR desktop backend is up: a live `orcabot-desktop` process +/// (pid file or pgrep) AND a healthy control plane. A healthy listener alone is +/// NOT enough — a foreign process on the default port (another Orcabot's +/// `wrangler dev`, an unrelated server answering 200 on /health) would otherwise +/// be mistaken for a running stack, so `up`/`tui` would attach to it instead of +/// launching our own backend (which picks a free port when the default is busy). +fn stack_running() -> bool { + app_pid().is_some() && controlplane_healthy() +} + fn controlplane_healthy() -> bool { matches!( http_get( - &format!("http://127.0.0.1:{}/health", CONTROLPLANE_PORT), + &format!("http://127.0.0.1:{}/health", cp_port()), Duration::from_secs(2) ), Some((200, _)) @@ -188,7 +262,7 @@ fn controlplane_healthy() -> bool { fn sandbox_health() -> Option { match http_get( - &format!("http://127.0.0.1:{}/health", SANDBOX_PORT), + &format!("http://127.0.0.1:{}/health", sb_port()), Duration::from_secs(2), ) { Some((200, body)) => Some(body), @@ -211,22 +285,47 @@ fn cmd_up(rest: &[String]) -> i32 { i += 1; } } - - if controlplane_healthy() { - println!("orcabot: stack already running (control plane healthy on :{CONTROLPLANE_PORT})"); - if sandbox_health().is_some() { - println!("orcabot: sandbox healthy on :{SANDBOX_PORT}"); - } else { - println!("orcabot: sandbox still starting…"); + ensure_stack_up(timeout_secs).0 +} + +/// Ensure the stack is up, returning `(exit_code, spawned)`. `spawned` is true +/// iff THIS call launched the backend (vs. attaching to one already present) — +/// decided inside the launch lock, so of two concurrent callers only the one that +/// actually spawned is told so (and thus claims ownership). +fn ensure_stack_up(timeout_secs: u64) -> (i32, bool) { + // Serialize the check-then-spawn against a concurrent `up`/`tui` so two + // first-launches can't both get past the app_pid() check and spawn rival + // backends. Held only across the fast critical section below (released before + // the long readiness poll). Best-effort: None just means we proceed unlocked. + let launch_lock = acquire_launch_lock(); + + // Separate "a backend exists" from "services are ready". If a verified backend + // process is already present — even one still staging binaries / starting + // workerd — attach to it and wait, NEVER launch a second orcabot-desktop. Two + // backends would pick overlapping ports, clobber the shared ports file, and + // race their service children. + if app_pid().is_some() { + if controlplane_healthy() { + println!("orcabot: stack already running (control plane healthy on :{})", cp_port()); + if sandbox_health().is_some() { + println!("orcabot: sandbox healthy on :{}", sb_port()); + } else { + println!("orcabot: sandbox still starting…"); + } + return (0, false); } - return 0; + println!("orcabot: a backend is already starting — waiting for it (not launching another)…"); + // Release the lock BEFORE the long wait: a backend already exists, so we + // don't need to hold off other launches while we poll for readiness. + drop(launch_lock); + return (wait_for_ready(timeout_secs), false); } let bin = match desktop_binary() { Some(b) => b, None => { eprintln!("orcabot: could not find the orcabot-desktop binary next to this CLI"); - return 1; + return (1, false); } }; @@ -235,17 +334,24 @@ fn cmd_up(rest: &[String]) -> i32 { Ok(f) => f, Err(e) => { eprintln!("orcabot: cannot open log {}: {e}", log_path.display()); - return 1; + return (1, false); } }; let log_err = match log.try_clone() { Ok(f) => f, Err(e) => { eprintln!("orcabot: log clone failed: {e}"); - return 1; + return (1, false); } }; + // We only reach here when no backend process exists (the app_pid() guard + // above returned early otherwise). Clear any stale ports file from a prior + // now-dead backend so the readiness poll only trusts health once OUR freshly + // launched backend rewrites it — otherwise a foreign listener on the default + // port could be mistaken for our control plane during the boot window. + let _ = fs::remove_file(data_dir().join("ports")); + println!("orcabot: launching headless stack ({})…", bin.display()); let mut command = Command::new(&bin); command @@ -265,22 +371,37 @@ fn cmd_up(rest: &[String]) -> i32 { Ok(c) => c, Err(e) => { eprintln!("orcabot: failed to launch desktop binary: {e}"); - return 1; + return (1, false); } }; let pid = child.id(); let _ = fs::write(pid_file(), pid.to_string()); println!("orcabot: started (pid {pid}), logs at {}", log_path.display()); - // Wait for readiness. + // Release the launch lock now that the backend exists and its pid is recorded + // (a concurrent `up` will now see it via app_pid() and attach); don't hold it + // across the long readiness poll. + drop(launch_lock); + + (wait_for_ready(timeout_secs), true) +} + +/// Poll until the control plane + sandbox are ready (or timeout). Shared by the +/// spawn path and the "attach to a backend that's still starting" path, so a +/// second `up`/`tui` invoked mid-boot waits for the existing backend instead of +/// launching a rival one. +fn wait_for_ready(timeout_secs: u64) -> i32 { let deadline = Instant::now() + Duration::from_secs(timeout_secs); let mut cp_ready = false; print!("orcabot: waiting for services"); let _ = std::io::stdout().flush(); while Instant::now() < deadline { - if !cp_ready && controlplane_healthy() { + // Only trust a healthy control plane once the backend has written its + // ports file — proves it's OUR stack (on whatever port it chose), not a + // foreign listener still occupying the default port. + if !cp_ready && data_dir().join("ports").exists() && controlplane_healthy() { cp_ready = true; - println!("\norcabot: control plane ready (:{CONTROLPLANE_PORT})"); + println!("\norcabot: control plane ready (:{})", cp_port()); print!("orcabot: waiting for sandbox VM"); let _ = std::io::stdout().flush(); } @@ -606,7 +727,7 @@ fn read_surface_token() -> Option { impl Remote { fn local() -> Remote { Remote { - base: format!("http://127.0.0.1:{}", CONTROLPLANE_PORT), + base: format!("http://127.0.0.1:{}", cp_port()), auth: RemoteAuth::Dev { user: DEV_USER.into() }, } } @@ -660,7 +781,7 @@ fn remote_from(rest: &[String]) -> Remote { i += 1; } let base = base - .unwrap_or_else(|| format!("http://127.0.0.1:{}", CONTROLPLANE_PORT)) + .unwrap_or_else(|| format!("http://127.0.0.1:{}", cp_port())) .trim_end_matches('/') .to_string(); match token { @@ -1656,20 +1777,21 @@ fn cmd_status() -> i32 { let sb = sandbox_health(); let fe = matches!( http_get( - &format!("http://127.0.0.1:{}/", FRONTEND_PORT), + &format!("http://127.0.0.1:{}/", fe_port()), Duration::from_secs(2) ), Some((code, _)) if code < 500 ); - println!("control plane (:{CONTROLPLANE_PORT}): {}", if cp { "ready" } else { "down" }); + println!("control plane (:{}): {}", cp_port(), if cp { "ready" } else { "down" }); println!( - "sandbox (:{SANDBOX_PORT}): {}", + "sandbox (:{}): {}", + sb_port(), match &sb { Some(h) => h.trim().to_string(), None => "down".to_string(), } ); - println!("frontend (:{FRONTEND_PORT}): {}", if fe { "ready" } else { "down" }); + println!("frontend (:{}): {}", fe_port(), if fe { "ready" } else { "down" }); if cp && sb.is_some() { 0 } else { @@ -1703,7 +1825,7 @@ fn cmd_exec(rest: &[String]) -> i32 { return 2; } if sandbox_health().is_none() { - eprintln!("orcabot: sandbox not reachable on :{SANDBOX_PORT} — run `orcabot up` first"); + eprintln!("orcabot: sandbox not reachable on :{} — run `orcabot up` first", sb_port()); return 1; } let token = match read_debug_token() { @@ -1719,7 +1841,7 @@ fn cmd_exec(rest: &[String]) -> i32 { let command = rest.join(" "); let body = serde_json::json!({ "cmd": command, "timeout_ms": 60000 }); let resp = agent(Duration::from_secs(65)) - .post(&format!("http://127.0.0.1:{}/debug/exec", SANDBOX_PORT)) + .post(&format!("http://127.0.0.1:{}/debug/exec", sb_port())) .set("X-Debug-Exec-Token", &token) .set("Content-Type", "application/json") .send_json(body); @@ -1765,7 +1887,7 @@ const DEV_EMAIL: &str = "desktop@localhost"; const DEV_NAME: &str = "Desktop User"; fn cp_call(method: &str, path: &str, body: Option) -> Result { - let url = format!("http://127.0.0.1:{}{}", CONTROLPLANE_PORT, path); + let url = format!("http://127.0.0.1:{}{}", cp_port(), path); let mut req = agent(Duration::from_secs(15)) .request(method, &url) .set("X-User-ID", DEV_USER) @@ -2060,7 +2182,7 @@ fn open_pty_ws( // Origin + the X-User-ID header as belt-and-suspenders. let url = format!( "ws://127.0.0.1:{}/sessions/{}/ptys/{}/ws?user_id={}", - CONTROLPLANE_PORT, session_id, pty_id, DEV_USER + cp_port(), session_id, pty_id, DEV_USER ); let mut req = url.into_client_request().map_err(|e| e.to_string())?; // This tungstenite client (unlike a browser) CAN set headers on the handshake, @@ -2085,10 +2207,13 @@ fn open_pty_ws( req.headers_mut().insert("X-Orcabot-Surface", v); } } - req.headers_mut().insert( - "Origin", - tungstenite::http::HeaderValue::from_static("http://localhost:8788"), - ); + // Origin must match the control plane's ALLOWED_ORIGINS, which is + // http://localhost: — follow the (possibly dynamic) port. + if let Ok(v) = + tungstenite::http::HeaderValue::from_str(&format!("http://localhost:{}", fe_port())) + { + req.headers_mut().insert("Origin", v); + } let (ws, _resp) = tungstenite::connect(req).map_err(|e| format!("ws connect: {e}"))?; if let tungstenite::stream::MaybeTlsStream::Plain(s) = ws.get_ref() { let _ = s.set_read_timeout(Some(read_timeout)); @@ -2399,7 +2524,7 @@ fn provider_matches(a: &str, b: &str) -> bool { fn connect_url(provider: &str) -> Result { let sub = connect_subpath(provider) .ok_or_else(|| format!("unknown provider '{provider}' (gmail|drive|calendar|github|twitter|box|onedrive)"))?; - let url = format!("http://127.0.0.1:{}/integrations/{}/connect", CONTROLPLANE_PORT, sub); + let url = format!("http://127.0.0.1:{}/integrations/{}/connect", cp_port(), sub); let ag = ureq::AgentBuilder::new() .timeout(Duration::from_secs(10)) .redirects(0) @@ -3114,7 +3239,7 @@ fn run_in_vm(command: &str) -> String { }; let body = serde_json::json!({ "cmd": command, "timeout_ms": 60000 }); match agent(Duration::from_secs(65)) - .post(&format!("http://127.0.0.1:{}/debug/exec", SANDBOX_PORT)) + .post(&format!("http://127.0.0.1:{}/debug/exec", sb_port())) .set("X-Debug-Exec-Token", &token) .set("Content-Type", "application/json") .send_json(body) @@ -3157,14 +3282,20 @@ fn cmd_tui() -> i32 { /// (hand back to the GUI without tearing down). fn run_tui(force_own: bool) -> i32 { let mut we_own = force_own; - if !controlplane_healthy() { - println!("orcabot: starting the stack (it will stop when you quit)…"); - let rc = cmd_up(&[]); + if !stack_running() { + // The pre-check only drives the (cosmetic) message; ownership comes from + // ensure_stack_up's authoritative `spawned` flag, decided inside the launch + // lock — so of two concurrent bare `orcabot`s only the one that actually + // spawned the backend tears it down on quit. + if app_pid().is_none() { + println!("orcabot: starting the stack (it will stop when you quit)…"); + } + let (rc, spawned) = ensure_stack_up(150); if rc != 0 || !controlplane_healthy() { eprintln!("orcabot: could not start the stack (see the log above); not opening the TUI."); return if rc != 0 { rc } else { 1 }; } - we_own = true; + we_own = we_own || spawned; } // The TUI needs a real terminal. If stdout isn't a TTY (piped/redirected), // don't panic in ratatui::init. If we own the stack but can't open a TUI, diff --git a/desktop/app/src-tauri/src/commands.rs b/desktop/app/src-tauri/src/commands.rs index eaa178d0..5befee12 100644 --- a/desktop/app/src-tauri/src/commands.rs +++ b/desktop/app/src-tauri/src/commands.rs @@ -625,6 +625,40 @@ pub fn get_surface_token() -> String { t.to_string() } +#[derive(Serialize, Clone)] +pub struct ServicePorts { + pub controlplane: u16, + pub frontend: u16, + pub sandbox: u16, + pub d1: u16, +} + +fn port_from_env(var: &str, default: u16) -> u16 { + std::env::var(var) + .ok() + .and_then(|v| v.trim().parse().ok()) + .unwrap_or(default) +} + +/// Return the ports the stack actually bound to this boot. The defaults (8787 / +/// 8788 / …) may have been busy, in which case `main.rs` picked free ports and +/// exported them via env. The loading screen reads this to build the redirect +/// (and to hand the control-plane port to the frontend via `?cp=`, since the +/// frontend bakes `:8787` at build time and can't otherwise learn it). +#[tauri::command] +pub fn get_ports() -> ServicePorts { + ServicePorts { + controlplane: port_from_env("CONTROLPLANE_PORT", 8787), + frontend: port_from_env("FRONTEND_PORT", 8788), + sandbox: port_from_env("SANDBOX_PORT", 8080), + // D1_SHIM_ADDR is a host:port; extract the port. + d1: std::env::var("D1_SHIM_ADDR") + .ok() + .and_then(|a| a.rsplit(':').next().and_then(|s| s.trim().parse().ok())) + .unwrap_or(9001), + } +} + /// Open an http(s) URL in the OS default browser. OAuth connect flows use this /// on desktop because `window.open` is a no-op inside the Tauri webview. #[tauri::command] diff --git a/desktop/app/src-tauri/src/main.rs b/desktop/app/src-tauri/src/main.rs index a972554b..2b2252e9 100644 --- a/desktop/app/src-tauri/src/main.rs +++ b/desktop/app/src-tauri/src/main.rs @@ -26,6 +26,25 @@ fn pid_file_path(data_dir: &Path) -> PathBuf { data_dir.join("desktop-services.pid") } +/// File recording the ports the stack actually bound to this boot (some may be +/// dynamic when a default was busy). The `orcabot` CLI reads this so it connects +/// to the right control plane / sandbox / frontend instead of the hardcoded +/// defaults. `key=value` per line; written early (before health) and removed on +/// shutdown alongside the pid file. +fn ports_file_path(data_dir: &Path) -> PathBuf { + data_dir.join("ports") +} + +fn write_ports_file(data_dir: &Path, cp: u16, fe: u16, sandbox: u16, d1: u16) { + let body = format!( + "controlplane={}\nfrontend={}\nsandbox={}\nd1={}\n", + cp, fe, sandbox, d1 + ); + if let Err(e) = std::fs::write(ports_file_path(data_dir), body) { + eprintln!("[ports] failed to write ports file: {}", e); + } +} + /// Path to the persisted SECRETS_ENCRYPTION_KEY. Generated on first launch. /// Losing this file makes all stored user secrets unreadable. fn secrets_key_path(data_dir: &Path) -> PathBuf { @@ -120,6 +139,38 @@ fn passthrough_env(workerd_env: &mut Vec<(&'static str, String)>, key: &'static } } +/// First free TCP port at/after `preferred` on loopback, skipping `used`. Falls +/// back to `preferred` if nothing is free in range (the later bind then fails +/// loudly). Used so the app boots even when a default port is occupied (e.g. a +/// stray `wrangler dev` on 8787) instead of silently failing to start. +fn pick_free_port(preferred: u16, used: &[u16]) -> u16 { + let mut p = preferred; + for _ in 0..200 { + if !used.contains(&p) && std::net::TcpListener::bind(("127.0.0.1", p)).is_ok() { + return p; + } + p = match p.checked_add(1) { + Some(n) => n, + None => break, + }; + } + preferred +} + +/// Ensure `var` holds a usable port. If the user set it explicitly, honor it +/// verbatim (their override). Otherwise pick a free port near `preferred`, +/// avoiding `used`, and store it. Returns the chosen port. +fn ensure_port_env(var: &str, preferred: u16, used: &[u16]) -> u16 { + if let Ok(v) = std::env::var(var) { + if let Ok(p) = v.trim().parse::() { + return p; + } + } + let port = pick_free_port(preferred, used); + std::env::set_var(var, port.to_string()); + port +} + /// Per-boot token that gates dev-auth to the trusted host frontend. Generated /// once at startup, passed to the control-plane worker (`SURFACE_TOKEN`) and /// handed to the GUI webview via the `get_surface_token` command. The sandbox VM @@ -347,6 +398,50 @@ impl DesktopServices { } let d1_db = d1_dir.join("controlplane.sqlite"); + + // Pick free ports BEFORE anything binds, so a stray process on a default + // port (e.g. `wrangler dev` on 8787) doesn't stop the app from starting. + // An explicit CONTROLPLANE_PORT / FRONTEND_PORT / D1_SHIM_ADDR override is + // honored verbatim. The chosen control-plane port is handed to the frontend + // at runtime via the loading screen (?cp=), since it bakes :8787 at build. + let cp_port = ensure_port_env("CONTROLPLANE_PORT", 8787, &[]); + let fe_port = ensure_port_env("FRONTEND_PORT", 8788, &[cp_port]); + let d1_port = match std::env::var("D1_SHIM_ADDR") { + Ok(addr) => addr + .rsplit(':') + .next() + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(9001), + Err(_) => { + let p = pick_free_port(9001, &[cp_port, fe_port]); + std::env::set_var("D1_SHIM_ADDR", format!("127.0.0.1:{}", p)); + p + } + }; + // Sandbox HOST port for the host→guest forward. The GUEST side stays baked at + // 8080 (config.env isn't delivered to the guest — it uses image defaults), so + // only this host TCP port follows a free port. Honors an explicit SANDBOX_PORT. + let sandbox_host_port = ensure_port_env("SANDBOX_PORT", 8080, &[cp_port, fe_port, d1_port]); + // The control plane reaches the sandbox at this host port; point SANDBOX_URL at + // it unless the user pinned one explicitly. + if std::env::var("SANDBOX_URL").is_err() { + std::env::set_var( + "SANDBOX_URL", + format!("http://127.0.0.1:{}", sandbox_host_port), + ); + } + + if cp_port != 8787 || fe_port != 8788 || d1_port != 9001 || sandbox_host_port != 8080 { + eprintln!( + "[ports] a default port was busy — using control-plane={} frontend={} d1-shim={} sandbox={}", + cp_port, fe_port, d1_port, sandbox_host_port + ); + } + + // Persist the bound ports so the `orcabot` CLI (which would otherwise assume + // the hardcoded defaults) connects to this stack correctly. + write_ports_file(&data_dir, cp_port, fe_port, sandbox_host_port, d1_port); + let d1_addr = std::env::var("D1_SHIM_ADDR").unwrap_or_else(|_| "127.0.0.1:9001".to_string()); let d1_shim_debug = std::env::var("D1_SHIM_DEBUG").ok(); @@ -508,6 +603,10 @@ impl DesktopServices { workerd_import_root.to_str().unwrap_or_default(), "--socket-addr", &format!("http=127.0.0.1:{}", controlplane_port), + // The d1-shim external service is hardcoded to 127.0.0.1:9001 in the + // capnp; override it at launch so a dynamically-chosen shim port works. + "--external-addr", + &format!("d1-shim={}", d1_addr), "--directory-path", &format!("do-storage={}", do_storage_dir.display()), workerd_config.to_str().unwrap_or_default(), @@ -563,8 +662,10 @@ impl DesktopServices { let workspace_dir = data_dir.join("workspace"); std::fs::create_dir_all(&workspace_dir)?; - // Build VM configuration - let sandbox_port: u16 = std::env::var("SANDBOX_PORT") + // Build VM configuration. This is the HOST-side sandbox port (the host→guest + // forward listens here); it may be dynamic. The guest sandbox always binds + // 8080 (baked default), which is the guest side of the forward. + let sandbox_host_port: u16 = std::env::var("SANDBOX_PORT") .ok() .and_then(|s| s.parse().ok()) .unwrap_or(8080); @@ -589,11 +690,22 @@ impl DesktopServices { let internal_api_token = std::env::var("INTERNAL_API_TOKEN").unwrap_or_else(|_| "dev-internal-token".to_string()); + // Host-side control-plane port for the guest→host reverse bridge. Matches the + // port the control-plane workerd actually bound to (possibly dynamic). The + // guest side of the bridge stays baked at 8787. + let controlplane_host_port: u16 = std::env::var("CONTROLPLANE_PORT") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(8787); + let mut config = VMConfig::new(staged_paths.image.clone(), workspace_dir) .with_cpus(2) .with_memory(2 * 1024 * 1024 * 1024) // 2GB - .with_port(sandbox_port) - .with_env("PORT", sandbox_port.to_string()) + .with_port(sandbox_host_port) + .with_controlplane_host_port(controlplane_host_port) + // Guest binds 8080 (image default); the host→guest forward maps the dynamic + // host port to that. PORT here is the guest bind, not the host listen. + .with_env("PORT", vm::SANDBOX_GUEST_PORT.to_string()) .with_env("SANDBOX_INTERNAL_TOKEN", sandbox_internal_token) .with_env("ALLOWED_ORIGINS", allowed_origins) .with_env("WORKSPACE_BASE", "/workspace") @@ -719,10 +831,11 @@ impl DesktopServices { } } - // Remove PID file since we've cleaned up + // Remove PID + ports files since we've cleaned up if let Ok(dd) = self.data_dir.lock() { if let Some(ref data_dir) = *dd { let _ = std::fs::remove_file(pid_file_path(data_dir)); + let _ = std::fs::remove_file(ports_file_path(data_dir)); } } } @@ -867,6 +980,7 @@ fn main() { commands::get_surface_token, commands::open_url, commands::reveal_workspace, + commands::get_ports, ]) .setup(|app| { let services = Arc::new(DesktopServices::new()); diff --git a/desktop/app/src-tauri/src/vm/config.rs b/desktop/app/src-tauri/src/vm/config.rs index 436f0a29..280c66e4 100644 --- a/desktop/app/src-tauri/src/vm/config.rs +++ b/desktop/app/src-tauri/src/vm/config.rs @@ -19,6 +19,11 @@ pub struct VMConfig { /// Port to expose from VM to host for sandbox service pub sandbox_port: u16, + /// Host-side TCP port the guest→host reverse bridge forwards to (the real + /// control-plane port). The GUEST side of the bridge stays fixed at 8787 + /// (baked into the image); only this host target follows a dynamic port. + pub controlplane_host_port: u16, + /// Environment variables to pass to sandbox process inside VM pub env: HashMap, @@ -44,6 +49,7 @@ impl VMConfig { cpus: 2, memory_bytes: 2 * 1024 * 1024 * 1024, // 2GB sandbox_port: 8080, + controlplane_host_port: 8787, env: HashMap::new(), kernel_path: None, initrd_path: None, @@ -70,6 +76,12 @@ impl VMConfig { self } + /// Set the host-side control-plane port for the guest→host reverse bridge. + pub fn with_controlplane_host_port(mut self, port: u16) -> Self { + self.controlplane_host_port = port; + self + } + /// Add an environment variable. pub fn with_env(mut self, key: impl Into, value: impl Into) -> Self { self.env.insert(key.into(), value.into()); @@ -114,6 +126,7 @@ impl Default for VMConfig { cpus: 2, memory_bytes: 2 * 1024 * 1024 * 1024, sandbox_port: 8080, + controlplane_host_port: 8787, env: HashMap::new(), kernel_path: None, initrd_path: None, diff --git a/desktop/app/src-tauri/src/vm/linux.rs b/desktop/app/src-tauri/src/vm/linux.rs index 4c81aadc..5dc32d3c 100644 --- a/desktop/app/src-tauri/src/vm/linux.rs +++ b/desktop/app/src-tauri/src/vm/linux.rs @@ -138,12 +138,13 @@ impl QemuVM { ), ]); - // Network with port forwarding + // Network with port forwarding: host TCP (config.sandbox_port, maybe + // dynamic if 8080 was busy) -> guest 8080 (fixed image default). cmd.args([ "-netdev", &format!( "user,id=net0,hostfwd=tcp::{}-:{}", - config.sandbox_port, config.sandbox_port + config.sandbox_port, super::SANDBOX_GUEST_PORT ), ]); cmd.args(["-device", "virtio-net-pci,netdev=net0"]); diff --git a/desktop/app/src-tauri/src/vm/macos.rs b/desktop/app/src-tauri/src/vm/macos.rs index a7d38a9c..03391c18 100644 --- a/desktop/app/src-tauri/src/vm/macos.rs +++ b/desktop/app/src-tauri/src/vm/macos.rs @@ -149,17 +149,21 @@ impl MacOSVM { "workspace:{}", config.workspace_path.display() ), - // Port forward via vsock: host TCP port -> guest vsock port - // The guest runs socat to bridge vsock:port -> localhost:port + // Port forward via vsock: host TCP port -> guest vsock port. The guest + // runs socat to bridge vsock:8080 -> localhost:8080. The host side + // (config.sandbox_port) may be dynamic if 8080 was busy on the host; + // the guest side is fixed at SANDBOX_GUEST_PORT (baked image default). "--port-forward", - &format!("{}:{}", config.sandbox_port, config.sandbox_port), - // Reverse forward: guest vsock:8787 -> host 127.0.0.1:8787 (control plane). - // Gives the sandbox a guest->host route so it can call the control plane - // (integration gateway, egress/secret approvals, event callbacks). The - // guest init runs the matching socat + sets CONTROLPLANE_URL. Both sides - // are pinned to CONTROLPLANE_PORT (see the const for why it's fixed). + &format!("{}:{}", config.sandbox_port, super::SANDBOX_GUEST_PORT), + // Reverse forward: guest vsock:8787 -> host 127.0.0.1:{cp host port} + // (control plane). Gives the sandbox a guest->host route so it can call + // the control plane (integration gateway, egress/secret approvals, event + // callbacks). The guest init runs the matching socat + sets + // CONTROLPLANE_URL. The GUEST side is pinned to CONTROLPLANE_PORT (baked + // into the image; see the const), but the HOST target follows the real + // control-plane port so dynamic-port boots still reach it. "--reverse-port-forward", - &format!("{}:{}", CONTROLPLANE_PORT, CONTROLPLANE_PORT), + &format!("{}:{}", CONTROLPLANE_PORT, config.controlplane_host_port), ]); cmd.stdout(Stdio::inherit()); @@ -184,8 +188,8 @@ impl MacOSVM { // host loopback (nothing exposed on a network interface). eprintln!( "[vm] native VZ backend: inbound vsock (sandbox :{}), reverse vsock \ - (guest→host control plane :8787) active.", - config.sandbox_port + (guest→host control plane :{}) active.", + config.sandbox_port, config.controlplane_host_port ); Ok(()) @@ -237,9 +241,10 @@ impl MacOSVM { // Network with port forwarding cmd.args([ "-netdev", + // host TCP (config.sandbox_port, maybe dynamic) -> guest 8080 (fixed). &format!( "user,id=net0,hostfwd=tcp::{}-:{}", - config.sandbox_port, config.sandbox_port + config.sandbox_port, super::SANDBOX_GUEST_PORT ), ]); cmd.args(["-device", "virtio-net-pci,netdev=net0"]); diff --git a/desktop/app/src-tauri/src/vm/mod.rs b/desktop/app/src-tauri/src/vm/mod.rs index 6530c9f7..b6351b22 100644 --- a/desktop/app/src-tauri/src/vm/mod.rs +++ b/desktop/app/src-tauri/src/vm/mod.rs @@ -22,6 +22,12 @@ pub mod image; pub use config::VMConfig; pub use error::VMError; +/// The port the guest sandbox always binds (baked into the image default; the +/// guest never receives a per-boot override — `config.env` isn't delivered to +/// it). It's the GUEST side of the host→guest port forward; the host side +/// (`VMConfig.sandbox_port`) may be dynamic when 8080 is busy on the host. +pub const SANDBOX_GUEST_PORT: u16 = 8080; + use std::time::Duration; /// Trait for platform-specific VM implementations. diff --git a/frontend/src/config/env.ts b/frontend/src/config/env.ts index ce1a378f..e26f0cf8 100644 --- a/frontend/src/config/env.ts +++ b/frontend/src/config/env.ts @@ -1,8 +1,8 @@ // Copyright 2026 Rob Macrae. All rights reserved. // SPDX-License-Identifier: LicenseRef-Proprietary -// REVISION: desktop-env-v7-user-setup -const MODULE_REVISION = "desktop-env-v7-user-setup"; +// REVISION: desktop-env-v8-dynamic-cp-port +const MODULE_REVISION = "desktop-env-v8-dynamic-cp-port"; console.log( `[env] REVISION: ${MODULE_REVISION} loaded at ${new Date().toISOString()}` ); @@ -38,10 +38,70 @@ const SITE_URL_BY_TARGET: Record = { production: "https://orcabot.com", }; -// API URLs with defaults -export const CLOUDFLARE_API_URL = +// API URLs with defaults. Desktop bakes NEXT_PUBLIC_API_URL=http://127.0.0.1:8787 +// at build time, so the client can't otherwise learn a dynamically-chosen control +// -plane port (the desktop app picks a free port if 8787 is busy). The trusted +// loading screen hands us the real port via ?cp= on the redirect; apply it here. +const BAKED_API_URL = process.env.NEXT_PUBLIC_API_URL || API_URL_BY_TARGET[FRONTEND_TARGET]; +const CP_PORT_STORAGE_KEY = "orcabot-cp-port"; + +/** + * If a `?cp=` override was handed to us (desktop dynamic-port boot), rewrite + * the control-plane base to that port and cache it for later navigations (which + * drop the query string). Strictly gated: only when the baked base is a loopback + * URL, so a cloud origin can never be redirected to an attacker-supplied port. + */ +function resolveApiUrl(): string { + if (typeof window === "undefined") return BAKED_API_URL; + let baseUrl: URL; + try { + baseUrl = new URL(BAKED_API_URL); + } catch { + return BAKED_API_URL; + } + const isLoopback = + baseUrl.hostname === "127.0.0.1" || + baseUrl.hostname === "localhost" || + baseUrl.hostname === "::1"; + // Only ever repoint a local control plane — never a cloud API origin. + if (!isLoopback) return BAKED_API_URL; + + const applyPort = (portStr: string | null): string | null => { + if (!portStr) return null; + const port = Number(portStr); + if (!Number.isInteger(port) || port < 1 || port > 65535) return null; + baseUrl.port = String(port); + // Drop the trailing slash URL() adds so the string matches the baked form. + return baseUrl.origin; + }; + + try { + const params = new URLSearchParams(window.location.search); + const fromUrl = applyPort(params.get("cp")); + if (fromUrl) { + try { + localStorage.setItem(CP_PORT_STORAGE_KEY, baseUrl.port); + } catch { + /* ignore */ + } + const url = new URL(window.location.href); + url.searchParams.delete("cp"); + window.history.replaceState({}, "", url.toString()); + return fromUrl; + } + const fromStore = applyPort(localStorage.getItem(CP_PORT_STORAGE_KEY)); + if (fromStore) return fromStore; + } catch { + /* ignore — fall back to the baked base */ + } + return BAKED_API_URL; +} + +// API URLs with defaults +export const CLOUDFLARE_API_URL = resolveApiUrl(); + export const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || SITE_URL_BY_TARGET[FRONTEND_TARGET];