have main issue
This commit is contained in:
parent
824e4026dd
commit
7e6084baeb
|
|
@ -28,7 +28,7 @@ LAZYBOY_API_KEY=
|
|||
# 網頁 UI / API(make start → lazyboy serve)
|
||||
# =============================================================================
|
||||
|
||||
# HTTP 綁定位址。0.0.0.0 才能用同一 Wi-Fi 的手機開。
|
||||
# HTTP 綁定位址。預設 0.0.0.0,同一 Wi-Fi 的手機用印出的 http://LAN:8787。
|
||||
# LAZYBOY_WEB_HOST=0.0.0.0
|
||||
|
||||
# JSON API + 若有 web/dist 時的 UI 埠。Vite 開發前端仍是 5173,會把 /api 代理到這裡。
|
||||
|
|
|
|||
30
Makefile
30
Makefile
|
|
@ -18,7 +18,7 @@ PID_WEB := $(RUNDIR)/web.pid
|
|||
LOG_SERVE := $(RUNDIR)/serve.log
|
||||
LOG_WEB := $(RUNDIR)/web.log
|
||||
|
||||
.PHONY: help start stop restart status logs start-serve start-web stop-serve stop-web
|
||||
.PHONY: help start stop restart status logs prepare-ui start-serve start-web stop-serve stop-web
|
||||
|
||||
# Shared POSIX helpers. Sourced by each recipe.
|
||||
define CTL
|
||||
|
|
@ -159,10 +159,14 @@ help:
|
|||
@echo "make status Show PIDs and ports"
|
||||
@echo "make logs Tail both logs (Ctrl-C to leave)"
|
||||
|
||||
start: start-serve start-web
|
||||
start: prepare-ui start-serve start-web
|
||||
@echo
|
||||
@echo "API http://127.0.0.1:$(PORT_API)"
|
||||
@echo "UI http://127.0.0.1:$(PORT_WEB)"
|
||||
@lan=$$(hostname -I 2>/dev/null | awk '{print $$1}'); \
|
||||
if [ -n "$$lan" ]; then \
|
||||
echo "Phone http://$$lan:$(PORT_API)"; \
|
||||
fi
|
||||
|
||||
restart: stop
|
||||
@$(MAKE) start
|
||||
|
|
@ -182,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)"
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,16 +1,23 @@
|
|||
//! Local HTTPS for the web UI. VNC stays on 127.0.0.1; browsers talk TLS to LazyBoy.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{Datelike, Duration, Utc};
|
||||
use rcgen::{
|
||||
BasicConstraints, CertificateParams, DistinguishedName, DnType, ExtendedKeyUsagePurpose,
|
||||
IsCa, KeyPair, KeyUsagePurpose, SanType,
|
||||
date_time_ymd, BasicConstraints, CertificateParams, DistinguishedName, DnType,
|
||||
ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose, SanType,
|
||||
};
|
||||
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
|
||||
use rustls::pki_types::PrivateKeyDer;
|
||||
use rustls::ServerConfig;
|
||||
use std::net::IpAddr;
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Bump when on-disk certs must be reissued. Format 1 used rcgen's 1975–4096
|
||||
/// validity, which Firefox/NSS reports as SEC_ERROR_BAD_SIGNATURE (not bypassable).
|
||||
const MATERIAL_FORMAT: &str = "2";
|
||||
const CA_VALID_DAYS: i64 = 3650;
|
||||
const SERVER_VALID_DAYS: i64 = 730;
|
||||
|
||||
pub struct TlsMaterial {
|
||||
pub ca_pem: String,
|
||||
pub cert_pem: String,
|
||||
|
|
@ -37,15 +44,46 @@ pub fn cert_dir() -> PathBuf {
|
|||
|
||||
pub fn lan_ips() -> Vec<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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ Ctrl-C 或 `/stop` 取消模型/工具/人工等待。命令以 process grou
|
|||
|
||||
`lazyboy web` 在 `:8787`(`LAZYBOY_WEB_PORT`)提供 Grok Bot 風格對話網頁與 PWA。手機與電腦同一 Wi-Fi 時,用終端機印出的 LAN URL 開啟,Safari/Chrome「加入主畫面」即可全螢幕當 app。`/novnc` 同源代理 Docker 桌面,所以手機不必直連 6080。
|
||||
|
||||
`spawn_subagent` 立刻返回。父回合若在子 agent 或背景命令仍在跑時以無工具結束,runtime 會等它完成、灌入 revival 訊息、再請模型繼續;不要用 `check_subagent` 輪詢完成。子 agent 不能再派子 agent。`LAZYBOY_SUBAGENT_MAX_ROUNDS` 預設 128。`kind=computerUse` 把桌面點擊交給子 agent(`computer`:screenshot/click/move/drag/type/key/scroll/wait,xdotool `DISPLAY=:1`);同一時間只能有一個 computerUse。父層只有唯讀 `screenshot`。人類登入仍走 `request_box_help`。
|
||||
`spawn_subagent` 立刻返回。父回合若在子 agent 或背景命令仍在跑時以無工具結束,runtime 會等它完成、灌入 revival 訊息、再請模型繼續;不要用 `check_subagent` 輪詢完成。子 agent 不能再派子 agent。`LAZYBOY_SUBAGENT_MAX_ROUNDS` 預設 128。`kind=computerUse` 把桌面點擊交給子 agent(`computer`:screenshot/click/move/drag/type/key/scroll/wait,xdotool `DISPLAY=:1`);同一座位一次只能有一個 computerUse,不同 named agent 的電腦互不搶螢幕。父層只有唯讀 `screenshot`。人類登入仍走 `request_box_help`。Web 開 named agent 會啟動該座位的 Docker 桌面並顯示就緒或錯誤。
|
||||
|
||||
HTTP 連線期限 15 秒、單次請求 180 秒;helper 回應期限 60 秒;網頁條件等待最多 30 秒;命令預設最多十分鐘,legacy shell 為 30 秒。等待時每 20 秒顯示實際階段與經過時間,不呼叫模型製造更新。
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
| 公開抓頁 | host → Cursor AiService.RunWebFetch | host 直接匿名 HTTP GET(HTML 轉文字、同 URL 十分鐘快取、redirect 重新檢查公開主機);只有網站拒絕純 HTTP 時才退回 xAI 遠端瀏覽,並明確標記 model_rendered_web_content 與 fallback_reason |
|
||||
| 登入網頁 | box Chrome | Docker Chromium,DOM helper 也在 Docker |
|
||||
| 人工登入 | RequestBoxHelp → agent 桌面 | request_box_help/browser_handoff → 同一個 Docker Chromium |
|
||||
| 桌面像素操作 | 父層 Screenshot;computerUse 子 agent 才有 Computer(click/type/…) | 父層 `screenshot` 唯讀;`spawn_subagent kind=computerUse` 才有 `computer`(xdotool `DISPLAY=:1`)。一次一個 computerUse |
|
||||
| 桌面像素操作 | 父層 Screenshot;computerUse 子 agent 才有 Computer(click/type/…) | 父層 `screenshot` 唯讀;`spawn_subagent kind=computerUse` 才有 `computer`(xdotool `DISPLAY=:1`)。每個 named agent 一台 Docker 桌面;同一台電腦一次一個 computerUse |
|
||||
| 瀏覽器資料 | /home/box/chrome-profile,另有 box store 同步 | /home/box/chrome-profile,lazyboy-box-home volume 持久保存 |
|
||||
|
||||
**WebSearch/WebFetch 不會讀取任何瀏覽器 profile 或 cookies。** 後端授權 token、模型 API key、網站登入 cookies 是三種不同資料。MCP 仍使用 connector 自己的授權,也不會自動取得 Chromium cookies。
|
||||
|
|
@ -37,7 +37,7 @@ Gateway 需要提供 JSON Connect `POST /aiserver.v1.AiService/RunWebSearch`(s
|
|||
|
||||
## 尚未等同參考版的部分
|
||||
|
||||
- 參考版提供 per-agent monitor/desktop;此版仍是單一 Docker 桌面(DISPLAY=:1),computerUse 以「一次一個」排他,不具有獨立螢幕隔離。執行器是 xdotool,不是參考版 box 內的 protobuf ComputerUse executor / CUA。
|
||||
- 參考版在同一台 box 上分 per-agent monitor。此版改成每個 named agent 一個 Docker 容器(同一映像),各自 DISPLAY=:1、Chrome profile 與 `/workspace`。臨時 worker 用發起任務的 owner 座位。computerUse 按座位排他,不是全域一台螢幕。執行器仍是 xdotool,不是參考版 box 內的 protobuf ComputerUse executor / CUA。
|
||||
- 參考版有 cloud box store/Chrome session snapshot,同步到遠端儲存;此版使用本機 Docker volume,未實作該雲端備份。
|
||||
- 參考版 local-docker connector 可掛入 `.codex`/`.claude` 的唯讀 CLI 認證,並將 host runner 放進容器;此版 host/model/MCP 編排仍在本地,未自動掛入這些私人目錄。
|
||||
- 本地工具沿用 LazyBoy2 既有工作區授權規則,沒有複製參考版每次 ExternalShell 的批准卡 UI。
|
||||
|
|
@ -64,6 +64,6 @@ Box 執行期行為:截圖前會等 `SCREENSHOT_SETTLE_MS` 讓桌面重繪;
|
|||
|
||||
一般工具名 `shell`/`read`/`await_shell` 一律指向 box;`box_shell`/`box_read`/`box_await` 保留為舊 box 呼叫的別名。本地檔案與命令工具全部改成 `external_*`;舊模糊名稱如 `exec_command`/`read_file` 會報錯,不會隱含操作本地。Box 命令等待時間到後保留執行,以 `await_shell` 收取結果。關閉桌面瀏覽器不會阻止 shell/read 工作,下一次 browser 工具會重開原 profile。
|
||||
|
||||
此處的 box 是本機 Docker Linux 環境,並非另租的雲端主機;檔案與瀏覽器狀態隔離在 container/volume,但 CPU、磁碟與 Docker daemon 仍屬於本機。CLI 的設定、對話紀錄與模型/MCP 編排也仍在本地。
|
||||
此處的 box 是本機 Docker Linux 環境,並非另租的雲端主機;每個 named agent 的檔案與瀏覽器狀態隔離在自己的 container/volume,但 CPU、磁碟與 Docker daemon 仍屬於本機。開 agent 會啟動該座位並等到桌面就緒(或留下錯誤);GET 狀態不會順便開機。CLI 的設定、對話紀錄與模型/MCP 編排也仍在本地。舊的 `lazyboy agent`/`run`/`computer` 仍使用全域 `lazyboy-box`。
|
||||
|
||||
CLI mock-provider 測試也已通過(包含串流工具呼叫、背景 subagent/external command 回覆與 session 恢復)。
|
||||
|
|
|
|||
|
|
@ -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。沒有連線的人工問題仍保持等待,只有明確回答或停止才繼續。
|
||||
|
|
|
|||
|
|
@ -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 }],
|
||||
} });
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 }] } });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 }] } });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
103
web/src/App.tsx
103
web/src/App.tsx
|
|
@ -155,10 +155,6 @@ function stepKey(name: string): StepKey | null {
|
|||
return "stepWorking";
|
||||
}
|
||||
|
||||
function isComputerStep(step: StepKey | null): boolean {
|
||||
return step === "stepComputer" || step === "stepBrowser" || step === "stepBox" || step === "stepShell";
|
||||
}
|
||||
|
||||
function stepFromProgress(message: string): StepKey | null {
|
||||
const text = message.trim();
|
||||
if (/思考中|thinking/i.test(text)) return "thinking";
|
||||
|
|
@ -351,7 +347,7 @@ export function App() {
|
|||
const [computerBusy, setComputerBusy] = useState<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
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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: "コンピュータを操作中",
|
||||
|
|
|
|||
Loading…
Reference in New Issue