Each job works on its own tabs; a Lima VM the box lives in is started when it's down

- Bots share a browser (the box's Chromium has one profile). Every step acted on the tab in
  front, so two jobs at once read and clicked on each other's pages and one finishing closed
  the other's. A job (named, and the name rides along to another computer's browser) owns the
  tabs it opens or lands on, popups it clicks open included; its steps use its latest one, and
  another job's tabs are never used, listed or closed. When it ends it closes only its own.
  Real run on the box: two jobs at once each read their own page with the other's in front,
  both tabs survived both endings; a popup was read by the job that opened it.
- A Docker host that is a Lima VM's socket and doesn't answer (the Mac restarted): AnyBot
  starts the VM (limactl start, at most once in 5 minutes), and a step that needs the box
  waits for it. No login item needed. Real run: the VM stopped, AnyBot had it back in 10 s;
  the box then woke on use, still gVisor with Chromium's sandbox.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha2RVfSYmrhqNnqx9Z4
This commit is contained in:
王性驊 2026-11-07 00:50:29 +08:00
parent 38f15c53a6
commit 81e87545cd
7 changed files with 374 additions and 52 deletions

View File

@ -729,7 +729,7 @@ AnyBot 是一個桌面聊天 App,介面和運作方式照 Grok Bot 重做。
- 預設是這台 Mac 的 Docker(OrbStack 或 Docker Desktop)。
- 可以填 Docker 主機,例如 `ssh://me@server`(Tailscale 的機器名稱也行),Bot 的電腦就會在那台主機上;看畫面時 AnyBot 會自己開 SSH 通道。
- **容器隔離**(設定 › 電腦 › 在哪裡執行):標準(runc:容器和 Docker 主機共用核心,Chromium 關掉自己的沙盒,容器是唯一那道牆)或強隔離(gVisor/runsc:電腦有自己的虛擬核心,權限全拿掉只留開機要的幾個,Chromium 的沙盒打開,兩道牆)。Docker 主機沒有 gVisor 時強隔離不能選;設定是強隔離、主機卻沒有 gVisor 時(例如換了 Docker 主機)先用標準開機並寫進紀錄,不會整台開不起來。那一列顯示電腦實際用的是哪種,和設定不同時提示按「更新」。Mac 上用 `deploy/mac/gvisor-vm.sh` 建一台自帶 Docker+gVisor 的 Lima 虛擬機(登入時自動啟動),`gvisor-migrate.sh` 把原本的檔案和登入搬過去(AnyBot 要先完全退出)。實測(Mac 上的 Lima VM):開機、畫面、VNC、無障礙編號、xdotool、截圖都正常;13 個 Chromium 程序都沒有 --no-sandbox,網頁程序 seccomp=2、有自己的命名空間;每一步 exec 55–69 ms、截圖 95 ms。
- **容器隔離**(設定 › 電腦 › 在哪裡執行):標準(runc:容器和 Docker 主機共用核心,Chromium 關掉自己的沙盒,容器是唯一那道牆)或強隔離(gVisor/runsc:電腦有自己的虛擬核心,權限全拿掉只留開機要的幾個,Chromium 的沙盒打開,兩道牆)。Docker 主機沒有 gVisor 時強隔離不能選;設定是強隔離、主機卻沒有 gVisor 時(例如換了 Docker 主機)先用標準開機並寫進紀錄,不會整台開不起來。那一列顯示電腦實際用的是哪種,和設定不同時提示按「更新」。Mac 上用 `deploy/mac/gvisor-vm.sh` 建一台自帶 Docker+gVisor 的 Lima 虛擬機(虛擬機沒開時 AnyBot 會自己開,例如 Mac 重開機後),`gvisor-migrate.sh` 把原本的檔案和登入搬過去(AnyBot 要先完全退出)。實測(Mac 上的 Lima VM):開機、畫面、VNC、無障礙編號、xdotool、截圖都正常;13 個 Chromium 程序都沒有 --no-sandbox,網頁程序 seccomp=2、有自己的命名空間;每一步 exec 55–69 ms、截圖 95 ms。
- 換位置後會在新的地方重新建立;舊檔案可以先「儲存快照」,換過去再「從快照復原」。
- **「網路流量改走這台 Mac」**(原版:網路流量可以改走你的本機電腦,「僅對新連線生效」,管理員可以關掉):打開後,Bot 的電腦之後執行的指令會經過這台 Mac 上的代理伺服器出去(例如要連公司內網或 VPN 才看得到的服務)。代理只聽這台 Mac 自己(127.0.0.1),用原版的埠 8791,被占用就往後找。設定頁顯示經過的連線數。Bot 的電腦在別的主機上時不能用。管理員可以用 `defaults write com.anybot.AnyBot AllowBoxTrafficViaThisMac false` 鎖住,這時顯示「🔒 由你的管理員設定,無法變更」。
@ -1257,6 +1257,7 @@ computer_use_screenshot、click、mouse_move、mouse_button(按住/放開)
- **記憶**:跟 Bot 說「記住我喜歡靠窗的座位」,它會用 remember 記下來;「忘記…」用 forget。每個 Bot 最多 200 條、每條 500 字(估計值),每次聊天都帶著。Bot 設定裡的「記得的事」可以一條條刪。
- **技能**:教 Bot 一套做法後請它「存成技能」(save_skill),所有 Bot 共用;系統指示只列名稱和一句說明,Bot 要用時先用 use_skill 讀完整做法。設定 →「連接器」頁的「技能」卡可以刪除。
- **自己進化**:一件事做了 5 步以上(操作電腦、網頁、指令,不算記憶和設定),回答完後在背景回頭看一次:這是在網站或程式上動手操作的事(填表、發文、回留言、上傳下載、改設定),就把這次真的成功的最短做法存成技能(用畫面上的名字,不寫編號和座標;繞路和失敗寫成「注意」;使用者說過的偏好寫進去);已經有同一件事的技能就合併成更好的一版,照著做而且很順就不動。回覆下面會多一行「(學會了新做法「…」)」或「(把做法「…」更新了)」。系統指示要 Bot 開始做事前先看技能清單、有符合的先 use_skill。實測(httpbin 訂 pizza,真的 Grok):第一次 11 步學到技能;第二次先讀技能、10 步 24 秒一次做完,回頭看後判斷不用改。
- **每件事用自己的分頁**:幾個 Bot 共用一個瀏覽器(Bot 的電腦裡的 Chromium)時,每件事只在自己打開或停在的分頁上做(點連結開出的新視窗也算它的),別的事的分頁不會被讀到、點到、列出或關掉;做完只收自己的分頁、留最後那一頁。
- **範本**:側邊欄「+」按右鍵 →「從範本建立」:研究助理、寫作夥伴、電腦助手、客服助理、開發助手,各有角色說明、指示、頭像和顏色。Bot 也能用 create_bot 的 template 參數建立。
### 保險庫(照原版重做,2026-11-04)

