AnyBot/src/cloudbox.rs

2980 lines
147 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 「Bot 的電腦」: AnyBot's own Linux computer that every bot shares (a cloud "box", like the
// reference assistant's).
//
// Behaviour spec of the box control plane:
// - The box is a container with an X11 desktop driven by xdotool (ComputerUseDriver XDOTOOL /
// NATIVE_X11), its size read with xrandr, watched through noVNC websockify on port 6080
// (`Ap.primaryNovncWebsockify:6080`).
// - Run states: ABSENT → 「建立中」, HIBERNATED → 「喚醒中」, STARTING → 「連線中」, RUNNING.
// - Health: GET /health with a 3 s timeout (QQn=3e3) that also says whether it's busy.
// - Store flush before a rebuild: POST /api/snapshotBoxStoreNow, 30 s (VQn=3e4).
// - Three rebuilds, one at a time (`isRecreateInFlight`):
// update (keep data, refused while busy): backing-up → creating → moving
// reset (wipe): cleaning-up → wiping → creating
// restore (force, from the durable store): cleaning-up → creating, then wait up to 90 s
// (zQn=9e4) polling every 2 s (HQn=2e3) for the copy-in to say "hydrated"
// - recreate-resume.json replays a rebuild cut short by a restart if it is under 10 minutes old
// ($Qn=10*6e4) and the box isn't healthy.
// Here the box is a local Docker container (or one on another Docker host, `ssh://me@server`),
// the durable store is a snapshot of the bot's home folder kept on this Mac, and xdotool runs
// through `docker exec`.
use base64::Engine;
use chrono::Datelike;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::path::PathBuf;
use std::process::{Command, Stdio};
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<String> = 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();
// A box with a volume of its own (a test box) is not the usual one moved: leave that alone.
if own_volume().is_some() || 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 box's image (ANYBOT_BOX_IMAGE, else the usual one): a throwaway box (a benchmark) on the
/// same Docker as the real one gets an image of its own, so building a newer box version for it
/// never changes the image the real box was made from.
pub fn image() -> &'static str {
static I: OnceLock<String> = OnceLock::new();
I.get_or_init(|| {
std::env::var("ANYBOT_BOX_IMAGE")
.ok()
.map(|n| n.trim().to_string())
.filter(|n| !n.is_empty() && n.len() <= 80 && n.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.' | ':' | '/')))
.unwrap_or_else(|| IMAGE.to_string())
})
}
/// The bot's files live here and survive an update (the original's mounted sand-data tree).
pub const VOLUME: &str = "anybot-box-home";
/// ANYBOT_BOX_VOLUME: a home volume of its own, for a throwaway box (a benchmark) next to the
/// real one on the same Docker: it never adopts the usual box or touches its volume.
fn own_volume() -> Option<&'static str> {
static V: OnceLock<Option<String>> = OnceLock::new();
V.get_or_init(|| {
std::env::var("ANYBOT_BOX_VOLUME")
.ok()
.map(|n| n.trim().to_string())
.filter(|n| !n.is_empty() && n.len() <= 60 && n != VOLUME && n.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'))
})
.as_deref()
}
/// This box's home volume (ANYBOT_BOX_VOLUME, else the usual one).
pub fn volume() -> &'static str {
own_volume().unwrap_or(VOLUME)
}
pub const HOME: &str = "/home/bot";
pub const DISPLAY: &str = ":1";
/// noVNC inside the box (Ap.primaryNovncWebsockify).
pub const NOVNC_PORT: u16 = 6080;
/// Where noVNC is published on this Mac (estimated; any free port works).
pub const HOST_PORT: u16 = 16080;
/// Desktop size (estimated; the original reads it with xrandr).
pub const SCREEN: &str = "1280x800x24";
/// Health probe timeout (QQn).
pub const HEALTH_TIMEOUT_MS: u64 = 3_000;
/// Saving a snapshot ("flush the durable store"). The original waits 30 s (VQn); a home folder of
/// a GB with an open browser takes minutes to pack, and a cut-off snapshot is no snapshot.
pub const SNAPSHOT_SAVE_TIMEOUT_MS: u64 = 600_000;
/// Restore copy-in: longest wait and poll interval (zQn, HQn).
pub const COPY_IN_WAIT_MS: u64 = 90_000;
pub const COPY_IN_POLL_MS: u64 = 2_000;
/// A rebuild marker older than this is cleared instead of replayed ($Qn).
pub const RESUME_FRESH_MS: u64 = 600_000;
/// Boot: longest wait for the desktop to come up (estimated).
pub const BOOT_WAIT_MS: u64 = 60_000;
/// Quiet time before the daily snapshot is taken (nobody using the box for this long).
pub const QUIET_SECS: u64 = 900;
/// The box's files are saved to this computer about once a day (it no longer sleeps, which is
/// when they used to be saved).
pub const SNAPSHOT_EVERY_SECS: i64 = 24 * 3600;
/// Secret names: an upper-case letter, then A–Z, 0–9, _ (original rule); size limits estimated.
pub const SECRET_MAX_VALUE_BYTES: usize = 32_768;
pub const SECRET_MAX_COUNT: usize = 100;
/// xdotool timing, matching the Mac helper where it applies (CUActuator).
const CLICK_REPEAT_MS: u64 = 80;
const TYPE_DELAY_MS: u64 = 12;
const DOCKERFILE: &str = include_str!("../box/Dockerfile");
const START_SH: &str = include_str!("../box/start.sh");
const FLUXBOX_INIT: &str = include_str!("../box/fluxbox-init");
const OVERLAY: &str = include_str!("../box/overlay");
const XRESOURCES: &str = include_str!("../box/Xresources");
const MENU: &str = include_str!("../box/menu");
const A11Y: &str = include_str!("../box/a11y");
const BROWSER: &str = include_str!("../box/browser");
// ---------- state ----------
#[derive(Clone, Debug, PartialEq)]
pub enum Run {
/// Docker isn't installed or isn't running.
NoDocker(String),
/// Not made yet (ABSENT).
Absent,
/// Asleep (HIBERNATED): paused or stopped.
Asleep,
/// Being made, woken or connected (the transient states).
Starting(&'static str),
Running,
/// A rebuild is under way: (phase, detail).
Rebuilding(String, String),
Failed(String),
}
impl Run {
pub fn label(&self) -> String {
match self {
Run::NoDocker(_) => "沒有 Docker".into(),
Run::Absent => "尚未建立".into(),
Run::Asleep => "休眠中".into(),
Run::Starting(s) => (*s).into(),
Run::Running => "執行中".into(),
Run::Rebuilding(p, _) => phase_label(p).into(),
Run::Failed(_) => "發生問題".into(),
}
}
pub fn is_running(&self) -> bool {
*self == Run::Running
}
}
pub fn phase_label(p: &str) -> &'static str {
match p {
"backing-up" => "正在備份",
"creating" => "正在建立",
"moving" => "正在搬移資料",
"cleaning-up" => "正在拆除舊電腦",
"wiping" => "正在清除資料",
"restoring" => "正在從快照還原",
"done" => "完成",
"failed" => "失敗",
_ => "處理中",
}
}
#[derive(Clone, Serialize, Deserialize, Default)]
pub struct Config {
/// Empty = this Mac's Docker; otherwise a Docker host such as `ssh://me@server`.
#[serde(default)]
pub docker_host: String,
/// 排程更新: local "HH:MM" to update when a newer computer is available (None = off).
#[serde(default)]
pub auto_update_at: Option<String>,
/// The day a scheduled update last ran (so it runs once).
#[serde(default)]
pub auto_update_done: String,
/// 網路流量改走你的電腦 (new connections only).
#[serde(default)]
pub egress_via_mac: bool,
/// VNC password for the desktop viewer (generated on first use; the published port is
/// loopback-only, but the box's own programs can reach it, and they should need a key).
#[serde(default)]
pub vnc_password: String,
/// 容器隔離: the Docker runtime the box runs on. Empty = Docker's default (runc: the box
/// shares the host's kernel, and Chromium runs with its own sandbox off). On a Linux Docker
/// host with gVisor installed, "runsc" gives the box a virtualized kernel and Chromium's
/// sandbox comes back — a second wall for the pages bots open. Takes effect on the next
/// 更新/重設/復原 (the container is recreated).
#[serde(default)]
pub runtime: String,
}
/// A scheduled update that was missed may still run for this long (the original: one hour).
pub const SCHEDULE_GRACE_MINUTES: i64 = 60;
struct Shared {
run: Run,
last_used: Instant,
rebuilding: bool,
log: Vec<String>,
/// The desktop's size in pixels (the bot's pictures are this, fitted; see box_scale).
size: Option<(u32, u32)>,
tunnel: Option<std::process::Child>,
host_port: u16,
}
fn shared() -> &'static Mutex<Shared> {
static S: OnceLock<Mutex<Shared>> = OnceLock::new();
S.get_or_init(|| Mutex::new(Shared { run: Run::Absent, last_used: Instant::now(), rebuilding: false, log: vec![], size: None, tunnel: None, host_port: HOST_PORT }))
}
fn config() -> &'static Mutex<Config> {
static C: OnceLock<Mutex<Config>> = OnceLock::new();
C.get_or_init(|| Mutex::new(load_config()))
}
/// The box's VNC password: made once, kept across restarts (the container is started with it,
/// the viewer opens with it). x11vnc ran with no password at all before — any program inside
/// the box (a page the bot opened, for instance) could watch and drive the desktop.
pub fn vnc_password() -> String {
{
let c = config().lock().unwrap();
if !c.vnc_password.is_empty() {
return c.vnc_password.clone();
}
}
let pw = crate::httpd::random_hex(10);
config().lock().unwrap().vnc_password.clone_from(&pw);
save_config(&config().lock().unwrap());
pw
}
/// The password the box's VNC server really has: the one it was made with (its VNC_PASSWORD), so a
/// viewer signs in even when this AnyBot's settings were made anew; else this AnyBot's own.
pub fn box_vnc_password() -> String {
dk_timeout(&["exec", container(), "printenv", "VNC_PASSWORD"], 5_000)
.ok()
.map(|b| String::from_utf8_lossy(&b).trim().to_string())
.filter(|p| !p.is_empty())
.unwrap_or_else(vnc_password)
}
static BUSY: AtomicUsize = AtomicUsize::new(0);
/// The person took control in the viewer: the bot's screen actions wait.
pub static HELD_BY_PERSON: AtomicBool = AtomicBool::new(false);
/// Marks the box as in use while alive (keeps it awake; an update waits for it).
pub struct Busy(bool);
impl Busy {
pub fn new(on: bool) -> Self {
if on {
BUSY.fetch_add(1, Ordering::SeqCst);
touch();
}
Busy(on)
}
}
impl Drop for Busy {
fn drop(&mut self) {
if self.0 {
BUSY.fetch_sub(1, Ordering::SeqCst);
touch();
}
}
}
pub fn is_busy() -> bool {
BUSY.load(Ordering::SeqCst) > 0
}
pub fn touch() {
shared().lock().unwrap().last_used = Instant::now();
}
pub fn run_state() -> Run {
shared().lock().unwrap().run.clone()
}
fn set_run(r: Run) {
shared().lock().unwrap().run = r;
}
pub fn log_lines() -> Vec<String> {
shared().lock().unwrap().log.clone()
}
fn log(line: &str) {
let mut s = shared().lock().unwrap();
s.log.push(format!("{} {line}", chrono::Local::now().format("%H:%M:%S")));
let n = s.log.len();
if n > 200 {
s.log.drain(..n - 200);
}
}
// ---------- files on this Mac ----------
fn data_dir() -> PathBuf {
crate::model::State::path().parent().map(|p| p.to_path_buf()).unwrap_or_else(std::env::temp_dir).join("box")
}
fn config_path() -> PathBuf {
data_dir().join("config.json")
}
fn load_config() -> Config {
std::fs::read_to_string(config_path()).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default()
}
pub fn docker_host() -> String {
config().lock().unwrap().docker_host.clone()
}
pub fn set_docker_host(h: &str) {
let h = h.trim().to_string();
let mut c = config().lock().unwrap();
if c.docker_host == h {
return;
}
c.docker_host = h;
let _ = std::fs::create_dir_all(data_dir());
let _ = std::fs::write(config_path(), serde_json::to_string_pretty(&*c).unwrap_or_default());
drop(c);
if let Some(mut t) = shared().lock().unwrap().tunnel.take() {
let _ = t.kill();
}
set_run(Run::Absent);
refresh();
}
fn save_config(c: &Config) {
let _ = std::fs::create_dir_all(data_dir());
let _ = std::fs::write(config_path(), serde_json::to_string_pretty(c).unwrap_or_default());
}
/// 容器隔離: the box's Docker runtime — "runc" (the default: a container sharing the host's
/// kernel) or "runsc" (gVisor: a virtualized kernel and user-space seccomp, for Docker hosts
/// that have it installed). Anything else falls back to the default.
pub fn box_runtime() -> String {
match config().lock().unwrap().runtime.as_str() {
"runsc" => "runsc".into(),
_ => "runc".into(),
}
}
/// The box's Docker host has gVisor (a runtime registered as runsc).
pub fn host_has_gvisor() -> bool {
dk(&["info", "--format", "{{range $k, $v := .Runtimes}}{{$k}} {{end}}"]).map(|o| o.split_whitespace().any(|r| r == "runsc")).unwrap_or(false)
}
/// The runtime the box is running on now ("runc", "runsc"), when there is a box.
pub fn running_runtime() -> Option<String> {
dk(&["inspect", "-f", "{{.HostConfig.Runtime}}", container()]).ok().map(|r| r.trim().to_string()).filter(|r| !r.is_empty())
}
/// Changing it takes effect on the box's next rebuild (更新/重設/復原), where the container
/// is created again — nothing is recreated here behind the person's back.
pub fn set_box_runtime(r: &str) {
let r = if r.trim() == "runsc" { "runsc" } else { "" };
let mut c = config().lock().unwrap();
if c.runtime == r {
return;
}
c.runtime = r.to_string();
save_config(&c);
log(&format!("容器隔離改為「{}」,下次重建電腦(更新、重設或復原)後生效。", if r == "runsc" { "gVisor(虛擬核心)" } else { "標準(共用主機核心)" }));
}
pub fn egress_via_mac() -> bool {
config().lock().unwrap().egress_via_mac
}
/// Turn routing through this Mac on or off. Only this Mac's Docker can reach it.
pub fn set_egress_via_mac(on: bool) -> Result<(), String> {
if on {
if !docker_host().is_empty() {
return Err("Bot 的電腦在別的主機上時,不能改走這台 Mac。".into());
}
crate::egress::start()?;
}
let mut c = config().lock().unwrap();
c.egress_via_mac = on;
save_config(&c);
Ok(())
}
/// Proxy settings for new commands in the box when routing through this Mac.
fn egress_env() -> String {
if !egress_via_mac() || !docker_host().is_empty() || !crate::policy::read_egress().allows() {
return String::new();
}
match crate::egress::start() {
Ok(port) => {
let url = format!("http://host.docker.internal:{port}");
["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"].iter().map(|k| format!(" -e {k}={url}")).collect::<String>() + " -e NO_PROXY=localhost,127.0.0.1 -e no_proxy=localhost,127.0.0.1"
}
Err(_) => String::new(),
}
}
pub fn auto_update_at() -> Option<String> {
config().lock().unwrap().auto_update_at.clone()
}
pub fn set_auto_update_at(at: Option<String>) {
let mut c = config().lock().unwrap();
c.auto_update_at = at;
save_config(&c);
}
/// The computer version baked into this AnyBot (the Dockerfile's label).
pub fn wanted_version() -> String {
DOCKERFILE.lines().find_map(|l| l.trim().strip_prefix("LABEL anybot.box.version=")).map(|v| v.trim_matches('"').to_string()).unwrap_or_default()
}
/// 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()]) {
Ok(v) => v.trim() != wanted_version(),
Err(_) => false,
}
}
/// Is `now` inside the window [at, at + grace)? (`at` is "HH:MM").
pub fn in_schedule_window(at: &str, now: chrono::NaiveTime) -> bool {
let Ok(t) = chrono::NaiveTime::parse_from_str(at.trim(), "%H:%M") else { return false };
let mins = (now - t).num_minutes().rem_euclid(24 * 60);
mins < SCHEDULE_GRACE_MINUTES
}
fn maybe_scheduled_update() {
let (at, done) = {
let c = config().lock().unwrap();
(c.auto_update_at.clone(), c.auto_update_done.clone())
};
let Some(at) = at else { return };
// The person's clock (a brain on a server keeps UTC: 03:00 used to be 11:00 in Taipei).
let now = chrono::Utc::now().with_timezone(&crate::routines::person_tz());
let today = now.format("%Y-%m-%d").to_string();
if done == today || !in_schedule_window(&at, now.time()) || is_busy() || !update_available() {
return;
}
log(&format!("排程更新({at})開始。"));
if rebuild(Rebuild::Update).is_ok() {
let mut c = config().lock().unwrap();
c.auto_update_done = today;
save_config(&c);
}
}
pub fn snapshot_path() -> PathBuf {
list_snapshots().pop().unwrap_or_else(|| snapshots_dir().join("snapshot.tar.gz"))
}
fn snapshots_dir() -> PathBuf {
data_dir().join("snapshots")
}
/// Every saved snapshot, oldest first. Names carry the time (`snapshot-YYYY-MM-DD-HHMMSS.tar.gz`)
/// so they sort by time and a copy keeps its stamp. The single old `snapshot.tar.gz` (from
/// before generations were kept) is moved in, stamped by its file date.
pub fn list_snapshots() -> Vec<PathBuf> {
let dir = snapshots_dir();
let _ = std::fs::create_dir_all(&dir);
let legacy = data_dir().join("snapshot.tar.gz");
if legacy.exists() {
let name = std::fs::metadata(&legacy)
.and_then(|m| m.modified())
.ok()
.map(|t| format!("snapshot-{}-legacy.tar.gz", chrono::DateTime::<chrono::Local>::from(t).format("%Y-%m-%d-%H%M%S")))
.unwrap_or_else(|| "snapshot-0000-00-00-000000-legacy.tar.gz".into());
let _ = std::fs::rename(&legacy, dir.join(name));
}
let mut v: Vec<PathBuf> = std::fs::read_dir(&dir)
.map(|rd| {
rd.flatten()
.map(|e| e.path())
.filter(|p| {
let n = p.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
n.starts_with("snapshot-") && n.ends_with(".tar.gz")
})
.collect()
})
.unwrap_or_default();
v.sort();
v
}
/// When a snapshot was saved (from its name; file dates don't survive copies).
fn snapshot_stamp(p: &std::path::Path) -> Option<chrono::DateTime<chrono::Local>> {
let stem = p.file_name()?.to_string_lossy().strip_suffix(".tar.gz")?.strip_prefix("snapshot-")?.trim_end_matches("-legacy").to_string();
chrono::NaiveDateTime::parse_from_str(&stem, "%Y-%m-%d-%H%M%S")
.ok()
.and_then(|t| chrono::TimeZone::from_local_datetime(&chrono::Local, &t).single())
.or_else(|| std::fs::metadata(p).ok().and_then(|m| m.modified().ok()).map(chrono::DateTime::<chrono::Local>::from))
}
/// When the last snapshot was saved.
pub fn snapshot_time() -> Option<chrono::DateTime<chrono::Local>> {
snapshot_stamp(&snapshot_path())
}
/// Snapshot generations: the last 7 days in full, then the newest one of each of the three
/// weeks before. The home folder holds every bot's logins — one bad snapshot overwriting the
/// only copy used to be one command away.
fn prune_snapshots() {
let list = list_snapshots();
if list.len() <= 7 {
return;
}
let now = chrono::Local::now();
let mut weeks_kept: Vec<(i32, u32)> = vec![];
let mut del = vec![];
for p in list.iter().rev() {
let Some(t) = snapshot_stamp(p) else { continue };
let age = (now - t).num_days();
if age <= 7 {
continue;
}
if age <= 28 {
let w = (t.iso_week().year(), t.iso_week().week());
if !weeks_kept.contains(&w) {
weeks_kept.push(w);
continue;
}
}
del.push(p.clone());
}
for p in del {
let _ = std::fs::remove_file(&p);
}
}
fn resume_path() -> PathBuf {
data_dir().join("recreate-resume.json")
}
/// Where docker and ssh live when the app is started from Finder (no shell PATH).
pub fn tool_path() -> String {
let base = std::env::var("PATH").unwrap_or_default();
let mut parts: Vec<String> = base.split(':').filter(|s| !s.is_empty()).map(String::from).collect();
let home = dirs::home_dir().map(|h| h.display().to_string()).unwrap_or_default();
for extra in ["/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin", &format!("{home}/.orbstack/bin"), "/Applications/Docker.app/Contents/Resources/bin"] {
if !parts.iter().any(|p| p == extra) {
parts.push(extra.to_string());
}
}
parts.join(":")
}
fn docker() -> Command {
let mut c = Command::new("docker");
c.env("PATH", tool_path());
let host = docker_host();
if !host.is_empty() {
c.env("DOCKER_HOST", host);
}
c.stdin(Stdio::null());
c
}
/// A program in the box with its stdin and stdout piped to this side (as the bot user).
pub fn exec_piped(args: &[&str]) -> Result<std::process::Child, String> {
let mut a = vec!["exec", "-i", "-u", "bot", container()];
a.extend_from_slice(args);
docker().args(&a).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().map_err(|e| format!("找不到 docker:{e}"))
}
/// The `docker …` prefix for shell strings (used by the command tools).
fn docker_prefix() -> String {
let host = docker_host();
if host.is_empty() {
"docker".into()
} else {
format!("DOCKER_HOST={} docker", crate::tools::sh_quote(&host))
}
}
/// Start (or restart) the box, once more when gVisor trips over what an unclean stop left
/// behind: after the VM or the computer went down hard, the first start fails with "mount source
/// already has a filestore file … repeated submounts are not supported" and clears it itself,
/// and the second one works.
fn start_once_more(args: &[&str]) -> Result<String, String> {
match dk(args) {
Err(e) if e.contains("filestore") || e.contains("repeated submounts") => {
log("上次沒有正常關機,留下的暫存檔擋住了開機,再試一次…");
std::thread::sleep(Duration::from_secs(2));
dk(args)
}
r => r,
}
}
/// Run docker; Ok(stdout) or Err(stderr).
fn dk(args: &[&str]) -> Result<String, String> {
// Never block forever on the daemon: a hung docker daemon used to pile up a new stuck
// thread every 20 s from the refresh loop. (docker's own CLI has no default timeout.)
let out = dk_timeout(args, 120_000)?;
Ok(String::from_utf8_lossy(&out).trim().to_string())
}
fn dk_timeout(args: &[&str], ms: u64) -> Result<Vec<u8>, String> {
dk_with_input(args, None, ms)
}
/// Run docker with `input` on its stdin (if any): Ok(stdout) or Err(stderr), killed after `ms`
/// ("逾時"). Both pipes are drained while waiting, or a big screenshot fills a pipe and stalls
/// docker.
fn dk_with_input(args: &[&str], input: Option<&[u8]>, ms: u64) -> Result<Vec<u8>, String> {
use std::io::{Read, Write};
let mut child = docker()
.args(args)
.stdin(if input.is_some() { Stdio::piped() } else { Stdio::null() })
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("找不到 docker:{e}"))?;
if let (Some(b), Some(mut si)) = (input, child.stdin.take()) {
si.write_all(b).map_err(|e| e.to_string())?;
}
let mut so = child.stdout.take().ok_or("沒有輸出")?;
let mut se = child.stderr.take().ok_or("沒有輸出")?;
let out_t = std::thread::spawn(move || {
let mut v = vec![];
let _ = so.read_to_end(&mut v);
v
});
let err_t = std::thread::spawn(move || {
let mut v = vec![];
let _ = se.read_to_end(&mut v);
v
});
let start = Instant::now();
let status = loop {
if let Ok(Some(st)) = child.try_wait() {
break st;
}
if start.elapsed() > Duration::from_millis(ms) {
let _ = child.kill();
let _ = child.wait();
return Err("逾時".into());
}
std::thread::sleep(Duration::from_millis(5));
};
let out = out_t.join().unwrap_or_default();
let err = err_t.join().unwrap_or_default();
if status.success() {
Ok(out)
} else {
Err(String::from_utf8_lossy(&err).trim().to_string())
}
}
// ---------- status ----------
// ---------- a Docker host in a Lima VM on this Mac ----------
// Strong isolation on a Mac runs the box in a Lima VM (deploy/mac/gvisor-vm.sh), and the Docker
// host is that VM's socket. After the Mac restarts nothing starts the VM, and Bot 的電腦 was just
// gone ("Docker 沒有在執行"). AnyBot starts it itself when its Docker doesn't answer.
/// The Lima VM a Docker host like unix:///Users/me/.lima/<vm>/sock/docker.sock is in.
fn lima_vm(host: &str) -> Option<String> {
let path = host.strip_prefix("unix://")?;
let (_, rest) = path.split_once("/.lima/")?;
let (vm, tail) = rest.split_once('/')?;
(tail == "sock/docker.sock" && !vm.is_empty() && vm.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')).then(|| vm.to_string())
}
/// A start of the VM is under way.
static LIMA_STARTING: AtomicBool = AtomicBool::new(false);
/// When the VM was last started (not again within LIMA_RETRY_SECS when it didn't come up).
static LIMA_TRIED: Mutex<Option<Instant>> = Mutex::new(None);
const LIMA_RETRY_SECS: u64 = 300;
/// Start the VM and wait until its Docker answers.
fn start_lima(vm: &str) -> Result<(), String> {
LIMA_STARTING.store(true, Ordering::SeqCst);
log(&format!("Docker 主機的虛擬機({vm})沒有開:正在開機…"));
let out = Command::new("limactl").env("PATH", tool_path()).args(["start", "--tty=false", vm]).stdin(Stdio::null()).output();
LIMA_STARTING.store(false, Ordering::SeqCst);
let out = out.map_err(|e| format!("找不到 limactl(Lima):{e}"))?;
if out.status.success() && dk(&["version", "--format", "{{.Server.Version}}"]).is_ok() {
log(&format!("虛擬機 {vm} 開好了。"));
Ok(())
} else {
let e = format!("開不了虛擬機 {vm}:{}", tail(String::from_utf8_lossy(&out.stderr).trim(), 300));
log(&e);
Err(e)
}
}
/// Docker doesn't answer and lives in a Lima VM here: start the VM — in the background, or
/// waiting for it (`wait`). None when the host isn't a Lima VM, or a start failed a moment ago.
fn wake_lima(wait: bool) -> Option<Result<(), String>> {
let vm = lima_vm(&docker_host())?;
if LIMA_STARTING.load(Ordering::SeqCst) {
if !wait {
return Some(Ok(()));
}
let t = Instant::now();
while LIMA_STARTING.load(Ordering::SeqCst) && t.elapsed().as_secs() < 240 {
std::thread::sleep(Duration::from_millis(500));
}
return Some(dk(&["version", "--format", "{{.Server.Version}}"]).map(|_| ()).map_err(|_| format!("虛擬機 {vm} 還沒開好")));
}
{
let mut tried = LIMA_TRIED.lock().unwrap_or_else(|e| e.into_inner());
if tried.is_some_and(|t| t.elapsed().as_secs() < LIMA_RETRY_SECS) && !wait {
return None;
}
*tried = Some(Instant::now());
}
if wait {
Some(start_lima(&vm))
} else {
LIMA_STARTING.store(true, Ordering::SeqCst);
std::thread::spawn(move || {
let _ = start_lima(&vm);
refresh();
});
Some(Ok(()))
}
}
/// 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 主機的虛擬機");
}
return Run::NoDocker(if e.contains("找不到") && !cfg!(target_os = "macos") {
"這台電腦沒有安裝 Docker。安裝 Docker Engine(Ubuntu / Debian:sudo apt install docker.io),再把自己加進 docker 群組(sudo usermod -aG docker $USER,然後重新登入),Bot 就會有自己的完整電腦。".into()
} else if e.contains("找不到") {
"這台 Mac 沒有安裝 Docker。安裝 OrbStack 或 Docker Desktop 後,Bot 就會有自己的完整電腦。".into()
} else if e.contains("permission denied") && !cfg!(target_os = "macos") {
"Docker 不讓 AnyBot 使用:請執行 sudo usermod -aG docker $USER,然後登出再登入。".into()
} else {
format!("Docker 沒有在執行:{}", e.lines().last().unwrap_or(""))
});
}
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() {
Run::Running
} else {
Run::Starting("連線中")
}
}
"paused" | "exited" | "created" => Run::Asleep,
"restarting" => Run::Starting("連線中"),
other => Run::Failed(format!("容器狀態:{other}")),
},
}
}
/// Refresh the shown state in the background (unless a rebuild or start owns it).
pub fn refresh() {
std::thread::spawn(|| {
let r = probe();
let mut s = shared().lock().unwrap();
if s.rebuilding || matches!(s.run, Run::Starting(_)) && !matches!(r, Run::Running) {
return;
}
s.run = r;
});
}
pub fn available() -> bool {
!matches!(run_state(), Run::NoDocker(_))
}
// ---------- image ----------
/// The box image is here and is the version this AnyBot carries (an old one would make a new
/// box without the newer parts: the numbered controls, the browser launcher).
fn image_exists() -> bool {
dk(&["image", "inspect", "-f", "{{index .Config.Labels \"anybot.box.version\"}}", image()]).map(|v| v.trim() == wanted_version()).unwrap_or(false)
}
/// Build the box image from the files baked into AnyBot.
fn build_image(pull: bool) -> Result<(), String> {
let dir = std::env::temp_dir().join(format!("anybot-box-build-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
for (name, body) in [("Dockerfile", DOCKERFILE), ("start.sh", START_SH), ("fluxbox-init", FLUXBOX_INIT), ("overlay", OVERLAY), ("Xresources", XRESOURCES), ("menu", MENU), ("a11y", A11Y), ("browser", BROWSER)] {
std::fs::write(dir.join(name), body).map_err(|e| e.to_string())?;
}
log("正在建立電腦的映像檔(第一次要幾分鐘)…");
let mut args = vec!["build", "-q", "-t", image()];
if pull {
args.push("--pull");
}
let d = dir.display().to_string();
args.push(&d);
// Builds take minutes (a cold one more than dk's short timeout allows).
let r = dk_timeout(&args, 20 * 60_000).map(|_| String::new());
let _ = std::fs::remove_dir_all(&dir);
r.map(|_| log("映像檔完成。")).map_err(|e| format!("建立映像檔失敗:{}", tail(&e, 600)))
}
fn tail(s: &str, n: usize) -> String {
let c: Vec<char> = s.chars().collect();
if c.len() <= n {
s.to_string()
} else {
c[c.len() - n..].iter().collect()
}
}
// ---------- lifecycle ----------
fn free_port(start: u16) -> u16 {
if !docker_host().is_empty() {
return start;
}
(start..start + 50).find(|p| std::net::TcpListener::bind(("127.0.0.1", *p)).is_ok()).unwrap_or(start)
}
fn create_container() -> Result<(), String> {
shared().lock().unwrap_or_else(|e| e.into_inner()).size = None;
if !image_exists() {
build_image(false)?;
}
let port = free_port(HOST_PORT);
let publish = format!("127.0.0.1:{port}:{NOVNC_PORT}");
let vol = format!("{}:{HOME}", volume());
let env = format!("SCREEN_SIZE={SCREEN}");
let vnc = format!("VNC_PASSWORD={}", vnc_password());
log("正在開一台新的電腦…");
// 容器隔離: on a Docker host with gVisor (`runsc`), the box gets a virtualized kernel
// instead of the host's, its seccomp filters run in user space, and Chromium's own sandbox
// works inside the box (ANYBOT_BOX_SANDBOX=1 re-enables it; see box/Dockerfile). With the
// plain runc runtime the box itself is the sandbox, like the original.
// A Docker host without gVisor (the setting moved with a new Docker host, or gVisor was
// removed) would leave no computer at all ("unknown runtime"): open it the standard way and
// say so; the settings page shows which one it runs on.
let hardened = box_runtime() == "runsc" && host_has_gvisor();
if box_runtime() == "runsc" && !hardened {
log("這台 Docker 主機沒有 gVisor(runsc),電腦先用標準隔離開機;裝好 gVisor 後按「更新」重建。");
}
let runtime = "runsc";
let sandbox_env = "ANYBOT_BOX_SANDBOX=1";
let caps: Vec<String> = ["CHOWN", "SETUID", "SETGID", "FOWNER", "DAC_OVERRIDE", "KILL", "NET_BIND_SERVICE"]
.iter()
.flat_map(|c| ["--cap-add".to_string(), (*c).to_string()])
.collect();
let mut args: Vec<&str> = vec!["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];
if hardened {
// (No swap on top of the 2 GB under gVisor; a standard box keeps Docker's default.)
args.extend(["--memory-swap", "2g"]);
args.push("--runtime");
args.push(runtime);
args.push("--cap-drop");
args.push("ALL");
for c in &caps {
args.push(c);
}
args.push("-e");
args.push(sandbox_env);
}
args.extend(["--label", "anybot.box=1", image()]);
// host.docker.internal → this Mac (for 網路流量改走你的電腦; Docker Desktop and OrbStack already have it).
// On a Docker host elsewhere (a VM, a server) this Mac can't tell which ports are taken there:
// another box may hold the usual one ("port is already allocated"). Then the next one.
let mut owned: Vec<String> = args.iter().map(|a| a.to_string()).collect();
let at = owned.iter().position(|a| *a == publish);
let mut port = port;
for _ in 0..20 {
let argv: Vec<&str> = owned.iter().map(String::as_str).collect();
match dk(&argv) {
Ok(_) => {
ensure_holder();
return Ok(());
}
Err(e) if e.contains("port is already allocated") && at.is_some() => {
let _ = dk(&["rm", "-f", container()]);
port += 1;
if let Some(i) = at {
owned[i] = format!("127.0.0.1:{port}:{NOVNC_PORT}");
}
}
Err(e) => return Err(format!("開不了電腦:{}", tail(&e, 400))),
}
}
Err("開不了電腦:找不到空的連接埠".into())
}
fn holder_name() -> String {
format!("{}-home-holder", container())
}
/// Keep the home volume attached to a container. A box removed by an update is a moment with no
/// container using the volume — and `docker volume prune` (a command people run to reclaim
/// disk) deletes volumes that are attached to nothing, logins and all. One sleeping holder
/// keeps it always attached.
fn ensure_holder() {
if dk(&["volume", "inspect", "-f", "{{.Name}}", volume()]).is_err() {
return;
}
if dk(&["inspect", "-f", "{{.State.Running}}", &holder_name()]).map(|s| s == "true").unwrap_or(false) {
return;
}
let _ = dk(&["rm", "-f", &holder_name()]);
let vol = format!("{}:{HOME}", volume());
let _ = dk(&["run", "-d", "--name", &holder_name(), "--label", "anybot.box=1", "-v", &vol, image(), "sleep", "infinity"]);
}
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() {
return Ok(());
}
std::thread::sleep(Duration::from_millis(250));
}
Err("電腦開機逾時".into())
}
// ---------- the box's own browser ----------
// Browser steps on Bot 的電腦 drive the Chromium on its desktop (the person can watch it in the
// box's window). Its DevTools port stays inside the box; a local port reaches it through
// `docker exec` (no published port, so it also works with a Docker host elsewhere).
const BOX_CDP_PORT: u16 = 9222;
fn box_cdp_alive(local: u16) -> bool {
crate::botbrowser::local().get(&format!("http://127.0.0.1:{local}/json/version")).call().map(|r| r.status().as_u16() == 200).unwrap_or(false)
}
/// Pass bytes on as they come. Not std::io::copy: on Linux it splices a socket into the
/// `docker exec` pipe and sits on a short request (a DevTools probe never got its answer, so
/// every Linux brain — the cloud brains — saw the box's browser as "not opening in time").
fn pump(from: &mut impl std::io::Read, to: &mut impl std::io::Write) {
let mut buf = [0u8; 16 * 1024];
loop {
match from.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
if to.write_all(&buf[..n]).is_err() {
break;
}
}
}
}
}
/// A local port that reaches the box's DevTools port.
fn box_cdp_forward() -> Result<u16, String> {
static PORT: Mutex<u16> = Mutex::new(0);
let mut p = PORT.lock().unwrap();
if *p != 0 {
return Ok(*p);
}
let l = std::net::TcpListener::bind("127.0.0.1:0").map_err(|e| e.to_string())?;
let port = l.local_addr().map_err(|e| e.to_string())?.port();
std::thread::spawn(move || {
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 (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 || pump(&mut r, &mut cin));
pump(&mut cout, &mut w);
let _ = conn.shutdown(std::net::Shutdown::Both);
let _ = child.kill();
let _ = up.join();
let _ = child.wait();
});
}
});
*p = port;
Ok(port)
}
/// Is the box's Chromium answering already?
pub fn browser_running() -> bool {
box_cdp_forward().map(box_cdp_alive).unwrap_or(false)
}
/// The box's Chromium, started on its desktop when it isn't running. Gives the local port.
pub fn browser_port() -> Result<u16, String> {
ensure_running()?;
touch();
let port = box_cdp_forward()?;
if box_cdp_alive(port) {
return Ok(port);
}
// The box's one browser launcher (same profile and port as the person's menu entry, web
// pages exposed to accessibility).
dk_timeout(&["exec", "-u", "bot", container(), "anybot-browser"], 30_000).map_err(|e| format!("Bot 的電腦打不開瀏覽器:{e}"))?;
let start = Instant::now();
while start.elapsed() < Duration::from_secs(20) {
if box_cdp_alive(port) {
return Ok(port);
}
std::thread::sleep(Duration::from_millis(300));
}
Err("這台電腦的瀏覽器沒有及時打開,請再試一次。".into())
}
static START_LOCK: Mutex<()> = Mutex::new(());
/// Make sure the box is up, waking or creating it as needed. Blocks until it's ready.
/// The box start (Docker host and StartedAt) last seen ready. While the box is running from that
/// same start, a step needs one `docker inspect` instead of four calls (version, inspect, the
/// ready probe, chown) — the ready file only matters right after a start.
static READY_START: Mutex<String> = Mutex::new(String::new());
fn box_start() -> Option<String> {
let s = dk(&["inspect", "-f", "{{.State.Status}} {{.State.StartedAt}}", container()]).ok()?;
Some(format!("{}|{}", docker_host(), s.trim()))
}
pub fn ensure_running() -> Result<(), String> {
let _g = START_LOCK.lock().unwrap();
if shared().lock().unwrap().rebuilding {
return Err("Bot 的電腦正在更新,請等它完成再試。".into());
}
touch();
// Docker in a Lima VM that isn't up (the Mac restarted): start the VM first.
if lima_vm(&docker_host()).is_some() && dk(&["version", "--format", "{{.Server.Version}}"]).is_err() {
if let Some(Err(e)) = wake_lima(true) {
set_run(Run::NoDocker(e.clone()));
return Err(e);
}
}
let known = READY_START.lock().unwrap_or_else(|e| e.into_inner()).clone();
if !known.is_empty() && box_start().as_deref() == Some(known.as_str()) {
set_run(Run::Running);
return Ok(());
}
let r = probe();
let result = match r {
Run::Running => Ok(()),
Run::NoDocker(e) => Err(e),
Run::Absent => {
set_run(Run::Starting("建立中"));
// Asked before the box is made: Docker fills a new volume with the image's own
// home (.bashrc, .profile…), so a new home never looked empty afterwards and the
// saved one was never put back.
let new_home = !home_volume_exists();
create_container().and_then(|_| {
// A brand-new box on an empty volume (a wiped Docker, a new host): the saved
// home brings the files and every bot's logins back by themselves.
if snapshot_path().exists() && (new_home || !home_has_files()) {
log("新的電腦家目錄是空的:從最近的快照還原檔案和登入…");
let src = snapshot_path();
if let Err(e) = copy_in_snapshot(&src) {
log(&format!("自動還原沒有完成:{e}(電腦照常用,檔案以現在為準)。"));
}
}
wait_ready(BOOT_WAIT_MS)
})
}
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 { start_once_more(&["start", container()]) };
woke.map_err(|e| format!("叫不醒電腦:{e}")).and_then(|_| wait_ready(BOOT_WAIT_MS))
}
Run::Starting(_) => {
set_run(Run::Starting("連線中"));
wait_ready(BOOT_WAIT_MS)
}
Run::Rebuilding(..) => Err("Bot 的電腦正在更新,請等它完成再試。".into()),
Run::Failed(e) => {
// A broken container: start it again.
log(&format!("電腦狀態異常({e}),重新啟動…"));
set_run(Run::Starting("連線中"));
start_once_more(&["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}")]);
*READY_START.lock().unwrap_or_else(|e| e.into_inner()) = box_start().filter(|s| s.contains("|running ")).unwrap_or_default();
}
Err(e) => {
log(e);
set_run(match probe() {
Run::NoDocker(x) => Run::NoDocker(x),
_ => Run::Failed(e.clone()),
});
}
}
touch();
result
}
/// Started once: keeps the box awake (wakes it when it was asleep), saves its files once a day,
/// and replays a rebuild cut short by a restart. The person asked for the bots' computer to be
/// always there: an idle box used to be paused after 15 minutes, and the person — and a bot
/// working from a brain far away — kept finding it "asleep".
pub fn start_background() {
static STARTED: AtomicBool = AtomicBool::new(false);
if STARTED.swap(true, Ordering::SeqCst) {
return;
}
std::thread::spawn(|| {
adopt_usual_box();
set_run(probe());
if IS_BRAIN.load(Ordering::SeqCst) {
resume_interrupted();
} else {
// Showing another brain from the start: this box and its VM aren't wanted at all.
sleep_on_a_screen();
}
loop {
// (First wait a little: a computer showing another brain says so right after start.)
std::thread::sleep(Duration::from_secs(20));
let (idle, rebuilding) = {
let s = shared().lock().unwrap();
(s.last_used.elapsed().as_secs(), s.rebuilding)
};
if !rebuilding {
let brain = IS_BRAIN.load(Ordering::SeqCst);
if brain && matches!(probe(), Run::Asleep) {
log("Bot 的電腦不休眠:叫醒它。");
let _ = ensure_running();
}
if !brain {
sleep_on_a_screen();
}
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.
// A screen leaves it stopped with the box.)
if brain && matches!(probe(), Run::Asleep | Run::Running) {
ensure_holder();
}
refresh();
if brain {
maybe_scheduled_update();
}
update_when_idle(idle);
}
}
});
}
/// The brain's box is older than this AnyBot (it lacks parts the bots now use: the numbered
/// controls, the browser launcher): update it by itself once nobody has used it for a minute,
/// once per version. Files and logins stay (they live in the home volume).
fn update_when_idle(idle_secs: u64) {
static TRIED: Mutex<String> = Mutex::new(String::new());
if !IS_BRAIN.load(Ordering::SeqCst) || idle_secs < 60 || is_busy() || HELD_BY_PERSON.load(Ordering::SeqCst) || viewer_open() || !update_available() {
return;
}
let want = wanted_version();
{
let mut t = TRIED.lock().unwrap_or_else(|e| e.into_inner());
if *t == want {
return;
}
*t = want.clone();
}
log(&format!("Bot 的電腦是舊版,自動更新到第 {want} 版(檔案和登入都會保留)。"));
if let Err(e) = rebuild(Rebuild::Update) {
log(&format!("自動更新沒有成功:{e}"));
}
}
/// 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. The person
/// wants neither running then, from the start: stop the box at once, 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() {
if IS_BRAIN.load(Ordering::SeqCst) || 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 (sleep_on_a_screen).
static IS_BRAIN: AtomicBool = AtomicBool::new(true);
pub fn set_brain(on: bool) {
IS_BRAIN.store(on, Ordering::SeqCst);
}
static VIEWER_OPEN: AtomicBool = AtomicBool::new(false);
pub fn set_viewer_open(on: bool) {
VIEWER_OPEN.store(on, Ordering::SeqCst);
if on {
touch();
}
}
fn viewer_open() -> bool {
VIEWER_OPEN.load(Ordering::SeqCst)
}
// ---------- snapshots (the durable store) ----------
/// Save the bot's home folder to this Mac, as a new generation. Returns its size in bytes.
pub fn save_snapshot() -> Result<u64, String> {
let dir = snapshots_dir();
let _ = std::fs::create_dir_all(&dir);
let stamp = chrono::Local::now().format("%Y-%m-%d-%H%M%S");
let tmp = dir.join(format!("snapshot-{stamp}.tmp"));
let final_path = dir.join(format!("snapshot-{stamp}.tar.gz"));
let file = std::fs::File::create(&tmp).map_err(|e| e.to_string())?;
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",
"--warning=no-file-changed", "-czf", "-", ".",
])
.stdout(Stdio::from(file))
.stderr(Stdio::piped())
.spawn()
.map_err(|e| e.to_string())?;
// Drain stderr: tar's file-changed warnings pile up fast, and a full 64 KB pipe stalls
// tar (and the snapshot) until the kill timer fires.
let mut se = child.stderr.take().ok_or("沒有輸出")?;
let err_t = std::thread::spawn(move || {
use std::io::Read;
let mut v = vec![];
let _ = se.read_to_end(&mut v);
v
});
let start = Instant::now();
let ok = loop {
// tar says 1 when a file changed while it was read (the open browser writes all the
// time): the archive is still whole. Only 2 and up are failures.
if let Ok(Some(st)) = child.try_wait() {
break st.code().map(|c| c <= 1).unwrap_or(false);
}
if start.elapsed() > Duration::from_millis(SNAPSHOT_SAVE_TIMEOUT_MS) {
let _ = child.kill();
break false;
}
std::thread::sleep(Duration::from_millis(50));
};
let _ = err_t.join();
if !ok {
let _ = std::fs::remove_file(&tmp);
return Err("快照沒有存成功".into());
}
std::fs::rename(&tmp, &final_path).map_err(|e| e.to_string())?;
let n = std::fs::metadata(&final_path).map(|m| m.len()).unwrap_or(0);
prune_snapshots();
log(&format!("已儲存快照({},共 {} 代)。", human(n), list_snapshots().len()));
Ok(n)
}
/// Put a saved snapshot's files back into the box's home folder.
fn copy_in_snapshot(src: &std::path::Path) -> Result<(), String> {
use std::io::Write;
let data = std::fs::read(src).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")])
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| e.to_string())?;
child.stdin.take().ok_or("寫不進電腦")?.write_all(&data).map_err(|e| e.to_string())?;
let out = child.wait_with_output().map_err(|e| e.to_string())?;
if !out.status.success() {
return Err(format!("還原失敗:{}", String::from_utf8_lossy(&out.stderr).trim()));
}
// 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 s.contains("outcome=hydrated") {
return Ok(());
}
}
std::thread::sleep(Duration::from_millis(COPY_IN_POLL_MS));
}
Err("還原沒有完成:copy-in outcome=unknown".into())
}
/// The home volume is there already (false also when Docker can't be asked: making the box
/// fails then, and nothing is restored).
fn home_volume_exists() -> bool {
dk(&["volume", "inspect", "-f", "{{.Name}}", volume()]).is_ok()
}
/// A saved snapshot can be read to its end (a whole gzip stream with something in it). Asked
/// before the home it is to replace is thrown away.
fn snapshot_whole(src: &std::path::Path) -> Result<(), String> {
let file = std::fs::File::open(src).map_err(|e| format!("打不開快照:{e}"))?;
let mut gz = flate2::read::GzDecoder::new(std::io::BufReader::new(file));
match std::io::copy(&mut gz, &mut std::io::sink()) {
Ok(0) => Err("快照是空的".into()),
Ok(_) => Ok(()),
Err(e) => Err(format!("快照壞了,讀不到最後:{e}")),
}
}
/// Throw the home volume away. Said when it can't be (another container still holds it): the
/// step after it would otherwise call old files a new computer.
fn remove_home_volume() -> Result<(), String> {
dk(&["volume", "rm", "-f", volume()]).map(|_| ()).map_err(|e| format!("清不掉電腦裡的資料({})。原本的檔案都還在,沒有動到。", tail(&e, 200)))
}
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)
}
pub fn human(b: u64) -> String {
match b {
b if b >= 1 << 30 => format!("{:.1} GB", b as f64 / (1u64 << 30) as f64),
b if b >= 1 << 20 => format!("{:.1} MB", b as f64 / (1u64 << 20) as f64),
b if b >= 1 << 10 => format!("{:.0} KB", b as f64 / 1024.0),
b => format!("{b} B"),
}
}
// ---------- rebuilds ----------
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Rebuild {
/// 重建電腦: keep files and logins, remove installed apps and packages.
Update,
/// 重設: a brand-new computer, nothing kept.
Reset,
/// 復原: start again from the last saved snapshot.
Restore,
}
impl Rebuild {
pub fn title(self) -> &'static str {
match self {
Rebuild::Update => "重建電腦",
Rebuild::Reset => "重設電腦",
Rebuild::Restore => "從快照復原",
}
}
/// The original's confirmation wording.
pub fn warning(self) -> &'static str {
match self {
Rebuild::Update => "你的檔案和登入資訊會保留,但已安裝的應用程式和套件會被移除。所有 Bot 會一起更新。正在工作的 Bot 會先停下來,聊天會保留。",
Rebuild::Reset => "重新建立電腦並重新連線。電腦裡的所有檔案、登入資訊和已安裝的應用程式都會被清除(你的 Bot 和聊天不受影響)。",
Rebuild::Restore => "從你上次儲存的快照重新啟動它。最近的變更可能會遺失,已安裝的 App 會被移除。你的 Bot、檔案和登入資訊都是安全的。",
}
}
}
#[derive(Serialize, Deserialize)]
struct ResumeMarker {
#[serde(rename = "resumeKind")]
kind: Rebuild,
#[serde(rename = "writtenAtMs")]
written_at_ms: u64,
}
fn now_ms() -> u64 {
SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0)
}
/// Start a rebuild in the background. Err = refused (already running, busy, no snapshot…).
pub fn rebuild(kind: Rebuild) -> Result<(), String> {
{
let mut s = shared().lock().unwrap();
if s.rebuilding {
return Err("已經有一個更新在進行中。".into());
}
if kind == Rebuild::Update && BUSY.load(Ordering::SeqCst) > 0 {
return Err("有 Bot 正在用這台電腦,等它做完再重建(或先停止它)。".into());
}
if kind == Rebuild::Restore && !snapshot_path().exists() {
return Err("還沒有儲存過快照,沒有東西可以還原。".into());
}
s.rebuilding = true;
}
std::thread::spawn(move || {
let _ = std::fs::create_dir_all(data_dir());
let _ = std::fs::write(resume_path(), serde_json::to_string(&ResumeMarker { kind, written_at_ms: now_ms() }).unwrap_or_default());
let phase = |p: &str, d: &str| {
log(&format!("{}:{d}", phase_label(p)));
shared().lock().unwrap().run = Run::Rebuilding(p.into(), d.into());
};
let r = run_rebuild(kind, &phase);
let _ = std::fs::remove_file(resume_path());
match r {
Ok(()) => {
log(&format!("{}完成。", kind.title()));
let mut s = shared().lock().unwrap();
s.rebuilding = false;
s.run = Run::Running;
s.last_used = Instant::now();
}
Err(e) => {
log(&format!("{}失敗:{e}", kind.title()));
let mut s = shared().lock().unwrap();
s.rebuilding = false;
s.run = Run::Failed(e);
}
}
});
Ok(())
}
fn run_rebuild(kind: Rebuild, phase: &dyn Fn(&str, &str)) -> Result<(), String> {
match kind {
Rebuild::Update => {
let had = matches!(probe(), Run::Running);
phase("backing-up", "儲存一份快照");
if had {
if let Err(e) = save_snapshot() {
log(&format!("快照沒有存成功({e}),改以掛載的資料為準。"));
}
}
let had_files = had && home_has_files();
phase("creating", "更新電腦的映像檔");
build_image(true)?;
phase("creating", "換上一台新的電腦");
let _ = dk(&["rm", "-f", container()]);
create_container()?;
wait_ready(BOOT_WAIT_MS)?;
phase("moving", "確認保留的檔案和新電腦的連線");
if had_files && !home_has_files() {
return Err("重建後找不到原本的檔案".into());
}
Ok(())
}
Rebuild::Reset => {
// The box's home is the only copy of every bot's logins: never wipe it without a
// snapshot of what's in it first — reset becomes undoable.
if matches!(probe(), Run::Running) {
phase("backing-up", "重設前:先存下現在的電腦");
save_snapshot().map_err(|e| format!("重設取消了:存不下現在的狀態({e})。先處理好這個,重設才救得回來。"))?;
}
phase("cleaning-up", "拆掉舊的電腦");
let _ = dk(&["rm", "-f", container()]);
let _ = dk(&["rm", "-f", &holder_name()]);
phase("wiping", "清除電腦裡的資料");
remove_home_volume()?;
phase("creating", "開一台全新的電腦");
if !image_exists() {
build_image(false)?;
}
create_container()?;
wait_ready(BOOT_WAIT_MS)
}
Rebuild::Restore => {
// Say which snapshot to restore before saving the current state (saving adds a
// newer "latest" that restore must not pick up).
let src = snapshot_path();
if matches!(probe(), Run::Running) {
phase("backing-up", "復原前:先存下現在的電腦");
save_snapshot().map_err(|e| format!("復原取消了:存不下現在的狀態({e})。先處理好這個,最近的變更才不會無聲消失。"))?;
}
// The snapshot is read through before anything is thrown away: the home was
// wiped first and the snapshot opened last, so one that couldn't be read (gone,
// cut short) left a computer with neither.
phase("backing-up", "確認快照讀得出來");
snapshot_whole(&src).map_err(|e| format!("復原取消了:{e}。電腦沒有動到。"))?;
phase("cleaning-up", "拆掉舊的電腦和它的資料");
let _ = dk(&["rm", "-f", container()]);
let _ = dk(&["rm", "-f", &holder_name()]);
remove_home_volume()?;
phase("creating", "從快照開一台新的電腦");
if !image_exists() {
build_image(false)?;
}
create_container()?;
wait_ready(BOOT_WAIT_MS)?;
phase("restoring", "把快照裡的檔案放回去");
copy_in_snapshot(&src)
}
}
}
/// resumeInterruptedRecreate: replay a fresh marker when the box isn't healthy.
fn resume_interrupted() {
let Some(m) = std::fs::read_to_string(resume_path()).ok().and_then(|s| serde_json::from_str::<ResumeMarker>(&s).ok()) else { return };
if now_ms().saturating_sub(m.written_at_ms) >= RESUME_FRESH_MS {
log("清掉一個太舊的未完成更新紀錄(超過十分鐘)。");
let _ = std::fs::remove_file(resume_path());
return;
}
if matches!(probe(), Run::Running) {
log("上次的更新被中斷,但電腦目前正常,所以沒有重做。需要的話請再執行一次。");
let _ = std::fs::remove_file(resume_path());
return;
}
log(&format!("接續上次被中斷的「{}」。", m.kind.title()));
let _ = rebuild(m.kind);
}
// ---------- disk ----------
/// Disk use in the box, for the settings page and the clean-up request.
pub fn disk_report() -> Result<String, String> {
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")])
}
// ---------- secrets (write-only) ----------
fn secrets_path() -> PathBuf {
data_dir().join("secrets.json")
}
fn env_file_path() -> PathBuf {
data_dir().join("secrets.env")
}
fn read_secrets() -> std::collections::BTreeMap<String, String> {
std::fs::read_to_string(secrets_path()).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default()
}
fn write_secrets(m: &std::collections::BTreeMap<String, String>) -> Result<(), String> {
let _ = std::fs::create_dir_all(data_dir());
let write = |p: PathBuf, body: String| -> Result<(), String> {
std::fs::write(&p, body).map_err(|e| e.to_string())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600));
}
Ok(())
};
write(secrets_path(), serde_json::to_string(m).unwrap_or_default())?;
// docker --env-file: one NAME=value per line (no newlines allowed in values).
write(env_file_path(), m.iter().map(|(k, v)| format!("{k}={}\n", v.replace('\n', "\\n"))).collect())
}
/// Names only: values can be written, never read back.
pub fn secret_names() -> Vec<String> {
read_secrets().into_keys().collect()
}
pub fn valid_secret_name(n: &str) -> bool {
let mut c = n.chars();
matches!(c.next(), Some('A'..='Z')) && c.all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_')
}
pub fn set_secret(name: &str, value: &str) -> Result<(), String> {
let name = name.trim();
if !valid_secret_name(name) {
return Err("名稱要大寫字母開頭,只能用 A–Z、0–9 和 _,例如 CRM_API_TOKEN".into());
}
if value.is_empty() {
return Err("請填入值".into());
}
// docker's --env-file keeps values on one line; a value with newlines used to be written
// with literal "\n" escapes and arrived broken (multi-line PEM keys) — say so up front.
if value.contains(['\n', '\r']) {
return Err("值裡不能有換行(多行金鑰請壓成一行,例如 -----BEGIN KEY-----…-----END KEY-----)".into());
}
if value.len() > SECRET_MAX_VALUE_BYTES {
return Err(format!("值太長了(最多 {} KB)", SECRET_MAX_VALUE_BYTES / 1024));
}
let mut m = read_secrets();
if !m.contains_key(name) && m.len() >= SECRET_MAX_COUNT {
return Err(format!("最多 {SECRET_MAX_COUNT} 個密鑰"));
}
m.insert(name.to_string(), value.to_string());
write_secrets(&m)
}
pub fn remove_secret(name: &str) {
let mut m = read_secrets();
if m.remove(name).is_some() {
let _ = write_secrets(&m);
}
}
// ---------- commands ----------
/// The bot's own folder inside the box.
pub fn bot_dir(bot_id: u64) -> String {
format!("{HOME}/bot-{bot_id}")
}
/// A local shell string that runs `cmd` in the box as the bot user, in `dir`, with the secrets.
pub fn exec_wrapper(dir: &str, cmd: &str, interactive: bool, job: &str) -> String {
use crate::tools::sh_quote;
// The job tag rides in the command line so the app can kill the in-container process later
// (killing the local docker client alone leaves the program running in the box).
let tagged = if job.is_empty() { cmd.to_string() } else { format!("export ANYBOT_JOB={job}; {cmd}") };
let mut inner = format!("mkdir -p {d} && cd {d} && {tagged}", d = sh_quote(dir));
// sudo can't raise the bot user to root under gVisor (setuid bits do nothing there: "effective
// uid is not 0"), though the box gives the bot sudo without a password. A command that asks
// for sudo runs as the box's root instead (root's sudo needs no setuid), and what it left in
// the bot's home is handed back to the bot. Still inside the box, same walls.
let user = if uses_sudo(cmd) {
inner = format!("m=$(mktemp); {inner}; rc=$?; find {HOME} -xdev -user root -newer \"$m\" -exec chown bot:bot {{}} + 2>/dev/null; rm -f \"$m\"; exit $rc");
"root"
} else {
"bot"
};
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 {user} -e DISPLAY={DISPLAY} -e HOME={HOME} -e USER=bot{}{env_arg} {box} bash -lc {}", docker_prefix(), if interactive { " -i" } else { "" }, egress_env(), sh_quote(&inner), r#box = container())
}
/// Run a read-only script in the box as the bot user, for AnyBot's own use (facts.rs): not one
/// of the bot's commands, so no job tag and no sudo rerouting.
pub fn read_facts(script: &str) -> Result<String, String> {
let out = dk_with_input(&["exec", "-i", "-u", "bot", "-e", &format!("HOME={HOME}"), container(), "sh", "-c", script], Some(b""), 10_000)?;
Ok(String::from_utf8_lossy(&out).to_string())
}
/// The command calls sudo (as a command word, not inside another word like "pseudo").
pub fn uses_sudo(cmd: &str) -> bool {
cmd.match_indices("sudo").any(|(i, _)| {
let before = cmd[..i].chars().next_back();
let after = cmd[i + 4..].chars().next();
before.is_none_or(|c| c.is_whitespace() || matches!(c, ';' | '&' | '|' | '(' | '`' | '{' | '!')) && after.is_none_or(|c| c.is_whitespace())
})
}
/// An argv that kills the box process carrying this job tag (and its children).
pub fn exec_kill_argv(job: &str) -> Vec<String> {
let mut argv = vec!["docker".to_string()];
let host = docker_host();
if !host.is_empty() {
argv.extend(["-H".to_string(), host]);
}
argv.extend([
"exec".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"),
]);
argv
}
/// Put a file into a bot's folder on the box (only when the box is already running).
pub fn copy_in(local: &str, bot_id: u64, name: &str) -> Result<String, String> {
if !run_state().is_running() {
return Err("Bot 的電腦沒有在執行".into());
}
// The name arrives from uploads: keep only its file part, so no `../` can steer the
// root-run `docker cp` out of this bot's attachments folder.
let safe = std::path::Path::new(name)
.file_name()
.map(|n| n.to_string_lossy().chars().filter(|c| *c != '\0').collect::<String>())
.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])?;
let dst = format!("{dir}/{safe}");
dk(&["cp", local, &format!("{c}:{dst}", c = container())])?;
let _ = dk(&["exec", container(), "chown", "bot:bot", &dst]);
Ok(dst)
}
/// Copy a file out of the box to this Mac (for showing the person). Returns the local path.
pub fn copy_out(path_in_box: &str) -> Result<PathBuf, String> {
ensure_running()?;
let name = std::path::Path::new(path_in_box).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "file".into());
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!("{}:{path_in_box}", container()), &local.display().to_string()]).map_err(|e| format!("拿不到檔案:{e}"))?;
Ok(local)
}
// ---------- the box's screen (xdotool) ----------
// One `docker exec` per step: the xdotool commands, then (for the bot) a short wait and the
// picture of the result, in one shell script. Each exec costs 50–200 ms locally and more on a
// Docker host elsewhere; a click with a modifier used to be four of them, plus one more for the
// screenshot the bot then had to ask for.
/// The latest picture of the box's screen, small (for the 電腦 card). While the box runs it is
/// taken now (and kept on disk); while it sleeps the last one taken is shown.
pub fn thumbnail(width: u32) -> Option<image::RgbaImage> {
let path = data_dir().join("last-screen.png");
if let Some(img) = frame() {
let h = (img.height() as f32 * width as f32 / img.width().max(1) as f32).round() as u32;
let small = image::imageops::resize(&img, width, h.max(1), image::imageops::FilterType::Triangle);
let _ = std::fs::create_dir_all(data_dir());
let _ = small.save(&path);
return Some(small);
}
image::open(&path).ok().map(|i| i.to_rgba8())
}
/// The screen now, for a small picture (no waking: None when the box isn't running). People watch
/// it live through VNC (vnc.rs).
pub fn frame() -> Option<image::RgbaImage> {
if !run_state().is_running() {
return None;
}
let jpeg = dk_timeout(&["exec", container(), "import", "-window", "root", "-display", DISPLAY, "-quality", "80", "jpeg:-"], 5_000).ok()?;
image::load_from_memory(&jpeg).ok().map(|i| i.to_rgba8())
}
/// The desktop's size in pixels (asked once per box).
fn box_size() -> Result<(u32, u32), String> {
if let Some(s) = shared().lock().unwrap_or_else(|e| e.into_inner()).size {
return Ok(s);
}
let out = dk(&["exec", "-u", "bot", "-e", "DISPLAY=:1", container(), "xdotool", "getdisplaygeometry"])?;
let mut it = out.split_whitespace().filter_map(|n| n.parse::<u32>().ok());
let size = match (it.next(), it.next()) {
(Some(w), Some(h)) if w > 0 && h > 0 => (w, h),
_ => return Err("讀不到這台電腦的螢幕大小".to_string()),
};
shared().lock().unwrap_or_else(|e| e.into_inner()).size = Some(size);
Ok(size)
}
/// Desktop pixels per picture pixel: the bot's pictures are the desktop fitted to 1024 wide,
/// always the same size, so its coordinates map without remembering a screenshot.
fn box_scale() -> Result<(f64, f64), String> {
let (w, h) = box_size()?;
let (fw, fh, _) = crate::computer::fit(w, h);
Ok((w as f64 / fw.max(1) as f64, h as f64 / fh.max(1) as f64))
}
/// A step on the box's screen as a shell script (for `sh -c` inside the box, as the bot user),
/// the text it reads on stdin (typing), and what to tell the bot. Coordinates are picture pixels
/// times `scale` (desktop pixels per picture pixel; 1 for a person's viewer, which shows the
/// desktop at full size). `inline` puts typed text inside the script quoted (a batch runs its
/// steps in one exec with one stdin, so steps can't ride on it).
pub fn box_script(name: &str, v: &Value, scale: (f64, f64)) -> Result<(String, String), String> {
// A control's number from the latest picture: its centre on the desktop.
// (the point to click, what it is) or why there is none.
type Found = Result<((i64, i64), String), String>;
let by_number = |p: &Value| -> Option<Found> {
let r = match &p["ref"] {
Value::String(r) if !r.trim().is_empty() => r.trim().to_string(),
Value::Number(n) => n.to_string(),
_ => return None,
};
let spots = BOX_SPOTS.lock().unwrap_or_else(|e| e.into_inner()).clone();
Some(spots.ok_or_else(|| "先用 computer_use_screenshot 看一下畫面,再照圖上的編號操作。".to_string()).and_then(|s| s.get(&r)).map(|((x, y), what)| ((x.round() as i64, y.round() as i64), format!("#{}({what})", crate::marks::number_of(&r).unwrap_or(0)))))
};
let pt = |p: &Value| -> Result<(i64, i64), String> {
if let Some(r) = by_number(p) {
return r.map(|(xy, _)| xy);
}
let (Some(x), Some(y)) = (p["x"].as_f64(), p["y"].as_f64()) else { return Err("請給編號 ref(截圖上的數字),或 x 和 y(截圖上的位置)".into()) };
Ok(((x * scale.0).round() as i64, (y * scale.1).round() as i64))
};
let at = |p: &Value| match by_number(p) {
Some(Ok((_, what))) => what,
_ => format!("({}, {})", p["x"].as_f64().unwrap_or(0.0), p["y"].as_f64().unwrap_or(0.0)),
};
let mods = mods_of(v);
let down: String = mods.iter().map(|m| format!(" keydown {m}")).collect();
let up: String = mods.iter().rev().map(|m| format!(" keyup {m}")).collect();
// Never `mousemove --sync`: it waits forever when the pointer is already there (a second
// click on the same button hung the box for two minutes).
let mv = |(x, y): (i64, i64)| format!("mousemove {x} {y} sleep 0.03");
let wrap = |body: String| -> String {
if mods.is_empty() {
format!("xdotool {body}")
} else {
// Keys held for the step are let go even when the step fails (a stuck ctrl spoils
// every later click and keystroke in the box).
format!("xdotool{down} {body}; rc=$?; xdotool{up}; [ $rc = 0 ]")
}
};
let button = button_no(v["button"].as_str().unwrap_or("left"));
Ok(match name {
"computer_use_click" => {
let n = v["count"].as_u64().unwrap_or(1).clamp(1, 3);
(wrap(format!("{} click --repeat {n} --delay {CLICK_REPEAT_MS} {button}", mv(pt(v)?))), format!("已{}{} {}。", if button == "3" { "右鍵" } else { "" }, if n >= 2 { format!("連點 {n} 下") } else { "點".into() }, at(v)))
}
"computer_use_mouse_move" => (format!("xdotool {}", mv(pt(v)?)), format!("已把滑鼠移到 {}。", at(v))),
"computer_use_scroll" => {
let amount = crate::computer::scroll_amount(v["amount"].as_i64());
let dir = v["direction"].as_str().unwrap_or("").trim().to_lowercase();
let wheel = match dir.as_str() {
"up" => "4",
"down" => "5",
"left" => "6",
"right" => "7",
_ => return Err(format!("方向「{dir}」要是 up、down、left 或 right")),
};
let go = if v["x"].is_null() && v["ref"].is_null() { String::new() } else { format!("{} ", mv(pt(v)?)) };
(wrap(format!("{go}click --repeat {amount} --delay 20 {wheel}")), format!("已捲動 {dir} ×{amount}。"))
}
"computer_use_drag" => {
// A point that fails to parse is an error, not a silently dropped waypoint.
let raw = v["path"].as_array().ok_or("拖曳要給 path:一個點陣,每個點有數字 x 和 y(用截圖上的座標)")?;
let path: Vec<(i64, i64)> = raw.iter().map(pt).collect::<Result<_, _>>()?;
if path.len() < 2 {
return Err("拖曳至少要兩個點(每個點要有數字 x 和 y,用截圖上的座標)".into());
}
let mut body = format!("{} mousedown {button} sleep 0.04", mv(path[0]));
let steps: Vec<(f64, f64)> = crate::computer::drag_points(&path.iter().map(|(x, y)| (*x as f64, *y as f64)).collect::<Vec<_>>());
for (x, y) in steps {
body.push_str(&format!(" mousemove {} {} sleep 0.008", x.round() as i64, y.round() as i64));
}
body.push_str(&format!(" mouseup {button}"));
(wrap(body), format!("已從 {} 拖曳到 {}。", at(&raw[0]), at(&raw[raw.len() - 1])))
}
"computer_use_typing" => {
let text = v["value"].as_str().unwrap_or("").to_string();
let submit = v["submit"].as_bool().unwrap_or(false);
if text.is_empty() && !submit {
return Err("請給要打的字".into());
}
let mut script = String::new();
// Into a field by its number: click it first, like a person would. A one-line field
// (an address bar, a search box) gets what it holds selected first, so the text
// replaces it: typed into the middle of 「about:blank」 the address became garbage.
// ("append": true keeps what is there.)
let field = match by_number(v) {
Some(r) => {
let ((x, y), what) = r?;
script.push_str(&format!("xdotool mousemove {x} {y} sleep 0.03 click 1 sleep 0.15"));
if replaces_field(&what, v) {
script.push_str(" && xdotool key --clearmodifiers ctrl+a sleep 0.05");
}
format!("在 {what}")
}
None => String::new(),
};
let one_line = by_number(v).and_then(|r| r.ok()).is_some_and(|(_, what)| replaces_field(&what, v));
if !text.is_empty() {
script.push_str(&format!(" && {}", type_script_as(&text, one_line)));
}
if submit {
script.push_str(&format!(" && sleep {} && xdotool key --clearmodifiers Return", if one_line { "0.2" } else { "0.06" }));
}
let script = script.trim_start_matches(" && ").to_string();
(script, format!("已打字({} 個字){field}{}。", text.chars().count(), if submit { ",再按了 return" } else { "" }))
}
"computer_use_press_key" => {
let keys = v["key"].as_str().unwrap_or("");
let hold = v["hold_duration_ms"].as_u64().unwrap_or(0).min(5_000);
let mut parts: Vec<String> = vec![];
for k in keys.split_whitespace() {
match combo_to_x(k) {
Ok(c) if hold > 0 => parts.push(format!("keydown {c} sleep {:.3} keyup {c}", hold as f64 / 1000.0)),
Ok(c) => parts.push(format!("key --clearmodifiers {c}")),
// 「@」「?」: a character, typed.
// `--args 1`: a plain `type` swallows every word after it as more text, so
// 「@ Return」 typed "@sleep0.02key…" and never pressed Return.
Err(_) if k.chars().count() == 1 => parts.push(format!("type --clearmodifiers --args 1 -- {}", crate::tools::sh_quote(k))),
Err(e) => return Err(e),
}
}
if parts.is_empty() {
return Err("請給要按的鍵".into());
}
(format!("xdotool {}", parts.join(" sleep 0.02 ")), format!("已按 {keys}。"))
}
other => return Err(format!("沒有「{other}」這個工具")),
})
}
/// Typing, as a script reading the text on stdin. Plain ASCII goes key by key; anything else
/// (Chinese, emoji) is pasted, because X keyboards can't type characters they have no key for in
/// every program: both selections hold it (xclip keeps serving after the exec ends, setsid), and
/// terminals paste with shift+Insert, everything else with ctrl+v.
/// Type text into the window in front: short plain text key by key; anything else (another
/// language, or long text, which key by key could outlast the step's 30 s and then get typed
/// twice when the bot tried again) pasted through the clipboard.
/// Typing into this control replaces what it holds: a one-line field (輸入框), unless the step
/// asks to add to it. A document's text area is left alone (selecting all would replace a file).
pub fn replaces_field(what: &str, v: &Value) -> bool {
// As a step names it (「#14(輸入框「Address and search bar」:…)」) or bare.
let desc = what.strip_prefix('#').and_then(|r| r.split_once('(')).map(|(_, d)| d).unwrap_or(what);
desc.starts_with("輸入框") && !v["append"].as_bool().unwrap_or(false)
}
fn type_script(text: &str) -> String {
type_script_as(text, false)
}
/// `paste`: put the text in at once (clipboard) even when it is short and plain. Into a one-line
/// field that is how it arrives whole: typed key by key into Chromium's address bar right after
/// it opened, the page load and the autocomplete took turns with the keys and the address came
/// out mangled (colons gone, the old text mixed in).
fn type_script_as(text: &str, paste: bool) -> String {
let q = crate::tools::sh_quote(text);
if !paste && text.is_ascii() && text.chars().count() <= TYPE_KEYS_MAX_CHARS {
return format!("xdotool type --clearmodifiers --delay {TYPE_DELAY_MS} -- {q}");
}
format!("printf '%s' {q} > /tmp/anybot-paste.txt && for s in clipboard primary; do setsid xclip -selection $s -i /tmp/anybot-paste.txt </dev/null >/dev/null 2>&1 & done; sleep 0.15; \
case \"$(xprop -id $(xdotool getactivewindow) WM_CLASS 2>/dev/null | tr A-Z a-z)\" in *term*) k=shift+Insert;; *) k=ctrl+v;; esac; xdotool key --clearmodifiers $k")
}
/// Longest text typed key by key (more is pasted).
const TYPE_KEYS_MAX_CHARS: usize = 200;
/// Wait for the screen to stop changing before the picture comes back: two matching probes mean
/// it settled. A probe is two small grayscale captures (top-left, center: ~60 ms on the box), so
/// a settled screen takes ~250 ms where every step used to pay a fixed 0.5 s — and a page that
/// is still loading is waited for (up to ~0.9 s) instead of captured mid-change. Plain sleep
/// when `import` is missing.
const SETTLE_SH: &str = "{ probe() { { import -window root -crop 160x140+80+30 -depth 8 pgm:-; import -window root -crop 160x160+560+320 -depth 8 pgm:-; } 2>/dev/null | md5sum; }; if command -v import >/dev/null 2>&1; then a=$(probe); i=0; while [ $i -lt 5 ]; do sleep 0.1; b=$(probe); [ \"$a\" = \"$b\" ] && break; a=$b; i=$((i+1)); done; else sleep 0.35; fi; }";
/// Run a script in the box (as the bot user, on its desktop). Gives back what it printed.
fn box_exec(script: &str) -> Result<String, String> {
// An stdin closed at once, as `docker exec -i` expects.
match dk_with_input(&["exec", "-i", "-u", "bot", "-e", "DISPLAY=:1", container(), "sh", "-c", script], Some(b""), 30_000) {
Ok(o) => Ok(String::from_utf8_lossy(&o).to_string()),
Err(e) if e == "逾時" => Err("這台電腦太久沒有回應".into()),
Err(e) if e.is_empty() => Err("這一步沒有成功".into()),
Err(e) => Err(format!("xdotool:{e}")),
}
}
// ---------- files for the box's browser ----------
// The brain drives the box's Chromium from outside, but that browser reads and writes the box's
// own disk: an upload, a download or a PDF has to be looked for there, not on the brain (a
// cloud brain once told the bot an attachment sitting in the box "isn't on this computer", and
// the bot spent 24 steps getting one picture into a post).
/// Where the box's browser keeps downloads and PDFs (the bot reads them there).
pub const BOX_DOWNLOADS: &str = "/home/bot/Downloads";
/// A path on Bot 的電腦 as written by the bot: `~` is the bot user's home.
pub fn box_path(p: &str) -> String {
match p.trim() {
"~" => "/home/bot".into(),
t => t.strip_prefix("~/").map(|rest| format!("/home/bot/{rest}")).unwrap_or_else(|| t.to_string()),
}
}
/// There is a file at `path` on Bot 的電腦.
pub fn box_has_file(path: &str) -> bool {
box_exec(&format!("[ -f {} ] && echo yes", crate::tools::sh_quote(path))).map(|o| o.trim() == "yes").unwrap_or(false)
}
/// Files in `dir` on Bot 的電腦 written at or after `since` (seconds since 1970).
pub fn box_new_files(dir: &str, since: u64) -> Vec<String> {
let script = format!("find {} -maxdepth 1 -type f -newermt @{since} 2>/dev/null; true", crate::tools::sh_quote(dir));
box_exec(&script).map(|o| o.lines().map(str::trim).filter(|l| !l.is_empty()).map(String::from).collect()).unwrap_or_default()
}
/// Make a folder on Bot 的電腦 (as the bot user).
pub fn box_mkdir(dir: &str) -> Result<(), String> {
box_exec(&format!("mkdir -p {}", crate::tools::sh_quote(dir))).map(|_| ())
}
/// Write a file on Bot 的電腦 (as the bot user), making its folder first.
pub fn box_write(path: &str, bytes: &[u8]) -> Result<(), String> {
let dir = std::path::Path::new(path).parent().map(|d| d.display().to_string()).unwrap_or_else(|| "/home/bot".into());
let script = format!("mkdir -p {} && cat > {}", crate::tools::sh_quote(&dir), crate::tools::sh_quote(path));
dk_with_input(&["exec", "-i", "-u", "bot", container(), "sh", "-c", &script], Some(bytes), 60_000).map(|_| ()).map_err(|e| format!("存不進 Bot 的電腦:{e}"))
}
/// The numbered controls of the box's latest look (centres in desktop pixels).
static BOX_SPOTS: Mutex<Option<crate::marks::Spots>> = Mutex::new(None);
/// The latest look's fingerprint (to say when a step changed nothing).
static BOX_LAST: Mutex<Option<u64>> = Mutex::new(None);
/// AT-SPI roles as words.
fn role_words(role: &str) -> String {
match role {
"push button" | "toggle button" => "按鈕",
"link" => "連結",
"entry" => "輸入框",
"password text" => "密碼欄",
"check box" => "勾選框",
"radio button" => "選項",
"menu item" | "check menu item" | "radio menu item" => "選單項目",
"menu" => "選單",
"page tab" | "tab" => "分頁",
"list item" | "tree item" => "項目",
"combo box" => "下拉選單",
"spin button" => "數字欄",
"slider" => "滑桿",
"heading" => "標題",
"image" | "icon" => "圖片",
"table cell" => "格子",
"column header" | "row header" => "欄名",
"dialog" | "alert" => "對話框",
"text" | "paragraph" | "section" | "document web" => "文字",
_ => "文字",
}
.to_string()
}
/// The box's controls from its accessibility tree (box/a11y's JSON).
pub fn elements_of(v: &Value) -> Vec<crate::marks::Element> {
v["elements"]
.as_array()
.into_iter()
.flatten()
.map(|e| {
let role = e["role"].as_str().unwrap_or("");
let active = e["active"].as_bool().unwrap_or(false);
let kind = if active && matches!(role, "text" | "paragraph" | "section" | "document web") { "輸入框".to_string() } else { role_words(role) };
let flags = e["flags"].as_array().into_iter().flatten().filter_map(|f| match f.as_str()? {
"focused" => Some("游標在這"),
"checked" => Some("已勾"),
"selected" => Some("已選"),
"disabled" => Some("停用"),
_ => None,
}).collect();
crate::marks::Element {
kind,
name: e["name"].as_str().unwrap_or("").to_string(),
value: e["value"].as_str().unwrap_or("").to_string(),
x: e["x"].as_f64().unwrap_or(0.0),
y: e["y"].as_f64().unwrap_or(0.0),
w: e["w"].as_f64().unwrap_or(0.0),
h: e["h"].as_f64().unwrap_or(0.0),
active,
flags,
window: e["window"].as_str().unwrap_or("").to_string(),
}
})
.collect()
}
/// Do a step's script (if any), wait for the screen to settle, and look: the picture with the
/// controls numbered and the list, in one `docker exec`. After a step it also says when nothing
/// changed, so the bot doesn't click the same empty place again and again.
fn box_look(script: Option<&str>, after: Option<String>, pointer: bool) -> Result<(String, Option<String>), String> {
let t_all = Instant::now();
// The picture is captured by a background `import` while the controls are read (the a11y
// read is the slow part on web pages); a JPEG costs less to make and to ship than a PNG.
let look = "( import -window root -quality 90 jpeg:- 2>/dev/null > /tmp/anybot-look.jpg & ip=$!; \
sleep 0.18; anybot-a11y --budget 3 > /tmp/anybot-look.json 2>/dev/null || echo '{\"elements\":[]}' > /tmp/anybot-look.json; \
printf '%s\\n' \"$(xdotool getactivewindow getwindowname 2>/dev/null | tr -d '\\n')\"; \
wc -c < /tmp/anybot-look.json; cat /tmp/anybot-look.json; wait $ip; cat /tmp/anybot-look.jpg )";
let full = match script {
Some(s) => format!("{{ {s}; }} || exit $?; {SETTLE_SH}; {look}"),
None => look.to_string(),
};
let out = match dk_with_input(&["exec", "-i", "-u", "bot", "-e", "DISPLAY=:1", container(), "sh", "-c", &full], Some(b""), 30_000) {
Ok(o) => o,
Err(e) if e == "逾時" => return Err("這台電腦太久沒有回應".into()),
Err(e) if e.is_empty() => return Err("這一步沒有成功".into()),
Err(e) => return Err(format!("xdotool:{e}")),
};
let exec_ms = t_all.elapsed().as_millis() as u64;
// "<front window's title>\n<bytes of the list>\n<list JSON><JPEG>"
let first = out.iter().position(|b| *b == b'\n').ok_or("這台電腦沒有回傳畫面")?;
let front = String::from_utf8_lossy(&out[..first]).trim().to_string();
let out = &out[first + 1..];
let nl = out.iter().position(|b| *b == b'\n').ok_or("這台電腦沒有回傳畫面")?;
let n: usize = String::from_utf8_lossy(&out[..nl]).trim().parse().map_err(|_| "這台電腦回傳的畫面讀不懂")?;
let json_end = (nl + 1 + n).min(out.len());
let tree: Value = serde_json::from_slice(&out[nl + 1..json_end]).unwrap_or(Value::Null);
let img = image::load_from_memory(&out[json_end..]).map_err(|e| format!("截圖讀不出來:{e}"))?.to_rgb8();
let (w, h) = img.dimensions();
shared().lock().unwrap_or_else(|e| e.into_inner()).size = Some((w, h));
// The window in front comes first (its controls numbered first, never crowded out by a
// window left open behind it), like a person looks at what's in front of them.
let mut els = elements_of(&tree);
if !front.is_empty() {
els.sort_by_key(|e| e.window != front);
}
// Numbers kept from the last look where the same control is still there (marks::Numbering).
static NUMBERS: Mutex<Option<crate::marks::Numbering>> = Mutex::new(None);
let (numbered, lines, spots) = {
let mut m = NUMBERS.lock().unwrap_or_else(|e| e.into_inner());
crate::marks::number_keeping(&els, false, m.get_or_insert_with(Default::default))
};
*BOX_SPOTS.lock().unwrap_or_else(|e| e.into_inner()) = Some(spots);
let (fw, fh, resize) = crate::computer::fit(w, h);
let mut pic = if resize { image::imageops::resize(&img, fw, fh, image::imageops::FilterType::Triangle) } else { img };
let fp = crate::marks::fingerprint(&els) ^ crate::marks::picture_fingerprint(&pic).rotate_left(1);
crate::marks::draw(&mut pic, &numbered, (w as f64 / fw.max(1) as f64, h as f64 / fh.max(1) as f64));
let jpeg = crate::computer::encode_jpeg(&pic).ok_or("截圖太大了,沒辦法送出")?;
let mut text = after.clone().map(|n| format!("{n}\n")).unwrap_or_default();
text.push_str(&format!("螢幕截圖({fw}×{fh},Linux 桌面)。"));
let unchanged = {
let mut last = BOX_LAST.lock().unwrap_or_else(|e| e.into_inner());
let same = pointer && after.is_some() && *last == Some(fp);
*last = Some(fp);
same
};
if unchanged {
text.push_str(crate::computer::NOOP_NOTE);
}
if lines.is_empty() {
text.push_str("\n畫面上沒有可以照編號點的東西:只好照圖上的位置點 x、y。");
} else {
text.push_str(&format!("\n可以操作的東西(圖上同樣的編號,用 ref 點):\n{}", lines.join("\n")));
}
// What moved since the last look (same signal the Mac path gives the quick model).
static LAST_ELS: Mutex<Vec<crate::marks::Element>> = Mutex::new(Vec::new());
{
let cur: Vec<crate::marks::Element> = numbered.iter().map(|(_, e)| e.clone()).collect();
let mut last = LAST_ELS.lock().unwrap_or_else(|e| e.into_inner());
let d = crate::marks::diff(&last, &cur);
*last = cur;
if !d.is_empty() {
text.push_str(&format!("\n跟上一張圖相比:\n{}", d.join("\n")));
}
}
// Step timing (for the 電腦 card): how long the box's side took vs. the whole step.
{
let total_ms = t_all.elapsed().as_millis() as u64;
let mut s = STEP_TIMES.lock().unwrap_or_else(|e| e.into_inner());
s.push((exec_ms, total_ms));
let n = s.len();
if n > 40 {
s.drain(..n - 40);
}
}
Ok((text, Some(base64::engine::general_purpose::STANDARD.encode(jpeg))))
}
/// The box's latest steps, timed: (count, average docker-exec ms, average whole-step ms).
/// Whole step = exec (xdotool + settle + 控件 + 截圖) + 這裡的編號繪製和壓縮.
pub fn step_stats() -> (usize, u64, u64) {
let s = STEP_TIMES.lock().unwrap_or_else(|e| e.into_inner());
if s.is_empty() {
return (0, 0, 0);
}
let n = s.len() as u64;
(s.len(), s.iter().map(|(a, _)| *a).sum::<u64>() / n, s.iter().map(|(_, b)| *b).sum::<u64>() / n)
}
static STEP_TIMES: Mutex<Vec<(u64, u64)>> = Mutex::new(Vec::new());
/// Programs people name, as the box knows them.
fn box_program(app: &str) -> Option<&'static str> {
let a = app.trim().to_lowercase();
if ["chrome", "chromium", "google chrome", "browser", "瀏覽器", "網頁瀏覽器"].iter().any(|n| a == *n) {
return Some("chromium");
}
if ["terminal", "終端機", "xterm", "shell", "命令列"].iter().any(|n| a == *n) {
return Some("xterm");
}
None
}
/// Open a program on the box, or bring its window to the front. The browser is AnyBot's
/// browser there (the one browser_* drive), never a second copy fighting over its profile.
fn open_on_box(app: &str) -> Result<String, String> {
let name = app.trim();
if name.is_empty() {
return Err("請給要打開的程式名稱".into());
}
let prog = match box_program(name) {
Some(p) => p.to_string(),
None if name.chars().all(|c| c.is_ascii_alphanumeric() || "._-".contains(c)) => name.to_lowercase(),
None => return Err(format!("這台是 Linux 桌面,沒有「{name}」。可以打開的例如 chromium(瀏覽器)、xterm(終端機),或用指令工具安裝。")),
};
if prog == "chromium" {
browser_port()?;
}
let q = crate::tools::sh_quote(&prog);
// Front if it has a window; else start it and wait (up to 8 s) for one.
let script = format!(
"w=$(xdotool search --onlyvisible --classname {q} 2>/dev/null | tail -1); \
if [ -z \"$w\" ]; then command -v {q} >/dev/null || {{ echo \"沒有 {prog} 這個程式\" >&2; exit 3; }}; (setsid {q} >/dev/null 2>&1 &); \
for i in $(seq 1 40); do sleep 0.2; w=$(xdotool search --onlyvisible --classname {q} 2>/dev/null | tail -1); [ -n \"$w\" ] && break; done; fi; \
if [ -n \"$w\" ]; then xdotool windowactivate $w 2>/dev/null; echo shown; fi; true"
);
let out = box_exec(&script)?;
Ok(if out.contains("shown") { format!("已打開「{prog}」,放在最前面。") } else { format!("已啟動「{prog}」,但 8 秒內還沒有出現視窗(可能還在開,看下面的畫面)。") })
}
/// Run a computer_use tool on the box. Returns (text, screenshot as base64 JPEG).
/// One screen step on the box at a time: two bots acting at once shared the picture files and the
/// numbers (a torn picture, or a number resolved against the other bot's picture).
static BOX_STEP: Mutex<()> = Mutex::new(());
pub fn computer_run(name: &str, v: &Value) -> Result<(String, Option<String>), String> {
let _one = BOX_STEP.lock().unwrap_or_else(|e| e.into_inner());
// A control named instead of numbered (「按 4」): its number in the latest look.
let mut named = v.clone();
if matches!(name, "computer_use_click" | "computer_use_typing" | "computer_use_scroll" | "computer_use_mouse_move" | "computer_use_batch") {
let spots = BOX_SPOTS.lock().unwrap_or_else(|e| e.into_inner()).clone();
crate::marks::names_to_numbers(&mut named, spots.as_ref())?;
}
let v = &named;
ensure_running()?;
let _busy = Busy::new(true);
match name {
"computer_use_check_permissions" => return Ok(("這台電腦不需要另外的權限:可以截圖,也可以操作滑鼠和鍵盤。".into(), None)),
"computer_use_screenshot" => return box_look(None, None, false),
"computer_use_zoom" => {
let out = dk_with_input(&["exec", "-i", "-u", "bot", "-e", "DISPLAY=:1", container(), "sh", "-c", "import -window root png:- 2>/dev/null"], Some(b""), 30_000).map_err(|e| format!("拍不到畫面:{e}"))?;
let img = image::load_from_memory(&out).map_err(|e| format!("截圖讀不出來:{e}"))?.to_rgb8();
return crate::computer::zoom(&img, v).map(|(t, j)| (t, Some(j)));
}
"computer_use_show" => return box_show(),
_ => {}
}
if HELD_BY_PERSON.load(Ordering::SeqCst) || crate::vnc::person_active() {
return Err("使用者正在自己操作這台電腦。請等他操作完(停下幾秒後)再繼續,或先截圖看看畫面。".into());
}
if name == "computer_use_open_app" {
let note = open_on_box(v["app"].as_str().unwrap_or(""))?;
return box_look(None, Some(note), false);
}
if name == "computer_use_batch" {
return box_batch(v, box_scale()?);
}
let (script, note) = box_script(name, v, box_scale()?)?;
box_look(Some(&script), Some(note), crate::computer::is_pointer_step(name))
}
/// The box's screen as the person would see it (no numbers), at the bot's picture size.
fn box_show() -> Result<(String, Option<String>), String> {
let out = dk_with_input(&["exec", "-i", "-u", "bot", "-e", "DISPLAY=:1", container(), "sh", "-c", "import -window root -quality 90 jpeg:- 2>/dev/null"], Some(b""), 30_000).map_err(|e| format!("拍不到畫面:{e}"))?;
let img = image::load_from_memory(&out).map_err(|e| format!("截圖讀不出來:{e}"))?.to_rgb8();
let (w, h) = img.dimensions();
let (fw, fh, resize) = crate::computer::fit(w, h);
let pic = if resize { image::imageops::resize(&img, fw, fh, image::imageops::FilterType::Triangle) } else { img };
let jpeg = crate::computer::encode_jpeg(&pic).ok_or("截圖太大了,沒辦法送出")?;
Ok((format!("{}({fw}×{fh})", crate::computer::SHOWN_NOTE), Some(base64::engine::general_purpose::STANDARD.encode(&jpeg))))
}
const BATCH_MARK: &str = "/tmp/anybot-batch.done";
/// Do a planned sequence of steps on the box in one round trip: the steps' scripts chained with
/// `&&` (so the chain stops at the first failure), one marker line per step that ran, and one
/// picture at the end — like computer_batch everywhere else, and one `docker exec` for all of
/// them instead of one per step.
fn box_batch(v: &Value, scale: (f64, f64)) -> Result<(String, Option<String>), String> {
let acts = v["actions"].as_array().ok_or("computer_use_batch 要給 actions:動作陣列,每個有 action(click、typing、press_key、scroll、mouse_move、drag)和它的參數")?;
if acts.is_empty() {
return Err("actions 是空的".into());
}
if acts.len() > crate::computer::MAX_BATCH {
return Err(format!("一次最多 {} 個動作({} 個太多了)", crate::computer::MAX_BATCH, acts.len()));
}
let mut parts = Vec::new();
let mut notes = Vec::new();
for (i, a) in acts.iter().enumerate() {
let name = crate::computer::action_tool(a["action"].as_str().unwrap_or(""))?;
let (script, note) = box_script(name, a, scale)?;
parts.push(format!("{{ {script} && echo x >> {BATCH_MARK}; }}"));
notes.push(format!("{}. {note}", i + 1));
}
// The chain runs in the same call as the picture; one more call reads how far it got.
let chain = format!("rm -f {BATCH_MARK}; {} ; true", parts.join(" && "));
let (text, pic) = box_look(Some(&chain), Some(String::new()), false)?;
let done = dk(&["exec", container(), "sh", "-c", &format!("wc -l < {BATCH_MARK} 2>/dev/null || echo 0; rm -f {BATCH_MARK}")])
.ok()
.and_then(|s| s.trim().parse::<usize>().ok())
.unwrap_or(0)
.min(acts.len());
let note = if done == acts.len() {
format!("已依序做完 {done} 步:\n{}", notes.join("\n"))
} else {
let ran: Vec<&str> = notes.iter().take(done).map(String::as_str).collect();
format!("做完 {done}/{} 步,第 {} 步沒有成功(後面沒有做):\n{}", acts.len(), done + 1, ran.join("\n"))
};
Ok((format!("{note}{text}"), pic))
}
// ---------- text a person types in a viewer ----------
/// Text from a person's viewer (any language): typed or pasted into the window in front, like
/// the bot's typing. (Their mouse and keys go through VNC itself.)
pub fn viewer_text(text: &str) {
if !text.is_empty() {
let _ = box_exec(&type_script(text));
}
touch();
}
fn key_to_x(name: &str) -> Option<String> {
let n = name.trim().to_lowercase();
let s = match n.as_str() {
"return" | "enter" => "Return",
"tab" => "Tab",
"space" => "space",
"delete" | "backspace" => "BackSpace",
"forwarddelete" | "del" => "Delete",
"escape" | "esc" => "Escape",
"home" => "Home",
"end" => "End",
"pageup" => "Prior",
"pagedown" => "Next",
"up" | "arrowup" => "Up",
"down" | "arrowdown" => "Down",
"left" | "arrowleft" => "Left",
"right" | "arrowright" => "Right",
"-" | "minus" => "minus",
"=" | "equal" => "equal",
"[" => "bracketleft",
"]" => "bracketright",
";" => "semicolon",
"'" => "apostrophe",
"," | "comma" => "comma",
"." | "period" => "period",
"/" | "slash" => "slash",
"\\" | "backslash" => "backslash",
"`" | "grave" => "grave",
f if f.starts_with('f') && f[1..].parse::<u8>().map(|x| (1..=12).contains(&x)).unwrap_or(false) => return Some(f.to_uppercase()),
c if c.chars().count() == 1 && c.chars().all(|ch| ch.is_ascii_alphanumeric()) => return Some(c.to_string()),
_ => return None,
};
Some(s.into())
}
fn modifier_to_x(m: &str) -> Option<&'static str> {
// On Linux the shortcuts people mean by cmd (copy, paste, select all) use ctrl.
match m.trim().to_lowercase().as_str() {
"cmd" | "command" | "meta" | "control" | "ctrl" => Some("ctrl"),
"shift" => Some("shift"),
"option" | "alt" | "opt" => Some("alt"),
"super" | "win" | "windows" => Some("super"),
_ => None,
}
}
/// "cmd+shift+t" → "ctrl+shift+t"
pub fn combo_to_x(combo: &str) -> Result<String, String> {
let parts: Vec<&str> = combo.split('+').map(str::trim).filter(|p| !p.is_empty()).collect();
let Some((key, mods)) = parts.split_last() else { return Err("請給要按的鍵".into()) };
let mut out: Vec<String> = vec![];
for m in mods {
out.push(modifier_to_x(m).ok_or_else(|| format!("不認得「{m}」這個修飾鍵"))?.to_string());
}
out.push(key_to_x(key).ok_or_else(|| format!("不認得「{key}」這個鍵"))?);
Ok(out.join("+"))
}
fn mods_of(v: &Value) -> Vec<String> {
v["modifier_keys"].as_array().map(|a| a.iter().filter_map(|m| m.as_str().and_then(modifier_to_x)).map(String::from).collect()).unwrap_or_default()
}
fn button_no(b: &str) -> &'static str {
match b {
"right" => "3",
"middle" => "2",
_ => "1",
}
}
// ---------- the browser viewer (noVNC) ----------
fn published_port() -> u16 {
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)
}
/// The noVNC address on this Mac (through an SSH tunnel when the box is on another host).
pub fn viewer_url(control: bool) -> Result<String, String> {
ensure_running()?;
let port = published_port();
let host = docker_host();
let local_port = if let Some(target) = host.strip_prefix("ssh://") {
let mut s = shared().lock().unwrap();
let alive = s.tunnel.as_mut().map(|c| matches!(c.try_wait(), Ok(None))).unwrap_or(false);
if !alive {
let lp = free_port(HOST_PORT + 100);
let child = Command::new("ssh")
.env("PATH", tool_path())
.args(["-N", "-o", "BatchMode=yes", "-o", "ExitOnForwardFailure=yes", "-L", &format!("{lp}:127.0.0.1:{port}"), target])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("開不了 SSH 通道:{e}"))?;
s.tunnel = Some(child);
s.host_port = lp;
drop(s);
std::thread::sleep(Duration::from_millis(800));
lp
} else {
s.host_port
}
} else {
port
};
// noVNC signs in with the box's VNC password (x11vnc refuses the keyless login now).
Ok(format!("http://127.0.0.1:{local_port}/vnc.html?autoconnect=1&resize=scale&reconnect=1&show_dot=1&password={}{}", vnc_password(), if control { "" } else { "&view_only=1" }))
}
pub fn shutdown() {
if let Some(mut t) = shared().lock().unwrap().tunnel.take() {
let _ = t.kill();
}
}
#[cfg(test)]
mod tests {
use super::*;
/// 復原 reads the snapshot through before it throws the home away: one cut short, empty or
/// gone is refused while the computer is still whole.
#[test]
fn a_snapshot_is_read_through_before_the_home_goes() {
use std::io::Write;
let dir = std::env::temp_dir().join(format!("anybot-snapshot-whole-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let mut gz = flate2::write::GzEncoder::new(vec![], flate2::Compression::default());
gz.write_all(&vec![7u8; 200_000]).unwrap();
let whole = gz.finish().unwrap();
let file = dir.join("snapshot.tar.gz");
std::fs::write(&file, &whole).unwrap();
assert!(snapshot_whole(&file).is_ok());
std::fs::write(&file, &whole[..whole.len() / 2]).unwrap();
assert!(snapshot_whole(&file).unwrap_err().contains("讀不到最後"));
std::fs::write(&file, b"").unwrap();
assert!(snapshot_whole(&file).is_err());
assert!(snapshot_whole(&dir.join("none.tar.gz")).unwrap_err().contains("打不開"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn typing_replaces_a_one_line_field_however_it_is_named() {
let none = serde_json::json!({});
assert!(replaces_field("#14(輸入框「Address and search bar」:about:blank(游標在這))", &none));
assert!(replaces_field("輸入框「Name」", &none));
assert!(!replaces_field("#3(文字「Untitled」)", &none), "a text area keeps its text");
assert!(!replaces_field("#14(輸入框「Search」)", &serde_json::json!({"append": true})));
// What the box runs: the field clicked, its text selected, the address pasted in one go.
let els = vec![crate::marks::Element { kind: "輸入框".into(), name: "Address and search bar".into(), value: "about:blank".into(), x: 100.0, y: 50.0, w: 400.0, h: 30.0, active: true, flags: vec![], window: "Chromium".into() }];
let (_, _, spots) = crate::marks::number_keeping(&els, false, &mut Default::default());
*BOX_SPOTS.lock().unwrap() = Some(spots);
let (script, _) = box_script("computer_use_typing", &serde_json::json!({"ref": "1", "value": "chrome://settings/search", "submit": true}), (1.0, 1.0)).unwrap();
assert!(script.contains("click 1") && script.contains("ctrl+a") && script.contains("xclip") && script.contains("ctrl+v") && !script.contains("xdotool type"), "{script}");
}
#[test]
fn a_command_asking_for_sudo_is_recognized() {
for c in ["sudo apt-get install -y tree", "cd x && sudo make install", "ls; sudo -n true", "(sudo id)", "echo ok | sudo tee /etc/x"] {
assert!(uses_sudo(c), "{c}");
}
for c in ["echo pseudo code", "ls sudoers.d", "cat /etc/sudoers", "grep sudo_user log"] {
assert!(!uses_sudo(c), "{c}");
}
assert!(exec_wrapper("/home/bot/bot-1", "sudo apt-get install -y tree", false, "j").contains("-u root"));
assert!(exec_wrapper("/home/bot/bot-1", "ls", false, "j").contains("-u bot"));
}
#[test]
fn secret_names_follow_the_original_rule() {
assert!(valid_secret_name("CRM_API_TOKEN"));
assert!(valid_secret_name("A1"));
assert!(!valid_secret_name("crm_token"));
assert!(!valid_secret_name("1ABC"));
assert!(!valid_secret_name("_ABC"));
assert!(!valid_secret_name("AB-C"));
}
#[test]
fn shortcuts_become_x_keys() {
assert_eq!(combo_to_x("cmd+c").unwrap(), "ctrl+c");
assert_eq!(combo_to_x("cmd+shift+t").unwrap(), "ctrl+shift+t");
assert_eq!(combo_to_x("return").unwrap(), "Return");
assert_eq!(combo_to_x("option+f4").unwrap(), "alt+F4");
assert!(combo_to_x("hyper+x").is_err());
}
#[test]
fn a_step_is_one_script() {
let s = (1.25, 1.25);
// Picture pixels → desktop pixels; never `mousemove --sync` (it hangs when the pointer is
// already there).
let (sc, note) = box_script("computer_use_click", &serde_json::json!({"x": 512, "y": 320}), s).unwrap();
assert_eq!(sc, "xdotool mousemove 640 400 sleep 0.03 click --repeat 1 --delay 80 1");
assert!(note.contains("(512, 320)"));
// Keys held for a step are let go even when it fails.
let (sc, _) = box_script("computer_use_click", &serde_json::json!({"x": 8, "y": 8, "modifier_keys": ["cmd", "shift"], "count": 2}), s).unwrap();
assert!(sc.starts_with("xdotool keydown ctrl keydown shift mousemove 10 10") && sc.ends_with("rc=$?; xdotool keyup shift keyup ctrl; [ $rc = 0 ]"), "{sc}");
assert!(!sc.contains("--sync"));
// Text rides in the script, quoted; return after it when asked.
let (sc, _) = box_script("computer_use_typing", &serde_json::json!({"value": "it's a 'test'", "submit": true}), s).unwrap();
assert!(sc.starts_with(r"xdotool type --clearmodifiers --delay 12 -- 'it'\''s a '\''test'\'''") && sc.ends_with("xdotool key --clearmodifiers Return"), "{sc}");
// Another language, or long text: pasted (key by key, 2,000 letters outlasted the step).
let (sc, _) = box_script("computer_use_typing", &serde_json::json!({"value": "中文"}), s).unwrap();
assert!(sc.contains("printf '%s' '中文' > /tmp/anybot-paste.txt") && sc.contains("xclip"), "{sc}");
let (sc, _) = box_script("computer_use_typing", &serde_json::json!({"value": "a".repeat(TYPE_KEYS_MAX_CHARS + 1)}), s).unwrap();
assert!(sc.contains("xclip") && !sc.contains("xdotool type"), "{sc}");
// Keys by name, a character typed (without swallowing the keys after it), several in order.
let (sc, _) = box_script("computer_use_press_key", &serde_json::json!({"key": "slash @ cmd+a"}), s).unwrap();
assert_eq!(sc, "xdotool key --clearmodifiers slash sleep 0.02 type --clearmodifiers --args 1 -- '@' sleep 0.02 key --clearmodifiers ctrl+a");
// A drag walks its path in small steps and lets go at the end.
let (sc, _) = box_script("computer_use_drag", &serde_json::json!({"path": [{"x": 0, "y": 0}, {"x": 48, "y": 0}]}), s).unwrap();
assert!(sc.starts_with("xdotool mousemove 0 0 sleep 0.03 mousedown 1") && sc.contains("mousemove 60 0") && sc.ends_with("mouseup 1"), "{sc}");
// No app numbers on this desktop: said, not guessed.
assert!(box_script("computer_use_click", &serde_json::json!({"ref": "e3"}), s).is_err());
assert!(box_script("computer_use_scroll", &serde_json::json!({"direction": "sideways"}), s).is_err());
}
#[test]
fn batch_action_names() {
// Batch action names, and the ones a batch must not carry.
assert_eq!(crate::computer::action_tool("Click").unwrap(), "computer_use_click");
assert_eq!(crate::computer::action_tool("typing").unwrap(), "computer_use_typing");
assert!(crate::computer::action_tool("screenshot").is_err());
assert!(crate::computer::action_tool("open_app").is_err());
assert!(crate::computer::action_tool("").is_err());
}
#[test]
fn snapshot_names_carry_their_time() {
// Names sort by time, and the stamp is readable from the name (copies lose file dates).
let p = std::path::Path::new("/x/snapshot-2026-11-05-123456.tar.gz");
let t = snapshot_stamp(p).expect("stamp parses");
assert_eq!(t.format("%Y-%m-%d %H:%M:%S").to_string(), "2026-11-05 12:34:56");
let legacy = std::path::Path::new("/x/snapshot-2026-10-01-000000-legacy.tar.gz");
assert_eq!(snapshot_stamp(legacy).expect("legacy parses").format("%Y-%m-%d").to_string(), "2026-10-01");
assert!(snapshot_stamp(std::path::Path::new("/x/notes.txt")).is_none());
}
/// A box made from this image (`ANYBOT_BOX_NAME=<test box> cargo test --release -- --ignored
/// box_numbers_are_exact --nocapture`, with a scratch ANYBOT_DATA_DIR). A page like Threads'
/// (small icon links down the side): every link, field and button gets a number on the
/// picture, a click by number lands on it, typing goes into the numbered field, and clicking
/// an empty spot twice is called out.
#[test]
#[ignore]
fn box_numbers_are_exact() {
ensure_running().expect("box starts");
let cancel = AtomicBool::new(false);
let page = r##"<!doctype html><meta charset=utf-8><title>start</title><body style="margin:0;font:16px sans-serif">
<nav style="position:fixed;left:0;top:0;width:64px;height:100%;background:#f3f3f3">
<a href="#h" aria-label="Home" onclick="document.title='Home';return false" style="display:block;margin:24px 16px;width:28px;height:28px;background:#888;border-radius:6px"></a>
<a href="#p" aria-label="Profile" onclick="document.title='Profile';return false" style="display:block;margin:24px 16px;width:28px;height:28px;background:#888;border-radius:6px"></a>
</nav><main style="margin-left:96px;padding:24px"><h1>測試頁</h1>
<input id=q aria-label="搜尋" style="width:280px;font-size:16px"> <button onclick="document.title='Go:'+document.getElementById('q').value">搜尋</button></main>"##;
dk_with_input(&["exec", "-i", "-u", "bot", container(), "sh", "-c", "mkdir -p /tmp/anybot-page && cat > /tmp/anybot-page/index.html && (cd /tmp/anybot-page && setsid python3 -m http.server 8765 --bind 127.0.0.1 >/dev/null 2>&1 &) ; sleep 0.5"], Some(page.as_bytes()), 10_000).expect("page served");
let opened = crate::botbrowser::in_box(|| crate::web::run_full("browser_open", r#"{"url":"http://127.0.0.1:8765/"}"#, &cancel));
assert!(opened.1, "{}", opened.0);
std::thread::sleep(Duration::from_millis(1500));
let title = || crate::botbrowser::in_box(|| crate::botbrowser::eval_current(None, "document.title")).map(|(_, v)| v.as_str().unwrap_or("").to_string()).unwrap_or_default();
let t0 = Instant::now();
let (txt, img) = computer_run("computer_use_screenshot", &serde_json::json!({})).unwrap();
println!("look: {} ms\n{txt}", t0.elapsed().as_millis());
if let Some(b) = &img {
let _ = std::fs::write(std::env::temp_dir().join("anybot-numbers.jpg"), base64::engine::general_purpose::STANDARD.decode(b).unwrap_or_default());
}
let number = |what: &str| -> String {
txt.lines().find(|l| l.contains(what)).and_then(|l| l.trim().strip_prefix('[')).and_then(|l| l.split(']').next()).unwrap_or_else(|| panic!("no number for {what} in:\n{txt}")).to_string()
};
// The small icon link down the side, by number.
let profile = number("連結「Profile」");
computer_run("computer_use_click", &serde_json::json!({"ref": profile})).unwrap();
assert_eq!(title(), "Profile");
// Typing into the numbered field (Chinese), then the numbered button.
let field = number("輸入框「搜尋」");
computer_run("computer_use_typing", &serde_json::json!({"ref": field, "value": "貓咪 cats"})).unwrap();
let (after, _) = computer_run("computer_use_click", &serde_json::json!({"ref": number("按鈕「搜尋」")})).unwrap();
assert_eq!(title(), "Go:貓咪 cats");
assert!(!after.contains("完全一樣"), "{after}");
// An empty spot, twice: the second time it says nothing changed.
computer_run("computer_use_click", &serde_json::json!({"x": 700, "y": 520})).unwrap();
let (again, _) = computer_run("computer_use_click", &serde_json::json!({"x": 700, "y": 520})).unwrap();
assert!(again.contains("畫面和上一步完全一樣"), "{again}");
}
/// computer_use_show: a plain picture of the box's screen (no numbers) comes back for the chat.
#[test]
#[ignore]
fn box_show_is_a_plain_picture() {
ensure_running().expect("box starts");
let (text, img) = computer_run("computer_use_show", &serde_json::json!({})).unwrap();
assert!(text.contains("放進聊天"), "{text}");
let jpeg = base64::engine::general_purpose::STANDARD.decode(img.expect("a picture")).unwrap();
let pic = image::load_from_memory(&jpeg).unwrap();
println!("{text} — {}×{}, {} KB", pic.width(), pic.height(), jpeg.len() / 1024);
assert_eq!((pic.width(), pic.height()), (1024, 640));
}
/// Numbers only on what can be seen: links scrolled up under the browser's toolbar get none (or
/// their visible part's centre), and nothing is numbered under a window that covers the page
/// (xterm has no accessibility; clicking a number there used to type into the terminal).
#[test]
#[ignore]
fn box_numbers_only_on_what_is_seen() {
ensure_running().expect("box starts");
let cancel = AtomicBool::new(false);
let links: String = (1..=60).map(|i| format!("<p style='margin:0;height:40px'><a href='#{i}'>L{i}</a></p>")).collect();
let page = format!("<!doctype html><meta charset=utf-8><title>seen</title><body style='margin:0;font:16px sans-serif'>{links}</body>");
dk_with_input(&["exec", "-i", "-u", "bot", container(), "sh", "-c", "mkdir -p /tmp/anybot-seen && cat > /tmp/anybot-seen/index.html && (cd /tmp/anybot-seen && setsid python3 -m http.server 8767 --bind 127.0.0.1 >/dev/null 2>&1 &) ; sleep 0.5"], Some(page.as_bytes()), 10_000).expect("page served");
let opened = crate::botbrowser::in_box(|| crate::web::run_full("browser_open", r#"{"url":"http://127.0.0.1:8767/"}"#, &cancel));
assert!(opened.1, "{}", opened.0);
// Scroll so a link straddles the top of the page area.
crate::botbrowser::in_box(|| crate::botbrowser::eval_current(None, "window.scrollTo(0, 1220); 1")).unwrap();
std::thread::sleep(Duration::from_millis(500));
let page_spots = || -> Vec<(f64, f64, String)> {
computer_run("computer_use_screenshot", &serde_json::json!({})).unwrap();
let spots = BOX_SPOTS.lock().unwrap().clone().unwrap();
spots.by_number.values().filter(|(_, w)| w.contains("「L")).map(|((x, y), w)| (*x, *y, w.clone())).collect()
};
let top = crate::botbrowser::in_box(|| crate::botbrowser::eval_current(None, "window.outerHeight - window.innerHeight")).map(|(_, v)| v.as_f64().unwrap_or(87.0)).unwrap_or(87.0);
let seen = page_spots();
assert!(!seen.is_empty());
for (x, y, w) in &seen {
assert!(*y >= top && *y < 800.0, "{w} numbered at ({x}, {y}), above the page area (top {top})");
}
assert!(!seen.iter().any(|(_, _, w)| w.contains("「L1」")), "L1 is scrolled away: {seen:?}");
// A terminal over the page: nothing numbered under it.
// (Found by its process: the shell inside renames the window.)
let geo = box_exec("setsid xterm -geometry 60x20+0+300 >/dev/null 2>&1 & echo $! > /tmp/anybot-seen.pid; sleep 1.2; w=$(xdotool search --pid $(cat /tmp/anybot-seen.pid) | head -1); xdotool getwindowgeometry --shell $w | sed -n 's/^[A-Z]*=//p' | sed -n '2,5p' | tr '\\n' ' '").unwrap();
let g: Vec<f64> = geo.split_whitespace().filter_map(|v| v.parse().ok()).collect();
assert_eq!(g.len(), 4, "{geo}");
let covered = page_spots();
let _ = box_exec("kill $(cat /tmp/anybot-seen.pid); rm -f /tmp/anybot-seen.pid; pkill -f 'http[.]server 8767'; rm -rf /tmp/anybot-seen");
for (x, y, w) in &covered {
assert!(!(*x >= g[0] && *x < g[0] + g[2] && *y >= g[1] && *y < g[1] + g[3]), "{w} numbered at ({x}, {y}) under the terminal {g:?}");
}
assert!(covered.len() < seen.len(), "some links are under the terminal: {} vs {}", covered.len(), seen.len());
println!("page links numbered: {} in view, {} with a terminal over the page (top of page area {top})", seen.len(), covered.len());
}
/// Uses the real Docker: `cargo test --release -- --ignored box_screen_is_exact --nocapture`.
/// The bot's clicks land where its picture says, the same spot twice doesn't hang, typing (ASCII, Chinese, a key by name) reaches the app,
/// and every step comes back with the screen after it.
#[test]
#[ignore]
fn box_screen_is_exact() {
ensure_running().expect("box starts");
let pointer = || {
let out = dk(&["exec", "-u", "bot", "-e", "DISPLAY=:1", container(), "xdotool", "getmouselocation"]).unwrap();
let num = |k: &str| out.split_whitespace().find_map(|w| w.strip_prefix(&format!("{k}:")).and_then(|v| v.parse::<i64>().ok())).unwrap();
(num("x"), num("y"))
};
let (w, h) = box_size().unwrap();
let (fw, fh, _) = crate::computer::fit(w, h);
println!("desktop {w}×{h}, picture {fw}×{fh}");
// A move, in the bot's picture pixels, with no screenshot taken first.
shared().lock().unwrap().size = None;
let (txt, img) = computer_run("computer_use_mouse_move", &serde_json::json!({"x": fw / 2, "y": fh / 2})).unwrap();
assert!(img.is_some(), "a picture comes back: {txt}");
assert_eq!(pointer(), ((w / 2) as i64, (h / 2) as i64));
// The same spot twice: quick, not a two-minute hang.
let t = Instant::now();
computer_run("computer_use_click", &serde_json::json!({"x": 40, "y": 40})).unwrap();
computer_run("computer_use_click", &serde_json::json!({"x": 40, "y": 40})).unwrap();
println!("two clicks with pictures: {} ms", t.elapsed().as_millis());
assert!(t.elapsed() < Duration::from_secs(6));
assert_eq!(pointer(), ((40.0 * w as f64 / fw as f64).round() as i64, (40.0 * h as f64 / fh as f64).round() as i64));
// Typing reaches the app in front: a fresh terminal, ASCII then Chinese, a key by name, return.
let _ = dk(&["exec", "-u", "bot", container(), "rm", "-f", "/home/bot/exact.txt"]);
let (txt, img) = computer_run("computer_use_open_app", &serde_json::json!({"app": "終端機"})).unwrap();
println!("{txt}");
assert!(img.is_some());
computer_run("computer_use_typing", &serde_json::json!({"value": "echo 'a-b' "})).unwrap();
computer_run("computer_use_typing", &serde_json::json!({"value": "中文"})).unwrap();
computer_run("computer_use_press_key", &serde_json::json!({"key": "space slash"})).unwrap();
let (txt, img) = computer_run("computer_use_typing", &serde_json::json!({"value": " > /home/bot/exact.txt", "submit": true})).unwrap();
assert!(img.is_some() && txt.contains("return"));
std::thread::sleep(Duration::from_millis(500));
let out = dk(&["exec", "-u", "bot", container(), "cat", "/home/bot/exact.txt"]).unwrap_or_default();
println!("typed: {out}");
assert_eq!(out.trim(), "a-b 中文 /");
let _ = box_exec("xdotool key --clearmodifiers ctrl+d");
}
/// Uses the real Docker: `cargo test --release -- --ignored box_batch_in_one_round_trip --nocapture`.
/// A planned sequence runs in one round trip: ASCII and Chinese typing and a submit land in the
/// front app, the note lists every step, and one picture comes back.
#[test]
#[ignore]
fn box_batch_in_one_round_trip() {
ensure_running().expect("box starts");
let _ = dk(&["exec", "-u", "bot", container(), "rm", "-f", "/home/bot/batch.txt"]);
let (txt, img) = computer_run("computer_use_open_app", &serde_json::json!({"app": "終端機"})).unwrap();
assert!(img.is_some(), "{txt}");
let t = Instant::now();
let (txt, img) = computer_run(
"computer_use_batch",
&serde_json::json!({"actions": [
{"action": "typing", "value": "echo batch-"},
{"action": "typing", "value": "中文"},
{"action": "typing", "value": "ok > /home/bot/batch.txt", "submit": true}
]}),
)
.unwrap();
println!("batch of 3: {} ms\n{txt}", t.elapsed().as_millis());
assert!(img.is_some() && txt.contains("已依序做完 3 步"), "{txt}");
std::thread::sleep(Duration::from_millis(600));
let out = dk(&["exec", "-u", "bot", container(), "cat", "/home/bot/batch.txt"]).unwrap_or_default();
println!("typed: {out}");
assert_eq!(out.trim(), "batch-中文ok");
// Too many, empty: said before anything runs.
assert!(computer_run("computer_use_batch", &serde_json::json!({"actions": []})).is_err());
let many: Vec<_> = (0..9).map(|_| serde_json::json!({"action": "press_key", "key": "f15"})).collect();
assert!(computer_run("computer_use_batch", &serde_json::json!({"actions": many})).is_err());
let _ = box_exec("xdotool key --clearmodifiers ctrl+d");
}
/// Uses the real Docker: `cargo test --release -- --ignored box_real_usage_drill --nocapture`.
/// The whole screen path, drilled on a page that reports what really happened: a form filled
/// and sent by one batch, a bare-x/y click and a drag landing on a canvas by the page's own
/// record, scrolling revealing a field that is then clicked and typed by number. Timed.
#[test]
#[ignore]
fn box_real_usage_drill() {
ensure_running().expect("box starts");
let cancel = AtomicBool::new(false);
let page = r##"<!doctype html><meta charset=utf-8><title>drill</title><body style="margin:0;font:16px sans-serif"><div style="padding:20px"><input id=q1 aria-label="Name" style="width:200px;font-size:16px"> <input id=q2 aria-label="Email" style="width:220px;font-size:16px"> <label>Agree <input type=checkbox id=ck></label> <button id=go onclick="document.getElementById('res').textContent='SUB:'+q1.value+':'+q2.value+':'+ck.checked">Send</button><div id=res>idle</div><canvas id=cv width=400 height=100 style="border:1px solid #777;display:block;margin-top:12px"></canvas><div id=dragx></div><div style="height:1200px">keep scrolling down</div><input id=q3 aria-label="Bottom field" style="width:200px;font-size:16px"></div><script>cv.addEventListener('click',e=>{document.title='cv:'+Math.round(e.offsetX)+','+Math.round(e.offsetY)});let down=false;cv.addEventListener('mousedown',()=>{down=true;dragx.textContent=''});window.addEventListener('mousemove',e=>{if(down)dragx.textContent=String(Math.round(e.clientX))});window.addEventListener('mouseup',()=>{down=false});</script>"##;
dk_with_input(&["exec", "-i", "-u", "bot", container(), "sh", "-c", "mkdir -p /tmp/anybot-page && cat > /tmp/anybot-page/index.html && (cd /tmp/anybot-page && setsid python3 -m http.server 8765 --bind 127.0.0.1 >/dev/null 2>&1 &) ; sleep 0.5"], Some(page.as_bytes()), 10_000).expect("page served");
// A clean slate: every earlier drill left its page (and its state) in a tab. Closing all
// pages closes the window too; browser_open starts the browser again with our page.
let close_pages = "python3 -c \"import json,urllib.request\nfor t in json.load(urllib.request.urlopen('http://127.0.0.1:9222/json/list')):\n if t.get('type')=='page':\n try: urllib.request.urlopen('http://127.0.0.1:9222/json/close/'+t['id'])\n except Exception: pass\"";
let _ = dk(&["exec", "-u", "bot", container(), "sh", "-c", close_pages]);
std::thread::sleep(Duration::from_millis(600));
let opened = crate::botbrowser::in_box(|| crate::web::run_full("browser_open", r#"{"url":"http://127.0.0.1:8765/"}"#, &cancel));
assert!(opened.1, "{}", opened.0);
std::thread::sleep(Duration::from_millis(1500));
let js = |e: &str| crate::botbrowser::in_box(|| crate::botbrowser::eval_current(None, e)).map(|(_, v)| v.as_str().unwrap_or("").to_string()).unwrap_or_default();
assert_eq!(js("document.title"), "drill", "the drill page is up");
let number = |txt: &str, what: &str| -> String {
txt.lines().find(|l| l.starts_with('[') && l.contains(what)).and_then(|l| l.trim().strip_prefix('[')).and_then(|l| l.split(']').next()).unwrap_or_else(|| panic!("no number for {what} in:\n{txt}")).to_string()
};
let mut times: Vec<(String, u64)> = vec![];
macro_rules! step {
($name:expr, $tool:expr, $args:expr) => {{
let t0 = Instant::now();
let r = computer_run($tool, &$args).unwrap();
times.push((format!("{} ({})", $name, $tool), t0.elapsed().as_millis() as u64));
r
}};
}
// 1. One look: the numbered picture.
let (txt, img) = step!("look", "computer_use_screenshot", serde_json::json!({}));
if let Some(b) = &img {
let _ = std::fs::write(std::env::temp_dir().join("anybot-drill-1.jpg"), base64::engine::general_purpose::STANDARD.decode(b).unwrap_or_default());
}
// 2. The form, all six steps in ONE batch call: clicks by number, Chinese and ASCII
// typing, and the Send button — the page says what arrived.
let batch = serde_json::json!({"actions": [
{"action": "click", "ref": number(&txt, "Name")},
{"action": "typing", "value": "王小明"},
{"action": "click", "ref": number(&txt, "Email")},
{"action": "typing", "value": "a@b.c"},
{"action": "click", "ref": number(&txt, "Agree")},
{"action": "click", "ref": number(&txt, "Send")}
]});
let (btxt, bimg) = step!("fill+send form", "computer_use_batch", batch);
assert!(btxt.contains("已依序做完 6 步"), "{btxt}");
assert_eq!(js("document.getElementById('res').textContent"), "SUB:王小明:a@b.c:true", "the batch really filled and sent the form");
if let Some(b) = &bimg {
let _ = std::fs::write(std::env::temp_dir().join("anybot-drill-2.jpg"), base64::engine::general_purpose::STANDARD.decode(b).unwrap_or_default());
}
// 3. A click at bare x,y (no number on a canvas): the canvas records where it landed.
// The page knows CSS pixels; the pointer lives on the desktop — add the window's own
// place and toolbar height (what the page's viewport sits under).
let geo: Value = serde_json::from_str(&js("JSON.stringify({sx:screenX,sy:screenY,ow:outerWidth,oh:outerHeight,iw:innerWidth,ih:innerHeight})")).unwrap();
println!("window: {geo}");
let off_x = geo["sx"].as_f64().unwrap() + (geo["ow"].as_f64().unwrap() - geo["iw"].as_f64().unwrap()) / 2.0;
let off_y = geo["sy"].as_f64().unwrap() + (geo["oh"].as_f64().unwrap() - geo["ih"].as_f64().unwrap()) - (geo["ow"].as_f64().unwrap() - geo["iw"].as_f64().unwrap()) / 2.0;
let rect: Value = serde_json::from_str(&js("JSON.stringify(document.getElementById('cv').getBoundingClientRect())")).unwrap();
let (sx, sy) = box_scale().unwrap();
let (cx, cy) = ((off_x + rect["left"].as_f64().unwrap() + 200.0) / sx, (off_y + rect["top"].as_f64().unwrap() + 50.0) / sy);
println!("rect: {rect} off=({off_x},{off_y}) scale=({sx},{sy}) picture=({cx},{cy})");
step!("canvas dot at (200,50)", "computer_use_click", serde_json::json!({"x": cx.round() as i64, "y": cy.round() as i64}));
let title = js("document.title");
println!("canvas saw: {title}");
let mut xy = title.trim_start_matches("cv:").split(',').map(|s| s.parse::<f64>().unwrap());
let (got_x, got_y) = (xy.next().unwrap(), xy.next().unwrap());
assert!((got_x - 200.0).abs() <= 15.0 && (got_y - 50.0).abs() <= 15.0, "canvas click off: {got_x},{got_y}");
// 4. A drag across the canvas: the page records the last pointer position it felt.
let y = (off_y + rect["top"].as_f64().unwrap() + 50.0) / sy;
let (x1, x2) = ((off_x + rect["left"].as_f64().unwrap() + 20.0) / sx, (off_x + rect["left"].as_f64().unwrap() + 320.0) / sx);
step!("drag 20→320", "computer_use_drag", serde_json::json!({"path": [{"x": x1.round() as i64, "y": y.round() as i64}, {"x": x2.round() as i64, "y": y.round() as i64}]}));
let dragx = js("document.getElementById('dragx').textContent").parse::<f64>().unwrap_or(-1.0);
println!("drag ended at clientX {dragx} (wanted ~{})", rect["left"].as_f64().unwrap() + 320.0);
assert!((dragx - (rect["left"].as_f64().unwrap() + 320.0)).abs() <= 20.0, "drag ended at {dragx}");
// 5. Scrolling reveals the field at the bottom; it gets a number, and clicking that
// number focuses it for typing.
let (stxt, _) = step!("scroll down", "computer_use_scroll", serde_json::json!({"direction": "down", "amount": 10}));
let scroll_y: f64 = js("String(window.scrollY)").parse().unwrap_or(0.0);
assert!(scroll_y > 100.0, "the page scrolled: {scroll_y}");
let (ctxt, _) = step!("bottom click", "computer_use_click", serde_json::json!({"ref": number(&stxt, "Bottom field")}));
step!("bottom type", "computer_use_typing", serde_json::json!({"ref": number(&ctxt, "Bottom field"), "value": "end"}));
assert_eq!(js("document.getElementById('q3').value"), "end", "the field at the bottom got the typing");
// 6. Same spot twice: the second says so, in words.
let (again, _) = computer_run("computer_use_click", &serde_json::json!({"x": 700, "y": 550})).unwrap();
let (again2, _) = computer_run("computer_use_click", &serde_json::json!({"x": 700, "y": 550})).unwrap();
println!("twice at (700,550): first「{}」second「{}」", again.lines().next().unwrap_or(""), again2.lines().find(|l| l.contains("完全一樣")).unwrap_or("(said nothing)"));
println!("\n--- step times ---");
for (n, ms) in &times {
println!("{ms:>6} ms {n}");
}
println!("total of {} timed steps: {} ms", times.len(), times.iter().map(|(_, m)| m).sum::<u64>());
}
/// Uses the real Docker: `cargo test --release -- --ignored box_end_to_end --nocapture`.
#[test]
#[ignore]
fn box_end_to_end() {
use crate::tools::{run_on, Place, RunIds, Target};
let cancel = AtomicBool::new(false);
let ids = RunIds::default();
ensure_running().expect("box starts");
assert!(run_state().is_running());
let t = Target { name: "Bot 的電腦".into(), place: Place::Box { dir: bot_dir(999) } };
let (out, ok) = run_on("shell", r#"{"command":"pwd && whoami && echo $DISPLAY"}"#, &t, &cancel, &ids);
println!("shell: {out}");
assert!(ok && out.contains("/home/bot/bot-999") && out.contains("bot") && out.contains(":1"));
let (out, ok) = run_on("edit_file", r#"{"path":"notes/hello.txt","old_text":"","new_text":"第一行\nhello world\n"}"#, &t, &cancel, &ids);
assert!(ok, "{out}");
let (out, ok) = run_on("edit_file", r#"{"path":"notes/hello.txt","old_text":"hello world","new_text":"你好 世界"}"#, &t, &cancel, &ids);
assert!(ok, "{out}");
let (out, ok) = run_on("read_file", r#"{"path":"notes/hello.txt"}"#, &t, &cancel, &ids);
println!("read: {out}");
assert!(ok && out.contains("你好 世界") && !out.contains("hello world"));
let (out, ok) = run_on("search", r#"{"pattern":"世界","path":"."}"#, &t, &cancel, &ids);
assert!(ok && out.contains("hello.txt"), "{out}");
// secrets reach commands, never shown by the app
set_secret("ANYBOT_TEST_TOKEN", "s3cret-value").unwrap();
let (out, ok) = run_on("shell", r#"{"command":"test \"$ANYBOT_TEST_TOKEN\" = s3cret-value && echo has-secret"}"#, &t, &cancel, &ids);
remove_secret("ANYBOT_TEST_TOKEN");
assert!(ok && out.contains("has-secret"), "{out}");
// background command takes input
let (out, ok) = run_on("shell", r#"{"command":"read -p 'name? ' n; echo got:$n","timeout_ms":1500}"#, &t, &cancel, &ids);
assert!(ok, "{out}");
let id: u64 = out.split('#').nth(1).and_then(|s| s.split(|c: char| !c.is_ascii_digit()).next()).and_then(|s| s.parse().ok()).expect("terminal id");
let (out, ok) = run_on("write_terminal", &format!(r#"{{"id":{id},"input":"daniel\n"}}"#), &t, &cancel, &ids);
println!("write_terminal: {out}");
assert!(ok && out.contains("got:daniel"), "{out}");
// screen
let (txt, img) = computer_run("computer_use_screenshot", &serde_json::json!({})).unwrap();
println!("{txt}");
assert!(img.is_some());
run_on("shell", r#"{"command":"(xterm >/dev/null 2>&1 &) ; sleep 1.5"}"#, &t, &cancel, &ids);
// Click the middle of that window, wherever it opened (other windows — a browser left
// open — may cover the corner), in the screenshot's pixels. (Its shell renames it, so it
// is found by its class: the newest xterm.)
let (geo, _) = run_on("shell", r#"{"command":"W=$(DISPLAY=:1 xdotool search --onlyvisible --class xterm | tail -1); DISPLAY=:1 xdotool getwindowgeometry --shell $W | tail -n +2 | head -4 | tr '\n' ' '"}"#, &t, &cancel, &ids);
let num = |k: &str| geo.split_whitespace().find_map(|w| w.strip_prefix(&format!("{k}=")).and_then(|v| v.parse::<f64>().ok())).unwrap_or(0.0);
let (cx, cy) = (num("X") + num("WIDTH") / 2.0, num("Y") + num("HEIGHT") / 2.0);
let k = 1024.0 / 1280.0;
computer_run("computer_use_click", &serde_json::json!({"x": cx * k, "y": cy * k})).unwrap();
computer_run("computer_use_typing", &serde_json::json!({"value": "echo 中文輸入OK > /home/bot/typed.txt"})).unwrap();
computer_run("computer_use_press_key", &serde_json::json!({"key": "return"})).unwrap();
std::thread::sleep(Duration::from_millis(800));
let (out, _) = run_on("shell", r#"{"command":"cat /home/bot/typed.txt"}"#, &t, &cancel, &ids);
println!("typed: {out}");
assert!(out.contains("中文輸入OK"), "{out}");
// snapshot (with the browser and its caches in the home folder), then restore — which
// rebuilds the whole box, so only on a box meant for it (ANYBOT_TEST_RESTORE=1).
let size = save_snapshot().expect("snapshot saves even while the browser writes");
println!("snapshot: {}", human(size));
assert!(size > 0);
if std::env::var("ANYBOT_TEST_RESTORE").as_deref() != Ok("1") {
let _ = run_on("shell", r#"{"command":"rm -rf /home/bot/bot-999 /home/bot/typed.txt"}"#, &t, &cancel, &ids);
return;
}
run_on("shell", r#"{"command":"rm -rf notes"}"#, &t, &cancel, &ids);
rebuild(Rebuild::Restore).unwrap();
let start = Instant::now();
while shared().lock().unwrap().rebuilding && start.elapsed() < Duration::from_secs(180) {
std::thread::sleep(Duration::from_millis(500));
}
println!("after restore: {:?}", run_state());
let (out, ok) = run_on("read_file", r#"{"path":"notes/hello.txt"}"#, &t, &cancel, &ids);
assert!(ok && out.contains("你好 世界"), "{out}");
let _ = run_on("shell", r#"{"command":"rm -rf /home/bot/bot-999 /home/bot/typed.txt"}"#, &t, &cancel, &ids);
}
/// `cargo test --release -- --ignored box_traffic_via_mac --nocapture`
#[test]
#[ignore]
fn box_traffic_via_mac() {
use crate::tools::{run_on, Place, RunIds, Target};
let cancel = AtomicBool::new(false);
ensure_running().unwrap();
set_egress_via_mac(true).unwrap();
let before = crate::egress::CONNECTIONS.load(Ordering::SeqCst);
let t = Target { name: "Bot 的電腦".into(), place: Place::Box { dir: bot_dir(997) } };
let (out, ok) = run_on("shell", r#"{"command":"curl -sS -o /dev/null -w '%{http_code}' https://example.com"}"#, &t, &cancel, &RunIds::default());
set_egress_via_mac(false).unwrap();
println!("{out} via mac: {}", crate::egress::CONNECTIONS.load(Ordering::SeqCst) - before);
assert!(ok && out.contains("200"), "{out}");
assert!(crate::egress::CONNECTIONS.load(Ordering::SeqCst) > before);
}
/// `cargo test --release -- --ignored box_update_keeps_files --nocapture`
#[test]
#[ignore]
fn box_update_keeps_files() {
use crate::tools::{run_on, Place, RunIds, Target};
let cancel = AtomicBool::new(false);
let ids = RunIds::default();
ensure_running().unwrap();
let t = Target { name: "Bot 的電腦".into(), place: Place::Box { dir: bot_dir(998) } };
let (o, ok) = run_on("shell", r#"{"command":"echo keep-me > kept.txt && sudo apt-get update -qq >/dev/null 2>&1 && sudo apt-get install -y -qq cowsay >/dev/null 2>&1; ls /usr/games/cowsay","timeout_ms":180000}"#, &t, &cancel, &ids);
assert!(ok, "{o}");
rebuild(Rebuild::Update).unwrap();
assert!(rebuild(Rebuild::Reset).is_err(), "only one rebuild at a time");
let start = Instant::now();
while shared().lock().unwrap().rebuilding && start.elapsed() < Duration::from_secs(600) {
std::thread::sleep(Duration::from_millis(500));
}
for l in log_lines() {
println!("{l}");
}
assert!(run_state().is_running(), "{:?}", run_state());
let (out, ok) = run_on("shell", r#"{"command":"cat kept.txt; ls /usr/games/cowsay 2>&1 || echo no-cowsay; cat /etc/anybot/menu | head -1"}"#, &t, &cancel, &ids);
println!("{out}");
assert!(ok && out.contains("keep-me") && out.contains("no-cowsay") && out.contains("Bot 的電腦"));
let _ = run_on("shell", r#"{"command":"cd .. && rm -rf bot-998"}"#, &t, &cancel, &ids);
}
#[test]
fn a_docker_host_in_a_lima_vm_is_known() {
assert_eq!(lima_vm("unix:///Users/daniel/.lima/anybot-gvisor/sock/docker.sock").as_deref(), Some("anybot-gvisor"));
assert_eq!(lima_vm("unix:///var/run/docker.sock"), None);
assert_eq!(lima_vm("ssh://me@server"), None);
assert_eq!(lima_vm("unix:///Users/x/.lima/a;rm -rf/sock/docker.sock"), None);
assert_eq!(lima_vm(""), None);
}
#[test]
fn box_paths_are_the_bot_users() {
assert_eq!(box_path("~/bot-6/附件/IMG_0587.jpeg"), "/home/bot/bot-6/附件/IMG_0587.jpeg");
assert_eq!(box_path(" ~ "), "/home/bot");
assert_eq!(box_path("/tmp/a.jpg"), "/tmp/a.jpg");
}
#[test]
fn schedule_window_is_one_hour() {
let t = |s: &str| chrono::NaiveTime::parse_from_str(s, "%H:%M").unwrap();
assert!(in_schedule_window("03:00", t("03:00")));
assert!(in_schedule_window("03:00", t("03:59")));
assert!(!in_schedule_window("03:00", t("04:00")));
assert!(!in_schedule_window("03:00", t("02:59")));
assert!(in_schedule_window("23:30", t("00:10")));
assert!(!in_schedule_window("bad", t("00:10")));
// On a server (UTC), 03:00 is the person's 03:00: 19:00 UTC in Taipei.
use chrono::TimeZone;
let at = chrono::Utc.with_ymd_and_hms(2026, 11, 6, 19, 0, 0).unwrap().with_timezone(&chrono_tz::Asia::Taipei);
assert!(in_schedule_window("03:00", at.time()));
assert_eq!(wanted_version(), "9");
}
#[test]
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("/home/bot/bot-3"));
}
}