diff --git a/.env.example b/.env.example index 940108b..eb47673 100644 --- a/.env.example +++ b/.env.example @@ -28,7 +28,7 @@ LAZYBOY_API_KEY= # 網頁 UI / API(make start → lazyboy serve) # ============================================================================= -# HTTP 綁定位址。0.0.0.0 才能用同一 Wi-Fi 的手機開。 +# HTTP 綁定位址。預設 0.0.0.0,同一 Wi-Fi 的手機用印出的 http://LAN:8787。 # LAZYBOY_WEB_HOST=0.0.0.0 # JSON API + 若有 web/dist 時的 UI 埠。Vite 開發前端仍是 5173,會把 /api 代理到這裡。 diff --git a/Makefile b/Makefile index db7e8ef..9e1c3ae 100644 --- a/Makefile +++ b/Makefile @@ -18,7 +18,7 @@ PID_WEB := $(RUNDIR)/web.pid LOG_SERVE := $(RUNDIR)/serve.log LOG_WEB := $(RUNDIR)/web.log -.PHONY: help start stop restart status logs start-serve start-web stop-serve stop-web +.PHONY: help start stop restart status logs prepare-ui start-serve start-web stop-serve stop-web # Shared POSIX helpers. Sourced by each recipe. define CTL @@ -159,10 +159,14 @@ help: @echo "make status Show PIDs and ports" @echo "make logs Tail both logs (Ctrl-C to leave)" -start: start-serve start-web +start: prepare-ui start-serve start-web @echo @echo "API http://127.0.0.1:$(PORT_API)" @echo "UI http://127.0.0.1:$(PORT_WEB)" + @lan=$$(hostname -I 2>/dev/null | awk '{print $$1}'); \ + if [ -n "$$lan" ]; then \ + echo "Phone http://$$lan:$(PORT_API)"; \ + fi restart: stop @$(MAKE) start @@ -182,12 +186,27 @@ logs: @echo "=== web $(LOG_WEB) ===" @tail -f "$(LOG_SERVE)" "$(LOG_WEB)" +prepare-ui: + @if [ ! -d "$(WEBDIR)/node_modules" ]; then \ + echo "npm install…"; \ + (cd "$(WEBDIR)" && npm install) || exit 1; \ + fi + @if [ ! -f "$(WEBDIR)/dist/index.html" ]; then \ + echo "building web UI (once, so :$(PORT_API) serves the app)…"; \ + (cd "$(WEBDIR)" && npm run build) || exit 1; \ + fi + start-serve: @mkdir -p "$(RUNDIR)" @eval "$$CTL"; \ if alive "$(PID_SERVE)"; then \ - echo "serve already running pid=$$(cat "$(PID_SERVE)")"; \ - exit 0; \ + fmt=$$(cat "$$HOME/.lazyboy/certs/format" 2>/dev/null || true); \ + if [ "$$fmt" = "2" ]; then \ + echo "serve already running pid=$$(cat "$(PID_SERVE)")"; \ + exit 0; \ + fi; \ + echo "TLS certs need reissue; restarting serve"; \ + stop_one serve "$(PID_SERVE)" "$(PORT_API)"; \ fi; \ echo "building lazyboy…"; \ cargo build -p lazyboy || exit 1; \ @@ -201,14 +220,19 @@ start-web: @mkdir -p "$(RUNDIR)" @eval "$$CTL"; \ if alive "$(PID_WEB)"; then \ - echo "web already running pid=$$(cat "$(PID_WEB)")"; \ - exit 0; \ + if lsof -nP -iTCP:"$(PORT_WEB)" -sTCP:LISTEN 2>/dev/null | grep -q '127.0.0.1:$(PORT_WEB)'; then \ + echo "web is localhost-only; restarting so the phone can connect"; \ + stop_one web "$(PID_WEB)" "$(PORT_WEB)"; \ + else \ + echo "web already running pid=$$(cat "$(PID_WEB)")"; \ + exit 0; \ + fi; \ fi; \ if [ ! -d "$(WEBDIR)/node_modules" ]; then \ echo "npm install…"; \ (cd "$(WEBDIR)" && npm install) || exit 1; \ fi; \ - nohup npm --prefix "$(WEBDIR)" run dev -- --host 127.0.0.1 --port "$(PORT_WEB)" > "$(LOG_WEB)" 2>&1 & echo $$! > "$(PID_WEB)"; \ + nohup npm --prefix "$(WEBDIR)" run dev -- --host 0.0.0.0 --port "$(PORT_WEB)" > "$(LOG_WEB)" 2>&1 & echo $$! > "$(PID_WEB)"; \ wait_alive "$(PID_WEB)" web "$(LOG_WEB)" || exit 1; \ wait_listen "$(PID_WEB)" "$(PORT_WEB)" web "$(LOG_WEB)" || exit 1; \ echo "started web pid=$$(cat "$(PID_WEB)") :$(PORT_WEB) log=$(LOG_WEB)" diff --git a/README.md b/README.md index 72bac67..818f5ed 100644 --- a/README.md +++ b/README.md @@ -48,9 +48,9 @@ See [team setup and behavior](docs/TEAM.md) for commands, budgets, privacy bound | Explicit local files | `external_list_dir`, `external_read_file`, `external_edit_file`, `external_write_file`, `external_grep` (regex), `external_glob`, `external_search_files` (literal) | | Web | `web_fetch` (direct anonymous HTTP GET, HTML → text, short cache), `web_search` (remote search service); no browser login; Docker browser for login-gated pages | | Background | `spawn_subagent` (returns immediately; `kind=computerUse` for desktop GUI), `check_subagent`, `message_subagent`, `stop_subagent`. Subagents and external command completion revive the turn; observe detached box commands with `await_shell`. | -| Desktop GUI | Parent: `screenshot` (read-only). Clicks: `spawn_subagent kind=computerUse`, which gets `computer` (screenshot/click/move/drag/type/key/scroll/wait via xdotool on `DISPLAY=:1`). One computerUse at a time. Passwords still `request_box_help`. | +| Desktop GUI | Parent: `screenshot` (read-only). Clicks: `spawn_subagent kind=computerUse`, which gets `computer` (screenshot/click/move/drag/type/key/scroll/wait via xdotool on `DISPLAY=:1`). One computerUse per agent computer. Passwords still `request_box_help`. | | MCP | `get_mcp_tools`, `call_mcp_tool`, `get_mcp_server_status`, `add_mcp_server`, `remove_mcp_server`. Config: `~/.lazyboy/mcp.json` (or `LAZYBOY_MCP_CONFIG`). Prefer a connector over the browser for that service. | -| My computer (box) | Docker Linux desktop (XFCE + **Chromium** + 終端機 + xdotool). Default tools: `shell`, `read`, `await_shell`, `screenshot`. Profile: `/home/box/chrome-profile`; the browser helper connects inside Docker to the same desktop browser. Viewer:`lazyboy computer`。 | +| My computer (box) | Docker Linux desktop (XFCE + **Chromium** + 終端機 + xdotool). Named agents each get their own container; opening an agent starts it and the UI confirms ready/error. Session CLI keeps `lazyboy-box`. Default tools: `shell`, `read`, `await_shell`, `screenshot`. Profile: `/home/box/chrome-profile` on that seat. Viewer:`lazyboy computer` or the agent overlay. | | Browser observation | `browser_navigate`, `browser_snapshot`, `browser_read_page`, `browser_tabs` | | Browser actions | `browser_click`, `browser_type`, `browser_press`, `browser_select`, `browser_scroll`, `browser_wait` | | Browser file exchange | `browser_upload`, `browser_download` | diff --git a/crates/lazyboy-core/src/agent.rs b/crates/lazyboy-core/src/agent.rs index aa5504f..6266e92 100644 --- a/crates/lazyboy-core/src/agent.rs +++ b/crates/lazyboy-core/src/agent.rs @@ -60,7 +60,7 @@ external_exec_command waits up to block_until_ms (default 30000ms) then backgrou ## Delegating background work Use spawn_subagent for a self-contained chunk (research, files, a multi-step investigation). It returns immediately with subagent_id. After dispatch, do not sit idle: send_message that you kicked it off, keep working, or end the turn with no tool calls. You are revived automatically when it finishes — never poll check_subagent for completion. check_subagent inspects a running child that may be stuck. message_subagent injects an instruction without aborting. stop_subagent aborts one. This revival is self-triggered, not someone reaching out; if the result is irrelevant and the user was not waiting, end with no send_message. -kind=computerUse delegates a GUI/desktop task that drives MY computer by screenshot, click, drag, type, key, scroll, and wait. Only one computerUse may run at a time (they share the screen); while it runs, leave the screen to it and use screenshot only to check in. Scope the goal tightly — site, exact values, success criteria, stopping point. If it needs a human (password, 2FA, captcha, payment), it stops and reports; then you call request_box_help and dispatch it again to continue. +kind=computerUse delegates a GUI/desktop task that drives MY computer by screenshot, click, drag, type, key, scroll, and wait. Only one computerUse may run at a time on this agent's computer; while it runs, leave the screen to it and use screenshot only to check in. Scope the goal tightly — site, exact values, success criteria, stopping point. If it needs a human (password, 2FA, captcha, payment), it stops and reports; then you call request_box_help and dispatch it again to continue. ## Two computers You have two machines. To the user, call the box \"my computer\" and the launch machine \"your computer\". Never mix paths. diff --git a/crates/lazyboy-core/src/box_runtime.rs b/crates/lazyboy-core/src/box_runtime.rs index 32b4030..f3129b5 100644 --- a/crates/lazyboy-core/src/box_runtime.rs +++ b/crates/lazyboy-core/src/box_runtime.rs @@ -1,144 +1,446 @@ -//! Agent's own Linux computer (Grok Bot box), Docker-backed. -//! User-facing name is always "my computer". Viewer is noVNC on 127.0.0.1:6080. +//! Named-agent Linux computers (Docker boxes). +//! Session CLI keeps `lazyboy-box`. Each named agent gets its own container. use crate::computer::{ settle_before, steps_for, validate_bounds, ComputerAction, ComputerStep, ScreenSize, }; use anyhow::{anyhow, Context, Result}; use serde_json::{json, Value}; +use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::process::Stdio; -use std::sync::Arc; -use std::time::Duration; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, OnceLock}; +use std::time::{Duration, Instant}; use tokio::process::Command; use tokio::sync::Mutex; const IMAGE: &str = "lazyboy-box:local"; -const CONTAINER: &str = "lazyboy-box"; -const VIEWER_PORT: u16 = 6080; +const SESSION_CONTAINER: &str = "lazyboy-box"; +const SESSION_WORKSPACE_VOLUME: &str = "grokboy-box-workspace"; +const SESSION_HOME_VOLUME: &str = "grokboy-box-home"; +const SESSION_VIEWER_PORT: u16 = 6080; +pub const SESSION_SEAT: &str = "session"; const BOX_REVISION: &str = "computer-use-5"; const BROWSER_PROFILE: &str = "/home/box/chrome-profile"; /// Per-stream cap on shell output returned to the model; the middle is elided. const JOB_OUTPUT_HEAD_CHARS: usize = 16_000; const JOB_OUTPUT_TAIL_CHARS: usize = 32_000; +const DEFAULT_MAX_RUNNING: usize = 3; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BoxSeat { + pub id: String, + pub container: String, + pub workspace_volume: String, + pub home_volume: String, + pub bind_port: Option, +} + +impl BoxSeat { + pub fn for_id(id: &str) -> Self { + let id = id.trim(); + if id.is_empty() || id == SESSION_SEAT { + return Self { + id: SESSION_SEAT.into(), + container: SESSION_CONTAINER.into(), + workspace_volume: SESSION_WORKSPACE_VOLUME.into(), + home_volume: SESSION_HOME_VOLUME.into(), + bind_port: Some(SESSION_VIEWER_PORT), + }; + } + let safe = sanitize_seat_id(id); + Self { + id: id.to_string(), + container: format!("lazyboy-box-{safe}"), + workspace_volume: format!("lazyboy-box-workspace-{safe}"), + home_volume: format!("lazyboy-box-home-{safe}"), + bind_port: None, + } + } + + pub fn web_viewer_url(&self) -> String { + if self.id == SESSION_SEAT { + "/novnc/vnc.html?autoconnect=true&resize=off&reconnect=true&show_dot=false&path=novnc/websockify" + .into() + } else { + let id = &self.id; + format!( + "/novnc/{id}/vnc.html?autoconnect=true&resize=off&reconnect=true&show_dot=false&path=novnc/{id}/websockify" + ) + } + } +} + +pub fn sanitize_seat_id(id: &str) -> String { + let mapped: String = id + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '-' + } + }) + .collect(); + let trimmed = mapped.trim_matches('-'); + if trimmed.is_empty() { + "agent".into() + } else { + trimmed.chars().take(64).collect() + } +} pub struct BoxHub { + seat: BoxSeat, inner: Mutex, + computer_use: AtomicBool, } struct BoxState { ready: bool, + starting: bool, + last_error: Option, + last_used: Instant, + host_port: Option, } impl BoxHub { pub fn new() -> Arc { + Self::for_seat(BoxSeat::for_id(SESSION_SEAT)) + } + + pub fn for_seat(seat: BoxSeat) -> Arc { Arc::new(Self { - inner: Mutex::new(BoxState { ready: false }), + seat, + inner: Mutex::new(BoxState { + ready: false, + starting: false, + last_error: None, + last_used: Instant::now(), + host_port: None, + }), + computer_use: AtomicBool::new(false), }) } - pub fn viewer_url() -> String { - format!("http://127.0.0.1:{VIEWER_PORT}/vnc.html?autoconnect=true&resize=scale") + pub fn seat_id(&self) -> &str { + &self.seat.id } - fn ready_payload() -> Value { + pub fn container(&self) -> &str { + &self.seat.container + } + + pub fn web_viewer_url(&self) -> String { + self.seat.web_viewer_url() + } + + pub fn viewer_url() -> String { + format!("http://127.0.0.1:{SESSION_VIEWER_PORT}/vnc.html?autoconnect=true&resize=scale") + } + + pub fn host_viewer_url_for_port(port: u16) -> String { + format!("http://127.0.0.1:{port}/vnc.html?autoconnect=true&resize=scale") + } + + pub fn try_begin_computer_use(&self) -> Result<()> { + self.computer_use + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .map_err(|_| { + anyhow!( + "A computerUse subagent is already using this agent's desktop. Only one can run at a time on the same computer." + ) + })?; + Ok(()) + } + + pub fn end_computer_use(&self) { + self.computer_use.store(false, Ordering::SeqCst); + } + + pub fn has_computer_use(&self) -> bool { + self.computer_use.load(Ordering::SeqCst) + } + + fn ready_payload(&self) -> Value { json!({ "ready": true, - "viewer_url": Self::viewer_url(), + "state": "ready", + "viewer_url": self.web_viewer_url(), + "host_viewer_url": Self::host_viewer_url_for_port( + self.seat.bind_port.unwrap_or(SESSION_VIEWER_PORT) + ), "browser_surface": "docker", "workspace": "/workspace", "browser_profile": BROWSER_PROFILE, "revision": BOX_REVISION, + "seat": self.seat.id, + "container": self.seat.container, }) } - pub async fn ensure_ready(&self) -> Result { - let mut state = self.inner.lock().await; - if state.ready && docker_running(CONTAINER).await? { - // One cheap probe on the hot path; only a restarted container pays - // for the full readiness wait. - if !desktop_up().await { - wait_desktop().await?; - } - return Ok(Self::ready_payload()); + fn status_payload(&self, state: &str, ready: bool, error: Option, crowded: bool) -> Value { + let mut payload = json!({ + "ready": ready, + "state": state, + "viewer_url": self.web_viewer_url(), + "browser_surface": "docker", + "workspace": "/workspace", + "browser_profile": BROWSER_PROFILE, + "revision": BOX_REVISION, + "seat": self.seat.id, + "container": self.seat.container, + "crowded": crowded, + }); + if let Some(error) = error { + payload["error"] = json!(error); } + payload + } + + /// Read-only. Never starts the container. + pub async fn inspect(&self) -> Value { + let (starting, last_error) = { + let state = self.inner.lock().await; + (state.starting, state.last_error.clone()) + }; + if starting { + return self.status_payload("starting", false, None, false); + } + match docker_running(&self.seat.container).await { + Ok(true) => { + if desktop_up(&self.seat.container).await { + let mut state = self.inner.lock().await; + state.ready = true; + if let Some(port) = host_port_of(&self.seat.container).await { + state.host_port = Some(port); + } + let mut payload = self.ready_payload(); + if let Some(port) = state.host_port { + payload["host_viewer_url"] = json!(Self::host_viewer_url_for_port(port)); + } + payload + } else { + self.status_payload("starting", false, None, false) + } + } + Ok(false) => { + if let Some(error) = last_error { + self.status_payload("error", false, Some(error), false) + } else { + self.status_payload("stopped", false, None, false) + } + } + Err(error) => self.status_payload("error", false, Some(error.to_string()), false), + } + } + + pub async fn ensure_ready(&self) -> Result { + { + let mut state = self.inner.lock().await; + if state.ready && docker_running(&self.seat.container).await.unwrap_or(false) { + if !desktop_up(&self.seat.container).await { + state.starting = true; + drop(state); + if let Err(error) = wait_desktop(&self.seat.container).await { + let mut state = self.inner.lock().await; + state.starting = false; + state.ready = false; + state.last_error = Some(error.to_string()); + return Err(error); + } + let mut state = self.inner.lock().await; + state.starting = false; + state.ready = true; + state.last_used = Instant::now(); + state.last_error = None; + return Ok(self.finish_payload(&mut state)); + } + state.last_used = Instant::now(); + return Ok(self.finish_payload(&mut state)); + } + state.starting = true; + state.last_error = None; + } + let result = self.bring_up().await; + let mut state = self.inner.lock().await; + state.starting = false; + match result { + Ok(()) => { + state.ready = true; + state.last_used = Instant::now(); + state.last_error = None; + Ok(self.finish_payload(&mut state)) + } + Err(error) => { + state.ready = false; + state.last_error = Some(error.to_string()); + Err(error) + } + } + } + + async fn bring_up(&self) -> Result<()> { docker_info().await?; - // Several processes (CLI, browser helper spawn, tests) each own a - // BoxHub. Without a host-wide lock they build the image twice and - // race each other removing the stale container. let _provision = ProvisionLock::acquire().await?; ensure_image().await?; - ensure_container().await?; - wait_desktop().await?; - state.ready = true; - let mut payload = Self::ready_payload(); + let port = ensure_container(&self.seat).await?; + wait_desktop(&self.seat.container).await?; + self.inner.lock().await.host_port = Some(port); + Ok(()) + } + + fn finish_payload(&self, state: &mut BoxState) -> Value { + let mut payload = self.ready_payload(); payload["instruction"] = json!( "This is my computer. Paths here are /workspace and /home/box, not the user's machine." ); - Ok(payload) + if let Some(port) = state.host_port.or(self.seat.bind_port) { + payload["host_viewer_url"] = json!(Self::host_viewer_url_for_port(port)); + } + payload } /// Reboot the existing container. Volumes (workspace + Chrome profile) stay. pub async fn restart(&self) -> Result { - let mut state = self.inner.lock().await; - docker_info().await?; - let _provision = ProvisionLock::acquire().await?; - state.ready = false; - if docker(["container", "inspect", CONTAINER]).await.is_ok() { - docker(["restart", "-t", "20", CONTAINER]).await?; - } else { - ensure_image().await?; - ensure_container().await?; + { + let mut state = self.inner.lock().await; + state.ready = false; + state.starting = true; + state.last_error = None; } - wait_desktop().await?; - state.ready = true; - let mut payload = Self::ready_payload(); - payload["action"] = json!("restart"); - Ok(payload) + let result = async { + docker_info().await?; + let _provision = ProvisionLock::acquire().await?; + if docker(["container", "inspect", self.container()]).await.is_ok() { + docker(["restart", "-t", "20", self.container()]).await?; + } else { + ensure_image().await?; + let port = ensure_container(&self.seat).await?; + self.inner.lock().await.host_port = Some(port); + } + wait_desktop(self.container()).await?; + if let Some(port) = host_port_of(self.container()).await { + self.inner.lock().await.host_port = Some(port); + } + Ok(()) + } + .await; + self.finish_action(result, "restart").await } - /// Rebuild the image from the repo Dockerfile. Recreate the container only - /// when the image id actually changed. Volumes stay. + /// Rebuild the image from the repo Dockerfile. Recreate this seat's + /// container only when the image id actually changed. Volumes stay. pub async fn update(&self) -> Result { - let mut state = self.inner.lock().await; - docker_info().await?; - let _provision = ProvisionLock::acquire().await?; - state.ready = false; - let before = local_image_id().await; - build_image().await?; - let after = local_image_id().await; - let image_changed = before != after; - if image_changed { - if docker(["container", "inspect", CONTAINER]).await.is_ok() { - remove_stale_container().await?; - } - ensure_container().await?; - } else if !docker_running(CONTAINER).await? { - ensure_container().await?; + { + let mut state = self.inner.lock().await; + state.ready = false; + state.starting = true; + state.last_error = None; } - wait_desktop().await?; - state.ready = true; - let mut payload = Self::ready_payload(); - payload["action"] = json!("update"); - payload["updated"] = json!(image_changed); - Ok(payload) + let result = async { + docker_info().await?; + let _provision = ProvisionLock::acquire().await?; + let before = local_image_id().await; + build_image().await?; + let after = local_image_id().await; + let image_changed = before != after; + if image_changed { + if docker(["container", "inspect", self.container()]).await.is_ok() { + remove_stale_container(self.container()).await?; + } + let port = ensure_container(&self.seat).await?; + self.inner.lock().await.host_port = Some(port); + } else if !docker_running(self.container()).await? { + let port = ensure_container(&self.seat).await?; + self.inner.lock().await.host_port = Some(port); + } + wait_desktop(self.container()).await?; + Ok(image_changed) + } + .await; + match result { + Ok(image_changed) => { + let mut payload = self.finish_action(Ok(()), "update").await?; + payload["updated"] = json!(image_changed); + Ok(payload) + } + Err(error) => self.finish_action(Err(error), "update").await, + } + } + + async fn finish_action(&self, result: Result<()>, action: &str) -> Result { + let mut state = self.inner.lock().await; + state.starting = false; + match result { + Ok(()) => { + state.ready = true; + state.last_used = Instant::now(); + state.last_error = None; + let mut payload = self.finish_payload(&mut state); + payload["action"] = json!(action); + Ok(payload) + } + Err(error) => { + state.ready = false; + state.last_error = Some(error.to_string()); + Err(error) + } + } + } + + pub async fn stop_keep_volumes(&self) -> Result<()> { + let mut state = self.inner.lock().await; + state.ready = false; + state.starting = false; + drop(state); + if docker_running(self.container()).await.unwrap_or(false) { + docker(["stop", "-t", "20", self.container()]).await?; + } + Ok(()) + } + + pub async fn wipe(&self) -> Result<()> { + let _ = self.stop_keep_volumes().await; + if docker(["container", "inspect", self.container()]).await.is_ok() { + let _ = docker(["rm", "-f", self.container()]).await; + } + let _ = docker(["volume", "rm", "-f", &self.seat.workspace_volume]).await; + let _ = docker(["volume", "rm", "-f", &self.seat.home_volume]).await; + let mut state = self.inner.lock().await; + state.ready = false; + state.host_port = None; + state.last_error = None; + Ok(()) + } + + async fn last_used(&self) -> Instant { + self.inner.lock().await.last_used + } + + pub async fn host_port(&self) -> Option { + if let Some(port) = self.inner.lock().await.host_port { + return Some(port); + } + host_port_of(self.container()).await } pub async fn ensure_browser_ready(&self) -> Result<()> { self.ensure_ready().await?; let probe = ["python3", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:9222/json/version', timeout=1).close()"]; - if docker_exec(&probe).await?.status == 0 { return Ok(()); } + if self.exec(&probe).await?.status == 0 { return Ok(()); } // The desktop autostart may already be launching Chromium; a second // launcher would race it for the profile. box-chrome serialises // launches, but skipping the launch entirely is cheaper. - let already_launching = docker_exec(&[ + let already_launching = self.exec(&[ "pgrep", "-f", "--", &format!("--user-data-dir={BROWSER_PROFILE}"), ]).await?.status == 0; if !already_launching { - docker_exec(&["bash", "-lc", "nohup box-chrome /tmp/lazyboy-browser.log 2>&1 &"]).await?; + self.exec(&["bash", "-lc", "nohup box-chrome /tmp/lazyboy-browser.log 2>&1 &"]).await?; } for _ in 0..80 { - if docker_exec(&probe).await?.status == 0 { return Ok(()); } + if self.exec(&probe).await?.status == 0 { return Ok(()); } tokio::time::sleep(Duration::from_millis(250)).await; } Err(anyhow!("Box Chromium did not start. Inspect /tmp/lazyboy-browser.log on the box; no local browser was opened.")) @@ -150,7 +452,7 @@ impl BoxHub { // All commands run detached in the box. A foreground wait expiring must // never kill an install or silently move it onto the user's computer. // The exit file is renamed into place so a poller never sees it empty. - docker_exec(&["bash", "-lc", &format!( + self.exec(&["bash", "-lc", &format!( "mkdir -p /tmp/gb-jobs/{id} && {{ nohup bash -lc {q} /tmp/gb-jobs/{id}/stdout 2>/tmp/gb-jobs/{id}/stderr & }}", q = shell_quote(&format!( "cd /workspace && bash -lc {}; code=$?; printf '%s\\n' \"$code\" >/tmp/gb-jobs/{id}/exit.tmp && mv -f /tmp/gb-jobs/{id}/exit.tmp /tmp/gb-jobs/{id}/exit", @@ -213,7 +515,7 @@ else: status['full_output'] = {'stdout': str(root / 'stdout'), 'stderr': str(root / 'stderr')} print(json.dumps(status)) "#; - let out = docker_exec(&[ + let out = self.exec(&[ "python3", "-c", py, @@ -243,7 +545,7 @@ chunk = lines[start:start+{limit}] print(json.dumps({{'path': str(p), 'offset': start+1, 'lines': len(lines), 'content': chr(10).join(chunk)}})) "# ); - let out = docker_exec(&["python3", "-c", &py, &path]).await?; + let out = self.exec(&["python3", "-c", &py, &path]).await?; if out.status != 0 { return Err(anyhow!(out.stderr.trim().to_string())); } @@ -265,11 +567,11 @@ print(json.dumps({{'path': str(p), 'offset': start+1, 'lines': len(lines), 'cont .unwrap_or("file") ), }; - docker(["exec", CONTAINER, "mkdir", "-p", parent_dir(&dest)]).await?; + docker(["exec", self.container(), "mkdir", "-p", parent_dir(&dest)]).await?; docker([ "cp", &host_path.to_string_lossy(), - &format!("{CONTAINER}:{dest}"), + &format!("{}:{dest}", self.container()), ]) .await?; Ok(json!({"box_path": dest, "bytes": std::fs::metadata(host_path)?.len()})) @@ -283,7 +585,7 @@ print(json.dumps({{'path': str(p), 'offset': start+1, 'lines': len(lines), 'cont } docker([ "cp", - &format!("{CONTAINER}:{src}"), + &format!("{}:{src}", self.container()), &host_path.to_string_lossy(), ]) .await?; @@ -299,10 +601,10 @@ print(json.dumps({{'path': str(p), 'offset': start+1, 'lines': len(lines), 'cont self.ensure_ready().await?; let shot = ShotFile::new(); self.capture_root_png(&shot).await?; - shot.fetch(dest).await?; + shot.fetch(self.container(), dest).await?; Ok(json!({ "path": dest, - "viewer_url": Self::viewer_url(), + "viewer_url": self.web_viewer_url(), })) } @@ -322,13 +624,13 @@ print(json.dumps({{'path': str(p), 'offset': start+1, 'lines': len(lines), 'cont if !actions.iter().any(ComputerAction::is_screenshot) { self.capture_root_png(&shot).await?; } - shot.fetch(dest).await?; + shot.fetch(self.container(), dest).await?; let cursor = self.cursor_position().await.ok(); Ok(json!({ "ok": true, "actions": names, "path": dest, - "viewer_url": Self::viewer_url(), + "viewer_url": self.web_viewer_url(), "cursor_position": cursor, "screen": {"width": screen.width, "height": screen.height}, "instruction": "Computer action ran on my computer. Read this screenshot before the next action. A batched then sequence returns only this final screen." @@ -342,7 +644,7 @@ print(json.dumps({{'path': str(p), 'offset': start+1, 'lines': len(lines), 'cont ComputerStep::SleepMs(0) => {} ComputerStep::SleepMs(ms) => { let secs = format!("{:.3}", f64::from(ms) / 1000.0); - let out = docker_exec(&[ + let out = self.exec(&[ "python3", "-c", "import sys,time; time.sleep(float(sys.argv[1]))", @@ -356,7 +658,7 @@ print(json.dumps({{'path': str(p), 'offset': start+1, 'lines': len(lines), 'cont ComputerStep::Xdotool(args) => { let mut argv: Vec<&str> = vec!["xdotool"]; argv.extend(args.iter().map(String::as_str)); - let out = docker_exec_env(&[("DISPLAY", ":1")], &argv).await?; + let out = self.exec_env(&[("DISPLAY", ":1")], &argv).await?; if out.status != 0 { let detail = out.stderr.trim(); if detail.contains("xdotool") && detail.contains("not found") { @@ -381,13 +683,13 @@ print(json.dumps({{'path': str(p), 'offset': start+1, 'lines': len(lines), 'cont } async fn display_geometry(&self) -> Result { - let out = docker_exec_env(&[("DISPLAY", ":1")], &["xdotool", "getdisplaygeometry"]).await?; + let out = self.exec_env(&[("DISPLAY", ":1")], &["xdotool", "getdisplaygeometry"]).await?; if out.status == 0 { if let Ok(size) = ScreenSize::parse(&out.stdout) { return Ok(size); } } - let info = docker_exec(&["bash", "-lc", "xdpyinfo -display :1 | awk '/dimensions:/{print $2}'"]).await?; + let info = self.exec(&["bash", "-lc", "xdpyinfo -display :1 | awk '/dimensions:/{print $2}'"]).await?; let raw = info.stdout.trim().replace('x', " "); ScreenSize::parse(&raw).map_err(|e| { anyhow!("could not read my computer's display size ({e}). Is the desktop up?") @@ -395,7 +697,7 @@ print(json.dumps({{'path': str(p), 'offset': start+1, 'lines': len(lines), 'cont } async fn cursor_position(&self) -> Result { - let out = docker_exec_env(&[("DISPLAY", ":1")], &["xdotool", "getmouselocation", "--shell"]).await?; + let out = self.exec_env(&[("DISPLAY", ":1")], &["xdotool", "getmouselocation", "--shell"]).await?; if out.status != 0 { return Err(anyhow!(out.stderr.trim().to_string())); } @@ -413,7 +715,7 @@ print(json.dumps({{'path': str(p), 'offset': start+1, 'lines': len(lines), 'cont } async fn capture_root_png(&self, shot: &ShotFile) -> Result<()> { - let out = docker_exec(&[ + let out = self.exec(&[ "import", "-display", ":1", @@ -430,6 +732,14 @@ print(json.dumps({{'path': str(p), 'offset': start+1, 'lines': len(lines), 'cont } Ok(()) } + + async fn exec(&self, args: &[&str]) -> Result { + docker_exec(self.container(), args).await + } + + async fn exec_env(&self, env: &[(&str, &str)], args: &[&str]) -> Result { + docker_exec_env(self.container(), env, args).await + } } /// A per-call screenshot file on the box. Concurrent callers (a parent @@ -445,17 +755,17 @@ impl ShotFile { } } - async fn fetch(&self, dest: &Path) -> Result<()> { + async fn fetch(&self, container: &str, dest: &Path) -> Result<()> { if let Some(parent) = dest.parent() { std::fs::create_dir_all(parent)?; } let copied = docker([ "cp", - &format!("{CONTAINER}:{}", self.box_path), + &format!("{container}:{}", self.box_path), &dest.to_string_lossy(), ]) .await; - let _ = docker_exec(&["rm", "-f", &self.box_path]).await; + let _ = docker_exec(container, &["rm", "-f", &self.box_path]).await; copied.map(|_| ()) } } @@ -625,14 +935,26 @@ async fn build_image() -> Result<()> { Ok(()) } -async fn container_image_id() -> Option { - docker(["inspect", "-f", "{{.Image}}", CONTAINER]) +async fn container_image_id(container: &str) -> Option { + docker(["inspect", "-f", "{{.Image}}", container]) .await .ok() .map(|out| out.stdout.trim().to_string()) .filter(|s| !s.is_empty()) } +async fn host_port_of(container: &str) -> Option { + let out = docker([ + "inspect", + "-f", + "{{(index (index .NetworkSettings.Ports \"6080/tcp\") 0).HostPort}}", + container, + ]) + .await + .ok()?; + out.stdout.trim().parse().ok() +} + async fn local_image_id() -> Option { docker(["inspect", "-f", "{{.Id}}", IMAGE]) .await @@ -641,37 +963,45 @@ async fn local_image_id() -> Option { .filter(|s| !s.is_empty()) } -async fn ensure_container() -> Result<()> { - if docker_running(CONTAINER).await? - && container_image_id().await == local_image_id().await +async fn ensure_container(seat: &BoxSeat) -> Result { + if docker_running(&seat.container).await? + && container_image_id(&seat.container).await == local_image_id().await { - return Ok(()); + if let Some(port) = host_port_of(&seat.container).await.or(seat.bind_port) { + return Ok(port); + } } - if docker(["container", "inspect", CONTAINER]).await.is_ok() - && container_image_id().await != local_image_id().await + if docker(["container", "inspect", &seat.container]).await.is_ok() + && container_image_id(&seat.container).await != local_image_id().await { - remove_stale_container().await?; + remove_stale_container(&seat.container).await?; } - if docker_running(CONTAINER).await? { - return Ok(()); + if docker_running(&seat.container).await? { + if let Some(port) = host_port_of(&seat.container).await.or(seat.bind_port) { + return Ok(port); + } } - if docker(["container", "inspect", "grokboy-box"]).await.is_ok() { + if seat.id == SESSION_SEAT && docker(["container", "inspect", "grokboy-box"]).await.is_ok() { let _ = docker(["rm", "-f", "grokboy-box"]).await; } + let publish = match seat.bind_port { + Some(port) => format!("127.0.0.1:{port}:6080"), + None => "127.0.0.1::6080".into(), + }; // The container name is Docker's cross-process creation lock. Losing a // create race must reuse the winner, never remove its container. let created = docker([ "create", "--name", - CONTAINER, + &seat.container, "--shm-size=2g", "-p", - &format!("127.0.0.1:{VIEWER_PORT}:6080"), + &publish, "--stop-timeout=20", "-v", - "grokboy-box-workspace:/workspace", + &format!("{}:/workspace", seat.workspace_volume), "-v", - "grokboy-box-home:/home/box", + &format!("{}:/home/box", seat.home_volume), IMAGE, ]) .await; @@ -679,7 +1009,7 @@ async fn ensure_container() -> Result<()> { // Docker reserves the name before inspect can see the new container. let mut visible = false; for _ in 0..50 { - if docker(["container", "inspect", CONTAINER]).await.is_ok() { + if docker(["container", "inspect", &seat.container]).await.is_ok() { visible = true; break; } @@ -690,8 +1020,11 @@ async fn ensure_container() -> Result<()> { } } // Starting an existing container also preserves its writable layer. - docker(["start", CONTAINER]).await?; - Ok(()) + docker(["start", &seat.container]).await?; + host_port_of(&seat.container) + .await + .or(seat.bind_port) + .ok_or_else(|| anyhow!("my computer started but the viewer port is missing")) } /// X server, window manager, VNC and noVNC all answering. The WM check @@ -700,8 +1033,8 @@ const DESKTOP_PROBE: &str = "xdpyinfo -display :1 >/dev/null 2>&1 \ && xprop -display :1 -root _NET_SUPPORTING_WM_CHECK 2>/dev/null | grep -q 'window id' \ && python3 -c 'import urllib.request, socket; urllib.request.urlopen(\"http://127.0.0.1:6080/vnc.html\", timeout=1).close(); socket.create_connection((\"127.0.0.1\", 5900), timeout=1).close()'"; -async fn desktop_up() -> bool { - docker_exec(&["bash", "-c", DESKTOP_PROBE]) +async fn desktop_up(container: &str) -> bool { + docker_exec(container, &["bash", "-c", DESKTOP_PROBE]) .await .map(|o| o.status == 0) .unwrap_or(false) @@ -710,30 +1043,30 @@ async fn desktop_up() -> bool { /// Stop and remove the container built from an older image. Another process /// (outside our provisioning lock, e.g. an older binary) may be doing the /// same; "already in progress" or "no such container" both mean it is gone. -async fn remove_stale_container() -> Result<()> { - if docker_running(CONTAINER).await? { - if let Err(error) = docker(["stop", "-t", "20", CONTAINER]).await { +async fn remove_stale_container(container: &str) -> Result<()> { + if docker_running(container).await? { + if let Err(error) = docker(["stop", "-t", "20", container]).await { if !is_concurrent_removal(&error) { return Err(error); } } } - if let Err(error) = docker(["rm", CONTAINER]).await { + if let Err(error) = docker(["rm", container]).await { if !is_concurrent_removal(&error) { return Err(error); } } for _ in 0..100 { // Gone, or already replaced by a container on the current image. - if docker(["container", "inspect", CONTAINER]).await.is_err() - || container_image_id().await == local_image_id().await + if docker(["container", "inspect", container]).await.is_err() + || container_image_id(container).await == local_image_id().await { return Ok(()); } tokio::time::sleep(Duration::from_millis(200)).await; } Err(anyhow!( - "stale container {CONTAINER} is still being removed; retry in a moment" + "stale container {container} is still being removed; retry in a moment" )) } @@ -744,9 +1077,9 @@ fn is_concurrent_removal(error: &anyhow::Error) -> bool { || text.contains("is already stopped") } -async fn wait_desktop() -> Result<()> { +async fn wait_desktop(container: &str) -> Result<()> { for _ in 0..120 { - if desktop_up().await { + if desktop_up(container).await { return Ok(()); } tokio::time::sleep(Duration::from_millis(250)).await; @@ -791,17 +1124,116 @@ where Ok(result) } -async fn docker_exec(args: &[&str]) -> Result { - docker_exec_env(&[], args).await +fn max_running() -> usize { + std::env::var("LAZYBOY_COMPUTER_MAX") + .ok() + .and_then(|s| s.parse().ok()) + .filter(|&n: &usize| n > 0) + .unwrap_or(DEFAULT_MAX_RUNNING) } -async fn docker_exec_env(env: &[(&str, &str)], args: &[&str]) -> Result { +pub struct BoxPool { + hubs: std::sync::Mutex>>, +} + +impl BoxPool { + pub fn new() -> Arc { + Arc::new(Self { + hubs: std::sync::Mutex::new(HashMap::new()), + }) + } + + pub fn global() -> Arc { + static POOL: OnceLock> = OnceLock::new(); + POOL.get_or_init(Self::new).clone() + } + + pub fn hub(&self, seat_id: &str) -> Arc { + let seat = BoxSeat::for_id(seat_id); + let key = seat.id.clone(); + self.hubs + .lock() + .unwrap() + .entry(key) + .or_insert_with(|| BoxHub::for_seat(seat)) + .clone() + } + + pub async fn inspect(&self, seat_id: &str) -> Value { + self.hub(seat_id).inspect().await + } + + pub async fn start(&self, seat_id: &str) -> Result { + let crowded = self.reclaim(seat_id).await; + let mut payload = self.hub(seat_id).ensure_ready().await?; + if crowded { + payload["crowded"] = json!(true); + } + Ok(payload) + } + + pub async fn restart(&self, seat_id: &str) -> Result { + self.hub(seat_id).restart().await + } + + pub async fn update(&self, seat_id: &str) -> Result { + self.hub(seat_id).update().await + } + + pub async fn drop_seat(&self, seat_id: &str) -> Result<()> { + if BoxSeat::for_id(seat_id).id == SESSION_SEAT { + return Ok(()); + } + let hub = self.hub(seat_id); + hub.wipe().await?; + self.hubs + .lock() + .unwrap() + .remove(&BoxSeat::for_id(seat_id).id); + Ok(()) + } + + async fn reclaim(&self, keep: &str) -> bool { + let max = max_running(); + let keep = BoxSeat::for_id(keep).id; + loop { + let hubs: Vec> = self.hubs.lock().unwrap().values().cloned().collect(); + let mut running = Vec::new(); + for hub in hubs { + if docker_running(hub.container()).await.unwrap_or(false) { + running.push((hub.last_used().await, hub)); + } + } + if running.len() < max { + return false; + } + running.sort_by_key(|(used, _)| *used); + let victim = running.into_iter().find(|(_, hub)| { + hub.seat_id() != keep && hub.seat_id() != SESSION_SEAT && !hub.has_computer_use() + }); + match victim { + Some((_, hub)) => { + if hub.stop_keep_volumes().await.is_err() { + return true; + } + } + None => return true, + } + } + } +} + +async fn docker_exec(container: &str, args: &[&str]) -> Result { + docker_exec_env(container, &[], args).await +} + +async fn docker_exec_env(container: &str, env: &[(&str, &str)], args: &[&str]) -> Result { let mut all = vec!["exec".to_string()]; for (key, value) in env { all.push("-e".into()); all.push(format!("{key}={value}")); } - all.push(CONTAINER.to_string()); + all.push(container.to_string()); all.extend(args.iter().map(|s| (*s).to_string())); let mut cmd = Command::new("docker"); cmd.args(&all) @@ -892,10 +1324,57 @@ mod tests { #[test] fn ready_payload_names_the_box() { - let payload = BoxHub::ready_payload(); + let hub = BoxHub::new(); + let payload = hub.ready_payload(); assert_eq!(payload["ready"], true); assert_eq!(payload["workspace"], "/workspace"); assert_eq!(payload["revision"], BOX_REVISION); - assert!(payload["viewer_url"].as_str().unwrap().contains("6080")); + assert_eq!(payload["seat"], SESSION_SEAT); + assert!(payload["viewer_url"].as_str().unwrap().contains("/novnc/")); + } + + #[test] + fn named_agents_get_isolated_seats() { + let a = BoxSeat::for_id("11111111-1111-1111-1111-111111111111"); + let b = BoxSeat::for_id("22222222-2222-2222-2222-222222222222"); + assert_ne!(a.container, b.container); + assert_ne!(a.home_volume, b.home_volume); + assert_ne!(a.workspace_volume, b.workspace_volume); + assert!(a.container.starts_with("lazyboy-box-")); + assert_eq!(BoxSeat::for_id("session").container, SESSION_CONTAINER); + assert_eq!(BoxSeat::for_id("").container, SESSION_CONTAINER); + assert_eq!(sanitize_seat_id("../etc"), "etc"); + } + + #[test] + fn pool_reuses_the_same_hub() { + let pool = BoxPool::new(); + let one = pool.hub("agent-a"); + let two = pool.hub("agent-a"); + assert!(Arc::ptr_eq(&one, &two)); + assert!(!Arc::ptr_eq(&one, &pool.hub("agent-b"))); + } + + #[tokio::test] + async fn inspect_does_not_start_a_container() { + let hub = BoxHub::for_seat(BoxSeat::for_id("missing-agent")); + let status = hub.inspect().await; + assert_eq!(status["ready"], false); + assert!( + status["state"] == "stopped" || status["state"] == "error", + "{status}" + ); + assert_eq!(status["seat"], "missing-agent"); + } + + #[test] + fn computer_use_lock_is_per_hub() { + let a = BoxHub::for_seat(BoxSeat::for_id("a")); + let b = BoxHub::for_seat(BoxSeat::for_id("b")); + a.try_begin_computer_use().unwrap(); + assert!(a.try_begin_computer_use().is_err()); + b.try_begin_computer_use().unwrap(); + a.end_computer_use(); + a.try_begin_computer_use().unwrap(); } } diff --git a/crates/lazyboy-core/src/browser_client.rs b/crates/lazyboy-core/src/browser_client.rs index af11cd3..3b9a5b6 100644 --- a/crates/lazyboy-core/src/browser_client.rs +++ b/crates/lazyboy-core/src/browser_client.rs @@ -22,6 +22,7 @@ struct Helper { input: ChildStdin, output: BufReader, next: u64, + container: Option, } impl Drop for Helper { fn drop(&mut self) { @@ -90,16 +91,17 @@ impl BrowserClient { .await; } } - pub async fn request( + pub async fn request_on( &self, cwd: &Path, profile: Option, mut req: Value, + container: Option<&str>, ) -> Result { let mut slot = self.state.lock().await; let mut helper = match slot.take() { - Some(h) => h, - None => spawn(cwd, profile).await?, + Some(h) if container.is_none() || h.container.as_deref() == container => h, + Some(_) | None => spawn(cwd, profile, container).await?, }; helper.next += 1; let id = helper.next.to_string(); @@ -138,17 +140,20 @@ impl BrowserClient { Ok(reply) } } -async fn spawn(cwd: &Path, profile: Option) -> Result { +async fn spawn(cwd: &Path, profile: Option, seat_id: Option<&str>) -> Result { if local_browser_enabled() { let script = crate::browser::find_helper_script(cwd) .ok_or_else(|| anyhow!("{}", crate::browser::INSTALL_HINT))?; return spawn_script(cwd, &script, profile).await; } - crate::box_runtime::BoxHub::new().ensure_browser_ready().await?; + let hub = crate::box_runtime::BoxPool::global().hub(seat_id.unwrap_or(crate::SESSION_SEAT)); + hub.ensure_browser_ready().await?; let mut command = tokio::process::Command::new("docker"); command.args(["exec", "-i", "-w", "/workspace", "-e", "LAZYBOY_BROWSER_CDP=http://127.0.0.1:9222", - "lazyboy-box", "flock", "-n", "/home/box/.lazyboy-browser.lock", "node", "/opt/lazyboy/playwright/browser_helper.mjs"]); - start_helper(command).await + hub.container(), "flock", "-n", "/home/box/.lazyboy-browser.lock", "node", "/opt/lazyboy/playwright/browser_helper.mjs"]); + let mut helper = start_helper(command).await?; + helper.container = Some(hub.seat_id().to_string()); + Ok(helper) } /// Local browser is an explicit compatibility mode, never a Docker fallback. @@ -181,6 +186,7 @@ async fn start_helper(mut command: tokio::process::Command) -> Result { input, output, next: 0, + container: None, }) } @@ -237,7 +243,7 @@ mod tests { timeout: Some(Duration::from_millis(250)), }; let error = client - .request(&dir, None, json!({"op":"ping"})) + .request_on(&dir, None, json!({"op":"ping"}), None) .await .unwrap_err(); assert!(error.to_string().contains("timed out")); @@ -249,7 +255,7 @@ mod tests { .unwrap(); *client.state.lock().await = Some(spawn_script(&dir, &script, None).await.unwrap()); assert!(client - .request(&dir, None, json!({"op":"ping"})) + .request_on(&dir, None, json!({"op":"ping"}), None) .await .unwrap_err() .to_string() @@ -259,7 +265,7 @@ mod tests { *client.state.lock().await = Some(spawn_script(&dir, &script, None).await.unwrap()); assert!(tokio::time::timeout( Duration::from_millis(20), - client.request(&dir, None, json!({"op":"ping"})) + client.request_on(&dir, None, json!({"op":"ping"}), None) ) .await .is_err()); @@ -269,7 +275,7 @@ mod tests { *client.state.lock().await = Some(spawn_script(&dir, &real_script, None).await.unwrap()); assert_eq!( client - .request(&dir, None, json!({"op":"ping"})) + .request_on(&dir, None, json!({"op":"ping"}), None) .await .unwrap()["pong"], true diff --git a/crates/lazyboy-core/src/lib.rs b/crates/lazyboy-core/src/lib.rs index 0220955..4b04c6b 100644 --- a/crates/lazyboy-core/src/lib.rs +++ b/crates/lazyboy-core/src/lib.rs @@ -29,7 +29,7 @@ pub use agent::{ truncate_messages, AgentVerdict, AGENT_SYSTEM, DEFAULT_CONTEXT_CHARS, DEFAULT_MAX_ROUNDS, DEFAULT_MAX_ROUNDS_TOTAL, LOOP_GUARD_REPEAT, SEND_MESSAGE_SILENCE_THRESHOLD, }; -pub use box_runtime::BoxHub; +pub use box_runtime::{BoxHub, BoxPool, BoxSeat, SESSION_SEAT}; pub use browser::{ browser_oneshot, browser_self_test, find_helper_script, wait_for_handoff_resume, HandoffWait, INSTALL_HINT as BROWSER_INSTALL_HINT, diff --git a/crates/lazyboy-core/src/local_tls.rs b/crates/lazyboy-core/src/local_tls.rs index ab45858..f3f5cb9 100644 --- a/crates/lazyboy-core/src/local_tls.rs +++ b/crates/lazyboy-core/src/local_tls.rs @@ -1,16 +1,23 @@ //! Local HTTPS for the web UI. VNC stays on 127.0.0.1; browsers talk TLS to LazyBoy. use anyhow::{Context, Result}; +use chrono::{Datelike, Duration, Utc}; use rcgen::{ - BasicConstraints, CertificateParams, DistinguishedName, DnType, ExtendedKeyUsagePurpose, - IsCa, KeyPair, KeyUsagePurpose, SanType, + date_time_ymd, BasicConstraints, CertificateParams, DistinguishedName, DnType, + ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose, SanType, }; -use rustls::pki_types::{CertificateDer, PrivateKeyDer}; +use rustls::pki_types::PrivateKeyDer; use rustls::ServerConfig; -use std::net::IpAddr; +use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket}; use std::path::{Path, PathBuf}; use std::sync::Arc; +/// Bump when on-disk certs must be reissued. Format 1 used rcgen's 1975–4096 +/// validity, which Firefox/NSS reports as SEC_ERROR_BAD_SIGNATURE (not bypassable). +const MATERIAL_FORMAT: &str = "2"; +const CA_VALID_DAYS: i64 = 3650; +const SERVER_VALID_DAYS: i64 = 730; + pub struct TlsMaterial { pub ca_pem: String, pub cert_pem: String, @@ -37,15 +44,46 @@ pub fn cert_dir() -> PathBuf { pub fn lan_ips() -> Vec { let mut ips = Vec::new(); + if let Some(ip) = outbound_ipv4() { + push_ip(&mut ips, &ip); + } for iface in ["en0", "en1", "eth0", "wlan0"] { if let Ok(out) = std::process::Command::new("ipconfig") .args(["getifaddr", iface]) .output() { if out.status.success() { - let ip = String::from_utf8_lossy(&out.stdout).trim().to_string(); - if !ip.is_empty() { - ips.push(ip); + push_ip(&mut ips, std::str::from_utf8(&out.stdout).unwrap_or("")); + } + } + } + if let Ok(out) = std::process::Command::new("ip") + .args(["-4", "-o", "addr", "show", "scope", "global"]) + .output() + { + if out.status.success() { + for line in String::from_utf8_lossy(&out.stdout).lines() { + let mut parts = line.split_whitespace(); + let iface = parts.nth(1).unwrap_or(""); + if skip_iface(iface) { + continue; + } + while let Some(tok) = parts.next() { + if tok == "inet" { + if let Some(cidr) = parts.next() { + push_ip(&mut ips, cidr); + } + break; + } + } + } + } + } + if ips.is_empty() { + if let Ok(out) = std::process::Command::new("hostname").arg("-I").output() { + if out.status.success() { + for tok in String::from_utf8_lossy(&out.stdout).split_whitespace() { + push_ip(&mut ips, tok); } } } @@ -53,6 +91,69 @@ pub fn lan_ips() -> Vec { ips } +fn skip_iface(name: &str) -> bool { + let name = name.trim_end_matches(':'); + name == "lo" + || name == "docker0" + || name.starts_with("br-") + || name.starts_with("veth") + || name.starts_with("cni") + || name.starts_with("flannel") + || name.starts_with("virbr") + || name.starts_with("lxc") +} + +fn outbound_ipv4() -> Option { + let sock = UdpSocket::bind(SocketAddr::from((Ipv4Addr::UNSPECIFIED, 0))).ok()?; + sock.connect(SocketAddr::from((Ipv4Addr::new(1, 1, 1, 1), 80))) + .ok()?; + match sock.local_addr().ok()? { + SocketAddr::V4(addr) => { + let ip = addr.ip(); + if ip.is_unspecified() || ip.is_loopback() { + None + } else { + Some(ip.to_string()) + } + } + SocketAddr::V6(_) => None, + } +} + +fn push_ip(ips: &mut Vec, raw: &str) { + let trimmed = raw.trim().split('/').next().unwrap_or("").trim(); + if trimmed.is_empty() { + return; + } + let Ok(addr) = trimmed.parse::() else { + return; + }; + if addr.is_loopback() || addr.is_unspecified() || addr.is_multicast() { + return; + } + match addr { + IpAddr::V4(v4) if v4.is_link_local() => return, + IpAddr::V6(v6) if v6.is_unicast_link_local() => return, + _ => {} + } + let text = addr.to_string(); + if !ips.iter().any(|existing| existing == &text) { + ips.push(text); + } +} + +fn apply_validity(params: &mut CertificateParams, days: i64) { + let today = Utc::now().date_naive(); + let start = today + .checked_sub_signed(Duration::days(1)) + .unwrap_or(today); + let end = today + .checked_add_signed(Duration::days(days)) + .unwrap_or(today); + params.not_before = date_time_ymd(start.year(), start.month() as u8, start.day() as u8); + params.not_after = date_time_ymd(end.year(), end.month() as u8, end.day() as u8); +} + fn hostnames() -> Vec { let mut names = vec!["localhost".into()]; for cmd in [["hostname", "-s"], ["hostname", ""]] { @@ -108,6 +209,13 @@ fn san_list(names: &[String]) -> Vec { pub fn ensure() -> Result { let dir = cert_dir(); std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?; + let format_path = dir.join("format"); + let current_format = std::fs::read_to_string(&format_path).unwrap_or_default(); + if current_format.trim() != MATERIAL_FORMAT { + for name in ["ca.pem", "ca.key", "server.pem", "server.key", "sans.txt"] { + let _ = std::fs::remove_file(dir.join(name)); + } + } let ca_pem_path = dir.join("ca.pem"); let ca_key_path = dir.join("ca.key"); let cert_path = dir.join("server.pem"); @@ -159,6 +267,8 @@ pub fn ensure() -> Result { (pem, key) }; + std::fs::write(&format_path, MATERIAL_FORMAT).ok(); + Ok(TlsMaterial { ca_pem, cert_pem, @@ -172,6 +282,7 @@ fn issue_ca() -> Result<(String, String)> { let mut params = CertificateParams::default(); params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; + apply_validity(&mut params, CA_VALID_DAYS); let mut dn = DistinguishedName::new(); dn.push(DnType::CommonName, "LazyBoy Local CA"); params.distinguished_name = dn; @@ -194,11 +305,10 @@ fn issue_server(ca_pem: &str, ca_key_pem: &str, names: &[String]) -> Result<(Str .collect(); let mut params = CertificateParams::new(dns).context("server certificate names")?; params.subject_alt_names = san_list(names); - params.key_usages = vec![ - KeyUsagePurpose::DigitalSignature, - KeyUsagePurpose::KeyEncipherment, - ]; + // ECDSA cannot key-encipher; a critical KeyEncipherment bit makes NSS reject the cert. + params.key_usages = vec![KeyUsagePurpose::DigitalSignature]; params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth]; + apply_validity(&mut params, SERVER_VALID_DAYS); let mut dn = DistinguishedName::new(); dn.push(DnType::CommonName, "LazyBoy"); params.distinguished_name = dn; @@ -213,13 +323,7 @@ pub fn server_config(material: &TlsMaterial) -> Result> { let _ = rustls::crypto::ring::default_provider().install_default(); let mut certs = Vec::new(); for item in rustls_pemfile::certs(&mut material.cert_pem.as_bytes()) { - certs.push(CertificateDer::from(item.context("parse server cert")?)); - } - let mut ca_bytes = material.ca_pem.as_bytes(); - if let Ok(ca) = rustls_pemfile::certs(&mut ca_bytes).collect::, _>>() { - for item in ca { - certs.push(CertificateDer::from(item)); - } + certs.push(item.context("parse server cert")?); } let mut key_bytes = material.key_pem.as_bytes(); let mut keys = rustls_pemfile::pkcs8_private_keys(&mut key_bytes); @@ -274,58 +378,6 @@ fn dirs_login_keychain() -> Option { } } -pub fn https_location(host: &str, path: &str, port: u16) -> String { - let host = host.trim(); - let path = if path.is_empty() { "/" } else { path }; - let with_port = if host.starts_with('[') { - if host.rfind("]:").is_some() { - host.to_string() - } else { - format!("{host}:{port}") - } - } else if host.matches(':').count() == 1 { - host.to_string() - } else { - format!("{host}:{port}") - }; - format!("https://{with_port}{path}") -} - -pub async fn redirect_plaintext(stream: tokio::net::TcpStream, port: u16) { - use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; - let mut reader = BufReader::new(stream); - let mut first = String::new(); - if reader.read_line(&mut first).await.unwrap_or(0) == 0 { - return; - } - let path = first - .split_whitespace() - .nth(1) - .unwrap_or("/") - .to_string(); - let mut host = String::new(); - loop { - let mut line = String::new(); - let n = reader.read_line(&mut line).await.unwrap_or(0); - if n == 0 || line == "\r\n" || line == "\n" { - break; - } - if let Some(rest) = line.strip_prefix("Host:") { - host = rest.trim().to_string(); - } - } - if host.is_empty() { - host = format!("127.0.0.1:{port}"); - } - let location = https_location(&host, &path, port); - let response = format!( - "HTTP/1.1 308 Permanent Redirect\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" - ); - let mut stream = reader.into_inner(); - let _ = stream.write_all(response.as_bytes()).await; - let _ = stream.shutdown().await; -} - pub fn is_tls_client_hello(first: u8) -> bool { first == 0x16 } @@ -334,6 +386,23 @@ pub fn is_tls_client_hello(first: u8) -> bool { mod tests { use super::*; + fn https_location(host: &str, path: &str, port: u16) -> String { + let host = host.trim(); + let path = if path.is_empty() { "/" } else { path }; + let with_port = if host.starts_with('[') { + if host.rfind("]:").is_some() { + host.to_string() + } else { + format!("{host}:{port}") + } + } else if host.matches(':').count() == 1 { + host.to_string() + } else { + format!("{host}:{port}") + }; + format!("https://{with_port}{path}") + } + #[test] fn https_location_keeps_port() { assert_eq!( @@ -362,4 +431,49 @@ mod tests { }; server_config(&material).unwrap(); } + + #[test] + fn issued_certs_avoid_rcgen_default_dates_that_break_firefox() { + let (ca, ca_key) = issue_ca().unwrap(); + let ca_params = CertificateParams::from_ca_cert_pem(&ca).unwrap(); + assert!( + ca_params.not_after < date_time_ymd(2100, 1, 1), + "Firefox NSS reports SEC_ERROR_BAD_SIGNATURE for rcgen's year-4096 notAfter" + ); + assert!(ca_params.not_before > date_time_ymd(2020, 1, 1)); + let (cert, _) = issue_server( + &ca, + &ca_key, + &["localhost".into(), "10.0.33.1".into()], + ) + .unwrap(); + let params = CertificateParams::from_ca_cert_pem(&cert).unwrap(); + assert!(params.not_after < date_time_ymd(2100, 1, 1)); + assert!(params.not_before > date_time_ymd(2020, 1, 1)); + assert!( + params + .subject_alt_names + .iter() + .any(|san| matches!(san, SanType::IpAddress(ip) if ip.to_string() == "10.0.33.1")), + "LAN IP must be an IP SAN, not only a DNS label: {:?}", + params.subject_alt_names + ); + assert!(!params.key_usages.contains(&KeyUsagePurpose::KeyEncipherment)); + } + + #[test] + fn outbound_ip_is_listed_when_routing_exists() { + let Some(ip) = outbound_ipv4() else { + return; + }; + let ips = lan_ips(); + assert!( + ips.contains(&ip), + "lan_ips={ips:?} should include outbound {ip}" + ); + assert!( + desired_names().contains(&ip), + "certificate SAN must include LAN IP {ip}" + ); + } } diff --git a/crates/lazyboy-core/src/subagents.rs b/crates/lazyboy-core/src/subagents.rs index 83aed30..d3d0100 100644 --- a/crates/lazyboy-core/src/subagents.rs +++ b/crates/lazyboy-core/src/subagents.rs @@ -224,7 +224,7 @@ impl Subagents { } if kind.is_computer_use() && self.has_running_computer_use() { return Err(anyhow!( - "A computerUse subagent is already using the box's desktop. Only one can run at a time." + "A computerUse subagent is already using this agent's desktop. Only one can run at a time on the same computer." )); } // Path ladder: pixels last. Require a lower rung (or explicit force) before computerUse. @@ -239,6 +239,9 @@ impl Subagents { .unwrap() .clone() .ok_or_else(|| anyhow!("no model client for subagent"))?; + if kind.is_computer_use() { + parent.box_hub.try_begin_computer_use()?; + } let id = uuid::Uuid::new_v4().to_string(); let title = title .map(str::trim) @@ -325,6 +328,9 @@ impl Subagents { .await; input.end(); child.shutdown().await; + if kind_run.is_computer_use() { + child.box_hub.end_computer_use(); + } let (kind, message) = match verdict { Ok(v) => (v.kind().to_string(), v.message().to_string()), Err(e) => ("failed".into(), format!("{e:#}")), @@ -357,7 +363,7 @@ impl Subagents { "kind": kind.as_str(), "status": "running", "instruction": if kind.is_computer_use() { - "computerUse subagent started in the background and owns the desktop. Do not click or type on the screen; a screenshot to check in is allowed. Do not wait or poll; you will be revived with its result. Only one computerUse can run at a time." + "computerUse subagent started in the background and owns this agent's desktop. Do not click or type on the screen; a screenshot to check in is allowed. Do not wait or poll; you will be revived with its result. Only one computerUse can run at a time on the same computer." } else { "Subagent started in the background. Do not wait or poll for completion. Keep working or end the turn with no tool calls; you will be revived automatically with its result." } diff --git a/crates/lazyboy-core/src/team/service.rs b/crates/lazyboy-core/src/team/service.rs index f363d0f..e84a448 100644 --- a/crates/lazyboy-core/src/team/service.rs +++ b/crates/lazyboy-core/src/team/service.rs @@ -587,6 +587,7 @@ impl Service { self.chats.lock().unwrap().remove(&a.id); self.chats.lock().unwrap().remove(&a.name); let deleted = self.store.delete_agent(&a.id)?; + let _ = crate::BoxPool::global().drop_seat(&deleted.id).await; Ok(json!({"ok": true, "id": deleted.id, "name": deleted.name})) } "tasks" => Ok(json!(self diff --git a/crates/lazyboy-core/src/team/worker.rs b/crates/lazyboy-core/src/team/worker.rs index 6cf4539..a18639e 100644 --- a/crates/lazyboy-core/src/team/worker.rs +++ b/crates/lazyboy-core/src/team/worker.rs @@ -1,5 +1,5 @@ use super::service::Service; -use super::store::AgentIdentity; +use super::store::{AgentIdentity, Store, TaskRecord}; use crate::{AgentEvent, AgentVerdict, ChatMessage, InputBroker, Runtime, Session, ToolContext}; use anyhow::{anyhow, Result}; use serde_json::{json, Value}; @@ -334,6 +334,7 @@ impl Service { None => { let mut ctx = ToolContext::new(&session.cwd).with_runtime(runtime.clone()); ctx.team = Some(self.context(&t.agent_id, Some(&t.id))); + ctx.box_hub = crate::BoxPool::global().hub(&seat_id_for_task(&self.store, &t)); ctx } }; @@ -498,6 +499,7 @@ impl Service { s.store.save_conversation(&agent_id, &session.messages) }); let mut ctx = ToolContext::new(a.cwd).with_runtime(runtime.clone()); + ctx.box_hub = crate::BoxPool::global().hub(&a.id); let team = self.context(agent, None); if self.store.direct_user_chat(id)? { *team.user_reply.lock().unwrap() = Some(body.to_owned()); @@ -624,3 +626,10 @@ pub(super) fn recorded_evidence(messages: &[ChatMessage]) -> Vec { } observations.into_iter().rev().take(6).collect() } + +fn seat_id_for_task(store: &Store, t: &TaskRecord) -> String { + match store.agent(&t.agent_id) { + Ok(agent) if agent.temporary => t.owner_id.clone(), + _ => t.agent_id.clone(), + } +} diff --git a/crates/lazyboy-core/src/tools.rs b/crates/lazyboy-core/src/tools.rs index 3a8ba23..d9a9ba4 100644 --- a/crates/lazyboy-core/src/tools.rs +++ b/crates/lazyboy-core/src/tools.rs @@ -701,6 +701,7 @@ async fn execute_tool_inner(ctx: &ToolContext, name: &str, arguments_json: &str) let ready = ctx.box_hub.ensure_ready().await?; let viewer = ready["viewer_url"] .as_str() + .or(ready["host_viewer_url"].as_str()) .unwrap_or("") .to_string(); ctx.runtime.park_question(&json!({ @@ -1015,7 +1016,7 @@ fn extra_tool_definitions() -> Vec { def("external_glob","Find files by name glob (e.g. **/*.rs) under a path.",json!({"pattern":{"type":"string"},"path":{"type":"string"},"limit":{"type":"integer"}}),json!(["pattern"])), def("web_search","Search public web via the configured remote service. Does not open a browser or use browser logins.",json!({"gap":{"type":"integer","minimum":0},"searchTerm":{"type":"string"},"explanation":{"type":"string"}}),json!(["searchTerm"])), def("web_fetch","Fast anonymous HTTP GET of a public URL; HTML is reduced to readable text. No cookies or JavaScript. Cached briefly — do not refetch the same URL. If content_kind is blocked_plain_http, the page needs browser_* (JS/login), not another fetch. At most one web_search per round; fetch several URLs in the same batch.",json!({"gap":{"type":"integer","minimum":0},"url":{"type":"string"},"max_bytes":{"type":"integer"}}),json!(["url"])), - def("spawn_subagent","Start a background subagent for a self-contained chunk of work. Returns immediately with subagent_id. Do not wait or poll; keep working or end the turn — you are revived automatically when it finishes. kind=computerUse delegates a GUI/desktop task that drives MY computer by screenshot/click/move/drag/type/key/scroll/wait; only one computerUse may run at a time because they share the screen. Path ladder: do not spawn computerUse for ordinary web until web_fetch/web_search and/or browser_* (or call_mcp_tool) have been tried this turn; set force=true for native GUI, file dialogs, drag, or sites that already defeated page-level automation.",json!({"goal":{"type":"string"},"title":{"type":"string"},"kind":{"type":"string","enum":["general","computerUse"],"description":"general (default) or computerUse"},"subagent_type":{"type":"string","description":"Alias of kind (Grok Bot Task subagent_type)"},"force":{"type":"boolean","description":"Bypass path-ladder gate for computerUse when the task is a native GUI, file dialog, drag, or a site that already defeated DOM automation"}}),json!(["goal"])), + def("spawn_subagent","Start a background subagent for a self-contained chunk of work. Returns immediately with subagent_id. Do not wait or poll; keep working or end the turn — you are revived automatically when it finishes. kind=computerUse delegates a GUI/desktop task that drives MY computer by screenshot/click/move/drag/type/key/scroll/wait; only one computerUse may run at a time on this agent's computer. Path ladder: do not spawn computerUse for ordinary web until web_fetch/web_search and/or browser_* (or call_mcp_tool) have been tried this turn; set force=true for native GUI, file dialogs, drag, or sites that already defeated page-level automation.",json!({"goal":{"type":"string"},"title":{"type":"string"},"kind":{"type":"string","enum":["general","computerUse"],"description":"general (default) or computerUse"},"subagent_type":{"type":"string","description":"Alias of kind (Grok Bot Task subagent_type)"},"force":{"type":"boolean","description":"Bypass path-ladder gate for computerUse when the task is a native GUI, file dialog, drag, or a site that already defeated DOM automation"}}),json!(["goal"])), def("check_subagent","Inspect a running background subagent (status, elapsed time, recent tools). Omit subagent_id to list all. Not for polling completion.",json!({"subagent_id":{"type":"string"}}),json!([])), def("message_subagent","Inject an instruction into a running subagent without aborting it. It keeps its context.",json!({"subagent_id":{"type":"string"},"message":{"type":"string"}}),json!(["subagent_id","message"])), def("stop_subagent","Abort a running background subagent.",json!({"subagent_id":{"type":"string"}}),json!(["subagent_id"])), @@ -1373,10 +1374,11 @@ async fn browser_tool(ctx: &ToolContext, name: &str, args: &Value) -> Result Result Result struct App { token: Option, cwd: PathBuf, - box_hub: std::sync::Arc, + box_pool: std::sync::Arc, ca_pem: Option, } @@ -79,7 +79,7 @@ pub async fn serve_http(listen: WebListen) -> Result<()> { let app = App { token, cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), - box_hub: BoxHub::new(), + box_pool: BoxPool::global(), ca_pem: tls.as_ref().map(|m| m.ca_pem.clone()), }; let cors = CorsLayer::new() @@ -106,6 +106,10 @@ pub async fn serve_http(listen: WebListen) -> Result<()> { .route("/api/agents/:id/events", get(api_events)) .route("/api/agents/:id/activity", get(api_activity)) .route("/api/agents/:id/handover", post(api_handover)) + .route( + "/api/agents/:id/computer", + get(api_agent_computer).post(api_agent_computer_action), + ) .route("/api/computer", get(api_computer).post(api_computer_action)) .route("/lazyboy-ca.crt", get(api_ca_cert)) .route("/novnc", any(novnc_proxy)) @@ -128,15 +132,12 @@ pub async fn serve_http(listen: WebListen) -> Result<()> { })?; let bound = listener.local_addr()?; if let Some(material) = tls { - eprintln!("LazyBoy https://127.0.0.1:{}", bound.port()); + eprintln!("LazyBoy http://127.0.0.1:{} (https 同埠)", bound.port()); for lan in crate::local_tls::lan_ips() { - eprintln!( - "手機同一個 Wi-Fi: https://{lan}:{}", - bound.port() - ); + eprintln!("手機同一個 Wi-Fi: http://{lan}:{}", bound.port()); } eprintln!( - "第一次請安裝本機憑證(否則瀏覽器會顯示「不是安全連線」): https://127.0.0.1:{}/lazyboy-ca.crt", + "要裝 PWA/鎖頭再走 https,並安裝憑證: http://127.0.0.1:{}/lazyboy-ca.crt", bound.port() ); eprintln!("憑證涵蓋 {}", material.names.join(", ")); @@ -486,15 +487,20 @@ const WEB_VIEWER: &str = "/novnc/vnc.html?autoconnect=true&resize=off&reconnect= fn computer_response(result: anyhow::Result) -> Json { match result { Ok(ready) => Json(json!({ - "ready": true, - "viewer_url": WEB_VIEWER, + "ready": ready.get("ready").and_then(|v| v.as_bool()).unwrap_or(true), + "state": ready.get("state").cloned().unwrap_or(json!("ready")), + "viewer_url": ready.get("viewer_url").cloned().unwrap_or(json!(WEB_VIEWER)), "workspace": ready.get("workspace"), "revision": ready.get("revision"), "action": ready.get("action"), "updated": ready.get("updated"), + "seat": ready.get("seat"), + "crowded": ready.get("crowded"), + "error": ready.get("error"), })), Err(error) => Json(json!({ "ready": false, + "state": "error", "error": error.to_string(), "viewer_url": WEB_VIEWER, })), @@ -506,7 +512,42 @@ async fn api_computer( headers: HeaderMap, ) -> Result, StatusCode> { authorize(&app, &headers)?; - Ok(computer_response(app.box_hub.ensure_ready().await)) + Ok(Json(app.box_pool.inspect(SESSION_SEAT).await)) +} + +async fn resolve_agent_seat(id: &str) -> Result)> { + let value = rpc(json!({"op": "get", "agent": id})).await?; + Ok(value["id"].as_str().unwrap_or(id).to_string()) +} + +async fn api_agent_computer( + State(app): State, + headers: HeaderMap, + Path(id): Path, +) -> Result, (StatusCode, Json)> { + authorize(&app, &headers).map_err(|s| api_err(s, "unauthorized"))?; + let seat = resolve_agent_seat(&id).await?; + Ok(Json(app.box_pool.inspect(&seat).await)) +} + +async fn api_agent_computer_action( + State(app): State, + headers: HeaderMap, + Path(id): Path, + Json(body): Json, +) -> Result, (StatusCode, Json)> { + authorize(&app, &headers).map_err(|s| api_err(s, "unauthorized"))?; + let seat = resolve_agent_seat(&id).await?; + let kind = match computer_action_kind(&body.action) { + Ok(kind) => kind, + Err(message) => return Err(api_err(StatusCode::BAD_REQUEST, &message)), + }; + let result = match kind { + "restart" => app.box_pool.restart(&seat).await, + "update" => app.box_pool.update(&seat).await, + _ => app.box_pool.start(&seat).await, + }; + Ok(computer_response(result)) } #[derive(Deserialize, Default)] @@ -537,9 +578,9 @@ async fn api_computer_action( Err(message) => return Err(api_err(StatusCode::BAD_REQUEST, &message)), }; let result = match kind { - "restart" => app.box_hub.restart().await, - "update" => app.box_hub.update().await, - _ => app.box_hub.ensure_ready().await, + "restart" => app.box_pool.restart(SESSION_SEAT).await, + "update" => app.box_pool.update(SESSION_SEAT).await, + _ => app.box_pool.start(SESSION_SEAT).await, }; Ok(computer_response(result)) } @@ -573,6 +614,37 @@ async fn api_ca_cert(State(app): State) -> Response { } } +fn is_novnc_asset(first: &str) -> bool { + first == "vnc.html" + || first == "vnc_lite.html" + || first == "websockify" + || first == "app" + || first == "core" + || first == "vendor" + || first.contains('.') +} + +fn split_novnc_target(rest: &str) -> (Option, String) { + let rest = if rest.is_empty() { "/" } else { rest }; + let (path, query) = match rest.split_once('?') { + Some((path, query)) => (path, Some(query)), + None => (rest, None), + }; + let trimmed = path.trim_start_matches('/'); + let mut parts = trimmed.splitn(2, '/'); + let first = parts.next().unwrap_or(""); + if first.is_empty() || is_novnc_asset(first) { + return (None, rest.to_string()); + } + let tail = parts.next().unwrap_or(""); + let mut rebuilt = format!("/{tail}"); + if let Some(query) = query { + rebuilt.push('?'); + rebuilt.push_str(query); + } + (Some(first.to_string()), rebuilt) +} + async fn novnc_proxy(State(app): State, req: Request) -> Response { let path_and_query = req .uri() @@ -588,9 +660,20 @@ async fn novnc_proxy(State(app): State, req: Request) -> Response { } else { rest }; + let (seat, rest) = split_novnc_target(&rest); if is_vnc_html(&rest) && novnc_opened_as_tab(req.headers()) { return Redirect::temporary("/").into_response(); } + let seat_id = seat.as_deref().unwrap_or(SESSION_SEAT); + let port = app + .box_pool + .hub(seat_id) + .host_port() + .await + .unwrap_or(if seat.is_none() { 6080 } else { 0 }); + if port == 0 { + return (StatusCode::BAD_GATEWAY, "this computer is not running").into_response(); + } if req .headers() .get(header::UPGRADE) @@ -599,19 +682,19 @@ async fn novnc_proxy(State(app): State, req: Request) -> Response { { return match WebSocketUpgrade::from_request(req, &app).await { Ok(upgrade) => upgrade - .on_upgrade(move |socket| proxy_vnc_ws(socket, rest)) + .on_upgrade(move |socket| proxy_vnc_ws(socket, rest, port)) .into_response(), Err(err) => err.into_response(), }; } - match proxy_vnc_http(req, &rest).await { + match proxy_vnc_http(req, &rest, port).await { Ok(resp) => resp, Err(_) => (StatusCode::BAD_GATEWAY, "computer viewer proxy failed").into_response(), } } -async fn proxy_vnc_http(req: Request, rest: &str) -> Result { - let url = format!("http://127.0.0.1:6080{rest}"); +async fn proxy_vnc_http(req: Request, rest: &str, port: u16) -> Result { + let url = format!("http://127.0.0.1:{port}{rest}"); let method = req.method().clone(); let client = reqwest::Client::new(); let mut builder = client.request(method, &url); @@ -672,9 +755,9 @@ async fn proxy_vnc_http(req: Request, rest: &str) -> Result return, - Ok(_) if crate::local_tls::is_tls_client_hello(head[0]) => {} - Ok(_) => { - crate::local_tls::redirect_plaintext(stream, port).await; - return; + Ok(0) | Err(_) => {} + Ok(_) if crate::local_tls::is_tls_client_hello(head[0]) => { + if let Ok(tls_stream) = acceptor.accept(stream).await { + serve_hyper(tls_stream, router).await; + } } - Err(_) => return, + Ok(_) => serve_hyper(stream, router).await, } - let Ok(tls_stream) = acceptor.accept(stream).await else { - return; - }; - let io = hyper_util::rt::TokioIo::new(tls_stream); - let service = hyper::service::service_fn(move |request: axum::extract::Request| { - let router = router.clone(); - async move { - tower::ServiceExt::oneshot(router, request.map(axum::body::Body::new)).await - } - }); - let _ = hyper_util::server::conn::auto::Builder::new(hyper_util::rt::TokioExecutor::new()) - .serve_connection_with_upgrades(io, service) - .await; }); } } +async fn serve_hyper(stream: I, router: Router) +where + I: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static, +{ + let io = hyper_util::rt::TokioIo::new(stream); + let service = hyper::service::service_fn( + move |request: axum::extract::Request| { + let router = router.clone(); + async move { tower::ServiceExt::oneshot(router, request.map(axum::body::Body::new)).await } + }, + ); + let _ = hyper_util::server::conn::auto::Builder::new(hyper_util::rt::TokioExecutor::new()) + .serve_connection_with_upgrades(io, service) + .await; +} + #[cfg(test)] mod tests { - use super::{computer_action_kind, is_vnc_html, novnc_opened_as_tab}; + use super::{computer_action_kind, is_vnc_html, novnc_opened_as_tab, split_novnc_target}; use axum::http::{HeaderMap, HeaderValue}; #[test] @@ -782,6 +867,21 @@ mod tests { assert!(!is_vnc_html("/websockify")); } + #[test] + fn agent_novnc_path_is_stripped() { + let (seat, rest) = split_novnc_target( + "/11111111-1111-1111-1111-111111111111/vnc.html?autoconnect=true", + ); + assert_eq!(seat.as_deref(), Some("11111111-1111-1111-1111-111111111111")); + assert_eq!(rest, "/vnc.html?autoconnect=true"); + let (seat, rest) = split_novnc_target("/vnc.html?autoconnect=true"); + assert_eq!(seat, None); + assert_eq!(rest, "/vnc.html?autoconnect=true"); + let (seat, rest) = split_novnc_target("/11111111-1111-1111-1111-111111111111/websockify"); + assert_eq!(seat.as_deref(), Some("11111111-1111-1111-1111-111111111111")); + assert_eq!(rest, "/websockify"); + } + #[test] fn vnc_direct_tab_uses_fetch_dest() { let mut headers = HeaderMap::new(); diff --git a/crates/lazyboy/src/main.rs b/crates/lazyboy/src/main.rs index f8d08ba..7a0836c 100644 --- a/crates/lazyboy/src/main.rs +++ b/crates/lazyboy/src/main.rs @@ -155,7 +155,7 @@ ENV: LAZYBOY_WEB_PROVIDER xai = enable native search on an xAI-compatible proxy LAZYBOY_WEB_HOST web UI bind (default 0.0.0.0) LAZYBOY_WEB_PORT web UI port (default 8787) - LAZYBOY_TLS 0 = plain HTTP (default is local HTTPS) + LAZYBOY_TLS 0 = plain HTTP only (default HTTP+HTTPS on the same port) LAZYBOY_WEB_TOKEN optional bearer token for the web UI LAZYBOY_HANDOFF_AUTO 1 = auto-resume handoff (tests); abort = auto-abort LAZYBOY_CONFIRM_AUTO 1 = auto-approve confirm (tests); abort = auto-deny @@ -178,7 +178,12 @@ During a task: enter additional instructions to steer the next step. async fn cmd_computer() -> Result<()> { let ready = BoxHub::new().ensure_ready().await?; println!("我的電腦已就緒"); - println!("{}", BoxHub::viewer_url()); + println!( + "{}", + ready["host_viewer_url"] + .as_str() + .unwrap_or(&BoxHub::viewer_url()) + ); if let Some(msg) = ready["instruction"].as_str() { eprintln!("{msg}"); } diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index f3f409a..3b0454f 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -12,7 +12,7 @@ python3 tests/cli_flow.py cargo run -p lazyboy -- smoke ``` -Coverage: answer/done/blocked/budget/failed; invalid and truncated model responses; complete call/result pairing; progress before tools; commentary-only loop protection; plan updates and unfinished-step validation; changed observations; context preservation; segmented reading, literal search and unique edits; workspace symlink checks; old session compatibility and interrupted-history recovery; incremental process output and stdin; helper deadline, mismatched ID, cancellation and restart; parent cannot call `computer`; `spawn_subagent kind=computerUse` is one-at-a-time and the child is the only agent offered `computer`; invalid click coordinates fail closed. Docker click+screenshot is `#[ignore]` (`computer_use_click_and_screenshot_on_box`). +Coverage: answer/done/blocked/budget/failed; invalid and truncated model responses; complete call/result pairing; progress before tools; commentary-only loop protection; plan updates and unfinished-step validation; changed observations; context preservation; segmented reading, literal search and unique edits; workspace symlink checks; old session compatibility and interrupted-history recovery; incremental process output and stdin; helper deadline, mismatched ID, cancellation and restart; parent cannot call `computer`; `spawn_subagent kind=computerUse` is one-at-a-time per agent computer and the child is the only agent offered `computer`; invalid click coordinates fail closed. Docker click+screenshot is `#[ignore]` (`computer_use_click_and_screenshot_on_box`). ## Interactive runtime integration diff --git a/docs/CLI-FLOW.md b/docs/CLI-FLOW.md index 140638a..73d829e 100644 --- a/docs/CLI-FLOW.md +++ b/docs/CLI-FLOW.md @@ -50,7 +50,7 @@ Ctrl-C 或 `/stop` 取消模型/工具/人工等待。命令以 process grou `lazyboy web` 在 `:8787`(`LAZYBOY_WEB_PORT`)提供 Grok Bot 風格對話網頁與 PWA。手機與電腦同一 Wi-Fi 時,用終端機印出的 LAN URL 開啟,Safari/Chrome「加入主畫面」即可全螢幕當 app。`/novnc` 同源代理 Docker 桌面,所以手機不必直連 6080。 -`spawn_subagent` 立刻返回。父回合若在子 agent 或背景命令仍在跑時以無工具結束,runtime 會等它完成、灌入 revival 訊息、再請模型繼續;不要用 `check_subagent` 輪詢完成。子 agent 不能再派子 agent。`LAZYBOY_SUBAGENT_MAX_ROUNDS` 預設 128。`kind=computerUse` 把桌面點擊交給子 agent(`computer`:screenshot/click/move/drag/type/key/scroll/wait,xdotool `DISPLAY=:1`);同一時間只能有一個 computerUse。父層只有唯讀 `screenshot`。人類登入仍走 `request_box_help`。 +`spawn_subagent` 立刻返回。父回合若在子 agent 或背景命令仍在跑時以無工具結束,runtime 會等它完成、灌入 revival 訊息、再請模型繼續;不要用 `check_subagent` 輪詢完成。子 agent 不能再派子 agent。`LAZYBOY_SUBAGENT_MAX_ROUNDS` 預設 128。`kind=computerUse` 把桌面點擊交給子 agent(`computer`:screenshot/click/move/drag/type/key/scroll/wait,xdotool `DISPLAY=:1`);同一座位一次只能有一個 computerUse,不同 named agent 的電腦互不搶螢幕。父層只有唯讀 `screenshot`。人類登入仍走 `request_box_help`。Web 開 named agent 會啟動該座位的 Docker 桌面並顯示就緒或錯誤。 HTTP 連線期限 15 秒、單次請求 180 秒;helper 回應期限 60 秒;網頁條件等待最多 30 秒;命令預設最多十分鐘,legacy shell 為 30 秒。等待時每 20 秒顯示實際階段與經過時間,不呼叫模型製造更新。 diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 58acc78..44c42d3 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -33,7 +33,7 @@ Checkpoint before and after tools, retain plan/pending questions/command metadat ## Boundaries -Current target: macOS CLI plus a split web stack — Rust `lazyboy serve` is JSON/SSE API for **named Agents** (not session files); the Vite/React app in `web/` is the Grok Bot-shaped shell (agent sidebar, conversation, composer, computer overlay, PWA). Optional Playwright DOM browser and Docker box desktop. Not an Electron clone. Persistent named agents use a local daemon, private memory and a background task scheduler; see [TEAM](TEAM.md). Desktop pixel control is a `computerUse` subagent (`computer` via xdotool); the parent cannot click. No separate search key, Electron UI, PTY, CUA, or per-agent virtual monitors in this version. Browser-only canvas apps and arbitrary natural-language task correctness remain outside what this MVP can guarantee. +Current target: macOS CLI plus a split web stack — Rust `lazyboy serve` is JSON/SSE API for **named Agents** (not session files); the Vite/React app in `web/` is the Grok Bot-shaped shell (agent sidebar, conversation, composer, computer overlay, PWA). Optional Playwright DOM browser and Docker box desktop. Not an Electron clone. Persistent named agents use a local daemon, private memory and a background task scheduler; see [TEAM](TEAM.md). Desktop pixel control is a `computerUse` subagent (`computer` via xdotool); the parent cannot click. Each named agent has its own Docker desktop; opening the agent starts that seat and the UI reports whether it became ready. No separate search key, Electron UI, PTY, or CUA in this version. Browser-only canvas apps and arbitrary natural-language task correctness remain outside what this MVP can guarantee. Legacy `chat` stays a no-tools streaming chat. `shell` stays available with a 30-second deadline. Synchronous human-wait library helpers are retained for AUTO/offline compatibility; interactive callers must supply the runtime input broker. diff --git a/docs/SURFACES.md b/docs/SURFACES.md index ba2072f..a23a0e1 100644 --- a/docs/SURFACES.md +++ b/docs/SURFACES.md @@ -11,7 +11,7 @@ | 公開抓頁 | host → Cursor AiService.RunWebFetch | host 直接匿名 HTTP GET(HTML 轉文字、同 URL 十分鐘快取、redirect 重新檢查公開主機);只有網站拒絕純 HTTP 時才退回 xAI 遠端瀏覽,並明確標記 model_rendered_web_content 與 fallback_reason | | 登入網頁 | box Chrome | Docker Chromium,DOM helper 也在 Docker | | 人工登入 | RequestBoxHelp → agent 桌面 | request_box_help/browser_handoff → 同一個 Docker Chromium | -| 桌面像素操作 | 父層 Screenshot;computerUse 子 agent 才有 Computer(click/type/…) | 父層 `screenshot` 唯讀;`spawn_subagent kind=computerUse` 才有 `computer`(xdotool `DISPLAY=:1`)。一次一個 computerUse | +| 桌面像素操作 | 父層 Screenshot;computerUse 子 agent 才有 Computer(click/type/…) | 父層 `screenshot` 唯讀;`spawn_subagent kind=computerUse` 才有 `computer`(xdotool `DISPLAY=:1`)。每個 named agent 一台 Docker 桌面;同一台電腦一次一個 computerUse | | 瀏覽器資料 | /home/box/chrome-profile,另有 box store 同步 | /home/box/chrome-profile,lazyboy-box-home volume 持久保存 | **WebSearch/WebFetch 不會讀取任何瀏覽器 profile 或 cookies。** 後端授權 token、模型 API key、網站登入 cookies 是三種不同資料。MCP 仍使用 connector 自己的授權,也不會自動取得 Chromium cookies。 @@ -37,7 +37,7 @@ Gateway 需要提供 JSON Connect `POST /aiserver.v1.AiService/RunWebSearch`(s ## 尚未等同參考版的部分 -- 參考版提供 per-agent monitor/desktop;此版仍是單一 Docker 桌面(DISPLAY=:1),computerUse 以「一次一個」排他,不具有獨立螢幕隔離。執行器是 xdotool,不是參考版 box 內的 protobuf ComputerUse executor / CUA。 +- 參考版在同一台 box 上分 per-agent monitor。此版改成每個 named agent 一個 Docker 容器(同一映像),各自 DISPLAY=:1、Chrome profile 與 `/workspace`。臨時 worker 用發起任務的 owner 座位。computerUse 按座位排他,不是全域一台螢幕。執行器仍是 xdotool,不是參考版 box 內的 protobuf ComputerUse executor / CUA。 - 參考版有 cloud box store/Chrome session snapshot,同步到遠端儲存;此版使用本機 Docker volume,未實作該雲端備份。 - 參考版 local-docker connector 可掛入 `.codex`/`.claude` 的唯讀 CLI 認證,並將 host runner 放進容器;此版 host/model/MCP 編排仍在本地,未自動掛入這些私人目錄。 - 本地工具沿用 LazyBoy2 既有工作區授權規則,沒有複製參考版每次 ExternalShell 的批准卡 UI。 @@ -64,6 +64,6 @@ Box 執行期行為:截圖前會等 `SCREENSHOT_SETTLE_MS` 讓桌面重繪; 一般工具名 `shell`/`read`/`await_shell` 一律指向 box;`box_shell`/`box_read`/`box_await` 保留為舊 box 呼叫的別名。本地檔案與命令工具全部改成 `external_*`;舊模糊名稱如 `exec_command`/`read_file` 會報錯,不會隱含操作本地。Box 命令等待時間到後保留執行,以 `await_shell` 收取結果。關閉桌面瀏覽器不會阻止 shell/read 工作,下一次 browser 工具會重開原 profile。 -此處的 box 是本機 Docker Linux 環境,並非另租的雲端主機;檔案與瀏覽器狀態隔離在 container/volume,但 CPU、磁碟與 Docker daemon 仍屬於本機。CLI 的設定、對話紀錄與模型/MCP 編排也仍在本地。 +此處的 box 是本機 Docker Linux 環境,並非另租的雲端主機;每個 named agent 的檔案與瀏覽器狀態隔離在自己的 container/volume,但 CPU、磁碟與 Docker daemon 仍屬於本機。開 agent 會啟動該座位並等到桌面就緒(或留下錯誤);GET 狀態不會順便開機。CLI 的設定、對話紀錄與模型/MCP 編排也仍在本地。舊的 `lazyboy agent`/`run`/`computer` 仍使用全域 `lazyboy-box`。 CLI mock-provider 測試也已通過(包含串流工具呼叫、背景 subagent/external command 回覆與 session 恢復)。 diff --git a/docs/TEAM.md b/docs/TEAM.md index 99741a3..dea0052 100644 --- a/docs/TEAM.md +++ b/docs/TEAM.md @@ -77,7 +77,7 @@ handoff 提供「已完成登入,請檢查」、「登入仍失敗,改做其 - 根任務樹共用 `LAZYBOY_MAX_ROUNDS_TOTAL`,預設 48;最後 4 次只供根任務使用。子 task 的請求也計入根計數,不會開一個 agent 就多拿 48 次。恢復不自動補預算;耗盡時需另開有明確範圍的新工作。 - 前景每次回覆最多 12 次模型請求。每個完成聊天回合/持久 agent 的任務最多再排一次記憶整理,失敗不阻擋聊天、不自動重試。這些與根任務執行預算分開計算。 - 同一 canonical 工作區內的工具操作互斥;長指令退出後才釋放鎖。操作不同工作區可並行。活躍指令須先結束或終止,才能等待子 task 或人工回答,避免拿著鎖等待別人工作。 -- 同一主 agent 的任務共用固定 Chromium profile,包含委派給其他 agent 的工作;不同主 agent 保持隔離。同時只有一個 task 持有瀏覽器,handoff 等待期間也不讓其他 worker 操作。`browser_release` 或任務結束會關閉瀏覽器並釋放使用權,保留登入資料。委派瀏覽器子任務前先 release,避免互等。 +- 每個長期 named agent 有自己的 Docker 電腦(容器、`/workspace`、Chromium profile)。開這個 agent 會啟動它的座位並確認桌面就緒。同一 owner 的背景 task 與 computerUse 子 agent 用這台電腦;`delegate_task` 給另一個 named agent 時改用被交辦者的電腦;臨時 `spawn_agent` worker 用發起任務的 owner 座位。同時只有一個 task 持有該座位的瀏覽器,handoff 等待期間也不讓其他 worker 操作同一座位。`browser_release` 或任務結束會關閉瀏覽器並釋放使用權,保留登入資料。委派瀏覽器子任務前先 release,避免互等。 - 升級首次使用時,固定 profile 優先連結至仍有 handoff 問題的舊 task profile,否則採該主 agent 最近使用的 profile,保留原資料,不合併不同 profile 的登入帳號。沒有舊 profile 才建立新的。一般 Chrome 的登入狀態不會自動匯入。 task 使用 `queued`、`running`、`waiting_input`、`terminal` 狀態;終態另有 done/answer/blocked/budget_exhausted/failed/cancelled/interrupted。停止執行中的 task 先提出取消,工具清理後才進 terminal。沒有連線的人工問題仍保持等待,只有明確回答或停止才繼續。 diff --git a/tests/activity_ui.mjs b/tests/activity_ui.mjs index bb1e6f6..269cca1 100644 --- a/tests/activity_ui.mjs +++ b/tests/activity_ui.mjs @@ -27,6 +27,7 @@ try { return route.fulfill({ contentType: "text/event-stream", body: 'data: {"events":[{"id":1,"kind":"runtime","payload":{"type":"waiting"}}]}\n\n' }); } + if (path.endsWith("/computer")) return route.fulfill({ json: { ready: true, state: "ready", viewer_url: "/fixture-viewer" } }); if (path === "/api/agents") return route.fulfill({ json: { agents: [{ id: "owner", name: "測試", running: true }], } }); diff --git a/tests/agent_profile_ui.mjs b/tests/agent_profile_ui.mjs index d657f5d..4842b8c 100644 --- a/tests/agent_profile_ui.mjs +++ b/tests/agent_profile_ui.mjs @@ -74,6 +74,9 @@ try { if (path.endsWith("/events")) { return route.fulfill({ contentType: "text/event-stream", body: "data: {\"events\":[]}\n\n" }); } + if (path.endsWith("/computer")) { + return route.fulfill({ json: { ready: true, state: "ready", viewer_url: "/fixture-viewer" } }); + } return route.fulfill({ json: {} }); }); await page.goto(process.env.LAZYBOY_TEST_UI_URL || "http://127.0.0.1:5173"); diff --git a/tests/chat_overlap_ui.mjs b/tests/chat_overlap_ui.mjs index c6c5e05..b0463a2 100644 --- a/tests/chat_overlap_ui.mjs +++ b/tests/chat_overlap_ui.mjs @@ -38,6 +38,9 @@ try { if (path.endsWith("/events")) { return route.fulfill({ contentType: "text/event-stream", body: "data: {\"events\":[]}\n\n" }); } + if (path.endsWith("/computer")) { + return route.fulfill({ json: { ready: true, state: "ready", viewer_url: "/fixture-viewer" } }); + } if (path === "/api/agents") { return route.fulfill({ json: { agents: [{ id: "owner", name: "測試", running: false }] } }); } diff --git a/tests/handover_ui.mjs b/tests/handover_ui.mjs index d75d88b..6bbc9dc 100644 --- a/tests/handover_ui.mjs +++ b/tests/handover_ui.mjs @@ -3,13 +3,14 @@ import { chromium } from "../tools/playwright/node_modules/playwright/index.mjs" import assert from "node:assert/strict"; const browser = await chromium.launch({ headless: true }); try { - const context = await browser.newContext({serviceWorkers:"block"}); + const context = await browser.newContext({serviceWorkers:"block", ignoreHTTPSErrors: true}); const page = await context.newPage(); page.setDefaultTimeout(5000); page.on("pageerror",e=>console.error(e.message)); await page.addInitScript(() => localStorage.setItem("lazyboy.locale", "zh-Hant")); let waiting = true; const submitted = []; + const computerCalls = []; const activity = () => ({ state: waiting ? "waiting_input" : "running", running: !waiting, queued: false, active_task_ids: waiting ? [] : ["task"], observed_at_ms: Date.now(), @@ -30,7 +31,10 @@ try { if (path.endsWith("/messages")) throw new Error("handover must not post ordinary chat"); if (path.endsWith("/events")) return route.fulfill({ contentType:"text/event-stream", body:'data: {"events":[{"id":1,"kind":"runtime","payload":{"type":"user_progress","message":"正在比對官方規則。"}},{"id":2,"kind":"runtime","payload":{"type":"progress","message":"JavaScript internal scratchpad"}},{"id":3,"kind":"runtime","payload":{"type":"user_progress","message":"正在整理攻略。"}}]}\n\n' }); - if (path === "/api/computer") return route.fulfill({ json:{ready:true,viewer_url:"/fixture-viewer"} }); + if (path.endsWith("/computer")) { + computerCalls.push({ method: route.request().method(), path }); + return route.fulfill({ json:{ready:true,state:"ready",viewer_url:"/fixture-viewer"} }); + } if (path === "/api/agents") return route.fulfill({ json:{agents:[{id:"owner",name:"測試"}]} }); if (path === "/api/agents/owner") return route.fulfill({ json:{id:"owner",name:"測試",expertise:"",preview:"",transcript:[],running:false,activity:activity()} }); return route.fulfill({ json:{} }); @@ -42,6 +46,8 @@ try { assert.equal(await page.locator(".thinking-row .avatar.thinking").count(),0); assert(await page.locator(".composer textarea").isDisabled()); assert.equal(await page.locator("iframe.desktop-frame").count(),1); + assert(computerCalls.some((call) => call.method === "POST" && call.path === "/api/agents/owner/computer"), JSON.stringify(computerCalls)); + assert((await page.locator("iframe.desktop-frame").getAttribute("src") || "").startsWith("/fixture-viewer")); await page.getByRole("button",{name:"完成並繼續",exact:true}).click(); await page.locator(".handover-card").waitFor({state:"hidden"}); await page.locator(".thinking-row .avatar.thinking").waitFor(); diff --git a/tests/settings_ui.mjs b/tests/settings_ui.mjs index 0bf9080..9264fe3 100644 --- a/tests/settings_ui.mjs +++ b/tests/settings_ui.mjs @@ -7,7 +7,7 @@ mkdirSync(outDir, { recursive: true }); const browser = await chromium.launch({ headless: true }); try { - const context = await browser.newContext({ locale: "zh-TW" }); + const context = await browser.newContext({ locale: "zh-TW", ignoreHTTPSErrors: true }); await context.grantPermissions(["clipboard-read", "clipboard-write"]); const page = await context.newPage(); page.setDefaultTimeout(8000); @@ -43,6 +43,9 @@ try { if (path.endsWith("/events")) { return route.fulfill({ contentType: "text/event-stream", body: "data: {\"events\":[]}\n\n" }); } + if (path.endsWith("/computer")) { + return route.fulfill({ json: { ready: true, state: "ready", viewer_url: "/fixture-viewer" } }); + } return route.fulfill({ json: {} }); }); await page.goto(process.env.LAZYBOY_TEST_UI_URL || "http://127.0.0.1:5173"); diff --git a/tests/transcript_ui.mjs b/tests/transcript_ui.mjs index 4651278..bc5bb3b 100644 --- a/tests/transcript_ui.mjs +++ b/tests/transcript_ui.mjs @@ -25,6 +25,9 @@ try { body: 'data: {"events":[{"id":9,"kind":"reply","payload":{"verdict":"answer","message":"這段 scratchpad 不該出現在聊天裡"}}]}\n\n', }); } + if (path.endsWith("/computer")) { + return route.fulfill({ json: { ready: true, state: "ready", viewer_url: "/fixture-viewer" } }); + } if (path === "/api/agents") { return route.fulfill({ json: { agents: [{ id: "owner", name: "測試", running: false }] } }); } diff --git a/web/package-lock.json b/web/package-lock.json index 9d32fa2..f203187 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -773,9 +773,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -884,9 +881,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -901,9 +895,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -918,9 +909,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -935,9 +923,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -952,9 +937,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -969,9 +951,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -986,9 +965,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1003,9 +979,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1020,9 +993,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1037,9 +1007,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1054,9 +1021,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1071,9 +1035,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1088,9 +1049,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/web/src/App.tsx b/web/src/App.tsx index 6491753..785af5b 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -155,10 +155,6 @@ function stepKey(name: string): StepKey | null { return "stepWorking"; } -function isComputerStep(step: StepKey | null): boolean { - return step === "stepComputer" || step === "stepBrowser" || step === "stepBox" || step === "stepShell"; -} - function stepFromProgress(message: string): StepKey | null { const text = message.trim(); if (/思考中|thinking/i.test(text)) return "thinking"; @@ -351,7 +347,7 @@ export function App() { const [computerBusy, setComputerBusy] = useState(null); const [imageFresh, setImageFresh] = useState(false); const [computerGen, setComputerGen] = useState(0); - const computerOpRef = useRef(null); + const computerOpRef = useRef(null); const tRef = useRef(t); tRef.current = t; const [typing, setTyping] = useState(false); @@ -504,6 +500,51 @@ export function App() { } }, [applyTranscript]); + const runComputer = useCallback(async (action: ComputerAction, id?: string) => { + const copy = tRef.current; + const target = id || activeIdRef.current; + if (!target) return; + const op = `${target}:${action}`; + if (computerOpRef.current === op) return; + if (action === "restart" && !window.confirm(copy.restartConfirm)) return; + if (action === "update" && !window.confirm(copy.updateConfirm)) return; + computerOpRef.current = op; + setComputerBusy(action); + setComputerReady(false); + if (action !== "start") setComputerUrl(""); + setComputerStatus(copy[computerBusyKey(action)]); + try { + const data = await api.computer(target, action); + if (activeIdRef.current !== target) return; + setComputerReady(Boolean(data.ready)); + if (data.ready) { + setComputerGen((n) => n + 1); + const suffix = data.viewer_url.includes("?") ? "&" : "?"; + setComputerUrl(`${data.viewer_url}${suffix}t=${Date.now()}`); + setImageFresh(action === "update" && data.updated === false); + setComputerStatus(data.crowded ? copy.computerCrowded : ""); + } else { + setComputerUrl(""); + setImageFresh(false); + setComputerStatus(data.error || copy.computerNotReady); + } + } catch (err) { + if (activeIdRef.current !== target) return; + setComputerReady(false); + setComputerUrl(""); + setImageFresh(false); + setComputerStatus(err instanceof Error ? err.message : String(err)); + } finally { + if (computerOpRef.current === op) computerOpRef.current = null; + if (activeIdRef.current === target) setComputerBusy(null); + } + }, []); + + const ensureComputer = useCallback(async () => { + if (!activeIdRef.current) return; + await runComputer("start", activeIdRef.current); + }, [runComputer]); + const openAgent = useCallback(async (id: string, keepChannel = false) => { activeIdRef.current = id; activityGeneration.current += 1; @@ -519,6 +560,12 @@ export function App() { setExecutionDetails([]); setError(""); setTyping(false); + setComputerReady(false); + setComputerUrl(""); + setImageFresh(false); + setComputerBusy("start"); + setComputerStatus(tRef.current.computerStarting); + void runComputer("start", id); try { const data = await api.agent(id); if (generation !== transcriptGeneration.current || activeIdRef.current !== id) return; @@ -533,47 +580,7 @@ export function App() { setTranscript([]); setError(err instanceof Error ? err.message : String(err)); } - }, [applyActivity, applyTranscript]); - - const runComputer = useCallback(async (action: ComputerAction) => { - const copy = tRef.current; - if (computerOpRef.current) return; - if (action === "restart" && !window.confirm(copy.restartConfirm)) return; - if (action === "update" && !window.confirm(copy.updateConfirm)) return; - computerOpRef.current = action; - setComputerBusy(action); - setComputerReady(false); - if (action !== "start") setComputerUrl(""); - setComputerStatus(copy[computerBusyKey(action)]); - try { - const data = await api.computer(action); - setComputerReady(Boolean(data.ready)); - if (data.ready) { - setComputerGen((n) => n + 1); - const suffix = data.viewer_url.includes("?") ? "&" : "?"; - setComputerUrl(`${data.viewer_url}${suffix}t=${Date.now()}`); - setImageFresh(action === "update" && data.updated === false); - setComputerStatus(""); - } else { - setComputerUrl(""); - setImageFresh(false); - setComputerStatus(data.error || copy.computerNotReady); - } - } catch (err) { - setComputerReady(false); - setComputerUrl(""); - setImageFresh(false); - setComputerStatus(err instanceof Error ? err.message : String(err)); - } finally { - computerOpRef.current = null; - setComputerBusy(null); - } - }, []); - - const ensureComputer = useCallback(async () => { - if (computerOpRef.current) return; - await runComputer("start"); - }, [runComputer]); + }, [applyActivity, applyTranscript, runComputer]); useEffect(() => { if (isPhone()) setRightOpen(false); @@ -859,13 +866,11 @@ export function App() { const liveStep = userProgress || (workingStep === "stepSearch" ? t.stepSearch : workingStep === "stepFetch" ? t.stepFetch : workingStep ? t.stepWorking : null); const computerLive = computerBusy ? t[computerBusyKey(computerBusy)] - : working && isComputerStep(workingStep) - ? liveStep || t.computerRunning - : computerReady - ? imageFresh - ? t.computerUpToDate - : t.computerRunning - : t.computerOff; + : computerReady + ? imageFresh + ? t.computerUpToDate + : t.computerRunning + : computerStatus || t.computerOff; const frame = computerUrl ? (