View File

@ -73,7 +73,7 @@ AnyBot 是一個 Rust 程式(`anybot`),用 egui/eframe 畫介面。同一
- **核准關卡**:工具動作照規則直接做、先問或拒絕(`policy.rs`,FEATURES.md 第 16、19 節)。「直接操作模式」可以跳過詢問。
- **指令的沙盒(macOS)**:Bot 執行的指令在 `sandbox-exec` 裡跑,讀不到也寫不到 AnyBot 的資料資料夾、`.gnupg`、鑰匙圈、`.aws` 等;只有 `attachments/` 和 `.ssh` 可以讀(都不能寫,`.ssh` 可讀讓 git 和 ssh 連得動)。只有沙盒管得到的才是祕密防線,核准層不再逐字掃路徑。環境變數裡的模型金鑰會先拿掉,PATH 會補齊 Homebrew 等位置(從 Finder 或 launchd 啟動也要找得到指令)。
- **Bot 的電腦**:放在容器裡,和你的電腦分開。標準(runc)容器和主機共用 Linux 核心,Chromium 在裡面關掉自己的沙盒,容器就是唯一那道牆。Docker 主機裝了 gVisor 時,設定 › 電腦 › 容器隔離可改「強隔離(gVisor)」:容器有自己的虛擬核心,Chromium 的沙盒重新開啟,牆變成兩道(虛擬核心+瀏覽器沙盒)。改完要按電腦的「更新」重建才生效。在 macOS 上沒有能裝 gVisor 的 Docker,用 `deploy/mac/gvisor-vm.sh` 建一台自帶 Docker+gVisor 的 Lima 虛擬機,再把 Docker 主機指過去。
- **Bot 的電腦**:放在容器裡,和你的電腦分開。標準(runc)容器和主機共用 Linux 核心,Chromium 在裡面關掉自己的沙盒,容器就是唯一那道牆。Docker 主機裝了 gVisor 時,設定 › 電腦 › 容器隔離可改「強隔離(gVisor)」:容器有自己的虛擬核心,Chromium 的沙盒重新開啟,牆變成兩道(虛擬核心+瀏覽器沙盒)。改完要按電腦的「更新」重建才生效。在 macOS 上沒有能裝 gVisor 的 Docker,用 `deploy/mac/gvisor-vm.sh` 建一台自帶 Docker+gVisor 的 Lima 虛擬機,再把 Docker 主機指過去;那台虛擬機沒開時(例如 Mac 重開機後)AnyBot 會自己開。
- **保險庫**:密碼、卡號、CVC 不會交給模型,由 AnyBot 直接填進網頁。
- **多台電腦**:金鑰只留在大腦(下面說明);手腳只接受帳號裡的 AnyBot,用配對碼加入的只接受那一個大腦。

View File

