AnyBot/src/tools.rs

1439 lines
68 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.

// 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<Value> {
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<Vec<String>> {
let mut parts = vec![];
let mut cur = String::new();
let (mut sq, mut dq) = (false, false);
let chars: Vec<char> = 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<String> = 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::<u64>().unwrap_or(0), r),
None => (0, s),
};
let mut secs = 0f64;
for part in rest.split(':') {
secs = secs * 60.0 + part.parse::<f64>().unwrap_or(0.0);
}
days * 86_400_000 + (secs * 1000.0).round() as u64
}
pub fn proc_table() -> Vec<ProcRow> {
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<u32> {
let mut seen = std::collections::HashSet::new();
let mut out = vec![];
let mut queue: std::collections::VecDeque<u32> = 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<PathBuf> {
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<PathBuf> {
let mut v: Vec<PathBuf> = 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)
}
/// Can the bot's own computer run commands without Docker? Only inside a sandbox (macOS):
/// elsewhere a command there would run on this computer itself, as AnyBot, keys and all.
pub fn sealed_runs_commands() -> bool {
sandbox_exec().is_some()
}
/// Why the bot's own computer can't run a command right now.
pub const NO_BOX_FOR_COMMANDS: &str = "你自己的電腦現在不能執行指令:AnyBot 所在的這台沒有 Docker(或連不到 Docker),沒有隔離的地方可以跑指令。請告訴使用者把 Docker 打開,或請他指名別台電腦來做。";
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<Mutex<Option<std::process::ChildStdin>>>,
output: Arc<Mutex<String>>,
child: Arc<Mutex<std::process::Child>>,
exit: Arc<Mutex<Option<i32>>>,
root: u32,
/// The bot it belongs to (the ANYBOT_CONVERSATION_ID tag).
conversation: String,
}
fn terminals() -> &'static Mutex<HashMap<u64, Terminal>> {
static T: OnceLock<Mutex<HashMap<u64, Terminal>>> = 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<String>,
/// 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<Value> {
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<String> {
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<Decision> {
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. No sandbox, no command.
if !sealed_runs_commands() {
return Decision::Deny(NO_BOX_FOR_COMMANDS.into());
}
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<String, String> {
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<String, String>| 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" && !sealed_runs_commands() {
return (NO_BOX_FOR_COMMANDS.into(), false);
}
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<String, String> {
let joined = if p.starts_with('/') { PathBuf::from(p) } else { dir.join(p) };
let mut parts: Vec<std::ffi::OsString> = 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<String, String> {
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<String> = 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<String, String> {
if pattern.is_empty() {
return Err("請給要搜尋的文字".into());
}
let deadline = Instant::now() + Duration::from_millis(SEARCH_TIMEOUT_MS);
let mut lines: Vec<String> = 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::<String>()) });
if files_only {
break;
}
if lines.len() >= SEARCH_HARD_MAX_LINES {
break 'walk;
}
}
}
}
}
let total = lines.len();
let mut out: Vec<String> = 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<String, String> {
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<String> = 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<String, String> {
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<String, String> {
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<Vec<String>>) -> Result<String, String> {
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<Vec<String>>) -> Result<String, String> {
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<dyn Read + Send>), child.stderr.take().map(|s| Box::new(s) as Box<dyn Read + Send>)].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<String, String> {
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<String, String> {
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() {
// No sandbox (Linux, Windows): no command runs there at all, asked or not.
let t = Target { name: crate::model::BOT_COMPUTER_NAME.into(), place: Place::Sealed { root: std::env::temp_dir().join("anybot-no-seal") } };
assert!(matches!(decide_on("shell", r#"{"command":"cat ~/.ssh/id_rsa"}"#, &t, &[]), Decision::Deny(_)));
let (r, ok) = run_on("shell", r#"{"command":"echo ran"}"#, &t, &AtomicBool::new(false), &RunIds::default());
assert!(!ok && !r.contains("ran"), "{r}");
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;
}
}