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)
|
# 網頁 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
|
# LAZYBOY_WEB_HOST=0.0.0.0
|
||||||
|
|
||||||
# JSON API + 若有 web/dist 時的 UI 埠。Vite 開發前端仍是 5173,會把 /api 代理到這裡。
|
# JSON API + 若有 web/dist 時的 UI 埠。Vite 開發前端仍是 5173,會把 /api 代理到這裡。
|
||||||
|
|
|
||||||
38
Makefile
38
Makefile
|
|
@ -18,7 +18,7 @@ PID_WEB := $(RUNDIR)/web.pid
|
||||||
LOG_SERVE := $(RUNDIR)/serve.log
|
LOG_SERVE := $(RUNDIR)/serve.log
|
||||||
LOG_WEB := $(RUNDIR)/web.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.
|
# Shared POSIX helpers. Sourced by each recipe.
|
||||||
define CTL
|
define CTL
|
||||||
|
|
@ -159,10 +159,14 @@ help:
|
||||||
@echo "make status Show PIDs and ports"
|
@echo "make status Show PIDs and ports"
|
||||||
@echo "make logs Tail both logs (Ctrl-C to leave)"
|
@echo "make logs Tail both logs (Ctrl-C to leave)"
|
||||||
|
|
||||||
start: start-serve start-web
|
start: prepare-ui start-serve start-web
|
||||||
@echo
|
@echo
|
||||||
@echo "API http://127.0.0.1:$(PORT_API)"
|
@echo "API http://127.0.0.1:$(PORT_API)"
|
||||||
@echo "UI http://127.0.0.1:$(PORT_WEB)"
|
@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
|
restart: stop
|
||||||
@$(MAKE) start
|
@$(MAKE) start
|
||||||
|
|
@ -182,12 +186,27 @@ logs:
|
||||||
@echo "=== web $(LOG_WEB) ==="
|
@echo "=== web $(LOG_WEB) ==="
|
||||||
@tail -f "$(LOG_SERVE)" "$(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:
|
start-serve:
|
||||||
@mkdir -p "$(RUNDIR)"
|
@mkdir -p "$(RUNDIR)"
|
||||||
@eval "$$CTL"; \
|
@eval "$$CTL"; \
|
||||||
if alive "$(PID_SERVE)"; then \
|
if alive "$(PID_SERVE)"; then \
|
||||||
echo "serve already running pid=$$(cat "$(PID_SERVE)")"; \
|
fmt=$$(cat "$$HOME/.lazyboy/certs/format" 2>/dev/null || true); \
|
||||||
exit 0; \
|
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; \
|
fi; \
|
||||||
echo "building lazyboy…"; \
|
echo "building lazyboy…"; \
|
||||||
cargo build -p lazyboy || exit 1; \
|
cargo build -p lazyboy || exit 1; \
|
||||||
|
|
@ -201,14 +220,19 @@ start-web:
|
||||||
@mkdir -p "$(RUNDIR)"
|
@mkdir -p "$(RUNDIR)"
|
||||||
@eval "$$CTL"; \
|
@eval "$$CTL"; \
|
||||||
if alive "$(PID_WEB)"; then \
|
if alive "$(PID_WEB)"; then \
|
||||||
echo "web already running pid=$$(cat "$(PID_WEB)")"; \
|
if lsof -nP -iTCP:"$(PORT_WEB)" -sTCP:LISTEN 2>/dev/null | grep -q '127.0.0.1:$(PORT_WEB)'; then \
|
||||||
exit 0; \
|
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; \
|
fi; \
|
||||||
if [ ! -d "$(WEBDIR)/node_modules" ]; then \
|
if [ ! -d "$(WEBDIR)/node_modules" ]; then \
|
||||||
echo "npm install…"; \
|
echo "npm install…"; \
|
||||||
(cd "$(WEBDIR)" && npm install) || exit 1; \
|
(cd "$(WEBDIR)" && npm install) || exit 1; \
|
||||||
fi; \
|
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_alive "$(PID_WEB)" web "$(LOG_WEB)" || exit 1; \
|
||||||
wait_listen "$(PID_WEB)" "$(PORT_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)"
|
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) |
|
| 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 |
|
| 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`. |
|
| 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. |
|
| 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 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 actions | `browser_click`, `browser_type`, `browser_press`, `browser_select`, `browser_scroll`, `browser_wait` |
|
||||||
| Browser file exchange | `browser_upload`, `browser_download` |
|
| 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
|
## 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.
|
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
|
## Two computers
|
||||||
You have two machines. To the user, call the box \"my computer\" and the launch machine \"your computer\". Never mix paths.
|
You have two machines. To the user, call the box \"my computer\" and the launch machine \"your computer\". Never mix paths.
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -22,6 +22,7 @@ struct Helper {
|
||||||
input: ChildStdin,
|
input: ChildStdin,
|
||||||
output: BufReader<ChildStdout>,
|
output: BufReader<ChildStdout>,
|
||||||
next: u64,
|
next: u64,
|
||||||
|
container: Option<String>,
|
||||||
}
|
}
|
||||||
impl Drop for Helper {
|
impl Drop for Helper {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
|
|
@ -90,16 +91,17 @@ impl BrowserClient {
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub async fn request(
|
pub async fn request_on(
|
||||||
&self,
|
&self,
|
||||||
cwd: &Path,
|
cwd: &Path,
|
||||||
profile: Option<PathBuf>,
|
profile: Option<PathBuf>,
|
||||||
mut req: Value,
|
mut req: Value,
|
||||||
|
container: Option<&str>,
|
||||||
) -> Result<Value> {
|
) -> Result<Value> {
|
||||||
let mut slot = self.state.lock().await;
|
let mut slot = self.state.lock().await;
|
||||||
let mut helper = match slot.take() {
|
let mut helper = match slot.take() {
|
||||||
Some(h) => h,
|
Some(h) if container.is_none() || h.container.as_deref() == container => h,
|
||||||
None => spawn(cwd, profile).await?,
|
Some(_) | None => spawn(cwd, profile, container).await?,
|
||||||
};
|
};
|
||||||
helper.next += 1;
|
helper.next += 1;
|
||||||
let id = helper.next.to_string();
|
let id = helper.next.to_string();
|
||||||
|
|
@ -138,17 +140,20 @@ impl BrowserClient {
|
||||||
Ok(reply)
|
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() {
|
if local_browser_enabled() {
|
||||||
let script = crate::browser::find_helper_script(cwd)
|
let script = crate::browser::find_helper_script(cwd)
|
||||||
.ok_or_else(|| anyhow!("{}", crate::browser::INSTALL_HINT))?;
|
.ok_or_else(|| anyhow!("{}", crate::browser::INSTALL_HINT))?;
|
||||||
return spawn_script(cwd, &script, profile).await;
|
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");
|
let mut command = tokio::process::Command::new("docker");
|
||||||
command.args(["exec", "-i", "-w", "/workspace", "-e", "LAZYBOY_BROWSER_CDP=http://127.0.0.1:9222",
|
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"]);
|
hub.container(), "flock", "-n", "/home/box/.lazyboy-browser.lock", "node", "/opt/lazyboy/playwright/browser_helper.mjs"]);
|
||||||
start_helper(command).await
|
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.
|
/// 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,
|
input,
|
||||||
output,
|
output,
|
||||||
next: 0,
|
next: 0,
|
||||||
|
container: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -237,7 +243,7 @@ mod tests {
|
||||||
timeout: Some(Duration::from_millis(250)),
|
timeout: Some(Duration::from_millis(250)),
|
||||||
};
|
};
|
||||||
let error = client
|
let error = client
|
||||||
.request(&dir, None, json!({"op":"ping"}))
|
.request_on(&dir, None, json!({"op":"ping"}), None)
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(error.to_string().contains("timed out"));
|
assert!(error.to_string().contains("timed out"));
|
||||||
|
|
@ -249,7 +255,7 @@ mod tests {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
*client.state.lock().await = Some(spawn_script(&dir, &script, None).await.unwrap());
|
*client.state.lock().await = Some(spawn_script(&dir, &script, None).await.unwrap());
|
||||||
assert!(client
|
assert!(client
|
||||||
.request(&dir, None, json!({"op":"ping"}))
|
.request_on(&dir, None, json!({"op":"ping"}), None)
|
||||||
.await
|
.await
|
||||||
.unwrap_err()
|
.unwrap_err()
|
||||||
.to_string()
|
.to_string()
|
||||||
|
|
@ -259,7 +265,7 @@ mod tests {
|
||||||
*client.state.lock().await = Some(spawn_script(&dir, &script, None).await.unwrap());
|
*client.state.lock().await = Some(spawn_script(&dir, &script, None).await.unwrap());
|
||||||
assert!(tokio::time::timeout(
|
assert!(tokio::time::timeout(
|
||||||
Duration::from_millis(20),
|
Duration::from_millis(20),
|
||||||
client.request(&dir, None, json!({"op":"ping"}))
|
client.request_on(&dir, None, json!({"op":"ping"}), None)
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.is_err());
|
.is_err());
|
||||||
|
|
@ -269,7 +275,7 @@ mod tests {
|
||||||
*client.state.lock().await = Some(spawn_script(&dir, &real_script, None).await.unwrap());
|
*client.state.lock().await = Some(spawn_script(&dir, &real_script, None).await.unwrap());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
client
|
client
|
||||||
.request(&dir, None, json!({"op":"ping"}))
|
.request_on(&dir, None, json!({"op":"ping"}), None)
|
||||||
.await
|
.await
|
||||||
.unwrap()["pong"],
|
.unwrap()["pong"],
|
||||||
true
|
true
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ pub use agent::{
|
||||||
truncate_messages, AgentVerdict, AGENT_SYSTEM, DEFAULT_CONTEXT_CHARS, DEFAULT_MAX_ROUNDS,
|
truncate_messages, AgentVerdict, AGENT_SYSTEM, DEFAULT_CONTEXT_CHARS, DEFAULT_MAX_ROUNDS,
|
||||||
DEFAULT_MAX_ROUNDS_TOTAL, LOOP_GUARD_REPEAT, SEND_MESSAGE_SILENCE_THRESHOLD,
|
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::{
|
pub use browser::{
|
||||||
browser_oneshot, browser_self_test, find_helper_script, wait_for_handoff_resume, HandoffWait,
|
browser_oneshot, browser_self_test, find_helper_script, wait_for_handoff_resume, HandoffWait,
|
||||||
INSTALL_HINT as BROWSER_INSTALL_HINT,
|
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.
|
//! Local HTTPS for the web UI. VNC stays on 127.0.0.1; browsers talk TLS to LazyBoy.
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
use chrono::{Datelike, Duration, Utc};
|
||||||
use rcgen::{
|
use rcgen::{
|
||||||
BasicConstraints, CertificateParams, DistinguishedName, DnType, ExtendedKeyUsagePurpose,
|
date_time_ymd, BasicConstraints, CertificateParams, DistinguishedName, DnType,
|
||||||
IsCa, KeyPair, KeyUsagePurpose, SanType,
|
ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose, SanType,
|
||||||
};
|
};
|
||||||
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
|
use rustls::pki_types::PrivateKeyDer;
|
||||||
use rustls::ServerConfig;
|
use rustls::ServerConfig;
|
||||||
use std::net::IpAddr;
|
use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
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 struct TlsMaterial {
|
||||||
pub ca_pem: String,
|
pub ca_pem: String,
|
||||||
pub cert_pem: String,
|
pub cert_pem: String,
|
||||||
|
|
@ -37,15 +44,46 @@ pub fn cert_dir() -> PathBuf {
|
||||||
|
|
||||||
pub fn lan_ips() -> Vec<String> {
|
pub fn lan_ips() -> Vec<String> {
|
||||||
let mut ips = Vec::new();
|
let mut ips = Vec::new();
|
||||||
|
if let Some(ip) = outbound_ipv4() {
|
||||||
|
push_ip(&mut ips, &ip);
|
||||||
|
}
|
||||||
for iface in ["en0", "en1", "eth0", "wlan0"] {
|
for iface in ["en0", "en1", "eth0", "wlan0"] {
|
||||||
if let Ok(out) = std::process::Command::new("ipconfig")
|
if let Ok(out) = std::process::Command::new("ipconfig")
|
||||||
.args(["getifaddr", iface])
|
.args(["getifaddr", iface])
|
||||||
.output()
|
.output()
|
||||||
{
|
{
|
||||||
if out.status.success() {
|
if out.status.success() {
|
||||||
let ip = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
push_ip(&mut ips, std::str::from_utf8(&out.stdout).unwrap_or(""));
|
||||||
if !ip.is_empty() {
|
}
|
||||||
ips.push(ip);
|
}
|
||||||
|
}
|
||||||
|
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
|
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> {
|
fn hostnames() -> Vec<String> {
|
||||||
let mut names = vec!["localhost".into()];
|
let mut names = vec!["localhost".into()];
|
||||||
for cmd in [["hostname", "-s"], ["hostname", ""]] {
|
for cmd in [["hostname", "-s"], ["hostname", ""]] {
|
||||||
|
|
@ -108,6 +209,13 @@ fn san_list(names: &[String]) -> Vec<SanType> {
|
||||||
pub fn ensure() -> Result<TlsMaterial> {
|
pub fn ensure() -> Result<TlsMaterial> {
|
||||||
let dir = cert_dir();
|
let dir = cert_dir();
|
||||||
std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
|
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_pem_path = dir.join("ca.pem");
|
||||||
let ca_key_path = dir.join("ca.key");
|
let ca_key_path = dir.join("ca.key");
|
||||||
let cert_path = dir.join("server.pem");
|
let cert_path = dir.join("server.pem");
|
||||||
|
|
@ -159,6 +267,8 @@ pub fn ensure() -> Result<TlsMaterial> {
|
||||||
(pem, key)
|
(pem, key)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
std::fs::write(&format_path, MATERIAL_FORMAT).ok();
|
||||||
|
|
||||||
Ok(TlsMaterial {
|
Ok(TlsMaterial {
|
||||||
ca_pem,
|
ca_pem,
|
||||||
cert_pem,
|
cert_pem,
|
||||||
|
|
@ -172,6 +282,7 @@ fn issue_ca() -> Result<(String, String)> {
|
||||||
let mut params = CertificateParams::default();
|
let mut params = CertificateParams::default();
|
||||||
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
|
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
|
||||||
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
|
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
|
||||||
|
apply_validity(&mut params, CA_VALID_DAYS);
|
||||||
let mut dn = DistinguishedName::new();
|
let mut dn = DistinguishedName::new();
|
||||||
dn.push(DnType::CommonName, "LazyBoy Local CA");
|
dn.push(DnType::CommonName, "LazyBoy Local CA");
|
||||||
params.distinguished_name = dn;
|
params.distinguished_name = dn;
|
||||||
|
|
@ -194,11 +305,10 @@ fn issue_server(ca_pem: &str, ca_key_pem: &str, names: &[String]) -> Result<(Str
|
||||||
.collect();
|
.collect();
|
||||||
let mut params = CertificateParams::new(dns).context("server certificate names")?;
|
let mut params = CertificateParams::new(dns).context("server certificate names")?;
|
||||||
params.subject_alt_names = san_list(names);
|
params.subject_alt_names = san_list(names);
|
||||||
params.key_usages = vec![
|
// ECDSA cannot key-encipher; a critical KeyEncipherment bit makes NSS reject the cert.
|
||||||
KeyUsagePurpose::DigitalSignature,
|
params.key_usages = vec![KeyUsagePurpose::DigitalSignature];
|
||||||
KeyUsagePurpose::KeyEncipherment,
|
|
||||||
];
|
|
||||||
params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth];
|
params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth];
|
||||||
|
apply_validity(&mut params, SERVER_VALID_DAYS);
|
||||||
let mut dn = DistinguishedName::new();
|
let mut dn = DistinguishedName::new();
|
||||||
dn.push(DnType::CommonName, "LazyBoy");
|
dn.push(DnType::CommonName, "LazyBoy");
|
||||||
params.distinguished_name = dn;
|
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 _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
let mut certs = Vec::new();
|
let mut certs = Vec::new();
|
||||||
for item in rustls_pemfile::certs(&mut material.cert_pem.as_bytes()) {
|
for item in rustls_pemfile::certs(&mut material.cert_pem.as_bytes()) {
|
||||||
certs.push(CertificateDer::from(item.context("parse server cert")?));
|
certs.push(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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let mut key_bytes = material.key_pem.as_bytes();
|
let mut key_bytes = material.key_pem.as_bytes();
|
||||||
let mut keys = rustls_pemfile::pkcs8_private_keys(&mut key_bytes);
|
let mut keys = rustls_pemfile::pkcs8_private_keys(&mut key_bytes);
|
||||||
|
|
@ -274,58 +378,6 @@ fn dirs_login_keychain() -> Option<String> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn https_location(host: &str, path: &str, port: u16) -> String {
|
|
||||||
let host = host.trim();
|
|
||||||
let path = if path.is_empty() { "/" } else { path };
|
|
||||||
let with_port = if host.starts_with('[') {
|
|
||||||
if host.rfind("]:").is_some() {
|
|
||||||
host.to_string()
|
|
||||||
} else {
|
|
||||||
format!("{host}:{port}")
|
|
||||||
}
|
|
||||||
} else if host.matches(':').count() == 1 {
|
|
||||||
host.to_string()
|
|
||||||
} else {
|
|
||||||
format!("{host}:{port}")
|
|
||||||
};
|
|
||||||
format!("https://{with_port}{path}")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn redirect_plaintext(stream: tokio::net::TcpStream, port: u16) {
|
|
||||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
|
||||||
let mut reader = BufReader::new(stream);
|
|
||||||
let mut first = String::new();
|
|
||||||
if reader.read_line(&mut first).await.unwrap_or(0) == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let path = first
|
|
||||||
.split_whitespace()
|
|
||||||
.nth(1)
|
|
||||||
.unwrap_or("/")
|
|
||||||
.to_string();
|
|
||||||
let mut host = String::new();
|
|
||||||
loop {
|
|
||||||
let mut line = String::new();
|
|
||||||
let n = reader.read_line(&mut line).await.unwrap_or(0);
|
|
||||||
if n == 0 || line == "\r\n" || line == "\n" {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if let Some(rest) = line.strip_prefix("Host:") {
|
|
||||||
host = rest.trim().to_string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if host.is_empty() {
|
|
||||||
host = format!("127.0.0.1:{port}");
|
|
||||||
}
|
|
||||||
let location = https_location(&host, &path, port);
|
|
||||||
let response = format!(
|
|
||||||
"HTTP/1.1 308 Permanent Redirect\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
|
||||||
);
|
|
||||||
let mut stream = reader.into_inner();
|
|
||||||
let _ = stream.write_all(response.as_bytes()).await;
|
|
||||||
let _ = stream.shutdown().await;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_tls_client_hello(first: u8) -> bool {
|
pub fn is_tls_client_hello(first: u8) -> bool {
|
||||||
first == 0x16
|
first == 0x16
|
||||||
}
|
}
|
||||||
|
|
@ -334,6 +386,23 @@ pub fn is_tls_client_hello(first: u8) -> bool {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn https_location(host: &str, path: &str, port: u16) -> String {
|
||||||
|
let host = host.trim();
|
||||||
|
let path = if path.is_empty() { "/" } else { path };
|
||||||
|
let with_port = if host.starts_with('[') {
|
||||||
|
if host.rfind("]:").is_some() {
|
||||||
|
host.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{host}:{port}")
|
||||||
|
}
|
||||||
|
} else if host.matches(':').count() == 1 {
|
||||||
|
host.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{host}:{port}")
|
||||||
|
};
|
||||||
|
format!("https://{with_port}{path}")
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn https_location_keeps_port() {
|
fn https_location_keeps_port() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
@ -362,4 +431,49 @@ mod tests {
|
||||||
};
|
};
|
||||||
server_config(&material).unwrap();
|
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() {
|
if kind.is_computer_use() && self.has_running_computer_use() {
|
||||||
return Err(anyhow!(
|
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.
|
// Path ladder: pixels last. Require a lower rung (or explicit force) before computerUse.
|
||||||
|
|
@ -239,6 +239,9 @@ impl Subagents {
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.clone()
|
.clone()
|
||||||
.ok_or_else(|| anyhow!("no model client for subagent"))?;
|
.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 id = uuid::Uuid::new_v4().to_string();
|
||||||
let title = title
|
let title = title
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
|
|
@ -325,6 +328,9 @@ impl Subagents {
|
||||||
.await;
|
.await;
|
||||||
input.end();
|
input.end();
|
||||||
child.shutdown().await;
|
child.shutdown().await;
|
||||||
|
if kind_run.is_computer_use() {
|
||||||
|
child.box_hub.end_computer_use();
|
||||||
|
}
|
||||||
let (kind, message) = match verdict {
|
let (kind, message) = match verdict {
|
||||||
Ok(v) => (v.kind().to_string(), v.message().to_string()),
|
Ok(v) => (v.kind().to_string(), v.message().to_string()),
|
||||||
Err(e) => ("failed".into(), format!("{e:#}")),
|
Err(e) => ("failed".into(), format!("{e:#}")),
|
||||||
|
|
@ -357,7 +363,7 @@ impl Subagents {
|
||||||
"kind": kind.as_str(),
|
"kind": kind.as_str(),
|
||||||
"status": "running",
|
"status": "running",
|
||||||
"instruction": if kind.is_computer_use() {
|
"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 {
|
} 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."
|
"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.id);
|
||||||
self.chats.lock().unwrap().remove(&a.name);
|
self.chats.lock().unwrap().remove(&a.name);
|
||||||
let deleted = self.store.delete_agent(&a.id)?;
|
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}))
|
Ok(json!({"ok": true, "id": deleted.id, "name": deleted.name}))
|
||||||
}
|
}
|
||||||
"tasks" => Ok(json!(self
|
"tasks" => Ok(json!(self
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
use super::service::Service;
|
use super::service::Service;
|
||||||
use super::store::AgentIdentity;
|
use super::store::{AgentIdentity, Store, TaskRecord};
|
||||||
use crate::{AgentEvent, AgentVerdict, ChatMessage, InputBroker, Runtime, Session, ToolContext};
|
use crate::{AgentEvent, AgentVerdict, ChatMessage, InputBroker, Runtime, Session, ToolContext};
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
@ -334,6 +334,7 @@ impl Service {
|
||||||
None => {
|
None => {
|
||||||
let mut ctx = ToolContext::new(&session.cwd).with_runtime(runtime.clone());
|
let mut ctx = ToolContext::new(&session.cwd).with_runtime(runtime.clone());
|
||||||
ctx.team = Some(self.context(&t.agent_id, Some(&t.id)));
|
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
|
ctx
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -498,6 +499,7 @@ impl Service {
|
||||||
s.store.save_conversation(&agent_id, &session.messages)
|
s.store.save_conversation(&agent_id, &session.messages)
|
||||||
});
|
});
|
||||||
let mut ctx = ToolContext::new(a.cwd).with_runtime(runtime.clone());
|
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);
|
let team = self.context(agent, None);
|
||||||
if self.store.direct_user_chat(id)? {
|
if self.store.direct_user_chat(id)? {
|
||||||
*team.user_reply.lock().unwrap() = Some(body.to_owned());
|
*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()
|
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 ready = ctx.box_hub.ensure_ready().await?;
|
||||||
let viewer = ready["viewer_url"]
|
let viewer = ready["viewer_url"]
|
||||||
.as_str()
|
.as_str()
|
||||||
|
.or(ready["host_viewer_url"].as_str())
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_string();
|
.to_string();
|
||||||
ctx.runtime.park_question(&json!({
|
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("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_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("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("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("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"])),
|
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() {
|
if let Some(url) = ctx.last_browser_url_value() {
|
||||||
let restored = ctx
|
let restored = ctx
|
||||||
.browser
|
.browser
|
||||||
.request(
|
.request_on(
|
||||||
&ctx.cwd,
|
&ctx.cwd,
|
||||||
ctx.runtime.profile_dir(),
|
ctx.runtime.profile_dir(),
|
||||||
json!({"op":"navigate","url":url}),
|
json!({"op":"navigate","url":url}),
|
||||||
|
Some(ctx.box_hub.seat_id()),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
if restored["ok"] != true {
|
if restored["ok"] != true {
|
||||||
|
|
@ -1389,10 +1391,11 @@ async fn browser_tool(ctx: &ToolContext, name: &str, args: &Value) -> Result<Val
|
||||||
required_text(args, "reason")?;
|
required_text(args, "reason")?;
|
||||||
let prep = ctx
|
let prep = ctx
|
||||||
.browser
|
.browser
|
||||||
.request(
|
.request_on(
|
||||||
&ctx.cwd,
|
&ctx.cwd,
|
||||||
ctx.runtime.profile_dir(),
|
ctx.runtime.profile_dir(),
|
||||||
json!({"op":"handoff_prepare"}),
|
json!({"op":"handoff_prepare"}),
|
||||||
|
Some(ctx.box_hub.seat_id()),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
if prep["ok"] != true {
|
if prep["ok"] != true {
|
||||||
|
|
@ -1443,12 +1446,12 @@ async fn browser_tool(ctx: &ToolContext, name: &str, args: &Value) -> Result<Val
|
||||||
}
|
}
|
||||||
let mut result = ctx
|
let mut result = ctx
|
||||||
.browser
|
.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?;
|
.await?;
|
||||||
result["surface"] = json!(if crate::browser_client::local_browser_enabled() { "local_browser" } else { "box_browser" });
|
result["surface"] = json!(if crate::browser_client::local_browser_enabled() { "local_browser" } else { "box_browser" });
|
||||||
if !crate::browser_client::local_browser_enabled() {
|
if !crate::browser_client::local_browser_enabled() {
|
||||||
result["profile"] = json!("/home/box/chrome-profile");
|
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);
|
browser::update_last_url(&ctx.last_browser_url, &result);
|
||||||
*ctx.runtime.browser_url.lock().unwrap() = ctx.last_browser_url_value();
|
*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.
|
//! Talks to the named-agent team daemon over the existing Unix RPC.
|
||||||
|
|
||||||
use crate::team;
|
use crate::team;
|
||||||
use crate::BoxHub;
|
use crate::{BoxPool, SESSION_SEAT};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use axum::body::{Body, Bytes};
|
use axum::body::{Body, Bytes};
|
||||||
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
|
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
|
||||||
|
|
@ -36,7 +36,7 @@ const FALLBACK_HTML: &str = r#"<!doctype html>
|
||||||
struct App {
|
struct App {
|
||||||
token: Option<String>,
|
token: Option<String>,
|
||||||
cwd: PathBuf,
|
cwd: PathBuf,
|
||||||
box_hub: std::sync::Arc<BoxHub>,
|
box_pool: std::sync::Arc<BoxPool>,
|
||||||
ca_pem: Option<String>,
|
ca_pem: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -79,7 +79,7 @@ pub async fn serve_http(listen: WebListen) -> Result<()> {
|
||||||
let app = App {
|
let app = App {
|
||||||
token,
|
token,
|
||||||
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
|
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()),
|
ca_pem: tls.as_ref().map(|m| m.ca_pem.clone()),
|
||||||
};
|
};
|
||||||
let cors = CorsLayer::new()
|
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/events", get(api_events))
|
||||||
.route("/api/agents/:id/activity", get(api_activity))
|
.route("/api/agents/:id/activity", get(api_activity))
|
||||||
.route("/api/agents/:id/handover", post(api_handover))
|
.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("/api/computer", get(api_computer).post(api_computer_action))
|
||||||
.route("/lazyboy-ca.crt", get(api_ca_cert))
|
.route("/lazyboy-ca.crt", get(api_ca_cert))
|
||||||
.route("/novnc", any(novnc_proxy))
|
.route("/novnc", any(novnc_proxy))
|
||||||
|
|
@ -128,15 +132,12 @@ pub async fn serve_http(listen: WebListen) -> Result<()> {
|
||||||
})?;
|
})?;
|
||||||
let bound = listener.local_addr()?;
|
let bound = listener.local_addr()?;
|
||||||
if let Some(material) = tls {
|
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() {
|
for lan in crate::local_tls::lan_ips() {
|
||||||
eprintln!(
|
eprintln!("手機同一個 Wi-Fi: http://{lan}:{}", bound.port());
|
||||||
"手機同一個 Wi-Fi: https://{lan}:{}",
|
|
||||||
bound.port()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"第一次請安裝本機憑證(否則瀏覽器會顯示「不是安全連線」): https://127.0.0.1:{}/lazyboy-ca.crt",
|
"要裝 PWA/鎖頭再走 https,並安裝憑證: http://127.0.0.1:{}/lazyboy-ca.crt",
|
||||||
bound.port()
|
bound.port()
|
||||||
);
|
);
|
||||||
eprintln!("憑證涵蓋 {}", material.names.join(", "));
|
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> {
|
fn computer_response(result: anyhow::Result<Value>) -> Json<Value> {
|
||||||
match result {
|
match result {
|
||||||
Ok(ready) => Json(json!({
|
Ok(ready) => Json(json!({
|
||||||
"ready": true,
|
"ready": ready.get("ready").and_then(|v| v.as_bool()).unwrap_or(true),
|
||||||
"viewer_url": WEB_VIEWER,
|
"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"),
|
"workspace": ready.get("workspace"),
|
||||||
"revision": ready.get("revision"),
|
"revision": ready.get("revision"),
|
||||||
"action": ready.get("action"),
|
"action": ready.get("action"),
|
||||||
"updated": ready.get("updated"),
|
"updated": ready.get("updated"),
|
||||||
|
"seat": ready.get("seat"),
|
||||||
|
"crowded": ready.get("crowded"),
|
||||||
|
"error": ready.get("error"),
|
||||||
})),
|
})),
|
||||||
Err(error) => Json(json!({
|
Err(error) => Json(json!({
|
||||||
"ready": false,
|
"ready": false,
|
||||||
|
"state": "error",
|
||||||
"error": error.to_string(),
|
"error": error.to_string(),
|
||||||
"viewer_url": WEB_VIEWER,
|
"viewer_url": WEB_VIEWER,
|
||||||
})),
|
})),
|
||||||
|
|
@ -506,7 +512,42 @@ async fn api_computer(
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
) -> Result<Json<Value>, StatusCode> {
|
) -> Result<Json<Value>, StatusCode> {
|
||||||
authorize(&app, &headers)?;
|
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)]
|
#[derive(Deserialize, Default)]
|
||||||
|
|
@ -537,9 +578,9 @@ async fn api_computer_action(
|
||||||
Err(message) => return Err(api_err(StatusCode::BAD_REQUEST, &message)),
|
Err(message) => return Err(api_err(StatusCode::BAD_REQUEST, &message)),
|
||||||
};
|
};
|
||||||
let result = match kind {
|
let result = match kind {
|
||||||
"restart" => app.box_hub.restart().await,
|
"restart" => app.box_pool.restart(SESSION_SEAT).await,
|
||||||
"update" => app.box_hub.update().await,
|
"update" => app.box_pool.update(SESSION_SEAT).await,
|
||||||
_ => app.box_hub.ensure_ready().await,
|
_ => app.box_pool.start(SESSION_SEAT).await,
|
||||||
};
|
};
|
||||||
Ok(computer_response(result))
|
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 {
|
async fn novnc_proxy(State(app): State<App>, req: Request) -> Response {
|
||||||
let path_and_query = req
|
let path_and_query = req
|
||||||
.uri()
|
.uri()
|
||||||
|
|
@ -588,9 +660,20 @@ async fn novnc_proxy(State(app): State<App>, req: Request) -> Response {
|
||||||
} else {
|
} else {
|
||||||
rest
|
rest
|
||||||
};
|
};
|
||||||
|
let (seat, rest) = split_novnc_target(&rest);
|
||||||
if is_vnc_html(&rest) && novnc_opened_as_tab(req.headers()) {
|
if is_vnc_html(&rest) && novnc_opened_as_tab(req.headers()) {
|
||||||
return Redirect::temporary("/").into_response();
|
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
|
if req
|
||||||
.headers()
|
.headers()
|
||||||
.get(header::UPGRADE)
|
.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 {
|
return match WebSocketUpgrade::from_request(req, &app).await {
|
||||||
Ok(upgrade) => upgrade
|
Ok(upgrade) => upgrade
|
||||||
.on_upgrade(move |socket| proxy_vnc_ws(socket, rest))
|
.on_upgrade(move |socket| proxy_vnc_ws(socket, rest, port))
|
||||||
.into_response(),
|
.into_response(),
|
||||||
Err(err) => err.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,
|
Ok(resp) => resp,
|
||||||
Err(_) => (StatusCode::BAD_GATEWAY, "computer viewer proxy failed").into_response(),
|
Err(_) => (StatusCode::BAD_GATEWAY, "computer viewer proxy failed").into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn proxy_vnc_http(req: Request, rest: &str) -> Result<Response, anyhow::Error> {
|
async fn proxy_vnc_http(req: Request, rest: &str, port: u16) -> Result<Response, anyhow::Error> {
|
||||||
let url = format!("http://127.0.0.1:6080{rest}");
|
let url = format!("http://127.0.0.1:{port}{rest}");
|
||||||
let method = req.method().clone();
|
let method = req.method().clone();
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let mut builder = client.request(method, &url);
|
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()))
|
.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 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 Ok((upstream, _)) = tokio_tungstenite::connect_async(url).await else {
|
||||||
let _ = client.close().await;
|
let _ = client.close().await;
|
||||||
return;
|
return;
|
||||||
|
|
@ -725,7 +808,6 @@ async fn serve_tls_mux(
|
||||||
.install_default()
|
.install_default()
|
||||||
.ok();
|
.ok();
|
||||||
let acceptor = tokio_rustls::TlsAcceptor::from(crate::local_tls::server_config(material)?);
|
let acceptor = tokio_rustls::TlsAcceptor::from(crate::local_tls::server_config(material)?);
|
||||||
let port = listener.local_addr()?.port();
|
|
||||||
loop {
|
loop {
|
||||||
let (stream, _) = listener.accept().await?;
|
let (stream, _) = listener.accept().await?;
|
||||||
let acceptor = acceptor.clone();
|
let acceptor = acceptor.clone();
|
||||||
|
|
@ -733,34 +815,37 @@ async fn serve_tls_mux(
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut head = [0u8; 1];
|
let mut head = [0u8; 1];
|
||||||
match stream.peek(&mut head).await {
|
match stream.peek(&mut head).await {
|
||||||
Ok(0) => return,
|
Ok(0) | Err(_) => {}
|
||||||
Ok(_) if crate::local_tls::is_tls_client_hello(head[0]) => {}
|
Ok(_) if crate::local_tls::is_tls_client_hello(head[0]) => {
|
||||||
Ok(_) => {
|
if let Ok(tls_stream) = acceptor.accept(stream).await {
|
||||||
crate::local_tls::redirect_plaintext(stream, port).await;
|
serve_hyper(tls_stream, router).await;
|
||||||
return;
|
}
|
||||||
}
|
}
|
||||||
Err(_) => return,
|
Ok(_) => serve_hyper(stream, router).await,
|
||||||
}
|
}
|
||||||
let Ok(tls_stream) = acceptor.accept(stream).await else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let io = hyper_util::rt::TokioIo::new(tls_stream);
|
|
||||||
let service = hyper::service::service_fn(move |request: axum::extract::Request<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;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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};
|
use axum::http::{HeaderMap, HeaderValue};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -782,6 +867,21 @@ mod tests {
|
||||||
assert!(!is_vnc_html("/websockify"));
|
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]
|
#[test]
|
||||||
fn vnc_direct_tab_uses_fetch_dest() {
|
fn vnc_direct_tab_uses_fetch_dest() {
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
|
|
|
||||||
|
|
@ -155,7 +155,7 @@ ENV:
|
||||||
LAZYBOY_WEB_PROVIDER xai = enable native search on an xAI-compatible proxy
|
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_HOST web UI bind (default 0.0.0.0)
|
||||||
LAZYBOY_WEB_PORT web UI port (default 8787)
|
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_WEB_TOKEN optional bearer token for the web UI
|
||||||
LAZYBOY_HANDOFF_AUTO 1 = auto-resume handoff (tests); abort = auto-abort
|
LAZYBOY_HANDOFF_AUTO 1 = auto-resume handoff (tests); abort = auto-abort
|
||||||
LAZYBOY_CONFIRM_AUTO 1 = auto-approve confirm (tests); abort = auto-deny
|
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<()> {
|
async fn cmd_computer() -> Result<()> {
|
||||||
let ready = BoxHub::new().ensure_ready().await?;
|
let ready = BoxHub::new().ensure_ready().await?;
|
||||||
println!("我的電腦已就緒");
|
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() {
|
if let Some(msg) = ready["instruction"].as_str() {
|
||||||
eprintln!("{msg}");
|
eprintln!("{msg}");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ python3 tests/cli_flow.py
|
||||||
cargo run -p lazyboy -- smoke
|
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
|
## 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。
|
`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 秒顯示實際階段與經過時間,不呼叫模型製造更新。
|
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
|
## 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.
|
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 |
|
| 公開抓頁 | 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 |
|
| 登入網頁 | box Chrome | Docker Chromium,DOM helper 也在 Docker |
|
||||||
| 人工登入 | RequestBoxHelp → agent 桌面 | request_box_help/browser_handoff → 同一個 Docker Chromium |
|
| 人工登入 | 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 持久保存 |
|
| 瀏覽器資料 | /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。
|
**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,未實作該雲端備份。
|
- 參考版有 cloud box store/Chrome session snapshot,同步到遠端儲存;此版使用本機 Docker volume,未實作該雲端備份。
|
||||||
- 參考版 local-docker connector 可掛入 `.codex`/`.claude` 的唯讀 CLI 認證,並將 host runner 放進容器;此版 host/model/MCP 編排仍在本地,未自動掛入這些私人目錄。
|
- 參考版 local-docker connector 可掛入 `.codex`/`.claude` 的唯讀 CLI 認證,並將 host runner 放進容器;此版 host/model/MCP 編排仍在本地,未自動掛入這些私人目錄。
|
||||||
- 本地工具沿用 LazyBoy2 既有工作區授權規則,沒有複製參考版每次 ExternalShell 的批准卡 UI。
|
- 本地工具沿用 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。
|
一般工具名 `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 恢復)。
|
CLI mock-provider 測試也已通過(包含串流工具呼叫、背景 subagent/external command 回覆與 session 恢復)。
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ handoff 提供「已完成登入,請檢查」、「登入仍失敗,改做其
|
||||||
- 根任務樹共用 `LAZYBOY_MAX_ROUNDS_TOTAL`,預設 48;最後 4 次只供根任務使用。子 task 的請求也計入根計數,不會開一個 agent 就多拿 48 次。恢復不自動補預算;耗盡時需另開有明確範圍的新工作。
|
- 根任務樹共用 `LAZYBOY_MAX_ROUNDS_TOTAL`,預設 48;最後 4 次只供根任務使用。子 task 的請求也計入根計數,不會開一個 agent 就多拿 48 次。恢復不自動補預算;耗盡時需另開有明確範圍的新工作。
|
||||||
- 前景每次回覆最多 12 次模型請求。每個完成聊天回合/持久 agent 的任務最多再排一次記憶整理,失敗不阻擋聊天、不自動重試。這些與根任務執行預算分開計算。
|
- 前景每次回覆最多 12 次模型請求。每個完成聊天回合/持久 agent 的任務最多再排一次記憶整理,失敗不阻擋聊天、不自動重試。這些與根任務執行預算分開計算。
|
||||||
- 同一 canonical 工作區內的工具操作互斥;長指令退出後才釋放鎖。操作不同工作區可並行。活躍指令須先結束或終止,才能等待子 task 或人工回答,避免拿著鎖等待別人工作。
|
- 同一 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 的登入狀態不會自動匯入。
|
- 升級首次使用時,固定 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。沒有連線的人工問題仍保持等待,只有明確回答或停止才繼續。
|
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",
|
return route.fulfill({ contentType: "text/event-stream",
|
||||||
body: 'data: {"events":[{"id":1,"kind":"runtime","payload":{"type":"waiting"}}]}\n\n' });
|
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: {
|
if (path === "/api/agents") return route.fulfill({ json: {
|
||||||
agents: [{ id: "owner", name: "測試", running: true }],
|
agents: [{ id: "owner", name: "測試", running: true }],
|
||||||
} });
|
} });
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,9 @@ try {
|
||||||
if (path.endsWith("/events")) {
|
if (path.endsWith("/events")) {
|
||||||
return route.fulfill({ contentType: "text/event-stream", body: "data: {\"events\":[]}\n\n" });
|
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: {} });
|
return route.fulfill({ json: {} });
|
||||||
});
|
});
|
||||||
await page.goto(process.env.LAZYBOY_TEST_UI_URL || "http://127.0.0.1:5173");
|
await page.goto(process.env.LAZYBOY_TEST_UI_URL || "http://127.0.0.1:5173");
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,9 @@ try {
|
||||||
if (path.endsWith("/events")) {
|
if (path.endsWith("/events")) {
|
||||||
return route.fulfill({ contentType: "text/event-stream", body: "data: {\"events\":[]}\n\n" });
|
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") {
|
if (path === "/api/agents") {
|
||||||
return route.fulfill({ json: { agents: [{ id: "owner", name: "測試", running: false }] } });
|
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";
|
import assert from "node:assert/strict";
|
||||||
const browser = await chromium.launch({ headless: true });
|
const browser = await chromium.launch({ headless: true });
|
||||||
try {
|
try {
|
||||||
const context = await browser.newContext({serviceWorkers:"block"});
|
const context = await browser.newContext({serviceWorkers:"block", ignoreHTTPSErrors: true});
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
page.setDefaultTimeout(5000);
|
page.setDefaultTimeout(5000);
|
||||||
page.on("pageerror",e=>console.error(e.message));
|
page.on("pageerror",e=>console.error(e.message));
|
||||||
await page.addInitScript(() => localStorage.setItem("lazyboy.locale", "zh-Hant"));
|
await page.addInitScript(() => localStorage.setItem("lazyboy.locale", "zh-Hant"));
|
||||||
let waiting = true;
|
let waiting = true;
|
||||||
const submitted = [];
|
const submitted = [];
|
||||||
|
const computerCalls = [];
|
||||||
const activity = () => ({
|
const activity = () => ({
|
||||||
state: waiting ? "waiting_input" : "running", running: !waiting, queued: false,
|
state: waiting ? "waiting_input" : "running", running: !waiting, queued: false,
|
||||||
active_task_ids: waiting ? [] : ["task"], observed_at_ms: Date.now(),
|
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("/messages")) throw new Error("handover must not post ordinary chat");
|
||||||
if (path.endsWith("/events")) return route.fulfill({ contentType:"text/event-stream",
|
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' });
|
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") 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()} });
|
if (path === "/api/agents/owner") return route.fulfill({ json:{id:"owner",name:"測試",expertise:"",preview:"",transcript:[],running:false,activity:activity()} });
|
||||||
return route.fulfill({ json:{} });
|
return route.fulfill({ json:{} });
|
||||||
|
|
@ -42,6 +46,8 @@ try {
|
||||||
assert.equal(await page.locator(".thinking-row .avatar.thinking").count(),0);
|
assert.equal(await page.locator(".thinking-row .avatar.thinking").count(),0);
|
||||||
assert(await page.locator(".composer textarea").isDisabled());
|
assert(await page.locator(".composer textarea").isDisabled());
|
||||||
assert.equal(await page.locator("iframe.desktop-frame").count(),1);
|
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.getByRole("button",{name:"完成並繼續",exact:true}).click();
|
||||||
await page.locator(".handover-card").waitFor({state:"hidden"});
|
await page.locator(".handover-card").waitFor({state:"hidden"});
|
||||||
await page.locator(".thinking-row .avatar.thinking").waitFor();
|
await page.locator(".thinking-row .avatar.thinking").waitFor();
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ mkdirSync(outDir, { recursive: true });
|
||||||
|
|
||||||
const browser = await chromium.launch({ headless: true });
|
const browser = await chromium.launch({ headless: true });
|
||||||
try {
|
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"]);
|
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
page.setDefaultTimeout(8000);
|
page.setDefaultTimeout(8000);
|
||||||
|
|
@ -43,6 +43,9 @@ try {
|
||||||
if (path.endsWith("/events")) {
|
if (path.endsWith("/events")) {
|
||||||
return route.fulfill({ contentType: "text/event-stream", body: "data: {\"events\":[]}\n\n" });
|
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: {} });
|
return route.fulfill({ json: {} });
|
||||||
});
|
});
|
||||||
await page.goto(process.env.LAZYBOY_TEST_UI_URL || "http://127.0.0.1:5173");
|
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',
|
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") {
|
if (path === "/api/agents") {
|
||||||
return route.fulfill({ json: { agents: [{ id: "owner", name: "測試", running: false }] } });
|
return route.fulfill({ json: { agents: [{ id: "owner", name: "測試", running: false }] } });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -773,9 +773,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -884,9 +881,6 @@
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -901,9 +895,6 @@
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -918,9 +909,6 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -935,9 +923,6 @@
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -952,9 +937,6 @@
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -969,9 +951,6 @@
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -986,9 +965,6 @@
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1003,9 +979,6 @@
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1020,9 +993,6 @@
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1037,9 +1007,6 @@
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1054,9 +1021,6 @@
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1071,9 +1035,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -1088,9 +1049,6 @@
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
|
||||||
111
web/src/App.tsx
111
web/src/App.tsx
|
|
@ -155,10 +155,6 @@ function stepKey(name: string): StepKey | null {
|
||||||
return "stepWorking";
|
return "stepWorking";
|
||||||
}
|
}
|
||||||
|
|
||||||
function isComputerStep(step: StepKey | null): boolean {
|
|
||||||
return step === "stepComputer" || step === "stepBrowser" || step === "stepBox" || step === "stepShell";
|
|
||||||
}
|
|
||||||
|
|
||||||
function stepFromProgress(message: string): StepKey | null {
|
function stepFromProgress(message: string): StepKey | null {
|
||||||
const text = message.trim();
|
const text = message.trim();
|
||||||
if (/思考中|thinking/i.test(text)) return "thinking";
|
if (/思考中|thinking/i.test(text)) return "thinking";
|
||||||
|
|
@ -351,7 +347,7 @@ export function App() {
|
||||||
const [computerBusy, setComputerBusy] = useState<ComputerAction | null>(null);
|
const [computerBusy, setComputerBusy] = useState<ComputerAction | null>(null);
|
||||||
const [imageFresh, setImageFresh] = useState(false);
|
const [imageFresh, setImageFresh] = useState(false);
|
||||||
const [computerGen, setComputerGen] = useState(0);
|
const [computerGen, setComputerGen] = useState(0);
|
||||||
const computerOpRef = useRef<ComputerAction | null>(null);
|
const computerOpRef = useRef<string | null>(null);
|
||||||
const tRef = useRef(t);
|
const tRef = useRef(t);
|
||||||
tRef.current = t;
|
tRef.current = t;
|
||||||
const [typing, setTyping] = useState(false);
|
const [typing, setTyping] = useState(false);
|
||||||
|
|
@ -504,6 +500,51 @@ export function App() {
|
||||||
}
|
}
|
||||||
}, [applyTranscript]);
|
}, [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) => {
|
const openAgent = useCallback(async (id: string, keepChannel = false) => {
|
||||||
activeIdRef.current = id;
|
activeIdRef.current = id;
|
||||||
activityGeneration.current += 1;
|
activityGeneration.current += 1;
|
||||||
|
|
@ -519,6 +560,12 @@ export function App() {
|
||||||
setExecutionDetails([]);
|
setExecutionDetails([]);
|
||||||
setError("");
|
setError("");
|
||||||
setTyping(false);
|
setTyping(false);
|
||||||
|
setComputerReady(false);
|
||||||
|
setComputerUrl("");
|
||||||
|
setImageFresh(false);
|
||||||
|
setComputerBusy("start");
|
||||||
|
setComputerStatus(tRef.current.computerStarting);
|
||||||
|
void runComputer("start", id);
|
||||||
try {
|
try {
|
||||||
const data = await api.agent(id);
|
const data = await api.agent(id);
|
||||||
if (generation !== transcriptGeneration.current || activeIdRef.current !== id) return;
|
if (generation !== transcriptGeneration.current || activeIdRef.current !== id) return;
|
||||||
|
|
@ -533,47 +580,7 @@ export function App() {
|
||||||
setTranscript([]);
|
setTranscript([]);
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
}
|
}
|
||||||
}, [applyActivity, applyTranscript]);
|
}, [applyActivity, applyTranscript, runComputer]);
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isPhone()) setRightOpen(false);
|
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 liveStep = userProgress || (workingStep === "stepSearch" ? t.stepSearch : workingStep === "stepFetch" ? t.stepFetch : workingStep ? t.stepWorking : null);
|
||||||
const computerLive = computerBusy
|
const computerLive = computerBusy
|
||||||
? t[computerBusyKey(computerBusy)]
|
? t[computerBusyKey(computerBusy)]
|
||||||
: working && isComputerStep(workingStep)
|
: computerReady
|
||||||
? liveStep || t.computerRunning
|
? imageFresh
|
||||||
: computerReady
|
? t.computerUpToDate
|
||||||
? imageFresh
|
: t.computerRunning
|
||||||
? t.computerUpToDate
|
: computerStatus || t.computerOff;
|
||||||
: t.computerRunning
|
|
||||||
: t.computerOff;
|
|
||||||
|
|
||||||
const frame = computerUrl ? (
|
const frame = computerUrl ? (
|
||||||
<iframe
|
<iframe
|
||||||
|
|
|
||||||
|
|
@ -105,14 +105,25 @@ export const api = {
|
||||||
request<{ queued?: string }>(`/api/agents/${id}/messages`, { method: "POST", body: JSON.stringify({ text }) }),
|
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) }),
|
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: "{}" }),
|
stop: (id: string) => request(`/api/agents/${id}/stop`, { method: "POST", body: "{}" }),
|
||||||
computer: (action: "start" | "restart" | "update" = "start") =>
|
computerStatus: (id: string) =>
|
||||||
request<{
|
request<{
|
||||||
ready: boolean;
|
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;
|
viewer_url: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
updated?: boolean;
|
updated?: boolean;
|
||||||
revision?: string;
|
revision?: string;
|
||||||
}>("/api/computer", {
|
crowded?: boolean;
|
||||||
|
}>(`/api/agents/${encodeURIComponent(id)}/computer`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ action }),
|
body: JSON.stringify({ action }),
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -36,10 +36,10 @@ const zhHant = {
|
||||||
settingsLabel: "LazyBoy 設定",
|
settingsLabel: "LazyBoy 設定",
|
||||||
workspaceDetail: "Agent 與對話都留在這台機器。",
|
workspaceDetail: "Agent 與對話都留在這台機器。",
|
||||||
versionLine: "版本 {version}",
|
versionLine: "版本 {version}",
|
||||||
computerUpdateCopy: "更新助手共用的電腦。檔案與登入會保留。",
|
computerUpdateCopy: "更新所有助手共用的電腦映像。每個 Agent 的檔案與登入會留在自己的電腦。",
|
||||||
updateComputer: "更新電腦",
|
updateComputer: "更新電腦",
|
||||||
restartComputer: "重啟電腦",
|
restartComputer: "重啟電腦",
|
||||||
restartComputerCopy: "電腦卡住時重啟。瀏覽器登入會保留。",
|
restartComputerCopy: "重啟目前這個 Agent 的電腦。瀏覽器登入會保留。",
|
||||||
appComputer: "LazyBoy 的電腦",
|
appComputer: "LazyBoy 的電腦",
|
||||||
computerBusyCopy: "有 Agent 正在工作。現在操作會中斷它。",
|
computerBusyCopy: "有 Agent 正在工作。現在操作會中斷它。",
|
||||||
localBuild: "本機版本",
|
localBuild: "本機版本",
|
||||||
|
|
@ -133,6 +133,7 @@ const zhHant = {
|
||||||
computerNotReady: "電腦未就緒",
|
computerNotReady: "電腦未就緒",
|
||||||
computerNotStarted: "尚未啟動",
|
computerNotStarted: "尚未啟動",
|
||||||
computerOff: "未啟動",
|
computerOff: "未啟動",
|
||||||
|
computerCrowded: "同時開著的電腦已達上限,已為這個 Agent 啟動,較閒的電腦先關掉。",
|
||||||
thinking: "思考中",
|
thinking: "思考中",
|
||||||
needsReply: "需要回覆",
|
needsReply: "需要回覆",
|
||||||
stepComputer: "操作電腦",
|
stepComputer: "操作電腦",
|
||||||
|
|
@ -173,10 +174,10 @@ const zhHans: Messages = {
|
||||||
settingsLabel: "LazyBoy 设置",
|
settingsLabel: "LazyBoy 设置",
|
||||||
workspaceDetail: "Agent 和对话都留在这台机器。",
|
workspaceDetail: "Agent 和对话都留在这台机器。",
|
||||||
versionLine: "版本 {version}",
|
versionLine: "版本 {version}",
|
||||||
computerUpdateCopy: "更新助手共用的电脑。文件和登录会保留。",
|
computerUpdateCopy: "更新所有助手共用的电脑镜像。每个 Agent 的文件和登录会留在自己的电脑。",
|
||||||
updateComputer: "更新电脑",
|
updateComputer: "更新电脑",
|
||||||
restartComputer: "重启电脑",
|
restartComputer: "重启电脑",
|
||||||
restartComputerCopy: "电脑卡住时重启。浏览器登录会保留。",
|
restartComputerCopy: "重启当前这个 Agent 的电脑。浏览器登录会保留。",
|
||||||
appComputer: "LazyBoy 的电脑",
|
appComputer: "LazyBoy 的电脑",
|
||||||
computerBusyCopy: "有 Agent 正在工作。现在操作会中断它。",
|
computerBusyCopy: "有 Agent 正在工作。现在操作会中断它。",
|
||||||
localBuild: "本地版本",
|
localBuild: "本地版本",
|
||||||
|
|
@ -270,6 +271,7 @@ const zhHans: Messages = {
|
||||||
computerNotReady: "电脑未就绪",
|
computerNotReady: "电脑未就绪",
|
||||||
computerNotStarted: "尚未启动",
|
computerNotStarted: "尚未启动",
|
||||||
computerOff: "未启动",
|
computerOff: "未启动",
|
||||||
|
computerCrowded: "同时开着的电脑已达上限,已为这个 Agent 启动,较闲的电脑先关掉。",
|
||||||
thinking: "思考中",
|
thinking: "思考中",
|
||||||
needsReply: "需要回复",
|
needsReply: "需要回复",
|
||||||
stepComputer: "操作电脑",
|
stepComputer: "操作电脑",
|
||||||
|
|
@ -307,10 +309,10 @@ const en: Messages = {
|
||||||
settingsLabel: "LazyBoy settings",
|
settingsLabel: "LazyBoy settings",
|
||||||
workspaceDetail: "Agents and chats stay on this machine.",
|
workspaceDetail: "Agents and chats stay on this machine.",
|
||||||
versionLine: "Version {version}",
|
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",
|
updateComputer: "Update LazyBoy's Computer",
|
||||||
restartComputer: "Restart 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",
|
appComputer: "LazyBoy's Computer",
|
||||||
computerBusyCopy: "An agent is working. This will interrupt it.",
|
computerBusyCopy: "An agent is working. This will interrupt it.",
|
||||||
localBuild: "Local build",
|
localBuild: "Local build",
|
||||||
|
|
@ -404,6 +406,7 @@ const en: Messages = {
|
||||||
computerNotReady: "Computer is not ready",
|
computerNotReady: "Computer is not ready",
|
||||||
computerNotStarted: "Not started yet",
|
computerNotStarted: "Not started yet",
|
||||||
computerOff: "Off",
|
computerOff: "Off",
|
||||||
|
computerCrowded: "Too many computers were running, so an idle one was stopped to start this agent's.",
|
||||||
thinking: "Thinking",
|
thinking: "Thinking",
|
||||||
needsReply: "Needs a reply",
|
needsReply: "Needs a reply",
|
||||||
stepComputer: "Using the computer",
|
stepComputer: "Using the computer",
|
||||||
|
|
@ -441,10 +444,10 @@ const ja: Messages = {
|
||||||
settingsLabel: "LazyBoy の設定",
|
settingsLabel: "LazyBoy の設定",
|
||||||
workspaceDetail: "Agent と会話はこのマシンに残ります。",
|
workspaceDetail: "Agent と会話はこのマシンに残ります。",
|
||||||
versionLine: "バージョン {version}",
|
versionLine: "バージョン {version}",
|
||||||
computerUpdateCopy: "アシスタントが共有するコンピュータを更新します。ファイルとログインは残ります。",
|
computerUpdateCopy: "全アシスタント共用のイメージを更新します。各 Agent のファイルとログインは自分のコンピュータに残ります。",
|
||||||
updateComputer: "コンピュータを更新",
|
updateComputer: "コンピュータを更新",
|
||||||
restartComputer: "コンピュータを再起動",
|
restartComputer: "コンピュータを再起動",
|
||||||
restartComputerCopy: "コンピュータが止まったときに再起動します。ブラウザのログインは残ります。",
|
restartComputerCopy: "この Agent のコンピュータを再起動します。ブラウザのログインは残ります。",
|
||||||
appComputer: "LazyBoy のコンピュータ",
|
appComputer: "LazyBoy のコンピュータ",
|
||||||
computerBusyCopy: "Agent が作業中です。今操作すると中断されます。",
|
computerBusyCopy: "Agent が作業中です。今操作すると中断されます。",
|
||||||
localBuild: "ローカルビルド",
|
localBuild: "ローカルビルド",
|
||||||
|
|
@ -538,6 +541,7 @@ const ja: Messages = {
|
||||||
computerNotReady: "コンピュータの準備ができていません",
|
computerNotReady: "コンピュータの準備ができていません",
|
||||||
computerNotStarted: "まだ起動していません",
|
computerNotStarted: "まだ起動していません",
|
||||||
computerOff: "停止中",
|
computerOff: "停止中",
|
||||||
|
computerCrowded: "同時に起動できるコンピュータ数の上限に達したため、この Agent を起動し、使っていないコンピュータを止めました。",
|
||||||
thinking: "考え中",
|
thinking: "考え中",
|
||||||
needsReply: "返信が必要です",
|
needsReply: "返信が必要です",
|
||||||
stepComputer: "コンピュータを操作中",
|
stepComputer: "コンピュータを操作中",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue