// What a bot may do on this computer: list, search, read and edit files, run commands // (long ones move to the background), and the rules that decide when to ask first. // // Limits come from the original's local tool runner (dist/local-exec-daemon/main.cjs): // search: MAIN_TIMEOUT_MS=25e3, HARD_MAX_OUTPUT_LINES=1e4 (@2983388), client limit Jut=2e3 (@2966784) // read: at most 8388608 bytes (gut, @2951190) // shell: on timeout a background-able command keeps running and is handed off (Mye, @3010873/@3016213) // Permission order follows the neighbouring investigation's design notes (fnd_8): // deny > ignore > allow > ask, with chained shell commands split into parts first. use serde_json::{json, Value}; use std::collections::HashMap; use std::io::Read; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; use std::time::{Duration, Instant}; pub const SEARCH_TIMEOUT_MS: u64 = 25_000; pub const SEARCH_HARD_MAX_LINES: usize = 10_000; pub const SEARCH_CLIENT_LIMIT_LINES: usize = 2_000; pub const READ_MAX_BYTES: u64 = 8_388_608; /// Lines returned by one read_file call when the model gives no limit (estimated). pub const READ_DEFAULT_LINES: usize = 2_000; /// Foreground time before a command is moved to the background (estimated; the original's comes per call). pub const SHELL_DEFAULT_TIMEOUT_MS: u64 = 30_000; pub const SHELL_MAX_TIMEOUT_MS: u64 = 600_000; /// A command on a relayed computer must answer before the relay's forward timeout (35 s): /// on the device it moves to the background by this deadline, and read_terminal picks it up. pub const DEVICE_SHELL_TIMEOUT_MS: u64 = 25_000; /// Output kept from a command: its start and its end, with the middle left out (sizes estimated; /// the original reports output_head / output_tail / elided_chars). pub const OUTPUT_HEAD_CHARS: usize = 6_000; pub const OUTPUT_TAIL_CHARS: usize = 6_000; /// Names the model sees, with JSON-schema parameters. pub fn specs() -> Vec { vec![ json!({"name": "list_dir", "description": "List the files and folders in a folder (relative to the work folder unless absolute).", "parameters": {"type": "object", "properties": {"path": {"type": "string", "description": "Folder to list; empty means the work folder."}}, "required": []}}), json!({"name": "search", "description": "Find text in files under a folder. Returns matching lines as path:line:text.", "parameters": {"type": "object", "properties": { "pattern": {"type": "string", "description": "Text to look for (regular expression when ripgrep is installed)."}, "path": {"type": "string", "description": "Folder or file to search; empty means the work folder."}, "files_only": {"type": "boolean", "description": "Only list the files that match."}}, "required": ["pattern"]}}), json!({"name": "read_file", "description": "Read a text file. Lines come back numbered.", "parameters": {"type": "object", "properties": { "path": {"type": "string"}, "offset": {"type": "integer", "description": "First line to read (1-based)."}, "limit": {"type": "integer", "description": "How many lines to read."}}, "required": ["path"]}}), json!({"name": "edit_file", "description": "Change a file. Replaces old_text (which must appear exactly once) with new_text. With empty old_text the file is created or overwritten with new_text.", "parameters": {"type": "object", "properties": { "path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "new_text"]}}), json!({"name": "shell", "description": "Run a shell command in the work folder. If it runs longer than timeout_ms it keeps running in the background and you get a terminal id to check with read_terminal (pass the same computer to read_terminal/write_terminal).", "parameters": {"type": "object", "properties": { "command": {"type": "string"}, "timeout_ms": {"type": "integer", "description": "How long to wait before moving it to the background (default 30000)."}}, "required": ["command"]}}), json!({"name": "read_terminal", "description": "Read the latest output of a command that was moved to the background.", "parameters": {"type": "object", "properties": {"id": {"type": "integer"}}, "required": ["id"]}}), json!({"name": "write_terminal", "description": "Send input to a command running in the background, as if typed into its terminal (e.g. answer a prompt: \"y\\n\"). close=true ends its input (like Ctrl-D).", "parameters": {"type": "object", "properties": {"id": {"type": "integer"}, "input": {"type": "string", "description": "Text to send; include \\n to press return."}, "close": {"type": "boolean"}}, "required": ["id"]}}), ] } /// 請你接手: the step that asks the person to do something by hand on a computer. pub const HANDOFF_TOOL: &str = "hand_over_computer"; pub fn label(name: &str) -> &'static str { match name { HANDOFF_TOOL => "請你接手電腦", "list_dir" => "查看資料夾", "search" => "搜尋檔案", "read_file" => "讀取檔案", "edit_file" => "修改檔案", "shell" => "執行指令", "read_terminal" => "查看背景指令", "write_terminal" => "輸入到背景指令", "open_for_person" => "打開給你看", "publish_site" => "發布網站", "place_call" => "打電話", "send_sms" => "傳簡訊", "post_to_x" => "在 X 發文", "show_card" => "顯示卡片", "vault_list" => "查看保險庫", "browser_fill_login" => "用保險庫登入", "browser_fill_card" => "填入卡片", "browser_fill_contact" => "填入聯絡資訊", _ => "工具", } } /// A tool call's arguments (none = an empty object). pub fn args_of(args: &str) -> Value { serde_json::from_str(if args.trim().is_empty() { "{}" } else { args }).unwrap_or(Value::Null) } fn arg_str(v: &Value, k: &str) -> String { v.get(k).and_then(Value::as_str).unwrap_or("").to_string() } /// One short line describing a call, for the chat and the approval card. pub fn describe(name: &str, args: &str) -> String { let v = args_of(args); match name { "shell" => arg_str(&v, "command"), "search" => format!("「{}」 {}", arg_str(&v, "pattern"), arg_str(&v, "path")).trim().to_string(), "read_terminal" => format!("背景指令 #{}", v.get("id").and_then(Value::as_u64).unwrap_or(0)), "write_terminal" => { let input: String = arg_str(&v, "input").chars().take(40).collect(); format!("背景指令 #{}:{}{}", v.get("id").and_then(Value::as_u64).unwrap_or(0), input.escape_debug(), if v.get("close").and_then(Value::as_bool).unwrap_or(false) { "(結束輸入)" } else { "" }) } "vault_list" => match arg_str(&v, "kind").as_str() { "login" => "登入資訊".into(), "card" => "卡片".into(), "contact" => "聯絡資訊".into(), _ => "全部項目".into(), }, "browser_fill_login" => { let site = arg_str(&v, "site"); if site.is_empty() { "目前的網站".into() } else { site } } "browser_fill_card" | "browser_fill_contact" => "目前的網頁".into(), _ => { let p = arg_str(&v, "path"); if p.is_empty() { "工作資料夾".into() } else { p } } } } // ---------- paths ---------- pub fn expand_home(p: &str) -> PathBuf { if let Some(rest) = p.strip_prefix("~/") { if let Some(h) = dirs::home_dir() { return h.join(rest); } } else if p == "~" { if let Some(h) = dirs::home_dir() { return h; } } PathBuf::from(p) } /// Make a path absolute and tidy (no `.` or `..`) without touching the disk. pub fn resolve(workdir: &Path, p: &str) -> PathBuf { let raw = if p.trim().is_empty() { workdir.to_path_buf() } else { let e = expand_home(p.trim()); if e.is_absolute() { e } else { workdir.join(e) } }; let mut out = PathBuf::new(); for c in raw.components() { match c { std::path::Component::ParentDir => { out.pop(); } std::path::Component::CurDir => {} other => out.push(other), } } out } fn inside(workdir: &Path, p: &Path) -> bool { p.starts_with(workdir) } /// Files a bot never reads or writes: secrets and version-control internals /// (the original blocks reads/writes by ignore files and repo-block globs), /// plus this app's own data folder, where API keys are saved. pub fn is_ignored(p: &Path) -> bool { if let Some(d) = crate::model::State::path().parent() { if p.starts_with(d) { return true; } } let s = p.to_string_lossy().replace('\\', "/"); let name = p.file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default(); s.contains("/.git/") || s.ends_with("/.git") || s.contains("/.ssh/") || s.contains("/.gnupg/") || s.contains("/Library/Keychains") || name == ".env" || name.starts_with(".env.") || name.ends_with(".pem") || name.ends_with(".key") || name.starts_with("id_rsa") || name.starts_with("id_ed25519") || name == ".netrc" || name == "credentials" } // ---------- permissions ---------- #[derive(Debug, PartialEq, Clone)] pub enum Decision { Allow, Ask { reason: String, grant: String }, Deny(String), } /// Commands that only look at things; chains made only of these run without asking. const READ_ONLY_COMMANDS: [&str; 24] = [ "ls", "pwd", "cat", "head", "tail", "wc", "echo", "rg", "grep", "find", "file", "stat", "du", "df", "date", "which", "tree", "sort", "uniq", "diff", "git status", "git diff", "git log", "git show", ]; /// Always refused. const DENIED_COMMANDS: [&str; 10] = ["sudo", "su", "rm -rf /", "rm -rf ~", "mkfs", "dd", "shutdown", "reboot", "diskutil erase", "chmod -R 777 /"]; /// Split `a && b | c; d` into its commands, keeping quoted text together. /// Returns None when the command hides other commands ($(), backticks, eval). pub fn split_commands(cmd: &str) -> Option> { let mut parts = vec![]; let mut cur = String::new(); let (mut sq, mut dq) = (false, false); let chars: Vec = cmd.chars().collect(); let mut i = 0; while i < chars.len() { let c = chars[i]; let next = chars.get(i + 1).copied(); if c == '\'' && !dq { sq = !sq; } else if c == '"' && !sq { dq = !dq; } else if !sq && (c == '`' || (c == '$' && next == Some('('))) { return None; } else if !sq && !dq && (c == ';' || c == '\n' || c == '|' || c == '&') { if !cur.trim().is_empty() { parts.push(cur.trim().to_string()); } cur.clear(); if (c == '|' || c == '&') && next == Some(c) { i += 1; } i += 1; continue; } cur.push(c); i += 1; } if sq || dq { return None; } if !cur.trim().is_empty() { parts.push(cur.trim().to_string()); } if parts.iter().any(|p| { let w = p.split_whitespace().next().unwrap_or(""); w == "eval" || w == "exec" || w == "source" || w == "." }) { return None; } Some(parts) } fn words(cmd: &str) -> Vec<&str> { cmd.split_whitespace().collect() } fn starts_with_words(cmd: &str, prefix: &str) -> bool { let (c, p) = (words(cmd), words(prefix)); !p.is_empty() && c.len() >= p.len() && c[..p.len()] == p[..] } /// The denied command hiding inside a wrapper: `(sudo …)`, `FOO=1 sudo …`, `env sudo …`, /// `nohup sudo …` all run the same thing, so peel the wrappings off before matching. fn denied_hit(part: &str) -> Option<&'static str> { let mut toks: Vec = Vec::new(); for w in words(part) { let mut w = w; while matches!(w.as_bytes().first(), Some(b'(') | Some(b'{')) { w = &w[1..]; } if w.is_empty() { continue; } // VAR=val prefixes set the environment; they are not the command. let is_assign = w.split_once('=').map(|(k, v)| !k.is_empty() && v.len() + 1 < w.len() && k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')).unwrap_or(false); if toks.is_empty() && is_assign { continue; } toks.push(w.to_string()); } const WRAP: [&str; 7] = ["env", "command", "nohup", "time", "nice", "stdbuf", "timeout"]; while toks.first().map(|t| WRAP.contains(&t.as_str())).unwrap_or(false) { toks.remove(0); while toks.first().map(|t| t.starts_with('-')).unwrap_or(false) { toks.remove(0); } // timeout takes a duration before the command. if toks.first().map(|t| !t.is_empty() && t.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false)).unwrap_or(false) { toks.remove(0); } } let joined = toks.join(" "); DENIED_COMMANDS.iter().find(|d| starts_with_words(&joined, d)).copied() } /// The "always allow" grant for a command: its first word, or first two for git/npm/cargo-style tools. pub fn shell_grant(part: &str) -> String { let w = words(part); let two = ["git", "npm", "pnpm", "yarn", "cargo", "brew", "pip", "pip3", "python3", "docker", "go"]; let key = if w.len() >= 2 && two.contains(&w[0]) { format!("{} {}", w[0], w[1]) } else { w.first().map(|s| s.to_string()).unwrap_or_default() }; format!("shell:{key}") } fn writes_files(part: &str) -> bool { part.contains('>') } pub fn decide(name: &str, args: &str, workdir: &Path, grants: &[String]) -> Decision { let v = args_of(args); let granted = |g: &str| grants.iter().any(|x| x == g); match name { "list_dir" | "search" | "read_file" => { let p = resolve(workdir, &arg_str(&v, "path")); if is_ignored(&p) { return Decision::Deny(format!("「{}」是私密或系統檔案,Bot 不能讀取。", p.display())); } if inside(workdir, &p) || granted("read:anywhere") { Decision::Allow } else { Decision::Ask { reason: format!("讀取工作資料夾以外的地方:{}", p.display()), grant: "read:anywhere".into() } } } "read_terminal" | "write_terminal" => Decision::Allow, "edit_file" => { let p = resolve(workdir, &arg_str(&v, "path")); if is_ignored(&p) { return Decision::Deny(format!("「{}」是私密或系統檔案,Bot 不能修改。", p.display())); } if !inside(workdir, &p) { return Decision::Ask { reason: format!("修改工作資料夾以外的檔案:{}", p.display()), grant: String::new() }; } if granted("edit_file") { Decision::Allow } else { Decision::Ask { reason: format!("修改檔案:{}", p.display()), grant: "edit_file".into() } } } "shell" => { let cmd = arg_str(&v, "command"); if cmd.trim().is_empty() { return Decision::Deny("沒有指令".into()); } let Some(parts) = split_commands(&cmd) else { // Commands the splitter can't read ($(), backticks, eval…) ask once and remember // by their first word: 「一律允許」 on `git` covers `git … $(…)` afterwards, so // the same shape of command doesn't ask again (models use $(…) constantly). if let Some(d) = denied_hit(&cmd) { return Decision::Deny(format!("「{d}」這類指令一律不允許。")); } let grant = shell_grant(&cmd); if !writes_files(&cmd) && granted(&grant) { return Decision::Allow; } return Decision::Ask { reason: format!("執行指令(含有隱藏的子指令,無法自動判斷):{cmd}"), grant }; }; // deny for part in &parts { if let Some(d) = denied_hit(part) { return Decision::Deny(format!("「{d}」這類指令一律不允許。")); } } // (No word-by-word path scan here: secret folders are the sandbox's job, and // scanning words as paths denied perfectly ordinary commands.) // allow: every part is read-only or already granted let ok = |part: &String| !writes_files(part) && (READ_ONLY_COMMANDS.iter().any(|r| starts_with_words(part, r)) || granted(&shell_grant(part))); let unapproved: Vec<&String> = parts.iter().filter(|p| !ok(p)).collect(); if unapproved.is_empty() { return Decision::Allow; } let grant = if unapproved.len() == 1 && !writes_files(unapproved[0]) { shell_grant(unapproved[0]) } else { String::new() }; Decision::Ask { reason: format!("執行指令:{cmd}"), grant } } other => Decision::Deny(format!("沒有「{other}」這個工具")), } } // ---------- process trees ---------- // From cursor_proclist.node (the original's native process scanner): // getDescendantPids @0x6748: breadth-first over children by parent pid (proc_listpids PROC_PPID_ONLY), // de-duplicated, stops once 0xfff (4095) pids are collected → at most 4096 processes. // ProclistScanWorker::Execute @0x3d00: scans the roots plus all descendants. // getProcessInformation @0x5234: memoryMB = resident bytes >> 20 (lsr #0x14); // cpuTimeMs = (user + system time, in Mach ticks × timebase) / 1 000 000; // each process is tied to its agent by CONVERSATION_ID= / REQUEST_ID= in its environment. pub const MAX_TRACKED_PROCESSES: usize = 4096; /// Our own names for the tags the original reads (it uses its vendor prefix). pub const ENV_CONVERSATION_ID: &str = "ANYBOT_CONVERSATION_ID"; pub const ENV_REQUEST_ID: &str = "ANYBOT_REQUEST_ID"; #[derive(Clone, Copy, Debug)] pub struct ProcRow { pub pid: u32, pub ppid: u32, pub rss_kb: u64, pub cpu_ms: u64, } /// `[[dd-]hh:]mm:ss[.cc]` as printed by ps, in milliseconds. pub fn parse_cpu_time(s: &str) -> u64 { let (days, rest) = match s.split_once('-') { Some((d, r)) => (d.parse::().unwrap_or(0), r), None => (0, s), }; let mut secs = 0f64; for part in rest.split(':') { secs = secs * 60.0 + part.parse::().unwrap_or(0.0); } days * 86_400_000 + (secs * 1000.0).round() as u64 } pub fn proc_table() -> Vec { let Ok(out) = Command::new("ps").args(["-A", "-o", "pid=,ppid=,rss=,time="]).output() else { return vec![] }; String::from_utf8_lossy(&out.stdout) .lines() .filter_map(|l| { let f: Vec<&str> = l.split_whitespace().collect(); (f.len() >= 4).then(|| ProcRow { pid: f[0].parse().unwrap_or(0), ppid: f[1].parse().unwrap_or(0), rss_kb: f[2].parse().unwrap_or(0), cpu_ms: parse_cpu_time(f[3]) }) }) .filter(|r| r.pid > 0) .collect() } /// The roots and everything they started, breadth-first, without repeats, at most 4096. pub fn descendants(table: &[ProcRow], roots: &[u32]) -> Vec { let mut seen = std::collections::HashSet::new(); let mut out = vec![]; let mut queue: std::collections::VecDeque = roots.iter().copied().filter(|p| *p > 0).collect(); while let Some(p) = queue.pop_front() { if !seen.insert(p) { continue; } out.push(p); if out.len() >= MAX_TRACKED_PROCESSES { break; } queue.extend(table.iter().filter(|r| r.ppid == p).map(|r| r.pid)); } out } /// (processes, cpu ms, memory MB) for a command and everything it started. pub fn tree_stats(root: u32) -> (usize, u64, u64) { let table = proc_table(); let pids = descendants(&table, &[root]); let rows: Vec<&ProcRow> = table.iter().filter(|r| pids.contains(&r.pid)).collect(); (rows.len(), rows.iter().map(|r| r.cpu_ms).sum(), rows.iter().map(|r| (r.rss_kb * 1024) >> 20).sum()) } /// Stop a command and everything it started (newest first), not just the shell. pub fn kill_tree(root: u32) { let table = proc_table(); let mut pids = descendants(&table, &[root]); pids.reverse(); if pids.is_empty() { return; } let _ = Command::new("kill").arg("-TERM").args(pids.iter().map(|p| p.to_string())).stderr(Stdio::null()).status(); } // ---------- sandbox ---------- // From sand-op-launcher `_main`: before exec it applies a sandbox profile // "(version 1)(allow default)(deny file-read* (with no-report) (subpath (param …))))" that hides // one private folder, builds a fresh environment, and refuses invocations outside an exact list. // Here: commands can't read or write the app's own data (where API keys are saved) or the // usual secret folders, and API keys are removed from their environment. const KEY_ENV_VARS: [&str; 6] = ["XAI_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY", "OPENROUTER_API_KEY", "ANTHROPIC_AUTH_TOKEN"]; pub fn protected_dirs() -> Vec { let mut v = vec![]; if let Some(d) = crate::model::State::path().parent() { v.push(d.to_path_buf()); } if let Some(h) = dirs::home_dir() { for s in [".ssh", ".gnupg", "Library/Keychains", ".aws", ".config/gcloud"] { v.push(h.join(s)); } } v } /// Inside the protected folders, what commands may still read: the chat's attachments (a picture /// the person sent is the bot's to look at; the keys next to it are not) and the ssh keys, so /// git and ssh can reach remote repos (writing them stays denied). pub fn readable_dirs() -> Vec { let mut v: Vec = crate::model::State::path().parent().map(|d| vec![d.join("attachments")]).unwrap_or_default(); if let Some(h) = dirs::home_dir() { v.push(h.join(".ssh")); } v } fn sandbox_exec() -> Option<&'static str> { let p = "/usr/bin/sandbox-exec"; (cfg!(target_os = "macos") && Path::new(p).exists()).then_some(p) } fn real(p: &Path) -> String { // The sandbox matches real paths, so resolve links like /var → /private/var. std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()).display().to_string() } /// The shell command, wrapped in the sandbox when the system has one. #[cfg(test)] pub fn build_command(cmd: &str, workdir: &Path, protected: &[PathBuf], conversation: &str, request: &str) -> Command { build_command_sealed(cmd, workdir, protected, &[], None, conversation, request) } /// `seal = Some(home)`: the bot's own computer. Nothing under the person's home folder is /// visible or writable except the bot's folder (`workdir`), like a separate machine. /// `readable`: folders inside `protected` that may still be read (never written). pub fn build_command_sealed(cmd: &str, workdir: &Path, protected: &[PathBuf], readable: &[PathBuf], seal: Option<&Path>, conversation: &str, request: &str) -> Command { let mut c = match sandbox_exec() { Some(se) if !protected.is_empty() || seal.is_some() => { let rules: String = (0..protected.len()).map(|i| format!(" (subpath (param \"P{i}\"))")).collect(); let mut profile = format!("(version 1)(allow default)(deny file-read* file-write* (with no-report){rules})"); if seal.is_some() { profile.push_str("(deny file-read* file-write* (with no-report) (require-all (subpath (param \"HOME\")) (require-not (subpath (param \"ROOT\")))))"); } // Later rules win: reading these is allowed again (writing stays denied). let open: Vec<&PathBuf> = readable.iter().filter(|r| protected.iter().any(|p| r.starts_with(p))).collect(); for i in 0..open.len() { profile.push_str(&format!("(allow file-read* (subpath (param \"R{i}\")))")); } let mut c = Command::new(se); for (i, p) in protected.iter().enumerate() { c.arg("-D").arg(format!("P{i}={}", real(p))); } for (i, r) in open.iter().enumerate() { c.arg("-D").arg(format!("R{i}={}", real(r))); } if let Some(home) = seal { c.arg("-D").arg(format!("HOME={}", real(home))).arg("-D").arg(format!("ROOT={}", real(workdir))); } c.arg("-p").arg(profile).arg("/bin/sh").arg("-c").arg(cmd); c } _ => { let mut c = Command::new("/bin/sh"); c.arg("-c").arg(cmd); c } }; for k in KEY_ENV_VARS { c.env_remove(k); } // The app started from Finder or launchd has no shell PATH (no /opt/homebrew/bin): a // command run on an always-on Mac (a Mac mini under LaunchAgent) must find its tools too. c.env("PATH", crate::cloudbox::tool_path()); c.env(ENV_CONVERSATION_ID, conversation).env(ENV_REQUEST_ID, request); #[cfg(unix)] { use std::os::unix::process::CommandExt; c.process_group(0); } c.current_dir(workdir).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()); c } /// A command AnyBot itself wraps around the bot's (ssh / docker exec): no sandbox, because the /// wrapper needs the person's SSH keys and Docker settings; the bot's part runs on the other machine. pub fn build_wrapper_command(cmd: &str, conversation: &str, request: &str) -> Command { let mut c = Command::new("/bin/sh"); c.arg("-c").arg(cmd); for k in KEY_ENV_VARS { c.env_remove(k); } c.env(ENV_CONVERSATION_ID, conversation).env(ENV_REQUEST_ID, request).env("PATH", crate::cloudbox::tool_path()); #[cfg(unix)] { use std::os::unix::process::CommandExt; c.process_group(0); } c.current_dir(std::env::temp_dir()).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()); c } // ---------- running tools ---------- /// Which chat and which tool call a command belongs to. #[derive(Clone, Default)] pub struct RunIds { pub conversation: String, pub request: String, } struct Terminal { command: String, stdin: Arc>>, output: Arc>, child: Arc>, exit: Arc>>, root: u32, /// The bot it belongs to (the ANYBOT_CONVERSATION_ID tag). conversation: String, } fn terminals() -> &'static Mutex> { static T: OnceLock>> = OnceLock::new(); T.get_or_init(|| Mutex::new(HashMap::new())) } fn next_terminal_id() -> u64 { static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); N.fetch_add(1, Ordering::Relaxed) } /// Keep the start and the end of long output (the original's output_head / output_tail). pub fn head_tail(s: &str) -> String { let n = s.chars().count(); if n <= OUTPUT_HEAD_CHARS + OUTPUT_TAIL_CHARS { return s.to_string(); } let head: String = s.chars().take(OUTPUT_HEAD_CHARS).collect(); let tail: String = s.chars().skip(n - OUTPUT_TAIL_CHARS).collect(); format!("{head}\n…(中間省略 {} 個字)…\n{tail}", n - OUTPUT_HEAD_CHARS - OUTPUT_TAIL_CHARS) } fn timeout_of(v: &Value) -> u64 { v.get("timeout_ms").and_then(Value::as_u64).unwrap_or(SHELL_DEFAULT_TIMEOUT_MS).min(SHELL_MAX_TIMEOUT_MS) } // ---------- computers ---------- // The original runs a bot's work on "Bot 的電腦" (its own cloud box) and only runs on this Mac when // the person turns on 在這台電腦上執行 (setting `local-execution`, ids fuUvu2 / Hd1KB+). Permission // questions name the machine ("允許 Grok Bot 在 {targetMachineDisplay} 上使用 {app} 嗎?", eQViQE). // Here the bot's own computer is a sealed folder, this Mac is the person's computer, and other // computers are reached over SSH. #[derive(Clone, Debug)] pub enum Place { /// The bot's own computer when Docker isn't there: everything outside `root` in the home /// folder is invisible. Sealed { root: PathBuf }, /// The bot's own computer: AnyBot's Linux box (Docker), starting in `dir` inside it. Box { dir: String }, /// The person's computer, starting in `workdir`. Local { workdir: PathBuf }, /// Another computer: `ssh target`, starting in `dir` (empty = its home). Ssh { target: String, dir: String }, /// 裝置連結: another AnyBot of the same account (through the relay), by its host id, /// starting in `dir` (empty = its home). Device { host: String, dir: String }, } #[derive(Clone, Debug)] pub struct Target { pub name: String, pub place: Place, } /// Which of a bot's computers is which, by name (every computer goes by one plain name). #[derive(Clone, Debug, Default)] pub struct Roles { /// The bot's own computer: 「Master 的電腦」. pub own: String, /// The computer the person is using now (where they typed to the bot), when known. pub person: Option, /// This AnyBot runs on a server: the computer it is on is nobody's desk. pub server: bool, } /// The tools with a `computer` choice added to each, listing the computers this bot can use. pub fn specs_for(computers: &[String], default: &str) -> Vec { let mut v = specs(); for t in v.iter_mut() { // Every tool that works on a computer names one, background terminals included: a command // moved to the background on the Mac mini is read on the Mac mini. // Each computer goes by its own name; the instructions say which is which. Working on // one and calling it another (the server as "your computer") is what confused people. let desc = format!("Which computer to work on, by its name. Default: {default} (the one the person chose). Use another only when the person names it; when unsure which one they mean, ask."); t["parameters"]["properties"]["computer"] = json!({ "type": "string", "enum": computers, "description": desc }); } v } pub fn sh_quote(s: &str) -> String { format!("'{}'", s.replace('\'', "'\\''")) } fn ssh_command(target: &str, dir: &str, cmd: &str) -> String { let inner = if dir.trim().is_empty() { cmd.to_string() } else { format!("cd {} && {}", sh_quote(dir), cmd) }; format!("ssh -o BatchMode=yes -o ConnectTimeout=15 {} {}", sh_quote(target), sh_quote(&inner)) } /// The local wrapper that runs `cmd` on a remote computer (an SSH host or the bot's box). fn wrapper_for(place: &Place, cmd: &str, interactive: bool, job: &str) -> Option { match place { Place::Ssh { target, dir } => Some(ssh_command(target, dir, cmd)), Place::Box { dir } => Some(crate::cloudbox::exec_wrapper(dir, cmd, interactive, job)), _ => None, } } pub fn decide_on(name: &str, args: &str, target: &Target, grants: &[String]) -> Decision { let v = args_of(args); let denied = |cmd: &str| -> Option { for part in split_commands(cmd)? { if let Some(d) = DENIED_COMMANDS.iter().find(|d| starts_with_words(&part, d)) { return Some(Decision::Deny(format!("「{d}」這類指令一律不允許。"))); } } None }; match &target.place { Place::Local { workdir } => decide(name, args, workdir, grants), Place::Sealed { root } => { if name == "shell" { // Its own computer: the sandbox keeps it inside its folder. return denied(&arg_str(&v, "command")).unwrap_or(Decision::Allow); } if name == "read_terminal" || name == "write_terminal" { return Decision::Allow; } let p = resolve(root, &arg_str(&v, "path")); if !inside(root, &p) { return Decision::Deny(format!("{} 看不到這台電腦上的其他檔案({})。要處理使用者的檔案,請在使用者的電腦上做(不確定是哪台就先問)。", target.name, p.display())); } Decision::Allow } // Its own computer: everything is allowed (it can be rebuilt), like the original's box. Place::Box { .. } => { if name == "shell" { if let Some(d) = denied(&arg_str(&v, "command")) { return d; } } Decision::Allow } Place::Ssh { .. } | Place::Device { .. } => match name { "list_dir" | "read_file" | "search" | "read_terminal" | "write_terminal" => Decision::Allow, "edit_file" => { if grants.iter().any(|g| g == "edit_file") { Decision::Allow } else { Decision::Ask { reason: format!("修改 {} 上的檔案:{}", target.name, arg_str(&v, "path")), grant: "edit_file".into() } } } _ => { let cmd = arg_str(&v, "command"); match decide("shell", args, Path::new("/nonexistent-remote"), grants) { Decision::Ask { grant, .. } => Decision::Ask { reason: format!("在 {} 上執行指令:{cmd}", target.name), grant }, other => other, } } }, } } /// Run a remote command and give back its output (no background hand-off). fn remote_capture(place: &Place, cmd: &str, stdin: Option<&[u8]>) -> Result { use std::io::Write; let w = wrapper_for(place, cmd, stdin.is_some(), "").ok_or("不是遠端電腦")?; let mut c = build_wrapper_command(&w, "", ""); let mut child = c.spawn().map_err(|e| format!("無法執行:{e}"))?; if let Some(data) = stdin { if let Some(mut si) = child.stdin.take() { si.write_all(data).map_err(|e| format!("寫入失敗:{e}"))?; } } else { drop(child.stdin.take()); } let out = child.wait_with_output().map_err(|e| e.to_string())?; let text = String::from_utf8_lossy(&out.stdout).to_string(); if out.status.success() { Ok(text) } else { Err(format!("{}{}", String::from_utf8_lossy(&out.stderr).trim(), if text.trim().is_empty() { String::new() } else { format!("\n{}", text.trim()) })) } } /// Run an allowed tool on a computer. pub fn run_on(name: &str, args: &str, target: &Target, cancel: &AtomicBool, ids: &RunIds) -> (String, bool) { let v = args_of(args); let done = |r: Result| match r { Ok(s) => (s, true), Err(e) => (e, false), }; match &target.place { Place::Local { workdir } => run(name, args, workdir, cancel, ids), Place::Device { host, dir } => { // The device runs the tool in the bot's work folder (its own home when unset), and // must answer before the relay gives up at 35 s: long commands move to the // background there, and read_terminal now names this computer to find them. let mut v = v.clone(); let cap = timeout_of(&v).min(DEVICE_SHELL_TIMEOUT_MS); if let Some(o) = v.as_object_mut() { if !dir.is_empty() && o.get("workdir").and_then(Value::as_str).unwrap_or("").is_empty() { o.insert("workdir".into(), serde_json::Value::String(dir.clone())); } o.insert("timeout_ms".into(), serde_json::Value::from(cap)); } let args = v.to_string(); match crate::remote::run_on_device(host, name, &args, "") { Ok(v) => { let mut text = v["text"].as_str().unwrap_or("").to_string(); if text.contains("read_terminal") { text.push_str(&format!("(這台是「{}」:read_terminal 的 computer 填「{}」。)", target.name, target.name)); } (text, v["ok"].as_bool().unwrap_or(false)) } Err(e) => (e, false), } } Place::Sealed { root } => { let _ = std::fs::create_dir_all(root); if name == "shell" { let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/Users")); return done(shell(&arg_str(&v, "command"), root, timeout_of(&v), cancel, ids, Some(&home))); } run(name, args, root, cancel, ids) } place @ (Place::Ssh { .. } | Place::Box { .. }) => { if let Place::Box { .. } = place { if let Err(e) = crate::cloudbox::ensure_running() { return (e, false); } } let _busy = crate::cloudbox::Busy::new(matches!(place, Place::Box { .. })); let path = arg_str(&v, "path"); let p = if path.is_empty() { ".".to_string() } else { path.clone() }; if v.is_null() && !args.trim().is_empty() { return ("參數不是有效的 JSON".into(), false); } // Box file tools stay inside this bot's own folder: every bot shares one container, // one user and one disk, so `../` or an absolute path reached (and rewrote) the // other bots' files — a planted .bashrc runs code on their next command. let (p, path) = match place { Place::Box { dir } if name != "shell" => match box_path(std::path::Path::new(dir), &p) { Ok(s) => (s.clone(), s), Err(e) => return (e, false), }, _ => (p, path), }; let remote = match name { "shell" if arg_str(&v, "command").trim().is_empty() => return ("請給要執行的指令".into(), false), "shell" => arg_str(&v, "command"), "list_dir" => format!("ls -la {}", sh_quote(&p)), "read_file" => { let from = v.get("offset").and_then(Value::as_u64).unwrap_or(1).max(1); let n = v.get("limit").and_then(Value::as_u64).unwrap_or(READ_DEFAULT_LINES as u64); format!("test -f {q} || {{ echo '找不到檔案:'{q} >&2; exit 1; }}; sed -n '{from},{}p' {q} | cat -n", from + n - 1, q = sh_quote(&p)) } "search" => { let flag = if v.get("files_only").and_then(Value::as_bool).unwrap_or(false) { "l" } else { "n" }; format!("(command -v rg >/dev/null && rg -{flag} --no-heading -- {pat} {p} || grep -rI{flag} -- {pat} {p}) | head -n {SEARCH_CLIENT_LIMIT_LINES}", pat = sh_quote(&arg_str(&v, "pattern")), p = sh_quote(&p)) } "edit_file" => { if path.is_empty() { return ("請給檔案路徑".into(), false); } let old = arg_str(&v, "old_text"); let new = arg_str(&v, "new_text"); let q = sh_quote(&path); let content = if old.is_empty() { new } else { let cur = match remote_capture(place, &format!("cat {q}"), None) { Ok(c) => c, Err(e) => return (format!("讀不到 {path}:{e}"), false), }; match cur.matches(old.as_str()).count() { 0 => return ("找不到 old_text,檔案沒有改。請先讀檔確認原文。".into(), false), 1 => cur.replacen(old.as_str(), &new, 1), n => return (format!("old_text 出現了 {n} 次,請多給一點前後文讓它只出現一次。"), false), } }; let write = format!("mkdir -p \"$(dirname {q})\" && cat > {q}"); return match remote_capture(place, &write, Some(content.as_bytes())) { Ok(_) => (format!("已寫入 {path}({} 行)。", content.lines().count()), true), Err(e) => (format!("寫不進 {path}:{e}"), false), }; } "read_terminal" | "write_terminal" => return run(name, args, Path::new("/"), cancel, ids), other => return (format!("沒有「{other}」這個工具"), false), }; // Tag the in-container command so stopping it here also stops it inside the box // (killing the local docker client alone left the program running in the box). let job = crate::httpd::random_hex(8); let wrapper = wrapper_for(place, &remote, name == "shell", &job).unwrap_or_default(); let kill = matches!(place, Place::Box { .. }).then(|| crate::cloudbox::exec_kill_argv(&job)); done(shell_wrapped(&wrapper, &remote, timeout_of(&v), cancel, ids, kill)) } } } /// Keep a Box file path inside the bot's own folder: relative paths join the bot's dir, and /// a normalised result that lands outside it (`../`, absolute paths to other bots' folders) /// is refused. All bots share one container and one user, so the folder is the only wall. pub fn box_path(dir: &Path, p: &str) -> Result { let joined = if p.starts_with('/') { PathBuf::from(p) } else { dir.join(p) }; let mut parts: Vec = vec![]; for c in joined.components() { use std::path::Component::*; match c { ParentDir => { parts.pop(); } CurDir => {} RootDir => parts.push(std::ffi::OsString::from("/")), Normal(x) => parts.push(x.to_os_string()), Prefix(x) => parts.push(x.as_os_str().to_os_string()), } } let mut norm = PathBuf::new(); for x in parts { norm.push(x); } if norm.starts_with(dir) { Ok(norm.display().to_string()) } else { Err(format!("這個檔案不在你的資料夾裡(Bot 的電腦上每個 Bot 只用自己的資料夾):{p}")) } } /// Run a tool that the rules already allowed. Returns (output, ok). pub fn run(name: &str, args: &str, workdir: &Path, cancel: &AtomicBool, ids: &RunIds) -> (String, bool) { let v = args_of(args); if v.is_null() && !args.trim().is_empty() { return ("參數不是有效的 JSON".into(), false); } let _ = std::fs::create_dir_all(workdir); let r = match name { "list_dir" => list_dir(&resolve(workdir, &arg_str(&v, "path"))), "search" => search(&arg_str(&v, "pattern"), &resolve(workdir, &arg_str(&v, "path")), v.get("files_only").and_then(Value::as_bool).unwrap_or(false)), "read_file" => read_file( &resolve(workdir, &arg_str(&v, "path")), v.get("offset").and_then(Value::as_u64).unwrap_or(1).max(1) as usize, v.get("limit").and_then(Value::as_u64).map(|l| l as usize).unwrap_or(READ_DEFAULT_LINES), ), "edit_file" => edit_file(&resolve(workdir, &arg_str(&v, "path")), &arg_str(&v, "old_text"), &arg_str(&v, "new_text")), "shell" => shell(&arg_str(&v, "command"), workdir, timeout_of(&v), cancel, ids, None), "read_terminal" => read_terminal(v.get("id").and_then(Value::as_u64).unwrap_or(0)), "write_terminal" => write_terminal(v.get("id").and_then(Value::as_u64).unwrap_or(0), &arg_str(&v, "input"), v.get("close").and_then(Value::as_bool).unwrap_or(false)), other => Err(format!("沒有「{other}」這個工具")), }; match r { Ok(s) => (s, true), Err(e) => (e, false), } } fn list_dir(p: &Path) -> Result { let rd = std::fs::read_dir(p).map_err(|e| format!("無法開啟資料夾 {}:{e}", p.display()))?; let mut rows: Vec<(bool, String, u64)> = rd .filter_map(|e| e.ok()) .filter(|e| !is_ignored(&e.path())) .map(|e| { let md = e.metadata().ok(); (md.as_ref().map(|m| m.is_dir()).unwrap_or(false), e.file_name().to_string_lossy().to_string(), md.map(|m| m.len()).unwrap_or(0)) }) .collect(); rows.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.to_lowercase().cmp(&b.1.to_lowercase()))); let total = rows.len(); let mut out: Vec = rows.into_iter().take(SEARCH_CLIENT_LIMIT_LINES).map(|(d, n, s)| if d { format!("{n}/") } else { format!("{n} {}", human_size(s)) }).collect(); if total > SEARCH_CLIENT_LIMIT_LINES { out.push(format!("…還有 {} 項", total - SEARCH_CLIENT_LIMIT_LINES)); } if out.is_empty() { out.push("(空資料夾)".into()); } Ok(format!("{}\n{}", p.display(), out.join("\n"))) } fn human_size(b: u64) -> String { match b { 0..=1023 => format!("{b} B"), 1024..=1_048_575 => format!("{:.1} KB", b as f64 / 1024.0), _ => format!("{:.1} MB", b as f64 / 1_048_576.0), } } fn search(pattern: &str, root: &Path, files_only: bool) -> Result { if pattern.is_empty() { return Err("請給要搜尋的文字".into()); } let deadline = Instant::now() + Duration::from_millis(SEARCH_TIMEOUT_MS); let mut lines: Vec = vec![]; let mut timed_out = false; // Prefer ripgrep when it's installed; fall back to a plain walk with substring matching. let rg = Command::new("rg") .args(["--no-heading", "--line-number", "--color", "never", "--max-columns", "300", "-g", "!.git", "-g", "!.env*", "-g", "!*.pem", "-g", "!*.key"]) .args(if files_only { vec!["-l"] } else { vec![] }) .arg("--") .arg(pattern) .arg(root) .stdout(Stdio::piped()) .stderr(Stdio::null()) .spawn(); if let Ok(mut child) = rg { let mut out = child.stdout.take().unwrap(); let buf = Arc::new(Mutex::new(Vec::new())); let b2 = buf.clone(); let reader = std::thread::spawn(move || { let mut chunk = [0u8; 8192]; while let Ok(n) = out.read(&mut chunk) { if n == 0 { break; } let mut b = b2.lock().unwrap(); b.extend_from_slice(&chunk[..n]); if b.iter().filter(|c| **c == b'\n').count() > SEARCH_HARD_MAX_LINES { break; } } }); loop { if child.try_wait().ok().flatten().is_some() || reader.is_finished() { break; } if Instant::now() > deadline { timed_out = true; let _ = child.kill(); break; } std::thread::sleep(Duration::from_millis(20)); } let _ = child.kill(); let _ = reader.join(); let text = String::from_utf8_lossy(&buf.lock().unwrap()).to_string(); lines = text.lines().map(|l| l.to_string()).collect(); } else { let needle = pattern.to_lowercase(); let mut stack = vec![root.to_path_buf()]; 'walk: while let Some(p) = stack.pop() { if Instant::now() > deadline { timed_out = true; break; } if is_ignored(&p) { continue; } if p.is_dir() { if let Ok(rd) = std::fs::read_dir(&p) { stack.extend(rd.filter_map(|e| e.ok()).map(|e| e.path()).filter(|q| q.file_name().map(|n| n != "node_modules" && n != "target").unwrap_or(true))); } continue; } let Ok(text) = std::fs::read_to_string(&p) else { continue }; for (i, l) in text.lines().enumerate() { if l.to_lowercase().contains(&needle) { lines.push(if files_only { p.display().to_string() } else { format!("{}:{}:{}", p.display(), i + 1, l.chars().take(300).collect::()) }); if files_only { break; } if lines.len() >= SEARCH_HARD_MAX_LINES { break 'walk; } } } } } let total = lines.len(); let mut out: Vec = lines.into_iter().take(SEARCH_CLIENT_LIMIT_LINES).collect(); if total > SEARCH_CLIENT_LIMIT_LINES { out.push(format!("…結果太多,只列出前 {SEARCH_CLIENT_LIMIT_LINES} 行(共 {total} 行以上)")); } if timed_out { out.push(format!("…搜尋超過 {} 秒,結果可能不完整", SEARCH_TIMEOUT_MS / 1000)); } if out.is_empty() { out.push("沒有找到".into()); } Ok(out.join("\n")) } fn read_file(p: &Path, offset: usize, limit: usize) -> Result { let md = std::fs::metadata(p).map_err(|e| format!("找不到 {}:{e}", p.display()))?; if md.is_dir() { return Err(format!("{} 是資料夾,請用 list_dir", p.display())); } if md.len() > READ_MAX_BYTES { return Err(format!("{} 太大了({}),最多讀 8 MB", p.display(), human_size(md.len()))); } let bytes = std::fs::read(p).map_err(|e| format!("無法讀取 {}:{e}", p.display()))?; if bytes.iter().take(8000).any(|b| *b == 0) { return Err(format!("{} 不是文字檔({})", p.display(), human_size(md.len()))); } let text = String::from_utf8_lossy(&bytes); let all: Vec<&str> = text.lines().collect(); let end = (offset - 1 + limit).min(all.len()); let mut out: Vec = all.iter().enumerate().skip(offset - 1).take(end.saturating_sub(offset - 1)).map(|(i, l)| format!("{:>5} {l}", i + 1)).collect(); if end < all.len() { out.push(format!("…還有 {} 行(用 offset={} 繼續讀)", all.len() - end, end + 1)); } if out.is_empty() { out.push("(空檔案)".into()); } Ok(out.join("\n")) } fn edit_file(p: &Path, old: &str, new: &str) -> Result { if old.is_empty() { if let Some(dir) = p.parent() { std::fs::create_dir_all(dir).map_err(|e| format!("無法建立資料夾:{e}"))?; } let existed = p.exists(); std::fs::write(p, new).map_err(|e| format!("無法寫入 {}:{e}", p.display()))?; return Ok(format!("{} {}({} 行)", if existed { "已覆寫" } else { "已建立" }, p.display(), new.lines().count())); } let text = std::fs::read_to_string(p).map_err(|e| format!("無法讀取 {}:{e}", p.display()))?; match text.matches(old).count() { 0 => Err(format!("在 {} 裡找不到要替換的文字", p.display())), 1 => { std::fs::write(p, text.replacen(old, new, 1)).map_err(|e| format!("無法寫入 {}:{e}", p.display()))?; Ok(format!("已修改 {}", p.display())) } n => Err(format!("要替換的文字在 {} 出現了 {n} 次,請多給一些前後文讓它只出現一次", p.display())), } } fn shell(cmd: &str, workdir: &Path, timeout_ms: u64, cancel: &AtomicBool, ids: &RunIds, seal: Option<&Path>) -> Result { run_command(build_command_sealed(cmd, workdir, &protected_dirs(), &readable_dirs(), seal, &ids.conversation, &ids.request), cmd, timeout_ms, cancel, ids, None) } /// Run a wrapper command (ssh / docker exec) the same way: background hand-off, stop, output. pub fn shell_wrapped(wrapper: &str, shown: &str, timeout_ms: u64, cancel: &AtomicBool, ids: &RunIds, kill: Option>) -> Result { run_command(build_wrapper_command(wrapper, &ids.conversation, &ids.request), shown, timeout_ms, cancel, ids, kill) } fn run_command(mut command: Command, cmd: &str, timeout_ms: u64, cancel: &AtomicBool, ids: &RunIds, kill: Option>) -> Result { let mut child = command.spawn().map_err(|e| format!("無法執行:{e}"))?; let stdin = Arc::new(Mutex::new(child.stdin.take())); let root = child.id(); let output = Arc::new(Mutex::new(String::new())); for stream in [child.stdout.take().map(|s| Box::new(s) as Box), child.stderr.take().map(|s| Box::new(s) as Box)].into_iter().flatten() { let out = output.clone(); std::thread::spawn(move || { let mut stream = stream; let mut chunk = [0u8; 4096]; while let Ok(n) = stream.read(&mut chunk) { if n == 0 { break; } // Cap the kept output (keep the newest): `yes` must not eat all the RAM. let mut out = out.lock().unwrap(); if out.len() < 4_000_000 { out.push_str(&String::from_utf8_lossy(&chunk[..n])); } } }); } let start = Instant::now(); loop { if let Ok(Some(status)) = child.try_wait() { std::thread::sleep(Duration::from_millis(30)); let out = head_tail(&output.lock().unwrap()); let code = status.code().unwrap_or(-1); return if code == 0 { Ok(if out.trim().is_empty() { "(完成,沒有輸出)".into() } else { out }) } else { Err(format!("結束代碼 {code}\n{out}")) }; } if cancel.load(Ordering::Relaxed) { kill_tree(root); let _ = child.kill(); // Reap it: a killed child nobody waits for stays a zombie for the app's lifetime. let _ = child.wait(); // The remote end (a program running inside the box) is not in this process tree. if let Some(k) = &kill { if let Some((head, rest)) = k.split_first() { let _ = Command::new(head).args(rest).status(); } } return Err("已停止(連同它啟動的程式)".into()); } if start.elapsed() >= Duration::from_millis(timeout_ms) { // Hand off to the background instead of killing it. let id = next_terminal_id(); let child = Arc::new(Mutex::new(child)); let exit = Arc::new(Mutex::new(None)); let (c2, e2) = (child.clone(), exit.clone()); std::thread::spawn(move || loop { if let Ok(Some(st)) = c2.lock().unwrap().try_wait() { *e2.lock().unwrap() = Some(st.code().unwrap_or(-1)); break; } std::thread::sleep(Duration::from_millis(200)); }); let so_far = head_tail(&output.lock().unwrap()); terminals().lock().unwrap().insert(id, Terminal { command: cmd.to_string(), stdin, output, child, exit, root, conversation: ids.conversation.clone() }); return Ok(format!("指令跑了超過 {} 秒,已移到背景繼續執行(背景指令 #{id})。用 read_terminal 查看進度。\n目前的輸出:\n{so_far}", timeout_ms / 1000)); } std::thread::sleep(Duration::from_millis(20)); } } fn read_terminal(id: u64) -> Result { let t = terminals().lock().unwrap(); let term = t.get(&id).ok_or_else(|| format!("沒有背景指令 #{id}"))?; let out = head_tail(&term.output.lock().unwrap()); let state = match *term.exit.lock().unwrap() { Some(c) => format!("已結束(代碼 {c})"), None => { let (n, cpu, mem) = tree_stats(term.root); format!("還在執行:{n} 個程式,CPU 時間 {:.1} 秒,記憶體 {mem} MB", cpu as f64 / 1000.0) } }; Ok(format!("背景指令 #{id}:{}\n狀態:{state}\n{out}", term.command)) } /// Send input to a background command (like typing into its terminal). `close` ends its input. fn write_terminal(id: u64, input: &str, close: bool) -> Result { use std::io::Write; // Copy the terminal's own handles out first: writing to a pipe whose buffer is full blocks, // and holding the shared registry lock while blocked stops every other terminal tool. let (stdin, exit) = { let t = terminals().lock().unwrap(); let term = t.get(&id).ok_or_else(|| format!("沒有背景指令 #{id}"))?; (term.stdin.clone(), term.exit.clone()) }; if exit.lock().unwrap().is_some() { return Err(format!("背景指令 #{id} 已經結束了")); } let mut slot = stdin.lock().unwrap(); if !input.is_empty() { let w = slot.as_mut().ok_or("這個指令的輸入已經關閉了")?; w.write_all(input.as_bytes()).and_then(|_| w.flush()).map_err(|e| format!("送不進去:{e}"))?; } if close { *slot = None; } drop(slot); std::thread::sleep(Duration::from_millis(400)); let out = read_terminal(id)?; Ok(format!("已送出{}{}。\n{out}", if input.is_empty() { String::new() } else { format!(" {} 個字", input.chars().count()) }, if close { ",並結束輸入" } else { "" })) } /// Stop every background command and what it started (when the app quits). pub fn stop_all() { for t in terminals().lock().unwrap().values() { // child before exit — the watcher thread takes them in this order; the reverse order // here could wedge both threads forever (AB-BA). let mut child = t.child.lock().unwrap(); if t.exit.lock().unwrap().is_none() { kill_tree(t.root); let _ = child.kill(); } } } /// Stop one bot's background commands (and what they started). Returns how many were running. pub fn stop_for(conversation: &str) -> usize { let mut n = 0; for t in terminals().lock().unwrap().values().filter(|t| t.conversation == conversation) { // Same lock order as the watcher thread (child, then exit). let mut child = t.child.lock().unwrap(); if t.exit.lock().unwrap().is_none() { kill_tree(t.root); let _ = child.kill(); n += 1; } } n } #[cfg(test)] mod tests { use super::*; fn wd() -> PathBuf { let d = std::env::temp_dir().join(format!("anybot-test-{}", std::process::id())); std::fs::create_dir_all(&d).unwrap(); d } #[test] fn splits_chains() { assert_eq!(split_commands("ls -la && git status | head; echo 'a;b'").unwrap(), vec!["ls -la", "git status", "head", "echo 'a;b'"]); assert!(split_commands("echo $(whoami)").is_none()); assert!(split_commands("eval ls").is_none()); } #[test] fn layered_rules() { let w = wd(); assert_eq!(decide("shell", r#"{"command":"ls && git status"}"#, &w, &[]), Decision::Allow); assert!(matches!(decide("shell", r#"{"command":"ls && sudo rm x"}"#, &w, &[]), Decision::Deny(_))); // Secrets are the sandbox's job; the permission layer no longer denies words that // merely look like secret paths. Read-only commands run (cat .env is cat), and the // sandbox hides the protected folders from them. assert_eq!(decide("shell", r#"{"command":"cat .env"}"#, &w, &[]), Decision::Allow); assert!(matches!(decide("shell", r#"{"command":"echo $(whoami)"}"#, &w, &[]), Decision::Ask { ref grant, .. } if grant == "shell:echo")); assert_eq!(decide("shell", r#"{"command":"echo $(whoami)"}"#, &w, &["shell:echo".into()]), Decision::Allow); assert!(matches!(decide("shell", r#"{"command":"npm install"}"#, &w, &[]), Decision::Ask { ref grant, .. } if grant == "shell:npm install")); assert_eq!(decide("shell", r#"{"command":"npm install && ls"}"#, &w, &["shell:npm install".into()]), Decision::Allow); assert!(matches!(decide("shell", r#"{"command":"echo hi > out.txt"}"#, &w, &[]), Decision::Ask { .. })); assert_eq!(decide("read_file", r#"{"path":"a.txt"}"#, &w, &[]), Decision::Allow); assert!(matches!(decide("read_file", r#"{"path":"/etc/hosts"}"#, &w, &[]), Decision::Ask { .. })); assert!(matches!(decide("read_file", r#"{"path":"../../.ssh/id_rsa"}"#, &w, &[]), Decision::Deny(_) | Decision::Ask { .. })); assert!(matches!(decide("edit_file", r#"{"path":"a.txt","new_text":"x"}"#, &w, &[]), Decision::Ask { .. })); assert_eq!(decide("edit_file", r#"{"path":"a.txt","new_text":"x"}"#, &w, &["edit_file".into()]), Decision::Allow); } #[test] fn edit_read_search() { let w = wd(); let c = AtomicBool::new(false); assert!(run("edit_file", r#"{"path":"n/a.txt","new_text":"hello\nworld\n"}"#, &w, &c, &RunIds::default()).1); assert!(run("edit_file", r#"{"path":"n/a.txt","old_text":"world","new_text":"there"}"#, &w, &c, &RunIds::default()).1); let (r, ok) = run("read_file", r#"{"path":"n/a.txt"}"#, &w, &c, &RunIds::default()); assert!(ok && r.contains("there"), "{r}"); let (s, ok) = run("search", r#"{"pattern":"there"}"#, &w, &c, &RunIds::default()); assert!(ok && s.contains("a.txt"), "{s}"); assert!(!run("edit_file", r#"{"path":"n/a.txt","old_text":"missing","new_text":"x"}"#, &w, &c, &RunIds::default()).1); } #[test] fn shell_moves_to_background() { let w = wd(); let c = AtomicBool::new(false); let (r, ok) = run("shell", r#"{"command":"echo start; sleep 1; echo end","timeout_ms":200}"#, &w, &c, &RunIds::default()); assert!(ok && r.contains("背景指令 #"), "{r}"); let id: u64 = r.split("背景指令 #").nth(1).unwrap().split(')').next().unwrap().parse().unwrap(); std::thread::sleep(Duration::from_millis(1500)); let (t, _) = run("read_terminal", &format!(r#"{{"id":{id}}}"#), &w, &c, &RunIds::default()); assert!(t.contains("end") && t.contains("已結束"), "{t}"); let (q, ok) = run("shell", r#"{"command":"echo quick"}"#, &w, &c, &RunIds::default()); assert!(ok && q.contains("quick")); } #[test] fn head_tail_trims_middle() { let s = "a".repeat(OUTPUT_HEAD_CHARS) + &"b".repeat(5000) + &"c".repeat(OUTPUT_TAIL_CHARS); let t = head_tail(&s); assert!(t.contains("中間省略 5000 個字")); } #[test] fn cpu_time_and_tree_walk() { assert_eq!(parse_cpu_time("0:01.50"), 1500); assert_eq!(parse_cpu_time("01:02:03"), 3_723_000); assert_eq!(parse_cpu_time("1-00:00:00"), 86_400_000); let t = [ ProcRow { pid: 10, ppid: 1, rss_kb: 2048, cpu_ms: 5 }, ProcRow { pid: 11, ppid: 10, rss_kb: 1024, cpu_ms: 7 }, ProcRow { pid: 12, ppid: 11, rss_kb: 0, cpu_ms: 0 }, ProcRow { pid: 13, ppid: 1, rss_kb: 0, cpu_ms: 0 }, ]; assert_eq!(descendants(&t, &[10]), vec![10, 11, 12]); assert_eq!(descendants(&t, &[10, 11]), vec![10, 11, 12]); } #[test] fn commands_are_tagged_and_keys_hidden() { let w = wd(); let ids = RunIds { conversation: "7".into(), request: "call_x".into() }; let (r, ok) = run("shell", r#"{"command":"echo $ANYBOT_CONVERSATION_ID/$ANYBOT_REQUEST_ID/${OPENAI_API_KEY:-none}"}"#, &w, &AtomicBool::new(false), &ids); assert!(ok && r.contains("7/call_x/none"), "{r}"); } #[test] fn sandbox_blocks_protected_folders() { if sandbox_exec().is_none() { return; } let w = wd(); let secret = std::env::temp_dir().join(format!("anybot-secret-{}", std::process::id())); std::fs::create_dir_all(&secret).unwrap(); std::fs::write(secret.join("k.txt"), "top-secret").unwrap(); let out = build_command(&format!("cat '{}/k.txt'", secret.display()), &w, &[secret.clone()], "1", "r").output().unwrap(); assert!(!String::from_utf8_lossy(&out.stdout).contains("top-secret")); let out = build_command(&format!("cat '{}/k.txt'", secret.display()), &w, &[], "1", "r").output().unwrap(); assert!(String::from_utf8_lossy(&out.stdout).contains("top-secret")); // The chat's attachments inside it: readable, not writable; the keys next to them: hidden. let att = secret.join("attachments"); std::fs::create_dir_all(&att).unwrap(); std::fs::write(att.join("photo.txt"), "the-photo").unwrap(); let run = |cmd: String| build_command_sealed(&cmd, &w, &[secret.clone()], &[att.clone()], None, "1", "r").output().unwrap(); assert!(String::from_utf8_lossy(&run(format!("cat '{}/photo.txt'", att.display())).stdout).contains("the-photo")); assert!(!String::from_utf8_lossy(&run(format!("cat '{}/k.txt'", secret.display())).stdout).contains("top-secret")); run(format!("echo changed > '{}/photo.txt'", att.display())); assert_eq!(std::fs::read_to_string(att.join("photo.txt")).unwrap(), "the-photo"); let _ = std::fs::remove_dir_all(&secret); } #[test] fn bot_computer_is_sealed() { if sandbox_exec().is_none() { return; } let Some(home) = dirs::home_dir() else { return }; let root = home.join("AnyBot 工作區").join(format!("test-seal-{}", std::process::id())); std::fs::create_dir_all(&root).unwrap(); let t = Target { name: crate::model::BOT_COMPUTER_NAME.into(), place: Place::Sealed { root: root.clone() } }; let c = AtomicBool::new(false); let (r, ok) = run_on("shell", r#"{"command":"echo hi > mine.txt && cat mine.txt"}"#, &t, &c, &RunIds::default()); assert!(ok && r.contains("hi"), "{r}"); let (r, _) = run_on("shell", r#"{"command":"ls ~/Documents >/dev/null 2>&1 && echo SEEN || echo HIDDEN"}"#, &t, &c, &RunIds::default()); assert!(r.contains("HIDDEN"), "{r}"); assert!(matches!(decide_on("read_file", r#"{"path":"~/Documents/x.txt"}"#, &t, &[]), Decision::Deny(_))); assert_eq!(decide_on("edit_file", r#"{"path":"a.txt","new_text":"x"}"#, &t, &[]), Decision::Allow); let _ = std::fs::remove_dir_all(&root); } #[test] fn ssh_commands_are_quoted() { assert_eq!(ssh_command("me@box", "/srv/app", "echo 'hi'"), "ssh -o BatchMode=yes -o ConnectTimeout=15 'me@box' 'cd '\\''/srv/app'\\'' && echo '\\''hi'\\'''"); let t = Target { name: "工作站".into(), place: Place::Ssh { target: "me@box".into(), dir: String::new() } }; assert!(matches!(decide_on("shell", r#"{"command":"npm test"}"#, &t, &[]), Decision::Ask { ref reason, .. } if reason.contains("工作站"))); assert_eq!(decide_on("shell", r#"{"command":"ls"}"#, &t, &[]), Decision::Allow); let s = specs_for(&["A".into(), "B".into()], "A"); assert_eq!(s[0]["parameters"]["properties"]["computer"]["enum"][1], "B"); } #[test] fn stop_ends_the_whole_tree() { let w = wd(); let cancel = Arc::new(AtomicBool::new(false)); let c2 = cancel.clone(); let marker = format!("anybot-sleep-{}", std::process::id()); let cmd = format!(r#"{{"command":"sh -c 'exec -a {marker} sleep 30' & sleep 30","timeout_ms":20000}}"#); let h = std::thread::spawn(move || run("shell", &cmd, &wd(), &c2, &RunIds::default())); std::thread::sleep(Duration::from_millis(800)); cancel.store(true, Ordering::Relaxed); let (r, ok) = h.join().unwrap(); assert!(!ok && r.contains("已停止"), "{r}"); std::thread::sleep(Duration::from_millis(300)); let ps = Command::new("ps").args(["-A", "-o", "command="]).output().unwrap(); assert!(!String::from_utf8_lossy(&ps.stdout).contains(&marker), "child still running"); let _ = w; } }