@ -162,46 +162,45 @@ enum BotBrowser {
Device(String, String),
}
/// How many running jobs are using each bot browser (bots share the box's Chromium).
static BROWSER_JOBS: Mutex<Vec<BotBrowser>> = Mutex::new(Vec::new());
/// The bot's browsers a job used. When the job ends — answered, stopped or failed — each closes
/// the tabs the job left open and keeps the page it ended on (every browser_open is a new tab;
/// nothing used to close them, and the box ran out of memory with 19). A browser another job is
/// still using is left alone.
/// nothing used to close them, and the box ran out of memory with 19). Only the job's own tabs:
/// another job's, and the person's, stay (see botbrowser's "each job its own tabs").
struct UsedBrowsers {
list: Vec<BotBrowser>,
via_name: String,
/// This job's name in the browsers (its tabs are kept apart by it).
job: String,
}
/// A job starts using a bot browser.
fn claim(b: &BotBrowser) {
BROWSER_JOBS.lock().unwrap().push(b.clone());
}
/// Names the jobs of this run (a bot's job on another computer's browser carries it along).
static JOB_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
/// A job is done with a bot browser: true when another job is still using it.
fn release(b: &BotBrowser) -> bool {
let mut jobs = BROWSER_JOBS.lock().unwrap();
if let Some(i) = jobs.iter().position(|x| x == b) {
jobs.remove(i);
/// A call's arguments with the job's name added: the browser keeps each job's tabs apart by it
/// (also on another computer's browser, which gets the arguments as they are).
fn with_job(args: &str, job: &str) -> String {
let mut v: serde_json::Value = serde_json::from_str(if args.trim().is_empty() { "{}" } else { args }).unwrap_or_else(|_| serde_json::json!({}));
if let Some(o) = v.as_object_mut() {
o.insert("_job".into(), serde_json::json!(job));
}
jobs.contains(b)
v.to_string()
}
impl UsedBrowsers {
fn new(via_name: &str, bot_id: u64) -> Self {
let n = JOB_SEQ.fetch_add(1, Ordering::Relaxed);
UsedBrowsers { list: vec![], via_name: via_name.to_string(), job: format!("{bot_id}-{}-{n}", crate::model::now_ms()) }
}
fn add(&mut self, b: BotBrowser) {
if !self.list.contains(&b) {
claim(&b);
self.list.push(b);
}
}
fn close_tabs(&mut self) {
for b in std::mem::take(&mut self.list) {
if release(&b) {
continue;
}
let args = |p: &str| if p.is_empty() { r#"{"action":"tidy"}"#.to_string() } else { serde_json::json!({"action": "tidy", "profile": p}).to_string() };
let args = |p: &str| with_job(&if p.is_empty() { r#"{"action":"tidy"}"#.to_string() } else { serde_json::json!({"action": "tidy", "profile": p}).to_string() }, &self.job);
let never = AtomicBool::new(false);
match &b {
BotBrowser::Here(p) => {
@ -645,7 +644,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
// The screen and browser steps of this turn (to stop the same step being tried over and over).
let mut tried: Vec<String> = Vec::new();
let mut local_on = job.local_execution;
let mut browsers = UsedBrowsers { list: vec![], via_name: job.via_name.clone() };
let mut browsers = UsedBrowsers::new(&job.via_name, bot_id);
maybe_compact(&job, &mut history, COMPACT_THRESHOLD_PERCENT, &cancel, &send);
for step in 0..MAX_STEPS {
if step > 0 {
@ -788,13 +787,15 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
});
}
// 裝置連結: the AnyBot browser of another computer (its own logins).
// (On this job's own tabs: another bot may be using the same browser.)
let args = with_job(&call.args, &browsers.job);
let (output, ok, img) = match place {
BrowserAt::Device(host) => match crate::remote::run_on_device(&host, &call.name, &call.args, &job.via_name) {
BrowserAt::Device(host) => match crate::remote::run_on_device(&host, &call.name, &args, &job.via_name) {
Ok(v) => (v["text"].as_str().unwrap_or("").to_string(), v["ok"].as_bool().unwrap_or(false), v["image"].as_str().map(String::from)),
Err(e) => (e, false, None),
},
BrowserAt::Box => crate::botbrowser::in_box(|| web::run_full(&call.name, &call.args, &cancel)),
BrowserAt::Here => web::run_full(&call.name, &call.args, &cancel),
BrowserAt::Box => crate::botbrowser::in_box(|| web::run_full(&call.name, &args, &cancel)),
BrowserAt::Here => web::run_full(&call.name, &args, &cancel),
};
// Which computer's browser it was, like every other step.
let output = if on.is_empty() { output } else { format!("[{on}] {output}") };
@ -1676,6 +1677,90 @@ printf 'a,b\n1,2\n' > report.csv && printf 'hello box' > '/tmp/anybot-up/上傳
assert_eq!(left.trim(), "http://127.0.0.1:8770/?n=3", "{left}");
}
/// Two bots' jobs at once on one browser (the box's): each reads its own page although the
/// other's is in front, and neither closes the other's tab when it ends; a popup a click
/// opens is the clicking job's. Needs the box:
/// `cargo test two_jobs_keep_to_their_own_tabs -- --ignored --nocapture`.
#[test]
#[ignore]
fn two_jobs_keep_to_their_own_tabs() {
let own = crate::model::BOT_COMPUTER_NAME;
let box_sh = |script: &str| String::from_utf8_lossy(&std::process::Command::new("docker").args(["exec", crate::cloudbox::container(), "sh", "-c", script]).output().unwrap().stdout).to_string();
let urls = r#"curl -s 127.0.0.1:9222/json/list | python3 -c 'import json,sys; print(" ".join(t["url"] for t in json.load(sys.stdin) if t["type"] == "page"))'"#;
let clean = r#"pkill -f 'http[.]server 8774'; rm -rf /tmp/anybot-two /home/bot/bot-991 /home/bot/bot-992 /home/bot/bot-993; curl -s 127.0.0.1:9222/json/list | python3 -c 'import json,sys,urllib.request; [urllib.request.urlopen("http://127.0.0.1:9222/json/close/" + t["id"]) for t in json.load(sys.stdin) if ":8774" in t["url"]]' 2>/dev/null; true"#;
// (What an earlier run that failed half-way left behind.)
box_sh(clean);
let setup = r#"mkdir -p /tmp/anybot-two && cd /tmp/anybot-two && echo '<meta charset=utf-8><title>A</title>頁面甲 <a href="b.html" target="_blank">開新視窗</a>' > a.html && echo '<meta charset=utf-8><title>B</title>頁面乙' > b.html && (setsid python3 -m http.server 8774 --bind 127.0.0.1 >/dev/null 2>&1 &) ; sleep 1; echo served"#;
let run = |replies: Vec<(&'static str, serde_json::Value)>, bot: u64| {
let port = fake_model(replies);
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("開頁面")], tools: tools::specs(), session: String::new() },
computers: vec![tools::Target { name: own.into(), place: tools::Place::Box { dir: crate::cloudbox::bot_dir(bot) } }],
default_computer: own.into(),
roles: tools::Roles { own: own.into(), person: None, server: true },
local_execution: false,
direct: Arc::new(AtomicBool::new(true)),
..test_job()
};
let (tx, rx) = channel();
spawn(job, bot, tx, Arc::new(AtomicBool::new(false)), eframe::egui::Context::default());
std::thread::spawn(move || {
let mut out = vec![];
loop {
match rx.recv_timeout(Duration::from_secs(120)).expect("loop stalled") {
(_, StreamEvent::ToolResult(m)) => out.push(format!("{}: {}", m.tool_name, m.text)),
(_, StreamEvent::Done) => break,
(_, StreamEvent::Error(e)) => panic!("{e}"),
_ => {}
}
}
out
})
};
let a = run(vec![
("shell", serde_json::json!({"command": setup})),
("browser_open", serde_json::json!({"url": "http://127.0.0.1:8774/a.html"})),
("shell", serde_json::json!({"command": "sleep 4; echo waited"})),
("browser_read", serde_json::json!({})),
("", serde_json::json!("好了")),
], 991);
std::thread::sleep(Duration::from_millis(2500));
let b = run(vec![
("browser_open", serde_json::json!({"url": "http://127.0.0.1:8774/b.html"})),
("browser_read", serde_json::json!({})),
("shell", serde_json::json!({"command": "sleep 4; echo waited"})),
("", serde_json::json!("好了")),
], 992);
let (a, b) = (a.join().unwrap(), b.join().unwrap());
for l in a.iter().chain(b.iter()) {
println!("{}\n", l.chars().take(160).collect::<String>());
}
assert!(a[3].starts_with("browser_read") && a[3].contains("頁面甲") && !a[3].contains("頁面乙"), "A read another job's page: {}", a[3]);
assert!(b[1].starts_with("browser_read") && b[1].contains("頁面乙"), "{}", b[1]);
let left = box_sh(urls);
println!("left after both: {left}");
assert!(left.contains("8774/a.html") && left.contains("8774/b.html"), "a job closed the other's tab: {left}");
// A popup a click opens belongs to the job that clicked: it reads it, then keeps only it.
let c = run(vec![
("browser_open", serde_json::json!({"url": "http://127.0.0.1:8774/a.html?c"})),
("browser_click", serde_json::json!({"text": "開新視窗"})),
("browser_read", serde_json::json!({})),
("", serde_json::json!("好了")),
], 993)
.join()
.unwrap();
for l in &c {
println!("{}\n", l.chars().take(160).collect::<String>());
}
assert!(c[2].starts_with("browser_read") && c[2].contains("頁面乙"), "{}", c[2]);
// (The tabs close right after the job's Done.)
std::thread::sleep(Duration::from_secs(2));
let left = box_sh(urls);
println!("left after the popup job: {left}");
assert!(!left.contains("a.html?c"), "the clicking job's first tab stayed: {left}");
box_sh(clean);
}
/// Words reach the control they name on a form laid out like httpbin's (each label and its
/// input inside a <p>): typing into 「Customer name:」, ticking 「Large」, pressing 「Submit
/// order」. They used to land on the <p> and do nothing. Needs the box:
@ -1743,13 +1828,13 @@ echo '<meta charset=utf-8><title>done</title>送出了' > done.html; (setsid pyt
}
#[test]
fn a_browser_another_job_uses_keeps_its_tabs() {
let b = BotBrowser::Device("test-host-tabs".into(), String::new());
claim(&b);
claim(&b);
assert!(release(&b), "the other job still uses it");
assert!(!release(&b), "the last one out closes the tabs");
assert!(!release(&b));
fn a_browser_step_carries_its_job() {
let a = with_job(r#"{"url":"https://example.com","profile":"工作"}"#, "6-1-1");
let v: serde_json::Value = serde_json::from_str(&a).unwrap();
assert_eq!((v["_job"].as_str(), v["url"].as_str(), v["profile"].as_str()), (Some("6-1-1"), Some("https://example.com"), Some("工作")));
assert_eq!(serde_json::from_str::<serde_json::Value>(&with_job("", "j")).unwrap()["_job"], "j");
// Two jobs never share a name.
assert_ne!(UsedBrowsers::new("b", 6).job, UsedBrowsers::new("b", 6).job);
}
#[test]

View File

@ -250,7 +250,6 @@ pub fn using_box() -> bool {
IN_BOX.with(|b| b.get())
}
/// Start this profile's window if it isn't running. Returns (browser name, port, just started).
/// The debugging port of a bot browser that is already open (None when it isn't; never starts one).
pub fn running_port(profile: Option<&str>) -> Option<u16> {
if using_box() {
@ -259,6 +258,95 @@ pub fn running_port(profile: Option<&str>) -> Option<u16> {
live_port(&resolve(profile).ok()?)
}
// ---------- each job its own tabs ----------
// Bots share a browser (the box's Chromium has one profile for all of them). Every step used to
// act on the tab in front, so two jobs at once read and clicked on each other's pages, and one
// finishing closed the other's. A job now owns the tabs it opens or lands on (a popup it clicked
// open too): its steps use the latest of those, and another job's tabs are never used, listed
// or closed by it.
thread_local! {
/// The job whose browser step runs on this thread (empty: not a bot's job).
static JOB: std::cell::RefCell<String> = const { std::cell::RefCell::new(String::new()) };
}
/// (tab id, job) for the tabs running jobs own.
static OWNERS: std::sync::Mutex<Vec<(String, String)>> = std::sync::Mutex::new(Vec::new());
/// Run `f` as a step of job `job` (on its own tabs).
pub fn as_job<T>(job: &str, f: impl FnOnce() -> T) -> T {
let before = JOB.with(|j| std::mem::replace(&mut *j.borrow_mut(), job.to_string()));
let r = f();
JOB.with(|j| *j.borrow_mut() = before);
r
}
pub fn job() -> String {
JOB.with(|j| j.borrow().clone())
}
fn owner(tab: &str) -> Option<String> {
OWNERS.lock().unwrap_or_else(|e| e.into_inner()).iter().find(|(t, _)| t == tab).map(|(_, j)| j.clone())
}
/// Another running job owns this tab.
pub fn others_tab(tab: &str) -> bool {
owner(tab).is_some_and(|j| j != job())
}
/// This step's job takes the tab, when it is nobody's.
pub fn claim(tab: &str) {
let me = job();
if me.is_empty() || tab.is_empty() {
return;
}
let mut o = OWNERS.lock().unwrap_or_else(|e| e.into_inner());
if !o.iter().any(|(t, _)| t == tab) {
o.push((tab.to_string(), me));
}
}
/// The job is over: its tabs are nobody's again (returned).
pub fn release(job: &str) -> Vec<String> {
let mut o = OWNERS.lock().unwrap_or_else(|e| e.into_inner());
let mine = o.iter().filter(|(_, j)| j == job).map(|(t, _)| t.clone()).collect();
o.retain(|(_, j)| j != job);
mine
}
/// The tab of a page list (latest-used first) this step works on: its job's latest own tab, else
/// the latest one no other job owns (a job's first step).
pub fn pick_tab(list: &[Value]) -> Option<&Value> {
let me = job();
let id = |t: &Value| t["id"].as_str().unwrap_or("").to_string();
if !me.is_empty() {
if let Some(t) = list.iter().find(|t| owner(&id(t)).as_deref() == Some(me.as_str())) {
return Some(t);
}
}
list.iter().find(|t| !others_tab(&id(t)))
}
/// The open page tabs' ids (none when the browser isn't open; it isn't started for this).
pub fn tab_ids(profile: Option<&str>) -> Vec<String> {
let Some(port) = running_port(profile) else { return vec![] };
let list: Value = local().get(&endpoint(port, "/json/list")).call().ok().and_then(|r| r.into_body().read_json().ok()).unwrap_or_default();
list.as_array().map(|a| a.iter().filter(|t| t["type"] == "page").filter_map(|t| t["id"].as_str().map(String::from)).collect()).unwrap_or_default()
}
/// Tabs that appeared during this step (a popup a click opened) are its job's.
pub fn adopt_new(profile: Option<&str>, before: &[String]) {
if job().is_empty() {
return;
}
for id in tab_ids(profile) {
if !before.contains(&id) {
claim(&id);
}
}
}
/// Start this profile's window if it isn't running. Returns (browser name, port, just started).
pub fn ensure(profile: &str, first_url: Option<&str>) -> Result<(&'static str, u16, bool), String> {
if using_box() {
let fresh = !crate::cloudbox::browser_running();
@ -325,6 +413,9 @@ pub fn open(url: &str, profile: Option<&str>) -> Result<String, String> {
if r.status().as_u16() >= 400 {
return Err(format!("{name} 沒辦法開新分頁({})", r.status()));
}
// The new tab is this job's (its next steps work there, not on another job's tab).
let made: Value = r.into_body().read_json().unwrap_or_default();
claim(made["id"].as_str().unwrap_or(""));
close_old_tabs(port);
}
// Back when the page has loaded (not after a fixed 3 s: a slow page was still blank then),
@ -342,14 +433,15 @@ const MAX_TABS: usize = 6;
/// Close the tabs past MAX_TABS that were used longest ago (Chrome lists the latest first).
fn close_old_tabs(port: u16) {
let Ok(list) = local().get(&endpoint(port, "/json/list")).call().map_err(|e| e.to_string()).and_then(|r| r.into_body().read_json::<Value>().map_err(|e| e.to_string())) else { return };
for id in old_tabs(&list) {
for id in old_tabs(&list, |id| !others_tab(id)) {
let _ = local().get(&endpoint(port, &format!("/json/close/{id}"))).call();
}
}
/// The page tabs past MAX_TABS in a /json/list answer (latest-used first).
fn old_tabs(list: &Value) -> Vec<String> {
list.as_array().map(|a| a.iter().filter(|t| t["type"] == "page").skip(MAX_TABS).filter_map(|t| t["id"].as_str().map(String::from)).collect()).unwrap_or_default()
/// The page tabs past MAX_TABS in a /json/list answer (latest-used first), among those `ours`
/// says may be closed (never another job's).
fn old_tabs(list: &Value, ours: impl Fn(&str) -> bool) -> Vec<String> {
list.as_array().map(|a| a.iter().filter(|t| t["type"] == "page").filter_map(|t| t["id"].as_str()).filter(|id| ours(id)).skip(MAX_TABS).map(String::from).collect()).unwrap_or_default()
}
/// Longest wait for a page browser_open opened (estimated).
@ -462,9 +554,35 @@ mod tests {
let mut list: Vec<Value> = (0..9).map(|i| serde_json::json!({"type": "page", "id": format!("p{i}")})).collect();
list.insert(2, serde_json::json!({"type": "browser_ui", "id": "ui"}));
list.insert(4, serde_json::json!({"type": "service_worker", "id": "sw"}));
assert_eq!(old_tabs(&Value::Array(list.clone())), vec!["p6", "p7", "p8"]);
assert_eq!(old_tabs(&Value::Array(list.clone()), |_| true), vec!["p6", "p7", "p8"]);
// Another job's tabs don't count and are never closed.
assert_eq!(old_tabs(&Value::Array(list.clone()), |id| id != "p0" && id != "p7"), vec!["p8"]);
list.truncate(5);
assert!(old_tabs(&Value::Array(list)).is_empty());
assert!(old_tabs(&Value::Array(list), |_| true).is_empty());
}
#[test]
fn each_job_works_on_its_own_tabs() {
let page = |id: &str| serde_json::json!({"type": "page", "id": id});
// Latest-used first: B's tab is in front, A's further back.
let list = vec![page("tab-b"), page("tab-a"), page("tab-free")];
as_job("job-a", || claim("tab-a"));
as_job("job-b", || claim("tab-b"));
assert_eq!(as_job("job-a", || pick_tab(&list).map(|t| t["id"].clone())), Some("tab-a".into()));
assert_eq!(as_job("job-b", || pick_tab(&list).map(|t| t["id"].clone())), Some("tab-b".into()));
// A new job starts on the latest tab nobody has; another job's tabs are off limits.
assert_eq!(as_job("job-c", || pick_tab(&list).map(|t| t["id"].clone())), Some("tab-free".into()));
assert!(as_job("job-c", || others_tab("tab-a") && !others_tab("tab-free")));
assert!(as_job("job-a", || !others_tab("tab-a")));
// A claimed tab stays its first owner's.
as_job("job-b", || claim("tab-a"));
assert_eq!(owner("tab-a").as_deref(), Some("job-a"));
// Outside a job nothing is claimed.
claim("tab-free");
assert_eq!(owner("tab-free"), None);
assert_eq!(release("job-a"), vec!["tab-a".to_string()]);
assert_eq!(release("job-b"), vec!["tab-b".to_string()]);
assert_eq!(owner("tab-a"), None);
}
#[test]

View File

@ -44,11 +44,14 @@ pub fn port_of(profile: Option<&str>) -> Result<(String, u16), String> {
}
impl Tab {
/// The tab in front (Chrome lists the most recently used first).
/// The tab this step works on: its job's latest own tab, else the latest one no other job
/// has (Chrome lists the most recently used first).
pub fn current(profile: Option<&str>) -> Result<Tab, String> {
let (_, port) = port_of(profile)?;
let list = targets(port)?;
let t = list.first().ok_or("AnyBot 瀏覽器沒有開著的分頁,請先用 browser_open 打開網址。")?;
// This job's own latest tab (another bot's job may have one in front).
let t = botbrowser::pick_tab(&list).ok_or("AnyBot 瀏覽器沒有開著的分頁,請先用 browser_open 打開網址。")?;
botbrowser::claim(t["id"].as_str().unwrap_or(""));
Tab::connect(port, t)
}
@ -837,8 +840,19 @@ fn shrink(b64: &str) -> Option<String> {
/// latest-used first). A browser that isn't open stays closed. Not offered to the model; the
/// loop calls it (browser_tabs action "tidy") when a job ends.
fn tidy(profile: Option<&str>) -> Result<String, String> {
let job = botbrowser::job();
// (Its tabs are nobody's from here on, whatever happens below.)
let mine = if job.is_empty() { vec![] } else { botbrowser::release(&job) };
let Some(port) = botbrowser::running_port(profile) else { return Ok("瀏覽器沒有開著。".into()) };
let closed = targets(port)?.iter().skip(1).filter(|t| botbrowser::local().get(&format!("http://127.0.0.1:{port}/json/close/{}", t["id"].as_str().unwrap_or(""))).call().is_ok()).count();
let list = targets(port)?;
let close: Vec<&Value> = if job.is_empty() {
// (An older brain that doesn't say which job: the old way, all but the front page.)
list.iter().skip(1).collect()
} else {
// The job's own tabs but its latest; other jobs' and the person's are left alone.
list.iter().filter(|t| mine.iter().any(|m| t["id"].as_str() == Some(m.as_str()))).skip(1).collect()
};
let closed = close.iter().filter(|t| botbrowser::local().get(&format!("http://127.0.0.1:{port}/json/close/{}", t["id"].as_str().unwrap_or(""))).call().is_ok()).count();
Ok(format!("關了 {closed} 個用完的分頁。"))
}
@ -852,13 +866,16 @@ fn tabs(args: &Value, profile: Option<&str>) -> Result<String, String> {
let r = if put { botbrowser::local().put(&url).send_empty() } else { botbrowser::local().get(&url).call() };
r.map_err(|e| e.to_string())?.into_body().read_to_string().map_err(|e| e.to_string())
};
let list = targets(port)?;
// Another job's tabs aren't listed, switched to or closed.
let list: Vec<Value> = targets(port)?.into_iter().filter(|t| !botbrowser::others_tab(t["id"].as_str().unwrap_or(""))).collect();
let current = botbrowser::pick_tab(&list).and_then(|t| t["id"].as_str()).unwrap_or("").to_string();
let idx = args["index"].as_u64().map(|i| i as usize);
let pick = |i: Option<usize>| -> Result<&Value, String> { i.and_then(|i| list.get(i.wrapping_sub(1))).ok_or_else(|| "請給分頁編號(先用 action=list 看)".to_string()) };
match s(args, "action").as_str() {
"switch" => {
let t = pick(idx)?;
http(&format!("/json/activate/{}", t["id"].as_str().unwrap_or("")), false)?;
botbrowser::claim(t["id"].as_str().unwrap_or(""));
Ok(format!("已切到分頁 {}:{}({})", idx.unwrap_or(0), t["title"].as_str().unwrap_or(""), t["url"].as_str().unwrap_or("")))
}
"close" => {
@ -868,14 +885,15 @@ fn tabs(args: &Value, profile: Option<&str>) -> Result<String, String> {
}
"new" => {
let url = crate::web::normalize_url(&s(args, "url")).unwrap_or_else(|_| "about:blank".into());
http(&format!("/json/new?{}", botbrowser::encode_component(&url)), true)?;
let made: Value = serde_json::from_str(&http(&format!("/json/new?{}", botbrowser::encode_component(&url)), true)?).unwrap_or_default();
botbrowser::claim(made["id"].as_str().unwrap_or(""));
let loaded = wait_loaded(profile, 10_000);
Ok(format!("已開新分頁:{url}{}", if loaded { "" } else { "(還在載入,可以用 browser_wait 等它)" }))
}
_ => Ok(if list.is_empty() {
"沒有開著的分頁。".into()
} else {
list.iter().enumerate().map(|(i, t)| format!("{}. {}{}\n {}", i + 1, t["title"].as_str().unwrap_or(""), if i == 0 { "(目前)" } else { "" }, t["url"].as_str().unwrap_or(""))).collect::<Vec<_>>().join("\n")
list.iter().enumerate().map(|(i, t)| format!("{}. {}{}\n {}", i + 1, t["title"].as_str().unwrap_or(""), if t["id"].as_str() == Some(current.as_str()) { "(目前)" } else { "" }, t["url"].as_str().unwrap_or(""))).collect::<Vec<_>>().join("\n")
}),
}
}

View File

@ -624,9 +624,81 @@ fn dk_with_input(args: &[&str], input: Option<&[u8]>, ms: u64) -> Result<Vec<u8>
// ---------- status ----------
/// Ask Docker what state the box is in (doesn't change anything).
// ---------- a Docker host in a Lima VM on this Mac ----------
// Strong isolation on a Mac runs the box in a Lima VM (deploy/mac/gvisor-vm.sh), and the Docker
// host is that VM's socket. After the Mac restarts nothing starts the VM, and Bot 的電腦 was just
// gone ("Docker 沒有在執行"). AnyBot starts it itself when its Docker doesn't answer.
/// The Lima VM a Docker host like unix:///Users/me/.lima/<vm>/sock/docker.sock is in.
fn lima_vm(host: &str) -> Option<String> {
let path = host.strip_prefix("unix://")?;
let (_, rest) = path.split_once("/.lima/")?;
let (vm, tail) = rest.split_once('/')?;
(tail == "sock/docker.sock" && !vm.is_empty() && vm.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')).then(|| vm.to_string())
}
/// A start of the VM is under way.
static LIMA_STARTING: AtomicBool = AtomicBool::new(false);
/// When the VM was last started (not again within LIMA_RETRY_SECS when it didn't come up).
static LIMA_TRIED: Mutex<Option<Instant>> = Mutex::new(None);
const LIMA_RETRY_SECS: u64 = 300;
/// Start the VM and wait until its Docker answers.
fn start_lima(vm: &str) -> Result<(), String> {
LIMA_STARTING.store(true, Ordering::SeqCst);
log(&format!("Docker 主機的虛擬機({vm})沒有開:正在開機…"));
let out = Command::new("limactl").env("PATH", tool_path()).args(["start", "--tty=false", vm]).stdin(Stdio::null()).output();
LIMA_STARTING.store(false, Ordering::SeqCst);
let out = out.map_err(|e| format!("找不到 limactl(Lima):{e}"))?;
if out.status.success() && dk(&["version", "--format", "{{.Server.Version}}"]).is_ok() {
log(&format!("虛擬機 {vm} 開好了。"));
Ok(())
} else {
let e = format!("開不了虛擬機 {vm}:{}", tail(String::from_utf8_lossy(&out.stderr).trim(), 300));
log(&e);
Err(e)
}
}
/// Docker doesn't answer and lives in a Lima VM here: start the VM — in the background, or
/// waiting for it (`wait`). None when the host isn't a Lima VM, or a start failed a moment ago.
fn wake_lima(wait: bool) -> Option<Result<(), String>> {
let vm = lima_vm(&docker_host())?;
if LIMA_STARTING.load(Ordering::SeqCst) {
if !wait {
return Some(Ok(()));
}
let t = Instant::now();
while LIMA_STARTING.load(Ordering::SeqCst) && t.elapsed().as_secs() < 240 {
std::thread::sleep(Duration::from_millis(500));
}
return Some(dk(&["version", "--format", "{{.Server.Version}}"]).map(|_| ()).map_err(|_| format!("虛擬機 {vm} 還沒開好")));
}
{
let mut tried = LIMA_TRIED.lock().unwrap_or_else(|e| e.into_inner());
if tried.is_some_and(|t| t.elapsed().as_secs() < LIMA_RETRY_SECS) && !wait {
return None;
}
*tried = Some(Instant::now());
}
if wait {
Some(start_lima(&vm))
} else {
LIMA_STARTING.store(true, Ordering::SeqCst);
std::thread::spawn(move || {
let _ = start_lima(&vm);
refresh();
});
Some(Ok(()))
}
}
/// Ask Docker what state the box is in (changes nothing, but starts a Lima VM Docker lives in).
pub fn probe() -> Run {
if let Err(e) = dk(&["version", "--format", "{{.Server.Version}}"]) {
if wake_lima(false).is_some() {
return Run::Starting("正在開 Docker 主機的虛擬機");
}
return Run::NoDocker(if e.contains("找不到") && !cfg!(target_os = "macos") {
"這台電腦沒有安裝 Docker。安裝 Docker Engine(Ubuntu / Debian:sudo apt install docker.io),再把自己加進 docker 群組(sudo usermod -aG docker $USER,然後重新登入),Bot 就會有自己的完整電腦。".into()
} else if e.contains("找不到") {
@ -900,6 +972,13 @@ pub fn ensure_running() -> Result<(), String> {
return Err("Bot 的電腦正在更新,請等它完成再試。".into());
}
touch();
// Docker in a Lima VM that isn't up (the Mac restarted): start the VM first.
if lima_vm(&docker_host()).is_some() && dk(&["version", "--format", "{{.Server.Version}}"]).is_err() {
if let Some(Err(e)) = wake_lima(true) {
set_run(Run::NoDocker(e.clone()));
return Err(e);
}
}
let known = READY_START.lock().unwrap_or_else(|e| e.into_inner()).clone();
if !known.is_empty() && box_start().as_deref() == Some(known.as_str()) {
set_run(Run::Running);
@ -2570,6 +2649,15 @@ mod tests {
let _ = run_on("shell", r#"{"command":"cd .. && rm -rf bot-998"}"#, &t, &cancel, &ids);
}
#[test]
fn a_docker_host_in_a_lima_vm_is_known() {
assert_eq!(lima_vm("unix:///Users/daniel/.lima/anybot-gvisor/sock/docker.sock").as_deref(), Some("anybot-gvisor"));
assert_eq!(lima_vm("unix:///var/run/docker.sock"), None);
assert_eq!(lima_vm("ssh://me@server"), None);
assert_eq!(lima_vm("unix:///Users/x/.lima/a;rm -rf/sock/docker.sock"), None);
assert_eq!(lima_vm(""), None);
}
#[test]
fn box_paths_are_the_bot_users() {
assert_eq!(box_path("~/bot-6/附件/IMG_0587.jpeg"), "/home/bot/bot-6/附件/IMG_0587.jpeg");

View File

@ -130,12 +130,24 @@ pub fn run_full(name: &str, args: &str, cancel: &AtomicBool) -> (String, bool, O
return ("點擊、輸入和其他操作只能在 AnyBot 自己的瀏覽器裡做。請用 browser_open 在 AnyBot 瀏覽器打開網頁(第一次要請使用者在那個視窗登入)。".into(), false, None);
}
let v: Value = serde_json::from_str(if args.trim().is_empty() { "{}" } else { args }).unwrap_or(Value::Null);
return match crate::browser::run(name, &v) {
Ok((t, img)) => (t, true, img),
Err(e) => (e, false, None),
};
return crate::botbrowser::as_job(&arg(&v, "_job"), || {
// A click or a key can open a tab (a link to a new window): it is this job's.
let watch = matches!(name, "browser_click" | "browser_press");
let profile = Some(arg(&v, "profile")).filter(|p| !p.is_empty());
let before = if watch { crate::botbrowser::tab_ids(profile.as_deref()) } else { vec![] };
let r = match crate::browser::run(name, &v) {
Ok((t, img)) => (t, true, img),
Err(e) => (e, false, None),
};
if watch {
crate::botbrowser::adopt_new(profile.as_deref(), &before);
}
r
});
}
let (t, ok) = run(name, args, cancel);
// browser_open / browser_read on the bot's browser: the job's own tabs too.
let v: Value = serde_json::from_str(if args.trim().is_empty() { "{}" } else { args }).unwrap_or(Value::Null);
let (t, ok) = crate::botbrowser::as_job(&arg(&v, "_job"), || run(name, args, cancel));
(t, ok, None)
}