From 102746d9e8e8072b300b4dda3810d4085dc44d16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Thu, 5 Nov 2026 18:20:45 +0800 Subject: [PATCH] A brain's box can go by its own name (ANYBOT_BOX_NAME); the server's brain takes over its old box under anybot-brain-box A desktop AnyBot left on the server (older build) took "anybot-box" for its own and paused it every ~20 s as idle while the brain woke it: the bots' computer kept showing as asleep. The brain now renames its box once (files are in the volume, kept by name) and uses that name. Co-Authored-By: Claude Opus 5.5 --- deploy/server/docker-compose.yml | 3 ++ src/cloudbox.rs | 92 +++++++++++++++++++++----------- 2 files changed, 63 insertions(+), 32 deletions(-) diff --git a/deploy/server/docker-compose.yml b/deploy/server/docker-compose.yml index f825b76..6995ad5 100644 --- a/deploy/server/docker-compose.yml +++ b/deploy/server/docker-compose.yml @@ -54,6 +54,9 @@ services: OPENAI_API_KEY: "${OPENAI_API_KEY:-}" GEMINI_API_KEY: "${GEMINI_API_KEY:-}" ANYBOT_RESTORE_FROM_ACCOUNT: "${ANYBOT_RESTORE_FROM_ACCOUNT:-}" + # The bots' computer goes by its own name: a desktop AnyBot on this same machine keeps + # "anybot-box" for itself (and used to pause the brain's one as idle). + ANYBOT_BOX_NAME: "anybot-brain-box" volumes: - anybot-data:/data # The bot's own computer runs as a container next to this one. diff --git a/src/cloudbox.rs b/src/cloudbox.rs index 1d20611..e37a137 100644 --- a/src/cloudbox.rs +++ b/src/cloudbox.rs @@ -27,7 +27,34 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Mutex, OnceLock}; use std::time::{Duration, Instant, SystemTime}; +/// The box's usual name. Two AnyBots on one Docker host (a server's brain and a desktop AnyBot +/// left on the same machine) each took "anybot-box" for their own, and the desktop one kept +/// pausing the brain's box as "idle". ANYBOT_BOX_NAME gives an AnyBot a box of its own name. pub const CONTAINER: &str = "anybot-box"; + +/// This AnyBot's box (ANYBOT_BOX_NAME, else "anybot-box"). +pub fn container() -> &'static str { + static C: OnceLock = OnceLock::new(); + C.get_or_init(|| { + std::env::var("ANYBOT_BOX_NAME") + .ok() + .map(|n| n.trim().to_string()) + .filter(|n| !n.is_empty() && n.len() <= 60 && n.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')) + .unwrap_or_else(|| CONTAINER.to_string()) + }) +} + +/// A box of its own name takes over the usual one this AnyBot used before (same files: the +/// home folder is a volume, kept by name; a rename doesn't stop it). +fn adopt_usual_box() { + let me = container(); + if me == CONTAINER || dk(&["inspect", "-f", "{{.Id}}", me]).is_ok() { + return; + } + if dk(&["inspect", "-f", "{{.Id}}", CONTAINER]).is_ok() && dk(&["rename", CONTAINER, me]).is_ok() { + log(&format!("Bot 的電腦改用自己的名字 {me}(原本叫 {CONTAINER})。")); + } +} pub const IMAGE: &str = "anybot-box:1"; /// The bot's files live here and survive an update (the original's mounted sand-data tree). pub const VOLUME: &str = "anybot-box-home"; @@ -321,7 +348,7 @@ pub fn wanted_version() -> String { /// The running computer is older than the one this AnyBot carries. pub fn update_available() -> bool { - match dk(&["inspect", "-f", "{{index .Config.Labels \"anybot.box.version\"}}", CONTAINER]) { + match dk(&["inspect", "-f", "{{index .Config.Labels \"anybot.box.version\"}}", container()]) { Ok(v) => v.trim() != wanted_version(), Err(_) => false, } @@ -460,12 +487,12 @@ pub fn probe() -> Run { format!("Docker 沒有在執行:{}", e.lines().last().unwrap_or("")) }); } - match dk(&["inspect", "-f", "{{.State.Status}}", CONTAINER]) { + match dk(&["inspect", "-f", "{{.State.Status}}", container()]) { Err(_) => Run::Absent, Ok(s) => match s.as_str() { "running" => { // The health probe (3 s, like the original's GET /health). - if dk_timeout(&["exec", CONTAINER, "test", "-f", "/tmp/anybot-box.ready"], HEALTH_TIMEOUT_MS).is_ok() { + if dk_timeout(&["exec", container(), "test", "-f", "/tmp/anybot-box.ready"], HEALTH_TIMEOUT_MS).is_ok() { Run::Running } else { Run::Starting("連線中") @@ -550,14 +577,14 @@ fn create_container() -> Result<(), String> { let vnc = format!("VNC_PASSWORD={}", vnc_password()); log("正在開一台新的電腦…"); // host.docker.internal → this Mac (for 網路流量改走你的電腦; Docker Desktop and OrbStack already have it). - dk(&["run", "-d", "--name", CONTAINER, "--hostname", "bot-computer", "--shm-size", "1g", "--pids-limit", "4096", "--memory", "2g", "--cpus", "2", "--add-host", "host.docker.internal:host-gateway", "-p", &publish, "-v", &vol, "-e", &env, "-e", &vnc, "--label", "anybot.box=1", IMAGE]).map_err(|e| format!("開不了電腦:{}", tail(&e, 400)))?; + dk(&["run", "-d", "--name", container(), "--hostname", "bot-computer", "--shm-size", "1g", "--pids-limit", "4096", "--memory", "2g", "--cpus", "2", "--add-host", "host.docker.internal:host-gateway", "-p", &publish, "-v", &vol, "-e", &env, "-e", &vnc, "--label", "anybot.box=1", IMAGE]).map_err(|e| format!("開不了電腦:{}", tail(&e, 400)))?; Ok(()) } fn wait_ready(ms: u64) -> Result<(), String> { let start = Instant::now(); while start.elapsed() < Duration::from_millis(ms) { - if dk(&["exec", CONTAINER, "test", "-f", "/tmp/anybot-box.ready"]).is_ok() { + if dk(&["exec", container(), "test", "-f", "/tmp/anybot-box.ready"]).is_ok() { return Ok(()); } std::thread::sleep(Duration::from_millis(250)); @@ -589,7 +616,7 @@ fn box_cdp_forward() -> Result { for conn in l.incoming().flatten() { std::thread::spawn(move || { let script = format!("exec 3<>/dev/tcp/127.0.0.1/{BOX_CDP_PORT} || exit 1; cat <&3 & cat >&3; kill $! 2>/dev/null"); - let Ok(mut child) = docker().args(["exec", "-i", CONTAINER, "bash", "-c", &script]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn() else { return }; + let Ok(mut child) = docker().args(["exec", "-i", container(), "bash", "-c", &script]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn() else { return }; let (Some(mut cin), Some(mut cout)) = (child.stdin.take(), child.stdout.take()) else { return }; let (Ok(mut r), Ok(mut w)) = (conn.try_clone(), conn.try_clone()) else { return }; let up = std::thread::spawn(move || { @@ -621,7 +648,7 @@ pub fn browser_port() -> Result { return Ok(port); } let dbg = format!("--remote-debugging-port={BOX_CDP_PORT}"); - dk(&["exec", "-d", "-u", "bot", "-e", "DISPLAY=:1", "-e", "HOME=/home/bot", CONTAINER, "chromium", &dbg, "--remote-allow-origins=*", "--user-data-dir=/home/bot/.config/anybot-browser", "about:blank"]).map_err(|e| format!("Bot 的電腦打不開瀏覽器:{e}"))?; + dk(&["exec", "-d", "-u", "bot", "-e", "DISPLAY=:1", "-e", "HOME=/home/bot", container(), "chromium", &dbg, "--remote-allow-origins=*", "--user-data-dir=/home/bot/.config/anybot-browser", "about:blank"]).map_err(|e| format!("Bot 的電腦打不開瀏覽器:{e}"))?; let start = Instant::now(); while start.elapsed() < Duration::from_secs(20) { if box_cdp_alive(port) { @@ -652,8 +679,8 @@ pub fn ensure_running() -> Result<(), String> { Run::Asleep => { set_run(Run::Starting("喚醒中")); log("喚醒電腦…"); - let st = dk(&["inspect", "-f", "{{.State.Status}}", CONTAINER]).unwrap_or_default(); - let woke = if st == "paused" { dk(&["unpause", CONTAINER]) } else { dk(&["start", CONTAINER]) }; + let st = dk(&["inspect", "-f", "{{.State.Status}}", container()]).unwrap_or_default(); + let woke = if st == "paused" { dk(&["unpause", container()]) } else { dk(&["start", container()]) }; woke.map_err(|e| format!("叫不醒電腦:{e}")).and_then(|_| wait_ready(BOOT_WAIT_MS)) } Run::Starting(_) => { @@ -665,13 +692,13 @@ pub fn ensure_running() -> Result<(), String> { // A broken container: start it again. log(&format!("電腦狀態異常({e}),重新啟動…")); set_run(Run::Starting("連線中")); - dk(&["restart", CONTAINER]).map_err(|e| e.to_string()).and_then(|_| wait_ready(BOOT_WAIT_MS)) + dk(&["restart", container()]).map_err(|e| e.to_string()).and_then(|_| wait_ready(BOOT_WAIT_MS)) } }; match &result { Ok(()) => { set_run(Run::Running); - let _ = dk(&["exec", CONTAINER, "sh", "-c", &format!("mkdir -p {HOME} && chown bot:bot {HOME}")]); + let _ = dk(&["exec", container(), "sh", "-c", &format!("mkdir -p {HOME} && chown bot:bot {HOME}")]); } Err(e) => { log(e); @@ -695,6 +722,7 @@ pub fn start_background() { return; } std::thread::spawn(|| { + adopt_usual_box(); set_run(probe()); resume_interrupted(); loop { @@ -750,7 +778,7 @@ pub fn save_snapshot() -> Result { let mut child = docker() // Caches are left out: the browser's grow to hundreds of MB and come back by themselves. .args([ - "exec", CONTAINER, "tar", "-C", HOME, "--exclude=./.cache", "--exclude=Cache", "--exclude=Code Cache", "--exclude=GPUCache", "--exclude=CacheStorage", "--exclude=ScriptCache", "--exclude=Crashpad", + "exec", container(), "tar", "-C", HOME, "--exclude=./.cache", "--exclude=Cache", "--exclude=Code Cache", "--exclude=GPUCache", "--exclude=CacheStorage", "--exclude=ScriptCache", "--exclude=Crashpad", "--warning=no-file-changed", "-czf", "-", ".", ]) .stdout(Stdio::from(file)) @@ -794,7 +822,7 @@ fn copy_in_snapshot() -> Result<(), String> { use std::io::Write; let data = std::fs::read(snapshot_path()).map_err(|_| "還沒有快照可以還原".to_string())?; let mut child = docker() - .args(["exec", "-i", CONTAINER, "sh", "-c", &format!("tar -C {HOME} -xzf - && chown -R bot:bot {HOME} && echo '[box-copy-in] result outcome=hydrated' >> /tmp/anybot-copy-in.log")]) + .args(["exec", "-i", container(), "sh", "-c", &format!("tar -C {HOME} -xzf - && chown -R bot:bot {HOME} && echo '[box-copy-in] result outcome=hydrated' >> /tmp/anybot-copy-in.log")]) .stdin(Stdio::piped()) .stdout(Stdio::null()) .stderr(Stdio::piped()) @@ -808,7 +836,7 @@ fn copy_in_snapshot() -> Result<(), String> { // Like readCopyInOutcome: wait for the copy-in to report "hydrated". let start = Instant::now(); while start.elapsed() < Duration::from_millis(COPY_IN_WAIT_MS) { - if let Ok(s) = dk(&["exec", CONTAINER, "sh", "-c", "cat /tmp/anybot-copy-in.log 2>/dev/null || true"]) { + if let Ok(s) = dk(&["exec", container(), "sh", "-c", "cat /tmp/anybot-copy-in.log 2>/dev/null || true"]) { if s.contains("outcome=hydrated") { return Ok(()); } @@ -819,7 +847,7 @@ fn copy_in_snapshot() -> Result<(), String> { } fn home_has_files() -> bool { - dk(&["exec", CONTAINER, "sh", "-c", &format!("ls -A {HOME} | head -1")]).map(|s| !s.trim().is_empty()).unwrap_or(false) + dk(&["exec", container(), "sh", "-c", &format!("ls -A {HOME} | head -1")]).map(|s| !s.trim().is_empty()).unwrap_or(false) } pub fn human(b: u64) -> String { @@ -931,7 +959,7 @@ fn run_rebuild(kind: Rebuild, phase: &dyn Fn(&str, &str)) -> Result<(), String> phase("creating", "更新電腦的映像檔"); build_image(true)?; phase("creating", "換上一台新的電腦"); - let _ = dk(&["rm", "-f", CONTAINER]); + let _ = dk(&["rm", "-f", container()]); create_container()?; wait_ready(BOOT_WAIT_MS)?; phase("moving", "確認保留的檔案和新電腦的連線"); @@ -942,7 +970,7 @@ fn run_rebuild(kind: Rebuild, phase: &dyn Fn(&str, &str)) -> Result<(), String> } Rebuild::Reset => { phase("cleaning-up", "拆掉舊的電腦"); - let _ = dk(&["rm", "-f", CONTAINER]); + let _ = dk(&["rm", "-f", container()]); phase("wiping", "清除電腦裡的資料"); let _ = dk(&["volume", "rm", "-f", VOLUME]); phase("creating", "開一台全新的電腦"); @@ -954,7 +982,7 @@ fn run_rebuild(kind: Rebuild, phase: &dyn Fn(&str, &str)) -> Result<(), String> } Rebuild::Restore => { phase("cleaning-up", "拆掉舊的電腦和它的資料"); - let _ = dk(&["rm", "-f", CONTAINER]); + let _ = dk(&["rm", "-f", container()]); let _ = dk(&["volume", "rm", "-f", VOLUME]); phase("creating", "從快照開一台新的電腦"); if !image_exists() { @@ -990,7 +1018,7 @@ fn resume_interrupted() { /// Disk use in the box, for the settings page and the clean-up request. pub fn disk_report() -> Result { ensure_running()?; - dk(&["exec", "-u", "bot", CONTAINER, "sh", "-c", &format!("df -h {HOME} | tail -1; echo; du -sh {HOME}/* {HOME}/.cache 2>/dev/null | sort -rh | head -8")]) + dk(&["exec", "-u", "bot", container(), "sh", "-c", &format!("df -h {HOME} | tail -1; echo; du -sh {HOME}/* {HOME}/.cache 2>/dev/null | sort -rh | head -8")]) } // ---------- secrets (write-only) ---------- @@ -1080,7 +1108,7 @@ pub fn exec_wrapper(dir: &str, cmd: &str, interactive: bool, job: &str) -> Strin let inner = format!("mkdir -p {d} && cd {d} && {tagged}", d = sh_quote(dir)); let env = env_file_path(); let env_arg = if env.exists() { format!(" --env-file {}", sh_quote(&env.display().to_string())) } else { String::new() }; - format!("{} exec{} -u bot -e DISPLAY={DISPLAY} -e HOME={HOME}{}{env_arg} {CONTAINER} bash -lc {}", docker_prefix(), if interactive { " -i" } else { "" }, egress_env(), sh_quote(&inner)) + format!("{} exec{} -u bot -e DISPLAY={DISPLAY} -e HOME={HOME}{}{env_arg} {box} bash -lc {}", docker_prefix(), if interactive { " -i" } else { "" }, egress_env(), sh_quote(&inner), r#box = container()) } /// An argv that kills the box process carrying this job tag (and its children). @@ -1092,7 +1120,7 @@ pub fn exec_kill_argv(job: &str) -> Vec { } argv.extend([ "exec".to_string(), - CONTAINER.to_string(), + container().to_string(), "sh".to_string(), "-c".to_string(), format!("for p in $(pgrep -f 'export ANYBOT_JOB={job}'); do pkill -9 -P $p; pkill -9 -P $p; kill -9 $p 2>/dev/null; done; true"), @@ -1113,10 +1141,10 @@ pub fn copy_in(local: &str, bot_id: u64, name: &str) -> Result { .filter(|n| !n.is_empty() && n != "." && n != "..") .ok_or_else(|| "檔案名稱無效".to_string())?; let dir = format!("{}/附件", bot_dir(bot_id)); - dk(&["exec", "-u", "bot", CONTAINER, "mkdir", "-p", &dir])?; + dk(&["exec", "-u", "bot", container(), "mkdir", "-p", &dir])?; let dst = format!("{dir}/{safe}"); - dk(&["cp", local, &format!("{CONTAINER}:{dst}")])?; - let _ = dk(&["exec", CONTAINER, "chown", "bot:bot", &dst]); + dk(&["cp", local, &format!("{c}:{dst}", c = container())])?; + let _ = dk(&["exec", container(), "chown", "bot:bot", &dst]); Ok(dst) } @@ -1127,21 +1155,21 @@ pub fn copy_out(path_in_box: &str) -> Result { let dir = std::env::temp_dir().join(format!("anybot-from-box-{}", chrono::Utc::now().timestamp_millis())); std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; let local = dir.join(&name); - dk(&["cp", &format!("{CONTAINER}:{path_in_box}"), &local.display().to_string()]).map_err(|e| format!("拿不到檔案:{e}"))?; + dk(&["cp", &format!("{}:{path_in_box}", container()), &local.display().to_string()]).map_err(|e| format!("拿不到檔案:{e}"))?; Ok(local) } // ---------- the box's screen (xdotool) ---------- fn xdo(args: &[&str]) -> Result { - let mut a = vec!["exec", "-u", "bot", "-e", "DISPLAY=:1", CONTAINER, "xdotool"]; + let mut a = vec!["exec", "-u", "bot", "-e", "DISPLAY=:1", container(), "xdotool"]; a.extend_from_slice(args); dk(&a).map_err(|e| format!("xdotool:{e}")) } /// The desktop as an image (full size). pub fn grab() -> Result { - let png = dk_timeout(&["exec", CONTAINER, "import", "-window", "root", "-display", DISPLAY, "png:-"], 10_000)?; + let png = dk_timeout(&["exec", container(), "import", "-window", "root", "-display", DISPLAY, "png:-"], 10_000)?; image::load_from_memory(&png).map(|i| i.to_rgb8()).map_err(|e| format!("截圖讀不出來:{e}")) } @@ -1164,7 +1192,7 @@ pub fn frame() -> Option { if !run_state().is_running() { return None; } - let png = dk_timeout(&["exec", CONTAINER, "import", "-window", "root", "-display", DISPLAY, "-quality", "1", "png:-"], 5_000).ok()?; + let png = dk_timeout(&["exec", container(), "import", "-window", "root", "-display", DISPLAY, "-quality", "1", "png:-"], 5_000).ok()?; image::load_from_memory(&png).ok().map(|i| i.to_rgba8()) } @@ -1384,7 +1412,7 @@ pub fn type_text(text: &str) -> Result<(), String> { use std::io::Write; // Both selections hold the text; xclip keeps serving it after docker exec returns (setsid). let mut child = docker() - .args(["exec", "-i", "-u", "bot", "-e", "DISPLAY=:1", CONTAINER, "sh", "-c", "cat > /tmp/anybot-paste.txt && for s in clipboard primary; do setsid xclip -selection $s -i /tmp/anybot-paste.txt /dev/null 2>&1 & done; sleep 0.15"]) + .args(["exec", "-i", "-u", "bot", "-e", "DISPLAY=:1", container(), "sh", "-c", "cat > /tmp/anybot-paste.txt && for s in clipboard primary; do setsid xclip -selection $s -i /tmp/anybot-paste.txt /dev/null 2>&1 & done; sleep 0.15"]) .stdin(Stdio::piped()) .stdout(Stdio::null()) .stderr(Stdio::null()) @@ -1392,7 +1420,7 @@ pub fn type_text(text: &str) -> Result<(), String> { .map_err(|e| e.to_string())?; child.stdin.take().ok_or("寫不進剪貼簿")?.write_all(text.as_bytes()).map_err(|e| e.to_string())?; let _ = child.wait(); - let class = dk(&["exec", "-u", "bot", "-e", "DISPLAY=:1", CONTAINER, "sh", "-c", "xprop -id $(xdotool getactivewindow) WM_CLASS 2>/dev/null"]).unwrap_or_default().to_lowercase(); + let class = dk(&["exec", "-u", "bot", "-e", "DISPLAY=:1", container(), "sh", "-c", "xprop -id $(xdotool getactivewindow) WM_CLASS 2>/dev/null"]).unwrap_or_default().to_lowercase(); let paste = if class.contains("term") { "shift+Insert" } else { "ctrl+v" }; xdo(&["key", "--clearmodifiers", paste])?; Ok(()) @@ -1427,7 +1455,7 @@ pub fn viewer_text(text: &str) { // ---------- the browser viewer (noVNC) ---------- fn published_port() -> u16 { - dk(&["port", CONTAINER, &NOVNC_PORT.to_string()]) + dk(&["port", container(), &NOVNC_PORT.to_string()]) .ok() .and_then(|s| s.lines().next().and_then(|l| l.rsplit(':').next()).and_then(|p| p.trim().parse().ok())) .unwrap_or(HOST_PORT) @@ -1629,7 +1657,7 @@ mod tests { fn wrapper_runs_as_the_bot_in_its_folder() { let w = exec_wrapper("/home/bot/bot-3", "ls -la", false, ""); assert!(w.contains("exec -u bot")); - assert!(w.contains(CONTAINER)); + assert!(w.contains(container())); assert!(w.contains("/home/bot/bot-3")); } }