Commands on another computer run in the bot's work folder and finish before the relay gives up; read_terminal goes to the same computer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
王性驊 2026-11-05 21:28:32 +08:00
parent db028b0b5b
commit 26fa2a8ee9
6 changed files with 87 additions and 37 deletions

View File

@ -71,7 +71,7 @@ AnyBot 是一個 Rust 程式(`anybot`),用 egui/eframe 畫介面。同一
## 安全 ## 安全
- **核准關卡**:工具動作照規則直接做、先問或拒絕(`policy.rs`,FEATURES.md 第 16、19 節)。「直接操作模式」可以跳過詢問。 - **核准關卡**:工具動作照規則直接做、先問或拒絕(`policy.rs`,FEATURES.md 第 16、19 節)。「直接操作模式」可以跳過詢問。
- **指令的沙盒(macOS)**:Bot 執行的指令在 `sandbox-exec` 裡跑,讀不到也寫不到 AnyBot 的資料資料夾和 `.ssh`、`.gnupg`、鑰匙圈、`.aws` 等;只有 `attachments/` 可以讀(不能寫)。環境變數裡的模型金鑰會先拿掉。 - **指令的沙盒(macOS)**:Bot 執行的指令在 `sandbox-exec` 裡跑,讀不到也寫不到 AnyBot 的資料資料夾、`.gnupg`、鑰匙圈、`.aws` 等;只有 `attachments/` 和 `.ssh` 可以讀(都不能寫,`.ssh` 可讀讓 git 和 ssh 連得動)。只有沙盒管得到的才是祕密防線,核准層不再逐字掃路徑。環境變數裡的模型金鑰會先拿掉,PATH 會補齊 Homebrew 等位置(從 Finder 或 launchd 啟動也要找得到指令)。
- **Bot 的電腦**:放在容器裡,和你的電腦分開。 - **Bot 的電腦**:放在容器裡,和你的電腦分開。
- **保險庫**:密碼、卡號、CVC 不會交給模型,由 AnyBot 直接填進網頁。 - **保險庫**:密碼、卡號、CVC 不會交給模型,由 AnyBot 直接填進網頁。
- **多台電腦**:金鑰只留在大腦(下面說明);手腳只接受帳號裡的 AnyBot,用配對碼加入的只接受那一個大腦。 - **多台電腦**:金鑰只留在大腦(下面說明);手腳只接受帳號裡的 AnyBot,用配對碼加入的只接受那一個大腦。
@ -115,7 +115,7 @@ AnyBot 是一個 Rust 程式(`anybot`),用 egui/eframe 畫介面。同一
### 手腳 ### 手腳
大腦的 Bot 呼叫工具時可以指定 `computer`。如果是帳號裡的另一台,就透過長連線送 `/api/device/run` 給它,由它在自己身上執行(螢幕、檔案、指令、它的 AnyBot 瀏覽器),再把文字和截圖傳回來。那台要開「裝置連結」才接受;若設了只給某個大腦用(`hands_for`),帳號伺服器會附上是哪一台在問,不是那台就拒絕。 大腦的 Bot 呼叫工具時可以指定 `computer`。如果是帳號裡的另一台,就透過長連線送 `/api/device/run` 給它,由它在自己身上執行(螢幕、檔案、指令、它的 AnyBot 瀏覽器),再把文字和截圖傳回來。那台要開「裝置連結」才接受;若設了只給某個大腦用(`hands_for`),帳號伺服器會附上是哪一台在問,不是那台就拒絕。指令在那台的工作資料夾(Bot 的 `workdir`)跑;連線經過帳號伺服器只有 35 秒,所以手腳上的指令超過 25 秒就移到背景,用同一台的 `read_terminal` 追蹤(computer 填那台)。
### 配對碼 ### 配對碼

View File

