have main issue

This commit is contained in:
daniel wang 2026-09-16 06:40:00 +00:00
parent 824e4026dd
commit 7e6084baeb
29 changed files with 1128 additions and 384 deletions

View File

@ -28,7 +28,7 @@ LAZYBOY_API_KEY=
# 網頁 UI / APImake 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 代理到這裡。

View File

@ -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,13 +186,28 @@ 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 \
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; \
load_env; \
@ -201,14 +220,19 @@ start-web:
@mkdir -p "$(RUNDIR)"
@eval "$$CTL"; \
if alive "$(PID_WEB)"; then \
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)"

View File

@ -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` |

View File

@ -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.

View File

@ -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<u16>,
}
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<BoxState>,
computer_use: AtomicBool,
}
struct BoxState {
ready: bool,
starting: bool,
last_error: Option<String>,
last_used: Instant,
host_port: Option<u16>,
}
impl BoxHub {
pub fn new() -> Arc<Self> {
Self::for_seat(BoxSeat::for_id(SESSION_SEAT))
}
pub fn for_seat(seat: BoxSeat) -> Arc<Self> {
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<Value> {
fn status_payload(&self, state: &str, ready: bool, error: Option<String>, 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;
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?;
state.ready = true;
if let Some(port) = host_port_of(&self.seat.container).await {
state.host_port = Some(port);
}
return Ok(Self::ready_payload());
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<Value> {
{
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<Value> {
{
let mut state = self.inner.lock().await;
state.ready = false;
state.starting = true;
state.last_error = None;
}
let result = async {
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?;
if docker(["container", "inspect", self.container()]).await.is_ok() {
docker(["restart", "-t", "20", self.container()]).await?;
} else {
ensure_image().await?;
ensure_container().await?;
let port = ensure_container(&self.seat).await?;
self.inner.lock().await.host_port = Some(port);
}
wait_desktop().await?;
state.ready = true;
let mut payload = Self::ready_payload();
payload["action"] = json!("restart");
Ok(payload)
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<Value> {
{
let mut state = self.inner.lock().await;
state.ready = false;
state.starting = true;
state.last_error = None;
}
let result = async {
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?;
if docker(["container", "inspect", self.container()]).await.is_ok() {
remove_stale_container(self.container()).await?;
}
ensure_container().await?;
} else if !docker_running(CONTAINER).await? {
ensure_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().await?;
state.ready = true;
let mut payload = Self::ready_payload();
payload["action"] = json!("update");
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<Value> {
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<u16> {
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 </dev/null >/tmp/lazyboy-browser.log 2>&1 &"]).await?;
self.exec(&["bash", "-lc", "nohup box-chrome </dev/null >/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} </dev/null >/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<ScreenSize> {
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<Value> {
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<CmdOut> {
docker_exec(self.container(), args).await
}
async fn exec_env(&self, env: &[(&str, &str)], args: &[&str]) -> Result<CmdOut> {
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<String> {
docker(["inspect", "-f", "{{.Image}}", CONTAINER])
async fn container_image_id(container: &str) -> Option<String> {
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<u16> {
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<String> {
docker(["inspect", "-f", "{{.Id}}", IMAGE])
.await
@ -641,37 +963,45 @@ async fn local_image_id() -> Option<String> {
.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<u16> {
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<CmdOut> {
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<CmdOut> {
pub struct BoxPool {
hubs: std::sync::Mutex<HashMap<String, Arc<BoxHub>>>,
}
impl BoxPool {
pub fn new() -> Arc<Self> {
Arc::new(Self {
hubs: std::sync::Mutex::new(HashMap::new()),
})
}
pub fn global() -> Arc<Self> {
static POOL: OnceLock<Arc<BoxPool>> = OnceLock::new();
POOL.get_or_init(Self::new).clone()
}
pub fn hub(&self, seat_id: &str) -> Arc<BoxHub> {
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<Value> {
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<Value> {
self.hub(seat_id).restart().await
}
pub async fn update(&self, seat_id: &str) -> Result<Value> {
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<Arc<BoxHub>> = 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<CmdOut> {
docker_exec_env(container, &[], args).await
}
async fn docker_exec_env(container: &str, env: &[(&str, &str)], args: &[&str]) -> Result<CmdOut> {
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();
}
}

View File

@ -22,6 +22,7 @@ struct Helper {
input: ChildStdin,
output: BufReader<ChildStdout>,
next: u64,
container: Option<String>,
}
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<PathBuf>,
mut req: Value,
container: Option<&str>,
) -> Result<Value> {
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<PathBuf>) -> Result<Helper> {
async fn spawn(cwd: &Path, profile: Option<PathBuf>, seat_id: Option<&str>) -> Result<Helper> {
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<Helper> {
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

View File

@ -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,

View File

@ -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 19754096
/// 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<String> {
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<String> {
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<String> {
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<String>, raw: &str) {
let trimmed = raw.trim().split('/').next().unwrap_or("").trim();
if trimmed.is_empty() {
return;
}
let Ok(addr) = trimmed.parse::<IpAddr>() 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<String> {
let mut names = vec!["localhost".into()];
for cmd in [["hostname", "-s"], ["hostname", ""]] {
@ -108,6 +209,13 @@ fn san_list(names: &[String]) -> Vec<SanType> {
pub fn ensure() -> Result<TlsMaterial> {
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<TlsMaterial> {
(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<Arc<ServerConfig>> {
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::<Result<Vec<_>, _>>() {
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,7 +378,15 @@ fn dirs_login_keychain() -> Option<String> {
}
}
pub fn https_location(host: &str, path: &str, port: u16) -> String {
pub fn is_tls_client_hello(first: u8) -> bool {
first == 0x16
}
#[cfg(test)]
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('[') {
@ -289,50 +401,7 @@ pub fn https_location(host: &str, path: &str, port: u16) -> String {
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
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn https_location_keeps_port() {
@ -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}"
);
}
}

View File

@ -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."
}

View File

@ -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

View File

@ -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<Value> {
}
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(),
}
}

View File

@ -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<Value> {
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<Val
if let Some(url) = ctx.last_browser_url_value() {
let restored = ctx
.browser
.request(
.request_on(
&ctx.cwd,
ctx.runtime.profile_dir(),
json!({"op":"navigate","url":url}),
Some(ctx.box_hub.seat_id()),
)
.await?;
if restored["ok"] != true {
@ -1389,10 +1391,11 @@ async fn browser_tool(ctx: &ToolContext, name: &str, args: &Value) -> Result<Val
required_text(args, "reason")?;
let prep = ctx
.browser
.request(
.request_on(
&ctx.cwd,
ctx.runtime.profile_dir(),
json!({"op":"handoff_prepare"}),
Some(ctx.box_hub.seat_id()),
)
.await?;
if prep["ok"] != true {
@ -1443,12 +1446,12 @@ async fn browser_tool(ctx: &ToolContext, name: &str, args: &Value) -> Result<Val
}
let mut result = ctx
.browser
.request(&ctx.cwd, ctx.runtime.profile_dir(), req)
.request_on(&ctx.cwd, ctx.runtime.profile_dir(), req, Some(ctx.box_hub.seat_id()))
.await?;
result["surface"] = json!(if crate::browser_client::local_browser_enabled() { "local_browser" } else { "box_browser" });
if !crate::browser_client::local_browser_enabled() {
result["profile"] = json!("/home/box/chrome-profile");
result["viewer_url"] = json!(BoxHub::viewer_url());
result["viewer_url"] = json!(ctx.box_hub.web_viewer_url());
}
browser::update_last_url(&ctx.last_browser_url, &result);
*ctx.runtime.browser_url.lock().unwrap() = ctx.last_browser_url_value();

View File

@ -2,7 +2,7 @@
//! Talks to the named-agent team daemon over the existing Unix RPC.
use crate::team;
use crate::BoxHub;
use crate::{BoxPool, SESSION_SEAT};
use anyhow::{Context, Result};
use axum::body::{Body, Bytes};
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
@ -36,7 +36,7 @@ const FALLBACK_HTML: &str = r#"<!doctype html>
struct App {
token: Option<String>,
cwd: PathBuf,
box_hub: std::sync::Arc<BoxHub>,
box_pool: std::sync::Arc<BoxPool>,
ca_pem: Option<String>,
}
@ -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<Value>) -> Json<Value> {
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<Json<Value>, 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<String, (StatusCode, Json<Value>)> {
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<App>,
headers: HeaderMap,
Path(id): Path<String>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
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<App>,
headers: HeaderMap,
Path(id): Path<String>,
Json(body): Json<ComputerBody>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
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<App>) -> 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>, 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<App>, req: Request) -> Response {
let path_and_query = req
.uri()
@ -588,9 +660,20 @@ async fn novnc_proxy(State(app): State<App>, 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<App>, 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<Response, anyhow::Error> {
let url = format!("http://127.0.0.1:6080{rest}");
async fn proxy_vnc_http(req: Request, rest: &str, port: u16) -> Result<Response, anyhow::Error> {
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<Response, anyhow::Er
.unwrap_or_else(|_| StatusCode::BAD_GATEWAY.into_response()))
}
async fn proxy_vnc_ws(client: WebSocket, rest: String) {
async fn proxy_vnc_ws(client: WebSocket, rest: String, port: u16) {
let path = rest.split('?').next().unwrap_or("/websockify");
let url = format!("ws://127.0.0.1:6080{path}");
let url = format!("ws://127.0.0.1:{port}{path}");
let Ok((upstream, _)) = tokio_tungstenite::connect_async(url).await else {
let _ = client.close().await;
return;
@ -725,7 +808,6 @@ async fn serve_tls_mux(
.install_default()
.ok();
let acceptor = tokio_rustls::TlsAcceptor::from(crate::local_tls::server_config(material)?);
let port = listener.local_addr()?.port();
loop {
let (stream, _) = listener.accept().await?;
let acceptor = acceptor.clone();
@ -733,34 +815,37 @@ async fn serve_tls_mux(
tokio::spawn(async move {
let mut head = [0u8; 1];
match stream.peek(&mut head).await {
Ok(0) => 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,
}
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<hyper::body::Incoming>| {
let router = router.clone();
async move {
tower::ServiceExt::oneshot(router, request.map(axum::body::Body::new)).await
Ok(_) => serve_hyper(stream, router).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<I>(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<hyper::body::Incoming>| {
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();

View File

@ -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}");
}

View File

@ -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

View File

@ -50,7 +50,7 @@ Ctrl-C 或 `/stop` 取消模型/工具/人工等待。命令以 process grou
`lazyboy web``:8787``LAZYBOY_WEB_PORT`)提供 Grok Bot 風格對話網頁與 PWA。手機與電腦同一 Wi-Fi 時,用終端機印出的 LAN URL 開啟SafariChrome「加入主畫面」即可全螢幕當 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/waitxdotool `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/waitxdotool `DISPLAY=:1`);同一座位一次只能有一個 computerUse不同 named agent 的電腦互不搶螢幕。父層只有唯讀 `screenshot`。人類登入仍走 `request_box_help`Web 開 named agent 會啟動該座位的 Docker 桌面並顯示就緒或錯誤。
HTTP 連線期限 15 秒、單次請求 180 秒helper 回應期限 60 秒;網頁條件等待最多 30 秒命令預設最多十分鐘legacy shell 為 30 秒。等待時每 20 秒顯示實際階段與經過時間,不呼叫模型製造更新。

