diff --git a/FEATURES.md b/FEATURES.md index 887ef92..8f07cfa 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -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) diff --git a/docs/architecture.md b/docs/architecture.md index 7a123f6..bb16680 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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,用配對碼加入的只接受那一個大腦。 diff --git a/src/agent.rs b/src/agent.rs index 1592bd1..0ccaeb3 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -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> = 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, 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 = 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 'A頁面甲 開新視窗' > a.html && echo 'B頁面乙' > 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::()); + } + 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::()); + } + 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

): typing into 「Customer name:」, ticking 「Large」, pressing 「Submit /// order」. They used to land on the

and do nothing. Needs the box: @@ -1743,13 +1828,13 @@ echo 'done送出了' > 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::(&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] diff --git a/src/botbrowser.rs b/src/botbrowser.rs index 2bae05b..091046d 100644 --- a/src/botbrowser.rs +++ b/src/botbrowser.rs @@ -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 { if using_box() { @@ -259,6 +258,95 @@ pub fn running_port(profile: Option<&str>) -> Option { 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 = const { std::cell::RefCell::new(String::new()) }; +} + +/// (tab id, job) for the tabs running jobs own. +static OWNERS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + +/// Run `f` as a step of job `job` (on its own tabs). +pub fn as_job(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 { + 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 { + 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 { + 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 { 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::().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 { - 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 { + 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 = (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] diff --git a/src/browser.rs b/src/browser.rs index f6fcd98..0870d95 100644 --- a/src/browser.rs +++ b/src/browser.rs @@ -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 { 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 { /// 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 { + 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 { 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 = 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| -> 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 { } "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::>().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::>().join("\n") }), } } diff --git a/src/cloudbox.rs b/src/cloudbox.rs index cb32e1e..4ed883e 100644 --- a/src/cloudbox.rs +++ b/src/cloudbox.rs @@ -624,9 +624,81 @@ fn dk_with_input(args: &[&str], input: Option<&[u8]>, ms: u64) -> Result // ---------- 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//sock/docker.sock is in. +fn lima_vm(host: &str) -> Option { + 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> = 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> { + 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"); diff --git a/src/web.rs b/src/web.rs index 7d93645..c6019f5 100644 --- a/src/web.rs +++ b/src/web.rs @@ -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) }