AnyBot/src/facts.rs

262 lines
13 KiB
Rust
Raw Normal View History

Bots work like someone who knows the computer: they know each machine, reach what they see, plan, and hand the planned steps to a quick model Measured on the bot's own box (8 tasks x 2, grok-4.7): 14/16 done, 120 steps, 1114 s before; 16/16, 102 steps, 574 s after (model time 1015 -> 471 s). A replayed skill: 49 s -> 15 s. Knowing the machine - facts.rs: 電腦現況 per computer (system, desktop, systemd, sudo, network, package manager, tools, apps, a fixed TZ), read once by a small read-only probe, kept 30 min, in the instructions at the start of a turn. - The box's sudo is the bot's: only what follows sudo is checked against the always-denied list (it was refused outright, while the instructions said it could sudo), and commands with sudo run as the box's root (setuid does nothing under gVisor). - Box image v8 drops ENV TZ=UTC (it overrode /etc/localtime). ANYBOT_BOX_IMAGE / ANYBOT_BOX_VOLUME give a throwaway box its own image and volume, never adopting the real one. - Mac shell sandbox: App Store app CLIs (they carry their own app sandbox) and ps/top run outside it; plain `osascript -e` too, asked the first time even in direct mode, refusing do shell script, terminals, keystrokes, file reads, ObjC and the secret folders. A command the sandbox broke says so, instead of looking like a broken program. Reaching what it sees - The Mac menu bar's status icons are numbered (each app's AXExtrasMenuBar, named from inside for the system's own), computer_use_zoom looks closer at full sharpness. - Numbers stay with their controls from look to look (they were reassigned every look, so a step's second click landed elsewhere); the box's front window is listed first; a control can be named instead of numbered (name "4"). - Web page numbering walks open shadow DOM (Chromium settings, web components). - A box on a Docker host elsewhere retries the next port when the usual one is taken (a retry without it came up with no network). Working like a person - Instructions: pick the route, know what done looks like, one route, check once, say what's stuck instead of faking it with a substitute, ask before cutting the connection. - StepPlan: the bot's model takes the first step, the step after trouble (failed, refused, repeated, nothing changed on screen, the quick model switching routes), one in four, and the final check; a quick model (Bot.step_model, auto: grok-4.20-0309-non-reasoning on xAI) does the planned steps in between, falling back to the bot's model if it can't be used. - Skills keep the steps that worked (with what each acted on, not numbers); replay_skill does them again with no model in between, finding each control again, handing back on a mismatch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uirha5F5gJt93t3yEx3XcS
2026-11-09 01:01:03 +00:00
// 電腦現況: what a person sitting at each computer would already know before starting — which
// system it is, whether it has a desktop and systemd, whether sudo works, whether the network
// is up, how to install things, which apps are there. Bots without this spent most of a job
// finding it out (or never did: they "set the timezone" through an environment variable because
// they believed sudo was broken, and tried timedatectl on a box without systemd).
//
// Read once per computer by one small read-only script, kept for half an hour, and put in the
// instructions at the start of a turn. The person's own computers are read only when the bot
// may already work on them.
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use crate::tools::{Place, Target};
/// How long a computer's facts are trusted (they change when someone installs or reconfigures).
pub const FRESH_SECS: u64 = 30 * 60;
/// Longest a turn waits for facts it doesn't have yet; late answers still fill the cache.
pub const WAIT_MS: u64 = 6_000;
/// Apps listed at most (the rest is "…").
const MAX_APPS: usize = 30;
/// Read-only, POSIX sh, the same on Linux and macOS: one `key=value` per line.
pub const PROBE: &str = r#"
k() { printf '%s=%s\n' "$1" "$2"; }
k kernel "$(uname -s)"; k arch "$(uname -m)"
if [ -r /etc/os-release ]; then . /etc/os-release; k os "$PRETTY_NAME"; elif command -v sw_vers >/dev/null 2>&1; then k os "macOS $(sw_vers -productVersion)"; fi
[ "$(uname -s)" = Linux ] && { [ -d /run/systemd/system ] && k systemd yes || k systemd no; }
if [ "$(id -u)" = 0 ]; then k sudo root; elif command -v sudo >/dev/null 2>&1; then if sudo -n true >/dev/null 2>&1; then k sudo yes; else k sudo password; fi; else k sudo none; fi
if command -v curl >/dev/null 2>&1; then curl -s -m 4 -o /dev/null https://www.google.com/generate_204 && k net yes || k net no; fi
for p in apt-get dnf yum pacman apk zypper brew; do command -v $p >/dev/null 2>&1 && k pkg $p; done
k desktop "${XDG_CURRENT_DESKTOP:-}"
[ -n "${TZ:-}" ] && k tz "$TZ"
for w in fluxbox gnome-shell plasmashell xfce4-session mate-session cinnamon sway Hyprland; do pgrep -x $w >/dev/null 2>&1 && k wm $w; done
t=""; for x in python3 node npm git docker curl wget jq ffmpeg xdotool wmctrl osascript shortcuts brew; do command -v $x >/dev/null 2>&1 && t="$t $x"; done; k tools "$t"
if [ -d /usr/share/applications ]; then k apps "$(for f in /usr/share/applications/*.desktop; do sed -n 's/^Name=//p' "$f" | head -1; done 2>/dev/null | sort -u | head -60 | tr '\n' '|')"; fi
if [ -d /Applications ]; then k apps "$(ls /Applications 2>/dev/null | sed -n 's/\.app$//p' | head -80 | tr '\n' '|')"; fi
"#;
#[derive(Default, Debug, Clone, PartialEq)]
pub struct Facts {
pub kernel: String,
pub arch: String,
pub os: String,
/// Some(true/false) on Linux; None elsewhere.
pub systemd: Option<bool>,
/// root, yes, password, none.
pub sudo: String,
pub net: Option<bool>,
pub pkg: Vec<String>,
pub desktop: String,
/// A TZ variable every program is started with (it overrides /etc/localtime).
pub tz: String,
pub wm: Vec<String>,
pub tools: Vec<String>,
pub apps: Vec<String>,
}
pub fn parse(out: &str) -> Facts {
let mut f = Facts::default();
for line in out.lines() {
let Some((k, v)) = line.split_once('=') else { continue };
let v = v.trim();
match k.trim() {
"kernel" => f.kernel = v.into(),
"arch" => f.arch = v.into(),
"os" => f.os = v.into(),
"systemd" => f.systemd = Some(v == "yes"),
"sudo" => f.sudo = v.into(),
"net" => f.net = Some(v == "yes"),
"pkg" => f.pkg.push(v.into()),
"desktop" => f.desktop = v.into(),
"tz" => f.tz = v.into(),
"wm" => f.wm.push(v.into()),
"tools" => f.tools = v.split_whitespace().map(String::from).collect(),
"apps" => f.apps.extend(v.split('|').map(str::trim).filter(|a| !a.is_empty()).map(String::from)),
_ => {}
}
}
f.apps.sort();
f.apps.dedup();
f
}
/// One line about a computer, in the words the bot is instructed in. `own_box`: the bot's own
/// box, where AnyBot runs sudo commands as root (sudo itself can't rise under gVisor).
pub fn describe(name: &str, f: &Facts, own_box: bool, mac_sandbox: bool) -> String {
let mut parts: Vec<String> = vec![];
let os = if f.os.is_empty() { f.kernel.clone() } else { f.os.clone() };
parts.push(if f.arch.is_empty() { os } else { format!("{os}({})", f.arch) });
let desk = if !f.wm.is_empty() { f.wm.join("、") } else { f.desktop.clone() };
if !desk.is_empty() {
parts.push(format!("桌面 {desk}"));
}
match f.systemd {
Some(false) => parts.push("沒有 systemd(systemctl、timedatectl、hostnamectl 都不能用:改系統設定就直接改設定檔,例如時區是 /etc/localtime 和 /etc/timezone)".into()),
Some(true) => parts.push("有 systemd".into()),
None => {}
}
let sudo = if own_box {
"可以 sudo(免密碼;AnyBot 會用 root 執行含 sudo 的指令)"
} else {
match f.sudo.as_str() {
"root" => "指令以 root 執行",
"yes" => "可以 sudo(免密碼)",
"password" if mac_sandbox => "sudo 在指令沙盒裡不能用",
"password" => "sudo 要密碼(不要嘗試,需要時請使用者自己執行)",
"none" => "沒有 sudo",
_ => "",
}
};
if !sudo.is_empty() {
parts.push(sudo.into());
}
if !f.tz.is_empty() && f.kernel != "Darwin" {
parts.push(format!("每個程式都帶著環境變數 TZ={}(容器設定的,會蓋過 /etc/localtime:改了系統時區,date 和桌面時鐘還是顯示 {};確認系統時區用 env -u TZ date,或 readlink /etc/localtime)", f.tz, f.tz));
}
match f.net {
Some(true) => parts.push("有網路".into()),
Some(false) => parts.push("連不到網路(不要嘗試下載或安裝,先告訴使用者)".into()),
None => {}
}
if let Some(p) = f.pkg.iter().find(|p| p.as_str() != "brew").or(f.pkg.first()) {
parts.push(format!("裝東西用 {p}"));
}
if !f.tools.is_empty() {
parts.push(format!("指令有 {}", f.tools.join("、")));
}
if mac_sandbox {
parts.push("指令在 AnyBot 的沙盒裡跑:ps、top、App Store 版 App 自己的指令列、單獨一條 osascript -e 會在沙盒外跑,其他碰不到祕密資料夾".into());
}
if !f.apps.is_empty() {
let more = if f.apps.len() > MAX_APPS { "…" } else { "" };
parts.push(format!("裝了的 App:{}{more}", f.apps.iter().take(MAX_APPS).cloned().collect::<Vec<_>>().join("、")));
}
format!("「{name}」:{}。", parts.join(";"))
}
fn cache() -> &'static Mutex<HashMap<String, (Instant, String)>> {
static C: std::sync::OnceLock<Mutex<HashMap<String, (Instant, String)>>> = std::sync::OnceLock::new();
C.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Which computers to read, and whether they may be read: the bot's own always; the person's
/// only when the bot may already work there (no command runs on them for this otherwise).
fn readable(t: &Target, may_use_persons: bool) -> bool {
match &t.place {
Place::Box { .. } => crate::cloudbox::run_state().is_running(),
Place::Local { .. } => true,
Place::Device { .. } | Place::Ssh { .. } => may_use_persons,
Place::Sealed { .. } => false,
}
}
/// Run the probe on one computer. The person's own Mac (this one) is read by AnyBot itself, not
/// as one of the bot's commands: nothing the bot asked runs there.
fn probe(t: &Target, via: &str) -> Result<String, String> {
match &t.place {
Place::Box { .. } => crate::cloudbox::read_facts(PROBE),
Place::Local { .. } => {
let out = std::process::Command::new("/bin/sh").arg("-c").arg(PROBE).env("PATH", crate::cloudbox::tool_path()).output().map_err(|e| e.to_string())?;
Ok(String::from_utf8_lossy(&out.stdout).to_string())
}
Place::Device { host, .. } => {
let args = serde_json::json!({"command": PROBE, "timeout_ms": 8000}).to_string();
crate::remote::run_on_device(host, "shell", &args, via).map(|v| v["text"].as_str().unwrap_or("").to_string())
}
Place::Ssh { .. } => {
let cancel = std::sync::atomic::AtomicBool::new(false);
let ids = crate::tools::RunIds { conversation: "facts".into(), request: "facts".into() };
let args = serde_json::json!({"command": PROBE, "timeout_ms": 8000}).to_string();
let (out, ok) = crate::tools::run_on("shell", &args, t, &cancel, &ids);
if ok { Ok(out) } else { Err(out) }
}
Place::Sealed { .. } => Err("no".into()),
}
}
fn key(t: &Target) -> String {
format!("{}|{:?}", t.name, t.place)
}
/// The facts section for a turn's instructions: cached facts, plus fresh reads of the rest (at
/// most WAIT_MS for all of them together; a slow one is used next turn).
pub fn for_turn(computers: &[Target], may_use_persons: bool, via: &str) -> String {
let wanted: Vec<&Target> = computers.iter().filter(|t| readable(t, may_use_persons)).collect();
let fresh = |t: &Target| cache().lock().unwrap_or_else(|e| e.into_inner()).get(&key(t)).filter(|(at, _)| at.elapsed().as_secs() < FRESH_SECS).map(|(_, l)| l.clone());
let missing: Vec<Target> = wanted.iter().filter(|t| fresh(t).is_none()).map(|t| (*t).clone()).collect();
if !missing.is_empty() {
let (tx, rx) = std::sync::mpsc::channel();
for t in missing {
let tx = tx.clone();
let via = via.to_string();
std::thread::spawn(move || {
if let Ok(out) = probe(&t, &via) {
let f = parse(&out);
if !f.kernel.is_empty() || !f.os.is_empty() {
let mac_sandbox = matches!(t.place, Place::Local { .. }) && cfg!(target_os = "macos") || f.kernel == "Darwin" && matches!(t.place, Place::Device { .. });
let line = describe(&t.name, &f, matches!(t.place, Place::Box { .. }), mac_sandbox);
cache().lock().unwrap_or_else(|e| e.into_inner()).insert(key(&t), (Instant::now(), line));
}
}
let _ = tx.send(());
});
}
drop(tx);
let end = Instant::now() + Duration::from_millis(WAIT_MS);
while let Some(left) = end.checked_duration_since(Instant::now()) {
if rx.recv_timeout(left).is_err() {
break;
}
}
}
let lines: Vec<String> = wanted.iter().filter_map(|t| fresh(t)).collect();
if lines.is_empty() {
return String::new();
}
format!(
"\n\n電腦現況(AnyBot 剛剛自動查的,照這個選做法,不用再花步驟去試探):\n{}",
lines.iter().map(|l| format!("- {l}")).collect::<Vec<_>>().join("\n")
)
}
#[cfg(test)]
mod tests {
use super::*;
const BOX: &str = "kernel=Linux\narch=aarch64\nos=Debian GNU/Linux 13 (trixie)\nsystemd=no\nsudo=password\nnet=yes\npkg=apt-get\ndesktop=\ntz=UTC\nwm=fluxbox\ntools= python3 node npm git curl wget jq xdotool\napps=Chromium|XTerm|galculator|\n";
const MAC: &str = "kernel=Darwin\narch=arm64\nos=macOS 26.1\nsudo=password\nnet=yes\npkg=brew\ndesktop=\ntools= python3 git curl osascript shortcuts brew\napps=Tailscale|LINE|Safari|\n";
#[test]
fn a_box_reads_like_a_person_would_know_it() {
let f = parse(BOX);
assert_eq!(f.systemd, Some(false));
assert_eq!(f.net, Some(true));
assert_eq!(f.apps, vec!["Chromium", "XTerm", "galculator"]);
let line = describe("Bot 的電腦", &f, true, false);
for want in ["Debian GNU/Linux 13", "fluxbox", "沒有 systemd", "/etc/localtime", "可以 sudo(免密碼", "有網路", "apt-get", "galculator", "TZ=UTC", "env -u TZ date"] {
assert!(line.contains(want), "{want}: {line}");
}
}
#[test]
fn a_mac_says_what_its_sandbox_lets_through() {
let line = describe("王小明的Mac mini", &parse(MAC), false, true);
assert!(line.contains("macOS 26.1") && line.contains("沙盒") && line.contains("osascript") && line.contains("裝東西用 brew") && line.contains("Tailscale"), "{line}");
assert!(!line.contains("systemd"), "{line}");
// Offline and no sudo, said plainly so the bot doesn't try.
let off = describe("x", &parse("kernel=Linux\nnet=no\nsudo=password\n"), false, false);
assert!(off.contains("連不到網路") && off.contains("sudo 要密碼"), "{off}");
}
/// The probe runs here and says something sensible (any Unix).
#[test]
fn the_probe_runs_on_this_computer() {
let out = std::process::Command::new("/bin/sh").arg("-c").arg(PROBE).output().unwrap();
let f = parse(&String::from_utf8_lossy(&out.stdout));
assert!(!f.kernel.is_empty() && !f.arch.is_empty(), "{f:?}");
}
}