View File

@ -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.

View File

@ -11,7 +11,7 @@
| 公開抓頁 | host → Cursor AiService.RunWebFetch | host 直接匿名 HTTP GETHTML 轉文字、同 URL 十分鐘快取、redirect 重新檢查公開主機);只有網站拒絕純 HTTP 時才退回 xAI 遠端瀏覽,並明確標記 model_rendered_web_content 與 fallback_reason |
| 登入網頁 | box Chrome | Docker ChromiumDOM helper 也在 Docker |
| 人工登入 | RequestBoxHelp → agent 桌面 | request_box_helpbrowser_handoff → 同一個 Docker Chromium |
| 桌面像素操作 | 父層 ScreenshotcomputerUse 子 agent 才有 Computerclick/type/…) | 父層 `screenshot` 唯讀;`spawn_subagent kind=computerUse` 才有 `computer`xdotool `DISPLAY=:1`)。一次一個 computerUse |
| 桌面像素操作 | 父層 ScreenshotcomputerUse 子 agent 才有 Computerclick/type/…) | 父層 `screenshot` 唯讀;`spawn_subagent kind=computerUse` 才有 `computer`xdotool `DISPLAY=:1`)。每個 named agent 一台 Docker 桌面;同一台電腦一次一個 computerUse |
| 瀏覽器資料 | /home/box/chrome-profile另有 box store 同步 | /home/box/chrome-profilelazyboy-box-home volume 持久保存 |
**WebSearchWebFetch 不會讀取任何瀏覽器 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 monitordesktop此版仍是單一 Docker 桌面DISPLAY=:1computerUse 以「一次一個」排他,不具有獨立螢幕隔離。執行器是 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 storeChrome 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 環境,並非另租的雲端主機;檔案與瀏覽器狀態隔離在 containervolume但 CPU、磁碟與 Docker daemon 仍屬於本機。CLI 的設定、對話紀錄與模型/MCP 編排也仍在本地。
此處的 box 是本機 Docker Linux 環境,並非另租的雲端主機;每個 named agent 的檔案與瀏覽器狀態隔離在自己的 containervolume但 CPU、磁碟與 Docker daemon 仍屬於本機。開 agent 會啟動該座位並等到桌面就緒或留下錯誤GET 狀態不會順便開機。CLI 的設定、對話紀錄與模型/MCP 編排也仍在本地。舊的 `lazyboy agent``run``computer` 仍使用全域 `lazyboy-box`
CLI mock-provider 測試也已通過(包含串流工具呼叫、背景 subagentexternal command 回覆與 session 恢復)。