@ -131,7 +131,7 @@ fn browser_place(job: &Job, name: &str, args: &str) -> (BrowserAt, String) {
let want = v.get("computer").and_then(|c| c.as_str()).map(str::trim).filter(|c| !c.is_empty()).unwrap_or(&job.default_computer).to_string(); let want = v.get("computer").and_then(|c| c.as_str()).map(str::trim).filter(|c| !c.is_empty()).unwrap_or(&job.default_computer).to_string();
let at = |t: &tools::Target| -> Option<BrowserAt> { let at = |t: &tools::Target| -> Option<BrowserAt> {
match &t.place { match &t.place {
tools::Place::Device { host } => Some(BrowserAt::Device(host.clone())), tools::Place::Device { host, .. } => Some(BrowserAt::Device(host.clone())),
tools::Place::Box { .. } if crate::cloudbox::available() => Some(BrowserAt::Box), tools::Place::Box { .. } if crate::cloudbox::available() => Some(BrowserAt::Box),
tools::Place::Local { .. } if crate::botbrowser::available() => Some(BrowserAt::Here), tools::Place::Local { .. } if crate::botbrowser::available() => Some(BrowserAt::Here),
_ => None, _ => None,
@ -262,7 +262,7 @@ fn roles_now(job: &Job) -> tools::Roles {
let mut r = job.roles.clone(); let mut r = job.roles.clone();
let host = crate::remote::person_host(); let host = crate::remote::person_host();
if !host.is_empty() { if !host.is_empty() {
if let Some(t) = job.computers.iter().find(|t| matches!(&t.place, tools::Place::Device { host: h } if *h == host)) { if let Some(t) = job.computers.iter().find(|t| matches!(&t.place, tools::Place::Device { host: h, .. } if *h == host)) {
r.person = Some(t.name.clone()); r.person = Some(t.name.clone());
} }
} }
@ -651,7 +651,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
// computer the person is using; a file is sent into the chat instead. // computer the person is using; a file is sent into the chat instead.
let person_now = roles_now(&job).person; let person_now = roles_now(&job).person;
let person_host = person_now.as_ref().and_then(|n| job.computers.iter().find(|t| &t.name == n)).and_then(|t| match &t.place { let person_host = person_now.as_ref().and_then(|n| job.computers.iter().find(|t| &t.name == n)).and_then(|t| match &t.place {
tools::Place::Device { host } => Some(host.clone()), tools::Place::Device { host, .. } => Some(host.clone()),
_ => None, _ => None,
}); });
if job.roles.server { if job.roles.server {
@ -887,7 +887,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
}; };
let on_box = matches!(target.as_ref().map(|t| &t.place), Ok(tools::Place::Box { .. })); let on_box = matches!(target.as_ref().map(|t| &t.place), Ok(tools::Place::Box { .. }));
let on_device = match target.as_ref().map(|t| &t.place) { let on_device = match target.as_ref().map(|t| &t.place) {
Ok(tools::Place::Device { host }) => Some(host.clone()), Ok(tools::Place::Device { host, .. }) => Some(host.clone()),
_ => None, _ => None,
}; };
// Permission to use computers at all, and this Mac in particular, is asked for // Permission to use computers at all, and this Mac in particular, is asked for
@ -1151,7 +1151,7 @@ mod tests {
assert!(!finds("你的虛擬電腦很吵", "bot 的電腦")); assert!(!finds("你的虛擬電腦很吵", "bot 的電腦"));
assert!(finds("在 bot 的電腦跑", "bot 的電腦")); assert!(finds("在 bot 的電腦跑", "bot 的電腦"));
let roles = tools::Roles { own: "Master 的電腦".into(), person: Some("王小明的Mac mini".into()), server: true }; let roles = tools::Roles { own: "Master 的電腦".into(), person: Some("王小明的Mac mini".into()), server: true };
let mac = tools::Target { name: "王小明的Mac mini".into(), place: tools::Place::Device { host: "m".into() } }; let mac = tools::Target { name: "王小明的Mac mini".into(), place: tools::Place::Device { host: "m".into(), dir: String::new() } };
assert!(mentions("幫我在 Mac 截圖", &mac, &roles)); assert!(mentions("幫我在 Mac 截圖", &mac, &roles));
assert!(!mentions("在 macOS 的登入機制下,幫 Bot 的電腦截圖", &mac, &roles)); assert!(!mentions("在 macOS 的登入機制下,幫 Bot 的電腦截圖", &mac, &roles));
} }
@ -1171,8 +1171,8 @@ mod tests {
// the everyday words point at the right one (「我的電腦」 = where the person typed). // the everyday words point at the right one (「我的電腦」 = where the person typed).
let roles = tools::Roles { own: "Master 的電腦".into(), person: Some("王小明的Mac mini".into()), server: true }; let roles = tools::Roles { own: "Master 的電腦".into(), person: Some("王小明的Mac mini".into()), server: true };
let own = tools::Target { name: "Master 的電腦".into(), place: tools::Place::Box { dir: String::new() } }; let own = tools::Target { name: "Master 的電腦".into(), place: tools::Place::Box { dir: String::new() } };
let mac = tools::Target { name: "王小明的Mac mini".into(), place: tools::Place::Device { host: "m".into() } }; let mac = tools::Target { name: "王小明的Mac mini".into(), place: tools::Place::Device { host: "m".into(), dir: String::new() } };
let desk = tools::Target { name: "ai".into(), place: tools::Place::Device { host: "a".into() } }; let desk = tools::Target { name: "ai".into(), place: tools::Place::Device { host: "a".into(), dir: String::new() } };
let server = tools::Target { name: "雲端大腦".into(), place: tools::Place::Local { workdir: Default::default() } }; let server = tools::Target { name: "雲端大腦".into(), place: tools::Place::Local { workdir: Default::default() } };
assert!(mentions("在 Master 的電腦打開 Threads", &own, &roles)); assert!(mentions("在 Master 的電腦打開 Threads", &own, &roles));
assert!(mentions("用master的電腦", &own, &roles)); assert!(mentions("用master的電腦", &own, &roles));
@ -1192,7 +1192,7 @@ mod tests {
let job = Job { let job = Job {
computers: vec![ computers: vec![
tools::Target { name: "Master 的電腦".into(), place: tools::Place::Box { dir: String::new() } }, tools::Target { name: "Master 的電腦".into(), place: tools::Place::Box { dir: String::new() } },
tools::Target { name: "王小明的Mac mini".into(), place: tools::Place::Device { host: "m".into() } }, tools::Target { name: "王小明的Mac mini".into(), place: tools::Place::Device { host: "m".into(), dir: String::new() } },
], ],
default_computer: "Master 的電腦".into(), default_computer: "Master 的電腦".into(),
roles, roles,
@ -1491,7 +1491,7 @@ mod tests {
}); });
let job = Job { let job = Job {
req: ChatRequest { kind: ProviderKind::Custom, base_url: format!("http://127.0.0.1:{port}/v1"), api_key: String::new(), model: "m".into(), system: String::new(), history: vec![Message::user("幫我登入 Threads")], tools: vec![], session: String::new() }, req: ChatRequest { kind: ProviderKind::Custom, base_url: format!("http://127.0.0.1:{port}/v1"), api_key: String::new(), model: "m".into(), system: String::new(), history: vec![Message::user("幫我登入 Threads")], tools: vec![], session: String::new() },
computers: vec![tools::Target { name: "王小明的Mac mini".into(), place: tools::Place::Device { host: "m".into() } }], computers: vec![tools::Target { name: "王小明的Mac mini".into(), place: tools::Place::Device { host: "m".into(), dir: String::new() } }],
default_computer: "王小明的Mac mini".into(), default_computer: "王小明的Mac mini".into(),
roles: tools::Roles { own: "Master 的電腦".into(), person: Some("王小明的Mac mini".into()), server: true }, roles: tools::Roles { own: "Master 的電腦".into(), person: Some("王小明的Mac mini".into()), server: true },
..test_job() ..test_job()

View File

@ -870,8 +870,12 @@ impl App {
for c in &self.state.remote_computers { for c in &self.state.remote_computers {
add(&mut v, tools::Target { name: c.name.clone(), place: tools::Place::Ssh { target: c.ssh_target.clone(), dir: c.dir.clone() } }); add(&mut v, tools::Target { name: c.name.clone(), place: tools::Place::Ssh { target: c.ssh_target.clone(), dir: c.dir.clone() } });
} }
// 裝置連結: other AnyBots of this account that allow it. // 裝置連結: other AnyBots of this account that allow it, working in the bot's work folder.
for d in self.linked_devices() { for d in self.linked_devices() {
let mut d = d;
if let tools::Place::Device { dir, .. } = &mut d.place {
dir.clone_from(&b.workdir);
}
add(&mut v, d); add(&mut v, d);
} }
v v
@ -883,7 +887,7 @@ impl App {
pub(super) fn roles_for(&self, b: &Bot, targets: &[tools::Target]) -> tools::Roles { pub(super) fn roles_for(&self, b: &Bot, targets: &[tools::Target]) -> tools::Roles {
let server = self.headless; let server = self.headless;
let local = targets.iter().find(|t| matches!(t.place, tools::Place::Local { .. })).map(|t| t.name.clone()); let local = targets.iter().find(|t| matches!(t.place, tools::Place::Local { .. })).map(|t| t.name.clone());
let device = |h: &str| targets.iter().find(|t| matches!(&t.place, tools::Place::Device { host } if host == h)).map(|t| t.name.clone()); let device = |h: &str| targets.iter().find(|t| matches!(&t.place, tools::Place::Device { host, .. } if host == h)).map(|t| t.name.clone());
let person = match self.typed_on.as_deref() { let person = match self.typed_on.as_deref() {
Some(h) if !h.is_empty() => device(h).or_else(|| if server { None } else { local.clone() }), Some(h) if !h.is_empty() => device(h).or_else(|| if server { None } else { local.clone() }),
_ if !server => local, _ if !server => local,

View File

@ -776,7 +776,7 @@ impl App {
let uses = remote::peer_uses(id); let uses = remote::peer_uses(id);
uses.is_empty() || uses == self.state.remote.host_id uses.is_empty() || uses == self.state.remote.host_id
}) })
.map(|(id, name, _)| tools::Target { name: name.clone(), place: tools::Place::Device { host: id.clone() } }) .map(|(id, name, _)| tools::Target { name: name.clone(), place: tools::Place::Device { host: id.clone(), dir: String::new() } })
.collect() .collect()
} }
} }

View File

@ -708,7 +708,13 @@ fn device_run(v: &Value, s: &Settings, via_relay: bool, from: &str) -> Resp {
return Resp::json(&json!({"text": text, "ok": ok})); return Resp::json(&json!({"text": text, "ok": ok}));
} }
if matches!(tool, "shell" | "read_file" | "edit_file" | "list_dir" | "search" | "read_terminal" | "write_terminal") { if matches!(tool, "shell" | "read_file" | "edit_file" | "list_dir" | "search" | "read_terminal" | "write_terminal") {
let t = crate::tools::Target { name: crate::model::THIS_COMPUTER_NAME.into(), place: crate::tools::Place::Local { workdir: crate::tools::expand_home(v["workdir"].as_str().unwrap_or("~")) } }; // The brain passes the bot's work folder inside the tool's args (empty = this home).
let dir = crate::tools::args_of(args).get("workdir").and_then(Value::as_str).unwrap_or("").trim().to_string();
let mut workdir = crate::tools::expand_home(if dir.is_empty() { "~" } else { &dir });
if !workdir.is_absolute() {
workdir = dirs::home_dir().unwrap_or(workdir);
}
let t = crate::tools::Target { name: crate::model::THIS_COMPUTER_NAME.into(), place: crate::tools::Place::Local { workdir } };
let ids = crate::tools::RunIds { conversation: "device".into(), request: httpd::random_hex(6) }; let ids = crate::tools::RunIds { conversation: "device".into(), request: httpd::random_hex(6) };
let (text, ok) = crate::tools::run_on(tool, args, &t, &std::sync::atomic::AtomicBool::new(false), &ids); let (text, ok) = crate::tools::run_on(tool, args, &t, &std::sync::atomic::AtomicBool::new(false), &ids);
return Resp::json(&json!({"text": text, "ok": ok})); return Resp::json(&json!({"text": text, "ok": ok}));

View File

@ -26,6 +26,9 @@ 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). /// 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_DEFAULT_TIMEOUT_MS: u64 = 30_000;
pub const SHELL_MAX_TIMEOUT_MS: u64 = 600_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; /// 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). /// the original reports output_head / output_tail / elided_chars).
pub const OUTPUT_HEAD_CHARS: usize = 6_000; pub const OUTPUT_HEAD_CHARS: usize = 6_000;
@ -49,7 +52,7 @@ pub fn specs() -> Vec<Value> {
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.", 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": { "parameters": {"type": "object", "properties": {
"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "new_text"]}}), "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.", 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": { "parameters": {"type": "object", "properties": {
"command": {"type": "string"}, "command": {"type": "string"},
"timeout_ms": {"type": "integer", "description": "How long to wait before moving it to the background (default 30000)."}}, "required": ["command"]}}), "timeout_ms": {"type": "integer", "description": "How long to wait before moving it to the background (default 30000)."}}, "required": ["command"]}}),
@ -330,7 +333,17 @@ pub fn decide(name: &str, args: &str, workdir: &Path, grants: &[String]) -> Deci
return Decision::Deny("沒有指令".into()); return Decision::Deny("沒有指令".into());
} }
let Some(parts) = split_commands(&cmd) else { let Some(parts) = split_commands(&cmd) else {
return Decision::Ask { reason: format!("執行指令(含有隱藏的子指令,無法自動判斷):{cmd}"), grant: String::new() }; // 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 // deny
for part in &parts { for part in &parts {
@ -338,14 +351,8 @@ pub fn decide(name: &str, args: &str, workdir: &Path, grants: &[String]) -> Deci
return Decision::Deny(format!("「{d}」這類指令一律不允許。")); return Decision::Deny(format!("「{d}」這類指令一律不允許。"));
} }
} }
// ignore: commands that point at secret files // (No word-by-word path scan here: secret folders are the sandbox's job, and
for part in &parts { // scanning words as paths denied perfectly ordinary commands.)
for w in words(part).iter().skip(1) {
if is_ignored(&resolve(workdir, w.trim_matches(['"', '\'']))) {
return Decision::Deny(format!("指令碰到私密或系統檔案:{w}"));
}
}
}
// allow: every part is read-only or already granted // 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 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(); let unapproved: Vec<&String> = parts.iter().filter(|p| !ok(p)).collect();
@ -466,9 +473,14 @@ pub fn protected_dirs() -> Vec<PathBuf> {
} }
/// Inside the protected folders, what commands may still read: the chat's attachments (a picture /// 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). /// 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> { pub fn readable_dirs() -> Vec<PathBuf> {
crate::model::State::path().parent().map(|d| vec![d.join("attachments")]).unwrap_or_default() 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> { fn sandbox_exec() -> Option<&'static str> {
@ -525,6 +537,9 @@ pub fn build_command_sealed(cmd: &str, workdir: &Path, protected: &[PathBuf], re
for k in KEY_ENV_VARS { for k in KEY_ENV_VARS {
c.env_remove(k); 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); c.env(ENV_CONVERSATION_ID, conversation).env(ENV_REQUEST_ID, request);
#[cfg(unix)] #[cfg(unix)]
{ {
@ -616,8 +631,9 @@ pub enum Place {
Local { workdir: PathBuf }, Local { workdir: PathBuf },
/// Another computer: `ssh target`, starting in `dir` (empty = its home). /// Another computer: `ssh target`, starting in `dir` (empty = its home).
Ssh { target: String, dir: String }, Ssh { target: String, dir: String },
/// 裝置連結: another AnyBot of the same account (through the relay), by its host id. /// 裝置連結: another AnyBot of the same account (through the relay), by its host id,
Device { host: String }, /// starting in `dir` (empty = its home).
Device { host: String, dir: String },
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@ -641,9 +657,8 @@ pub struct Roles {
pub fn specs_for(computers: &[String], default: &str) -> Vec<Value> { pub fn specs_for(computers: &[String], default: &str) -> Vec<Value> {
let mut v = specs(); let mut v = specs();
for t in v.iter_mut() { for t in v.iter_mut() {
if t["name"] == "read_terminal" || t["name"] == "write_terminal" { // Every tool that works on a computer names one, background terminals included: a command
continue; // 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 // 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. // 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."); 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.");
@ -760,10 +775,30 @@ pub fn run_on(name: &str, args: &str, target: &Target, cancel: &AtomicBool, ids:
}; };
match &target.place { match &target.place {
Place::Local { workdir } => run(name, args, workdir, cancel, ids), Place::Local { workdir } => run(name, args, workdir, cancel, ids),
Place::Device { host } => match crate::remote::run_on_device(host, name, args, "") { Place::Device { host, dir } => {
Ok(v) => (v["text"].as_str().unwrap_or("").to_string(), v["ok"].as_bool().unwrap_or(false)), // 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), Err(e) => (e, false),
}, }
}
Place::Sealed { root } => { Place::Sealed { root } => {
let _ = std::fs::create_dir_all(root); let _ = std::fs::create_dir_all(root);
if name == "shell" { if name == "shell" {
@ -1235,7 +1270,12 @@ mod tests {
let w = wd(); let w = wd();
assert_eq!(decide("shell", r#"{"command":"ls && git status"}"#, &w, &[]), Decision::Allow); 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(_))); assert!(matches!(decide("shell", r#"{"command":"ls && sudo rm x"}"#, &w, &[]), Decision::Deny(_)));
assert!(matches!(decide("shell", r#"{"command":"cat .env"}"#, &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!(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_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!(matches!(decide("shell", r#"{"command":"echo hi > out.txt"}"#, &w, &[]), Decision::Ask { .. }));