diff --git a/FEATURES.md b/FEATURES.md index 587cfef..f7ca202 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -738,7 +738,7 @@ AnyBot 是一個桌面聊天 App,介面和運作方式照 Grok Bot 重做。 - 預設是這台 Mac 的 Docker(OrbStack 或 Docker Desktop)。 - 可以填 Docker 主機,例如 `ssh://me@server`(Tailscale 的機器名稱也行),Bot 的電腦就會在那台主機上;看畫面時 AnyBot 會自己開 SSH 通道。 -- **容器隔離**(設定 › 電腦 › 在哪裡執行):標準(runc:容器和 Docker 主機共用核心,Chromium 關掉自己的沙盒,容器是唯一那道牆)或強隔離(gVisor/runsc:電腦有自己的虛擬核心,權限全拿掉只留開機要的幾個,Chromium 的沙盒打開,兩道牆)。Docker 主機沒有 gVisor 時強隔離不能選;設定是強隔離、主機卻沒有 gVisor 時(例如換了 Docker 主機)先用標準開機並寫進紀錄,不會整台開不起來。那一列顯示電腦實際用的是哪種,和設定不同時提示按「更新」。Mac 上用 `deploy/mac/gvisor-vm.sh` 建一台自帶 Docker+gVisor 的 Lima 虛擬機(虛擬機沒開時 AnyBot 會自己開,例如 Mac 重開機後),`gvisor-migrate.sh` 把原本的檔案和登入搬過去(AnyBot 要先完全退出)。實測(Mac 上的 Lima VM):開機、畫面、VNC、無障礙編號、xdotool、截圖都正常;13 個 Chromium 程序都沒有 --no-sandbox,網頁程序 seccomp=2、有自己的命名空間;每一步 exec 55–69 ms、截圖 95 ms。 +- **容器隔離**(設定 › 電腦 › 在哪裡執行):標準(runc:容器和 Docker 主機共用核心,Chromium 關掉自己的沙盒,容器是唯一那道牆)或強隔離(gVisor/runsc:電腦有自己的虛擬核心,權限全拿掉只留開機要的幾個,Chromium 的沙盒打開,兩道牆)。Docker 主機沒有 gVisor 時強隔離不能選;設定是強隔離、主機卻沒有 gVisor 時(例如換了 Docker 主機)先用標準開機並寫進紀錄,不會整台開不起來。那一列顯示電腦實際用的是哪種,和設定不同時提示按「更新」。Mac 上用 `deploy/mac/gvisor-vm.sh` 建一台自帶 Docker+gVisor 的 Lima 虛擬機(4 GB;虛擬機沒開時 AnyBot 會自己開,例如 Mac 重開機後;虛擬機用過的記憶體 Mac 拿不回來,開越大就長越大,以前 6 GB 會整個長滿。腳本也關掉虛擬機用不到的數據機、磁碟自動掛載、多路徑儲存服務,約 70 MB),這台 AnyBot 是別的大腦的螢幕時(Bot 用的是大腦那邊的電腦,只借這台的畫面、瀏覽器和 App),自己的電腦閒置 2 分鐘就停下來,虛擬機裡沒有別的東西在跑就連虛擬機一起關掉,把記憶體還給 Mac;開機時看設定就知道自己是螢幕,不會先把虛擬機叫醒;變回大腦時兩個都會自己開回來(第一次多等 30–60 秒)。螢幕上也不跑「保住資料卷」的小容器和排程更新。`gvisor-migrate.sh` 把原本的檔案和登入搬過去(AnyBot 要先完全退出)。實測(Mac 上的 Lima VM):開機、畫面、VNC、無障礙編號、xdotool、截圖都正常;13 個 Chromium 程序都沒有 --no-sandbox,網頁程序 seccomp=2、有自己的命名空間;每一步 exec 55–69 ms、截圖 95 ms。 - 換位置後會在新的地方重新建立;舊檔案可以先「儲存快照」,換過去再「從快照復原」。 - **「網路流量改走這台 Mac」**(原版:網路流量可以改走你的本機電腦,「僅對新連線生效」,管理員可以關掉):打開後,Bot 的電腦之後執行的指令會經過這台 Mac 上的代理伺服器出去(例如要連公司內網或 VPN 才看得到的服務)。代理只聽這台 Mac 自己(127.0.0.1),用原版的埠 8791,被占用就往後找。設定頁顯示經過的連線數。Bot 的電腦在別的主機上時不能用。管理員可以用 `defaults write com.anybot.AnyBot AllowBoxTrafficViaThisMac false` 鎖住,這時顯示「🔒 由你的管理員設定,無法變更」。 diff --git a/deploy/mac/gvisor-vm.sh b/deploy/mac/gvisor-vm.sh index fde3c49..607c95c 100755 --- a/deploy/mac/gvisor-vm.sh +++ b/deploy/mac/gvisor-vm.sh @@ -15,7 +15,9 @@ set -euo pipefail VM=anybot-gvisor CPUS=4 -MEM=6 # GiB +MEM=4 # GiB: the box is capped at 2 GB, Docker and the system take ~0.5 GB. + # The Mac never gets back memory the VM once used, so a bigger VM + # just grows bigger (it reached all of 6 GB). DISK=60 # GiB if [ "${1:-}" = "--rm" ]; then @@ -63,6 +65,8 @@ if d.get("runtimes", {}).get("runsc", {}).get("path") != "runsc": EOF ) if [ "$CHANGED" = changed ]; then sudo systemctl restart docker; fi +# Services a VM without modems, removable disks or SAN storage never uses (~70 MB). +sudo systemctl disable --now ModemManager.service udisks2.service multipathd.service multipathd.socket >/dev/null 2>&1 || true # Prove the wall works: a container whose kernel really is gVisor. docker run --rm --runtime=runsc --entrypoint sh alpine:3.20 -c "grep -q gVisor /proc/version && uname -r" | sed "s/^/runsc 容器核心: /" ' diff --git a/src/app/computers_ui.rs b/src/app/computers_ui.rs index 82e25d2..07f3fa1 100644 --- a/src/app/computers_ui.rs +++ b/src/app/computers_ui.rs @@ -94,6 +94,10 @@ impl App { /// Called once at start. pub(super) fn computers_start(&mut self) { if !self.demo { + // A computer that will show another brain (mirror_boot, a moment later) says so now: + // the box's first look would otherwise start the Lima VM it is about to put to sleep. + let r = &self.state.remote; + cloudbox::set_brain(r.brain_id.is_empty() || r.host_token.is_empty()); cloudbox::start_background(); } } diff --git a/src/cloudbox.rs b/src/cloudbox.rs index daa0412..9c63dba 100644 --- a/src/cloudbox.rs +++ b/src/cloudbox.rs @@ -741,9 +741,13 @@ fn wake_lima(wait: bool) -> Option> { } } -/// Ask Docker what state the box is in (changes nothing, but starts a Lima VM Docker lives in). +/// Ask Docker what state the box is in (changes nothing, but starts a Lima VM Docker lives in — +/// on a brain only: a computer showing another brain put its VM to sleep on purpose). pub fn probe() -> Run { if let Err(e) = dk(&["version", "--format", "{{.Server.Version}}"]) { + if !IS_BRAIN.load(Ordering::SeqCst) && lima_vm(&docker_host()).is_some() { + return Run::Asleep; + } if wake_lima(false).is_some() { return Run::Starting("正在開 Docker 主機的虛擬機"); } @@ -1129,21 +1133,28 @@ pub fn start_background() { (s.last_used.elapsed().as_secs(), s.rebuilding) }; if !rebuilding { - if IS_BRAIN.load(Ordering::SeqCst) && matches!(probe(), Run::Asleep) { + let brain = IS_BRAIN.load(Ordering::SeqCst); + if brain && matches!(probe(), Run::Asleep) { log("Bot 的電腦不休眠:叫醒它。"); let _ = ensure_running(); } + if !brain { + sleep_on_a_screen(idle); + } let quiet = idle >= QUIET_SECS && !is_busy() && !HELD_BY_PERSON.load(Ordering::SeqCst) && !viewer_open(); let due = snapshot_time().map(|t| (chrono::Local::now() - t).num_seconds() >= SNAPSHOT_EVERY_SECS).unwrap_or(true); if quiet && due && run_state().is_running() { let _ = save_snapshot(); } - // (Only when a box exists on this Docker: the holder is its volume's seat-saver.) - if matches!(probe(), Run::Asleep | Run::Running) { + // (Only when a box exists on this Docker: the holder is its volume's seat-saver. + // A screen leaves it stopped with the box.) + if brain && matches!(probe(), Run::Asleep | Run::Running) { ensure_holder(); } refresh(); - maybe_scheduled_update(); + if brain { + maybe_scheduled_update(); + } update_when_idle(idle); } } @@ -1172,8 +1183,45 @@ fn update_when_idle(idle_secs: u64) { } } +/// How long a computer showing another brain keeps its own box after its last use. +const SCREEN_SLEEP_SECS: u64 = 120; + +/// This AnyBot shows another brain: the bots work on that brain's computer (and borrow only this +/// one's screen, browser and apps), so this box — and on a Mac the Lima VM it runs in — only held +/// memory: the VM grew to its whole 6 GB, which the Mac never gets back while it runs. Stop the +/// box once nobody has used it for a while, and the VM when nothing else runs in it. Becoming a +/// brain again starts both (probe and ensure_running wake the VM). +fn sleep_on_a_screen(idle_secs: u64) { + if IS_BRAIN.load(Ordering::SeqCst) || idle_secs < SCREEN_SLEEP_SECS || is_busy() || HELD_BY_PERSON.load(Ordering::SeqCst) || viewer_open() { + return; + } + // Docker is down already (the VM sleeps), or nothing of ours runs: nothing to do. + let Ok(running) = dk(&["ps", "--format", "{{.Names}}"]) else { return }; + let ours = [container().to_string(), holder_name()]; + let ours_running: Vec<&String> = ours.iter().filter(|n| running.lines().any(|l| l.trim() == n.as_str())).collect(); + let others = running.lines().map(str::trim).filter(|l| !l.is_empty() && !ours.iter().any(|n| n == l)).count(); + if ours_running.is_empty() && others > 0 { + return; + } + for name in &ours_running { + log(&format!("這台電腦現在是別的大腦的螢幕,Bot 用的是大腦那邊的電腦:先讓這台的 {name} 休息。")); + let _ = dk_timeout(&["stop", "-t", "20", name.as_str()], 40_000); + } + set_run(Run::Asleep); + if others > 0 { + return; + } + if let Some(vm) = lima_vm(&docker_host()) { + log(&format!("虛擬機 {vm} 裡沒有別的東西在跑:關掉它,把記憶體還給 Mac(變回大腦時會自己開)。")); + let out = Command::new("limactl").env("PATH", tool_path()).args(["stop", vm.as_str()]).stdin(Stdio::null()).output(); + if let Some(o) = out.ok().filter(|o| !o.status.success()) { + log(&format!("關不了虛擬機 {vm}:{}", tail(String::from_utf8_lossy(&o.stderr).trim(), 300))); + } + } +} + /// This AnyBot is the brain (its box is the bots' computer). A computer showing another brain -/// doesn't use its own box, so it isn't kept awake there. +/// doesn't use its own box, so it isn't kept awake there (sleep_on_a_screen). static IS_BRAIN: AtomicBool = AtomicBool::new(true); pub fn set_brain(on: bool) { IS_BRAIN.store(on, Ordering::SeqCst);