View File

@ -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。沒有連線的人工問題仍保持等待只有明確回答或停止才繼續。

View File

@ -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 }],
} });

View File

@ -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");

View File

@ -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 }] } });
}

View File

@ -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();

View File

@ -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");

View File

@ -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 }] } });
}

42
web/package-lock.json generated
View File

@ -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": [

View File

@ -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<ComputerAction | null>(null);
const [imageFresh, setImageFresh] = useState(false);
const [computerGen, setComputerGen] = useState(0);
const computerOpRef = useRef<ComputerAction | null>(null);
const computerOpRef = useRef<string | null>(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;
: computerStatus || t.computerOff;
const frame = computerUrl ? (
<iframe

View File

@ -105,14 +105,25 @@ export const api = {
request<{ queued?: string }>(`/api/agents/${id}/messages`, { method: "POST", body: JSON.stringify({ text }) }),
handoverDone: (id: string, taskId: string, questionId: string, action: "done" | "cancel" = "done") => request(`/api/agents/${id}/handover`, { method: "POST", body: JSON.stringify({ task_id: taskId, question_id: questionId, action }), signal: AbortSignal.timeout(8000) }),
stop: (id: string) => request(`/api/agents/${id}/stop`, { method: "POST", body: "{}" }),
computer: (action: "start" | "restart" | "update" = "start") =>
computerStatus: (id: string) =>
request<{
ready: boolean;
state?: string;
viewer_url: string;
error?: string;
revision?: string;
crowded?: boolean;
}>(`/api/agents/${encodeURIComponent(id)}/computer`),
computer: (id: string, action: "start" | "restart" | "update" = "start") =>
request<{
ready: boolean;
state?: string;
viewer_url: string;
error?: string;
updated?: boolean;
revision?: string;
}>("/api/computer", {
crowded?: boolean;
}>(`/api/agents/${encodeURIComponent(id)}/computer`, {
method: "POST",
body: JSON.stringify({ action }),
}),

View File

@ -36,10 +36,10 @@ const zhHant = {
settingsLabel: "LazyBoy 設定",
workspaceDetail: "Agent 與對話都留在這台機器。",
versionLine: "版本 {version}",
computerUpdateCopy: "更新助手共用的電腦。檔案與登入會留。",
computerUpdateCopy: "更新所有助手共用的電腦映像每個 Agent 的檔案與登入會留在自己的電腦。",
updateComputer: "更新電腦",
restartComputer: "重啟電腦",
restartComputerCopy: "電腦卡住時重啟。瀏覽器登入會保留。",
restartComputerCopy: "重啟目前這個 Agent 的電腦。瀏覽器登入會保留。",
appComputer: "LazyBoy 的電腦",
computerBusyCopy: "有 Agent 正在工作。現在操作會中斷它。",
localBuild: "本機版本",
@ -133,6 +133,7 @@ const zhHant = {
computerNotReady: "電腦未就緒",
computerNotStarted: "尚未啟動",
computerOff: "未啟動",
computerCrowded: "同時開著的電腦已達上限,已為這個 Agent 啟動,較閒的電腦先關掉。",
thinking: "思考中",
needsReply: "需要回覆",
stepComputer: "操作電腦",
@ -173,10 +174,10 @@ const zhHans: Messages = {
settingsLabel: "LazyBoy 设置",
workspaceDetail: "Agent 和对话都留在这台机器。",
versionLine: "版本 {version}",
computerUpdateCopy: "更新助手共用的电脑。文件和登录会留。",
computerUpdateCopy: "更新所有助手共用的电脑镜像每个 Agent 的文件和登录会留在自己的电脑。",
updateComputer: "更新电脑",
restartComputer: "重启电脑",
restartComputerCopy: "电脑卡住时重启。浏览器登录会保留。",
restartComputerCopy: "重启当前这个 Agent 的电脑。浏览器登录会保留。",
appComputer: "LazyBoy 的电脑",
computerBusyCopy: "有 Agent 正在工作。现在操作会中断它。",
localBuild: "本地版本",
@ -270,6 +271,7 @@ const zhHans: Messages = {
computerNotReady: "电脑未就绪",
computerNotStarted: "尚未启动",
computerOff: "未启动",
computerCrowded: "同时开着的电脑已达上限,已为这个 Agent 启动,较闲的电脑先关掉。",
thinking: "思考中",
needsReply: "需要回复",
stepComputer: "操作电脑",
@ -307,10 +309,10 @@ const en: Messages = {
settingsLabel: "LazyBoy settings",
workspaceDetail: "Agents and chats stay on this machine.",
versionLine: "Version {version}",
computerUpdateCopy: "Updates the computer your assistants share. Your files and logins stay.",
computerUpdateCopy: "Rebuilds the image shared by every assistant. Each agent's files and logins stay on its own computer.",
updateComputer: "Update LazyBoy's Computer",
restartComputer: "Restart LazyBoy's Computer",
restartComputerCopy: "Restart if the computer gets stuck. Browser logins are kept.",
restartComputerCopy: "Restart this agent's computer. Browser logins are kept.",
appComputer: "LazyBoy's Computer",
computerBusyCopy: "An agent is working. This will interrupt it.",
localBuild: "Local build",
@ -404,6 +406,7 @@ const en: Messages = {
computerNotReady: "Computer is not ready",
computerNotStarted: "Not started yet",
computerOff: "Off",
computerCrowded: "Too many computers were running, so an idle one was stopped to start this agent's.",
thinking: "Thinking",
needsReply: "Needs a reply",
stepComputer: "Using the computer",
@ -441,10 +444,10 @@ const ja: Messages = {
settingsLabel: "LazyBoy の設定",
workspaceDetail: "Agent と会話はこのマシンに残ります。",
versionLine: "バージョン {version}",
computerUpdateCopy: "アシスタントが共有するコンピュータを更新します。ファイルとログインは残ります。",
computerUpdateCopy: "全アシスタント共用のイメージを更新します。各 Agent のファイルとログインは自分のコンピュータに残ります。",
updateComputer: "コンピュータを更新",
restartComputer: "コンピュータを再起動",
restartComputerCopy: "コンピュータが止まったときに再起動します。ブラウザのログインは残ります。",
restartComputerCopy: "この Agent のコンピュータを再起動します。ブラウザのログインは残ります。",
appComputer: "LazyBoy のコンピュータ",
computerBusyCopy: "Agent が作業中です。今操作すると中断されます。",
localBuild: "ローカルビルド",
@ -538,6 +541,7 @@ const ja: Messages = {
computerNotReady: "コンピュータの準備ができていません",
computerNotStarted: "まだ起動していません",
computerOff: "停止中",
computerCrowded: "同時に起動できるコンピュータ数の上限に達したため、この Agent を起動し、使っていないコンピュータを止めました。",
thinking: "考え中",
needsReply: "返信が必要です",
stepComputer: "コンピュータを操作中",