// 保險庫 / 1Password / 安全表單: the bot can sign in to a site in AnyBot's browser without ever // seeing the password (the original keeps logins in a vault and fills them through a secure // form; it also connects to 1Password). The login comes from the macOS Keychain (AnyBot's own // saved logins or any internet password there) or the 1Password CLI (`op`), and is typed into // the page by AnyBot; the model only hears that it was filled. use serde_json::Value; use std::process::Command; pub const TOOL: &str = "browser_fill_login"; /// Keychain entries AnyBot saves are marked with this label. const LABEL: &str = "AnyBot 登入"; pub const LIST_TOOL: &str = "vault_list"; pub const CARD_TOOL: &str = "browser_fill_card"; pub const CONTACT_TOOL: &str = "browser_fill_contact"; /// The vault tools the bot gets (with AnyBot's browser). pub fn specs() -> Vec { vec![ serde_json::json!({"name": LIST_TOOL, "description": "List what the person saved in AnyBot's 保險庫 (vault): logins (site, username, notes — never the password), payment cards (nickname, brand, last 4 digits, expiry — never the number or security code) and contact info (name, address, phones, emails). Notes say when to use an item. Use the ids with browser_fill_login / browser_fill_card / browser_fill_contact.", "parameters": {"type": "object", "properties": {"kind": {"type": "string", "enum": ["all", "login", "card", "contact"]}, "query": {"type": "string", "description": "Words to search for"}}}}), serde_json::json!({"name": TOOL, "description": "Sign in on the current tab of AnyBot's browser with a login from the person's vault (or the macOS Keychain / 1Password). You never see the password; the person approves each use. Open the login page with browser_open first; after filling, press the page's sign-in button (computer_use or ask the person) and check with browser_read.", "parameters": {"type": "object", "properties": {"login_id": {"type": "string", "description": "From vault_list (default: the saved login for the current site)"}, "site": {"type": "string", "description": "Domain to use (default: the current tab's)"}, "submit": {"type": "boolean", "description": "Also submit the form"}}}}), serde_json::json!({"name": CARD_TOOL, "description": "Fill a saved payment card into the checkout form on the current tab of AnyBot's browser (card number, expiry, security code, name). You never see the number or security code; the person approves every use. Card fields inside a payment provider's frame may need the person's help. Never submit a payment without the person saying so.", "parameters": {"type": "object", "properties": {"card_id": {"type": "string", "description": "From vault_list"}}, "required": ["card_id"]}}), serde_json::json!({"name": CONTACT_TOOL, "description": "Fill saved contact info (name, address, phone, email) into the form on the current tab of AnyBot's browser, e.g. a shipping or billing address.", "parameters": {"type": "object", "properties": {"contact_id": {"type": "string", "description": "From vault_list"}}, "required": ["contact_id"]}}), ] } /// vault_list: what the bot may know (no passwords, card numbers or security codes). pub fn list_for_bot(args: &str) -> String { use crate::vault_store::Kind; let v: Value = serde_json::from_str(if args.trim().is_empty() { "{}" } else { args }).unwrap_or_default(); let kind = v["kind"].as_str().unwrap_or("all"); let q = v["query"].as_str().unwrap_or("").trim().to_lowercase(); let items = match crate::vault_store::list() { Ok(i) => i, Err(e) => return format!("讀不到保險庫:{e}"), }; let rows: Vec = items .iter() .filter(|i| match kind { "login" => i.kind == Kind::Login, "card" => i.kind == Kind::Card, "contact" => i.kind == Kind::Contact, _ => true, }) .filter(|i| q.is_empty() || i.haystack().contains(&q)) .map(|i| match i.kind { Kind::Login => serde_json::json!({"id": i.id, "kind": "login", "title": i.display_title(), "sites": i.sites, "username": i.username, "notes": i.notes}), Kind::Card => serde_json::json!({"id": i.id, "kind": "card", "title": i.display_title(), "brand": i.brand, "last4": i.last4, "expiry": format!("{:02}/{}", i.exp_month, i.exp_year % 100), "holder": i.holder, "billing_address": i.billing.one_line(), "notes": i.notes}), Kind::Contact => serde_json::json!({"id": i.id, "kind": "contact", "label": i.title, "name": i.name, "address": {"line1": i.address.line1, "line2": i.address.line2, "city": i.address.city, "region": i.address.region, "postal_code": i.address.postal, "country": i.address.country}, "phones": i.phones, "emails": i.emails, "notes": i.notes}), }) .collect(); if rows.is_empty() { return "保險庫裡沒有符合的項目。請使用者到「設定 → 保險庫」新增,或改問使用者。".into(); } serde_json::to_string_pretty(&rows).unwrap_or_default() } /// "https://accounts.example.com/login?x" → "accounts.example.com" pub fn host_of(url: &str) -> String { let rest = url.split("://").nth(1).unwrap_or(url); rest.split(['/', '?', '#']).next().unwrap_or("").split('@').last().unwrap_or("").split(':').next().unwrap_or("").to_lowercase() } /// accounts.google.com → [accounts.google.com, google.com] pub fn candidates(host: &str) -> Vec { let parts: Vec<&str> = host.split('.').collect(); let mut v = vec![host.to_string()]; for i in 1..parts.len().saturating_sub(1) { v.push(parts[i..].join(".")); } v } pub struct Login { pub username: String, pub password: String, pub source: &'static str, } // ---------- Linux: the desktop keyring (GNOME Keyring / KWallet) through Secret Service ---------- // Entries are stored with attributes application=anybot, server=, user=. fn secret_tool(args: &[&str], input: Option<&str>) -> Option { use std::io::Write; let mut c = Command::new("secret-tool"); // The label has Chinese in it: make sure secret-tool reads its arguments as UTF-8 (C.UTF-8 // exists everywhere; the person's own locale might not be installed). c.env("LC_ALL", "C.UTF-8"); c.args(args).stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::piped()).stdin(if input.is_some() { std::process::Stdio::piped() } else { std::process::Stdio::null() }); let mut child = c.spawn().ok()?; if let (Some(text), Some(mut w)) = (input, child.stdin.take()) { let _ = w.write_all(text.as_bytes()); } child.wait_with_output().ok() } /// `secret-tool search --all` blocks → (attributes, secret). pub fn parse_secret_search(out: &str) -> Vec<(std::collections::HashMap, String)> { let mut v = vec![]; for block in out.split("\n[").map(|b| b.trim_start_matches('[')) { let mut attrs = std::collections::HashMap::new(); let mut secret = String::new(); for l in block.lines() { if let Some((k, val)) = l.split_once(" = ") { match k.trim() { "secret" => secret = val.to_string(), k if k.starts_with("attribute.") => { attrs.insert(k.trim_start_matches("attribute.").to_string(), val.to_string()); } _ => {} } } } if !attrs.is_empty() { v.push((attrs, secret)); } } v } fn linux_find(host: &str) -> Option { let o = secret_tool(&["search", "--all", "--unlock", "application", "anybot", "server", host], None)?; // secret-tool prints the matches on stdout, or (older versions) on stderr. let text = format!("{}\n{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr)); let (attrs, secret) = parse_secret_search(&text).into_iter().find(|(_, s)| !s.is_empty())?; Some(Login { username: attrs.get("user").cloned().unwrap_or_default(), password: secret, source: "系統鑰匙圈" }) } /// One Keychain login by site and account (bringing old AnyBot logins into the vault file). pub fn keychain_login(site: &str, account: &str) -> Option { if !cfg!(target_os = "macos") { return linux_find(site).filter(|l| l.username == account); } let out = Command::new("security").args(["find-internet-password", "-s", site, "-a", account, "-g"]).output().ok()?; if !out.status.success() { return None; } Some(Login { username: account.to_string(), password: parse_g_password(&String::from_utf8_lossy(&out.stderr))?, source: "鑰匙圈" }) } fn keychain(host: &str) -> Option { if !cfg!(target_os = "macos") { return linux_find(host); } // `-g` prints the attributes on stdout and the password on stderr, either as `"text"` or, // when it isn't plain ASCII, as `0x "escaped"` (`-w` gives bare hex then, which // can't be told apart from a password that looks like hex). let out = Command::new("security").args(["find-internet-password", "-s", host, "-g"]).output().ok()?; if !out.status.success() { return None; } let meta = String::from_utf8_lossy(&out.stdout).to_string(); let account = meta.lines().find(|l| l.contains("\"acct\"")).and_then(|l| l.split("=\"").nth(1)).map(|s| s.trim_end_matches('"').to_string()).unwrap_or_default(); let password = parse_g_password(&String::from_utf8_lossy(&out.stderr))?; Some(Login { username: account, password, source: "鑰匙圈" }) } /// The password line of `security find-…-password -g`. pub fn parse_g_password(stderr: &str) -> Option { let line = stderr.lines().find_map(|l| l.strip_prefix("password: ").or_else(|| (l == "password:").then_some("")))?; if let Some(hex) = line.strip_prefix("0x") { let hex: String = hex.chars().take_while(|c| c.is_ascii_hexdigit()).collect(); let bytes: Option> = (0..hex.len() / 2).map(|i| u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()).collect(); return String::from_utf8(bytes?).ok(); } Some(line.strip_prefix('"').and_then(|l| l.strip_suffix('"')).unwrap_or(line).to_string()) } fn onepassword(host: &str) -> Option { let path = crate::cloudbox::tool_path(); let list = Command::new("op").env("PATH", &path).args(["item", "list", "--categories", "Login", "--format", "json"]).output().ok()?; if !list.status.success() { return None; } let items: Value = serde_json::from_slice(&list.stdout).ok()?; let id = items.as_array()?.iter().find(|i| i["urls"].as_array().into_iter().flatten().any(|u| host_of(u["href"].as_str().unwrap_or("")).ends_with(host)))?["id"].as_str()?.to_string(); let got = Command::new("op").env("PATH", &path).args(["item", "get", &id, "--fields", "label=username,label=password", "--reveal", "--format", "json"]).output().ok()?; let fields: Value = serde_json::from_slice(&got.stdout).ok()?; let arr = fields.as_array()?; let get = |l: &str| arr.iter().find(|f| f["label"].as_str() == Some(l)).and_then(|f| f["value"].as_str()).unwrap_or("").to_string(); Some(Login { username: get("username"), password: get("password"), source: "1Password" }) } pub fn find(host: &str) -> Option { for h in candidates(host) { if let Some(l) = keychain(&h) { return Some(l); } } for h in candidates(host) { if let Some(l) = onepassword(&h) { return Some(l); } } None } /// Save a login in the macOS Keychain (the person types it in AnyBot's settings). /// (Kept for the live tests and older callers.) #[cfg_attr(not(test), allow(dead_code))] pub fn save(site: &str, username: &str, password: &str) -> Result<(), String> { let host = host_of(site); let host = if host.is_empty() { site.trim().to_lowercase() } else { host }; if host.is_empty() || password.is_empty() { return Err("請填網站和密碼".into()); } if !cfg!(target_os = "macos") { let label = format!("{LABEL}:{host}"); let o = secret_tool(&["store", "--label", &label, "application", "anybot", "server", &host, "user", username], Some(password)) .ok_or("找不到 secret-tool:請安裝 libsecret-tools(系統鑰匙圈的指令)。")?; return if o.status.success() { Ok(()) } else { Err(format!("存不進系統鑰匙圈:{}", String::from_utf8_lossy(&o.stderr).trim())) }; } let out = Command::new("security").args(["add-internet-password", "-U", "-s", &host, "-a", username, "-l", LABEL, "-w", password]).output().map_err(|e| e.to_string())?; if out.status.success() { index_add(&host, username); Ok(()) } else { Err(String::from_utf8_lossy(&out.stderr).trim().to_string()) } } /// AnyBot's list of the logins it saved: site and account only (the passwords stay in the /// keychain). macOS prints a non-ASCII label like 「AnyBot 登入」 as hex bytes, so the keychain /// can't be searched by label reliably; this list is the source of truth for what to show. fn index_path() -> std::path::PathBuf { crate::model::State::path().parent().map(|d| d.join("vault-index.json")).unwrap_or_default() } fn read_index() -> Option> { let mut list: Vec<(String, String)> = std::fs::read_to_string(index_path()).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default(); // Once: bring in logins AnyBot saved before the list existed. let marker = index_path().with_file_name("vault-index-imported"); if cfg!(target_os = "macos") && !marker.exists() { for l in legacy_keychain_logins() { if !list.contains(&l) { list.push(l); } } write_index(&list); let _ = std::fs::write(&marker, "1"); } Some(list) } fn write_index(list: &[(String, String)]) { let _ = std::fs::write(index_path(), serde_json::to_string_pretty(list).unwrap_or_default()); } /// (Kept for the live tests and older callers.) #[cfg_attr(not(test), allow(dead_code))] fn index_add(site: &str, account: &str) { let mut list = read_index().unwrap_or_default(); if !list.iter().any(|(s, a)| s == site && a == account) { list.push((site.to_string(), account.to_string())); write_index(&list); } } /// The keychain has this login (no password is read, so no prompt). fn keychain_has(site: &str, account: &str) -> bool { Command::new("security").args(["find-internet-password", "-s", site, "-a", account]).output().map(|o| o.status.success()).unwrap_or(false) } /// Logins saved before the index existed: keychain items with AnyBot's label, which /// `security dump-keychain` prints either as text or as hex bytes. fn legacy_keychain_logins() -> Vec<(String, String)> { let Ok(out) = Command::new("security").args(["dump-keychain"]).output() else { return vec![] }; let text = String::from_utf8_lossy(&out.stdout); let hex: String = LABEL.bytes().map(|b| format!("{b:02X}")).collect(); let mut v = vec![]; for block in text.split("keychain: ") { if !block.contains(&format!("=\"{LABEL}\"")) && !block.to_uppercase().contains(&format!("0X{hex}")) { continue; } let get = |k: &str| block.lines().find(|l| l.contains(&format!("\"{k}\""))).and_then(|l| l.split("=\"").nth(1)).map(|s| s.trim_end_matches('"').to_string()).unwrap_or_default(); let (s, a) = (get("srvr"), get("acct")); if !s.is_empty() { v.push((s, a)); } } v } /// Sites AnyBot saved (names only). pub fn saved_sites() -> Vec<(String, String)> { if !cfg!(target_os = "macos") { let Some(o) = secret_tool(&["search", "--all", "application", "anybot"], None) else { return vec![] }; let text = format!("{}\n{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr)); return parse_secret_search(&text).into_iter().filter_map(|(a, _)| Some((a.get("server")?.clone(), a.get("user").cloned().unwrap_or_default()))).collect(); } let list = read_index().unwrap_or_default(); // Drop entries someone deleted in 鑰匙圈存取 meanwhile. let kept: Vec<_> = list.iter().filter(|(s, a)| keychain_has(s, a)).cloned().collect(); if kept.len() != list.len() { write_index(&kept); } kept } /// (Kept for the live tests and older callers.) #[cfg_attr(not(test), allow(dead_code))] pub fn remove(site: &str, account: &str) { if !cfg!(target_os = "macos") { let _ = secret_tool(&["clear", "application", "anybot", "server", site, "user", account], None); return; } let _ = Command::new("security").args(["delete-internet-password", "-s", site, "-a", account]).output(); if let Some(mut list) = read_index() { list.retain(|(s, a)| !(s == site && a == account)); write_index(&list); } } /// The JavaScript that types the login into the page (as a JSON-encoded string literal). pub fn fill_script(username: &str, password: &str, submit: bool) -> String { let u = serde_json::to_string(username).unwrap_or_default(); let p = serde_json::to_string(password).unwrap_or_default(); format!( r#"(() => {{ const set = (el, v) => {{ const d = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value'); d.set.call(el, v); el.dispatchEvent(new Event('input', {{bubbles: true}})); el.dispatchEvent(new Event('change', {{bubbles: true}})); }}; const vis = el => el.offsetParent !== null && !el.disabled; const pw = [...document.querySelectorAll('input[type=password]')].find(vis); const users = [...document.querySelectorAll('input[type=email],input[type=text],input[autocomplete=username],input[name*=user i],input[name*=mail i],input[id*=user i],input[id*=mail i]')].filter(vis); let user = null; if (pw) {{ for (const el of users) {{ if (el.compareDocumentPosition(pw) & Node.DOCUMENT_POSITION_FOLLOWING) user = el; }} }} else {{ user = users[0] || null; }} if (user && {u}) set(user, {u}); if (pw) set(pw, {p}); let submitted = false; if ({submit} && (pw || user)) {{ const field = pw || user; const f = field.form; // The form's own button; else (pages without a
, like many shops) the nearest visible // button that says 登入 / Log in / Sign in / 繼續 / 下一步. let b = f ? (f.querySelector('button[type=submit],input[type=submit]') || f.querySelector('button')) : null; if (!b) {{ const words = /登入|登录|log ?in|sign ?in|繼續|继续|下一步|next|continue/i; const cands = [...document.querySelectorAll('button,input[type=submit],[role=button],a')].filter(vis).filter(el => words.test((el.innerText || el.value || el.getAttribute('aria-label') || '').trim())); const fr = field.getBoundingClientRect(); cands.sort((a, c) => {{ const ra = a.getBoundingClientRect(), rc = c.getBoundingClientRect(); return (Math.abs(ra.top - fr.top) + Math.abs(ra.left - fr.left)) - (Math.abs(rc.top - fr.top) + Math.abs(rc.left - fr.left)); }}); b = cands[0] || null; }} if (b && !b.disabled) {{ b.click(); submitted = true; }} else if (f) {{ f.requestSubmit ? f.requestSubmit() : f.submit(); submitted = true; }} }} return JSON.stringify({{user: !!user, password: !!pw, submitted}}); }})()"# ) } fn args_of(args: &str) -> Value { serde_json::from_str(if args.trim().is_empty() { "{}" } else { args }).unwrap_or_default() } /// The current tab's host in AnyBot's browser. fn current_host() -> Result { let (url, _) = crate::botbrowser::eval_current(None, "1")?; Ok(host_of(&url)) } /// What browser_fill_login will use, worked out before asking the person. pub struct LoginPlan { pub host: String, /// A vault login (public fields only). pub item: Option, /// The vault login is saved for this site (else the person is warned before allowing it). pub matched: bool, /// A Keychain / 1Password login (when the vault has none). other: Option, } impl LoginPlan { /// The approval question. pub fn question(&self) -> String { match &self.item { Some(i) if self.matched => format!("用保險庫的登入資訊({}{})登入 {}(Bot 看不到密碼)", i.display_title(), if i.username.is_empty() { String::new() } else { format!(" · {}", i.username) }, self.host), Some(i) => format!( "也要在 {} 上使用 {} 嗎?(已為 {} 儲存)\n只有在它與已儲存的網站屬於同一服務時才允許。仿冒網站可能會竊取你的密碼。", self.host, i.display_title(), i.sites.join("、") ), None => format!("用{}的登入資料({})登入 {}(Bot 看不到密碼)", self.other.as_ref().map(|l| l.source).unwrap_or("已儲存"), self.other.as_ref().map(|l| l.username.as_str()).unwrap_or(""), self.host), } } } pub fn plan_login(args: &str) -> Result { let v = args_of(args); let tab = current_host()?; let host = v["site"].as_str().filter(|s| !s.trim().is_empty()).map(host_of).filter(|h| !h.is_empty()).unwrap_or(tab); if let Some(id) = v["login_id"].as_str().filter(|s| !s.is_empty()) { let item = crate::vault_store::get(id).filter(|i| i.kind == crate::vault_store::Kind::Login).ok_or("保險庫裡沒有這筆登入資訊(先用 vault_list 看 id)")?; let matched = item.fills_on(&host); return Ok(LoginPlan { host, item: Some(item), matched, other: None }); } if let Some(item) = crate::vault_store::logins_for(&host).into_iter().next() { return Ok(LoginPlan { host, item: Some(item), matched: true, other: None }); } match find(&host) { Some(l) => Ok(LoginPlan { host, item: None, matched: true, other: Some(l) }), None => Err(format!("找不到「{host}」的登入資料。請使用者在「設定 → 保險庫」新增一筆登入資訊(或存在 macOS 鑰匙圈/1Password)。")), } } /// Type the planned login into the page (after the person allowed it). pub fn fill_login(plan: &LoginPlan, submit: bool) -> Result { let (username, password, source) = match (&plan.item, &plan.other) { (Some(i), _) => { let (u, p) = crate::vault_store::login_secret(&i.id).ok_or("保險庫裡沒有這筆登入資訊")?; if !plan.matched { crate::vault_store::add_site(&i.id, &plan.host)?; } (u, p, "保險庫") } (None, Some(l)) => (l.username.clone(), l.password.clone(), l.source), _ => return Err("沒有可用的登入資料".into()), }; let (_, r) = crate::botbrowser::eval_current(None, &fill_script(&username, &password, submit))?; let got: Value = r.as_str().and_then(|s| serde_json::from_str(s).ok()).unwrap_or_default(); if got["password"].as_bool() != Some(true) && got["user"].as_bool() != Some(true) { return Err("這一頁找不到帳號或密碼欄位。先打開登入頁(有些網站要先按「登入」或先填帳號、按下一步才出現密碼欄)。".into()); } Ok(format!( "已在 {} 填入{}{}(來自{source},你看不到密碼)。{}", plan.host, if got["user"].as_bool() == Some(true) { format!("帳號 {username}") } else { String::new() }, if got["password"].as_bool() == Some(true) { "和密碼" } else { "(這頁沒有密碼欄,可能要先按下一步)" }, if submit && got["submitted"].as_bool() == Some(true) { "也已按下登入,請用 browser_read 確認是否登入成功。如果還在登入頁(常見原因:需要驗證碼、簡訊碼或人機驗證),請使用者在 AnyBot 瀏覽器視窗裡完成,之後會記住登入。" } else if submit { "但頁面上找不到登入按鈕,沒有送出。請用 computer_use 按登入按鈕,或請使用者按。" } else { "接著按頁面上的登入按鈕。" } )) } /// JavaScript that fills a form's fields by their autocomplete hints and common names. /// `values` maps a field role to its value; the page gets them as one JSON string literal. pub fn form_fill_script(values: &Value) -> String { let data = serde_json::to_string(&values.to_string()).unwrap_or_default(); format!( r#"(() => {{ const V = JSON.parse({data}); const vis = el => el.offsetParent !== null && !el.disabled && !el.readOnly; const set = (el, v) => {{ if (el.tagName === 'SELECT') {{ const want = String(v).toLowerCase(); const opt = [...el.options].find(o => o.value.toLowerCase() === want || o.text.trim().toLowerCase() === want || (want.length > 1 && o.text.trim().toLowerCase().startsWith(want))); if (!opt) return false; el.value = opt.value; }} else {{ const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, String(v)); }} el.dispatchEvent(new Event('input', {{bubbles: true}})); el.dispatchEvent(new Event('change', {{bubbles: true}})); return true; }}; const rules = {{ 'cc-number': [/card.?num|cc.?num|cardnumber|ccnumber|卡號/i], 'cc-exp': [/^(cc-?)?exp(iry|iration)?(-?date)?$|mm.?\/?.?yy|有效期/i], 'cc-exp-month': [/exp.*month|cc-?month|card.?month|月/i], 'cc-exp-year': [/exp.*year|cc-?year|card.?year|年/i], 'cc-csc': [/cvc|cvv|csc|security.?code|card.?code|安全碼|驗證碼/i], 'cc-name': [/name.?on.?card|card.?holder|holder.?name|cc-?name|持卡人/i], 'name': [/^(full.?)?name$|姓名|收件人/i], 'address-line1': [/address.?(line)?.?1|street|^address$|地址/i], 'address-line2': [/address.?(line)?.?2|apt|suite|unit/i], 'address-level2': [/city|town|城市|縣市/i], 'address-level1': [/state|province|region|county|州|省/i], 'postal-code': [/zip|postal|postcode|郵遞區號|郵遞/i], 'country': [/country|國家/i], 'tel': [/phone|mobile|tel|手機|電話/i], 'email': [/e-?mail|電子郵件/i], }}; const fields = [...document.querySelectorAll('input:not([type=hidden]):not([type=checkbox]):not([type=radio]):not([type=password]),select,textarea')].filter(vis); const used = new Set(); const filled = []; for (const role of Object.keys(V)) {{ if (V[role] === '' || V[role] == null) continue; let el = fields.find(f => !used.has(f) && (f.autocomplete || '').toLowerCase().split(/\s+/).includes(role)); if (!el) {{ const pats = rules[role] || []; el = fields.find(f => {{ const ac = (f.autocomplete || '').toLowerCase(); return !used.has(f) && (ac === '' || ac === 'on' || ac === 'off' || ac.startsWith('cc-') === role.startsWith('cc-')) && pats.some(p => p.test([f.type === 'text' ? '' : f.type, f.name, f.id, f.placeholder, f.getAttribute('aria-label') || '', (f.labels && f.labels[0] ? f.labels[0].innerText : '')].join(' '))); }}); }} if (el && set(el, V[role])) {{ used.add(el); filled.push(role); }} }} const frames = document.querySelectorAll('iframe').length; return JSON.stringify({{filled, frames}}); }})()"# ) } /// Names for the roles, as the bot is told what was filled. fn role_name(r: &str) -> &'static str { match r { "cc-number" => "卡號", "cc-exp" | "cc-exp-month" | "cc-exp-year" => "有效期限", "cc-csc" => "安全碼", "cc-name" => "持卡人姓名", "name" => "姓名", "address-line1" | "address-line2" => "地址", "address-level2" => "城市", "address-level1" => "州/省/地區", "postal-code" => "郵遞區號", "country" => "國家/地區", "tel" => "電話", "email" => "電子郵件", _ => "欄位", } } fn filled_summary(r: &Value) -> (Vec<&'static str>, u64) { let mut names: Vec<&'static str> = r["filled"].as_array().into_iter().flatten().filter_map(|x| x.as_str()).map(role_name).collect(); names.dedup(); (names, r["frames"].as_u64().unwrap_or(0)) } /// For the approval question: (current host, card label) without secrets. pub fn plan_card(args: &str) -> Result<(String, crate::vault_store::Item), String> { let id = args_of(args)["card_id"].as_str().unwrap_or("").to_string(); let card = crate::vault_store::get(&id).filter(|i| i.kind == crate::vault_store::Kind::Card).ok_or("保險庫裡沒有這張卡片(先用 vault_list 看 id)")?; Ok((current_host()?, card)) } pub fn fill_card(args: &str) -> Result { let id = args_of(args)["card_id"].as_str().unwrap_or("").to_string(); let c = crate::vault_store::card_secret(&id).ok_or("保險庫裡沒有這張卡片")?; let values = serde_json::json!({ "cc-number": c.number, "cc-exp": format!("{:02}/{:02}", c.exp_month, c.exp_year % 100), "cc-exp-month": format!("{:02}", c.exp_month), "cc-exp-year": c.exp_year.to_string(), "cc-csc": c.csc, "cc-name": c.holder, }); let (_, r) = crate::botbrowser::eval_current(None, &form_fill_script(&values))?; let got: Value = r.as_str().and_then(|s| serde_json::from_str(s).ok()).unwrap_or_default(); let (names, frames) = filled_summary(&got); if names.is_empty() { return Err(if frames > 0 { "這一頁的付款欄位在付款服務的框架裡,AnyBot 填不進去。請使用者自己填卡號,或改用網站提供的其他付款方式。".into() } else { "這一頁找不到付款欄位。先打開結帳頁的付款步驟。".into() }); } Ok(format!("已填入 {} •••• {} 的{}(你看不到卡號和安全碼)。送出付款前一定要先問使用者。", c.display_title(), c.last4, names.join("、"))) } pub fn plan_contact(args: &str) -> Result<(String, crate::vault_store::Item), String> { let id = args_of(args)["contact_id"].as_str().unwrap_or("").to_string(); let c = crate::vault_store::get(&id).filter(|i| i.kind == crate::vault_store::Kind::Contact).ok_or("保險庫裡沒有這筆聯絡資訊(先用 vault_list 看 id)")?; Ok((current_host()?, c)) } pub fn fill_contact(args: &str) -> Result { let (_, c) = plan_contact(args)?; let values = serde_json::json!({ "name": c.name, "address-line1": c.address.line1, "address-line2": c.address.line2, "address-level2": c.address.city, "address-level1": c.address.region, "postal-code": c.address.postal, "country": c.address.country, "tel": c.phones.first().cloned().unwrap_or_default(), "email": c.emails.first().cloned().unwrap_or_default(), }); let (_, r) = crate::botbrowser::eval_current(None, &form_fill_script(&values))?; let got: Value = r.as_str().and_then(|s| serde_json::from_str(s).ok()).unwrap_or_default(); let (names, _) = filled_summary(&got); if names.is_empty() { return Err("這一頁找不到地址或聯絡欄位。".into()); } Ok(format!("已填入「{}」的{}。請用 browser_read 確認。", c.display_title(), names.join("、"))) } #[cfg(test)] mod tests { use super::*; #[test] fn hosts_and_candidates() { assert_eq!(host_of("https://accounts.Google.com/signin?x=1"), "accounts.google.com"); assert_eq!(host_of("https://me:pw@example.com:8443/a"), "example.com"); assert_eq!(candidates("a.b.example.com"), vec!["a.b.example.com", "b.example.com", "example.com"]); let found = parse_secret_search("[/org/freedesktop/secrets/collection/login/7]\nlabel = AnyBot 登入:x.com\nsecret = hunter2\ncreated = 2026-11-04\nattribute.application = anybot\nattribute.server = x.com\nattribute.user = me\n[/org/freedesktop/secrets/collection/login/8]\nsecret = pw2\nattribute.server = y.com\n"); assert_eq!(found.len(), 2); assert_eq!(found[0].1, "hunter2"); assert_eq!(found[0].0["user"], "me"); assert_eq!(found[1].0["server"], "y.com"); } #[test] fn keychain_password_lines() { assert_eq!(parse_g_password("password: \"hunter2\"\n").unwrap(), "hunter2"); assert_eq!(parse_g_password("password: \"a\"b\"\n").unwrap(), "a\"b"); assert_eq!(parse_g_password("password: 0x706122737320E4BDA0E5A5BD \"pa\\\"ss \\344\\275\\240\\345\\245\\275\"\n").unwrap(), "pa\"ss 你好"); assert_eq!(parse_g_password("password: \n").unwrap(), ""); assert!(parse_g_password("nothing").is_none()); } #[test] fn the_script_keeps_the_password_in_a_string() { let js = fill_script("me@x.com", "p\"w'", true); assert!(js.contains(r#""p\"w'""#)); assert!(js.contains("input[type=password]") && js.contains("if (true && (pw || user))")); } /// Needs a keyring: on Linux run under `dbus-run-session` with gnome-keyring unlocked. #[test] #[ignore] fn keyring_live() { save("https://login.example.org/x", "me@example.org", "s3cret pass").unwrap(); let l = find("login.example.org").expect("found"); assert_eq!((l.username.as_str(), l.password.as_str()), ("me@example.org", "s3cret pass")); assert!(saved_sites().iter().any(|(s, a)| s == "login.example.org" && a == "me@example.org")); // Logins from before the list existed (label printed as hex by `security`) show up too. if cfg!(target_os = "macos") { assert!(saved_sites().iter().any(|(s, _)| s == "legacy.example.org"), "legacy entry: {:?}", saved_sites()); } remove("login.example.org", "me@example.org"); assert!(find("login.example.org").is_none()); } /// The whole bot-side flow on this computer: local pages in AnyBot's browser (a throwaway /// profile), items in a scratch vault, and the fill tools. Run with ANYBOT_DATA_DIR set to a /// scratch folder; opens a browser window for a moment. #[test] #[ignore] fn fill_live() { use crate::vault_store as store; use std::io::{Read, Write}; let dir = crate::model::State::path().parent().unwrap().to_path_buf(); std::env::set_var("ANYBOT_VAULT_DIR", &dir); std::env::set_var("ANYBOT_VAULT_KEY_FILE", dir.join("vault-test.key")); let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let port = listener.local_addr().unwrap().port(); std::thread::spawn(move || { for mut c in listener.incoming().flatten() { let mut buf = [0u8; 2048]; let n = c.read(&mut buf).unwrap_or(0); let req = String::from_utf8_lossy(&buf[..n]); let page = if req.starts_with("GET /checkout") { "" } else { "
" }; let _ = write!(c, "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{page}", page.len()); } }); crate::botbrowser::add("vault-test").unwrap(); let run = || -> Result<(), String> { let login = store::put_login(store::LoginInput { id: None, sites: vec!["127.0.0.1".into()], username: "tester@example.org".into(), password: "pa\"ss 你好".into(), notes: String::new() })?; let card = store::put_card(store::CardInput { id: None, number: "4111111111111111".into(), expiry: "08/39".into(), csc: "123".into(), holder: "WANG XIAOMING".into(), nickname: "測試".into(), billing: store::Address::default(), notes: String::new() })?; let contact = store::put_contact(store::ContactInput { id: None, label: "住家".into(), name: "王小明".into(), address: store::Address { line1: "信義路五段 7 號".into(), city: "台北市".into(), postal: "110".into(), ..Default::default() }, phones: vec!["0912345678".into()], emails: vec![], notes: String::new() })?; // What the bot sees has no secrets. let seen = list_for_bot("{}"); assert!(seen.contains("tester@example.org") && seen.contains("1111") && !seen.contains("4111111111111111") && !seen.contains("pa\\\"ss") && !seen.contains("123\""), "{seen}"); crate::botbrowser::open(&format!("http://127.0.0.1:{port}/login"), Some("vault-test"))?; let plan = plan_login("{}")?; assert!(plan.matched && plan.item.as_ref().map(|i| i.id == login).unwrap_or(false)); let said = fill_login(&plan, false)?; assert!(!said.contains("pa\"ss"), "{said}"); let (_, got) = crate::botbrowser::eval_current(None, "JSON.stringify([e.value, p.value])")?; assert_eq!(got.as_str().unwrap(), r#"["tester@example.org","pa\"ss 你好"]"#); crate::botbrowser::open(&format!("http://127.0.0.1:{port}/checkout"), Some("vault-test"))?; let said = fill_card(&format!("{{\"card_id\":\"{card}\"}}"))?; assert!(!said.contains("4111111111111111") && !said.contains("123"), "{said}"); fill_contact(&format!("{{\"contact_id\":\"{contact}\"}}"))?; let (_, got) = crate::botbrowser::eval_current(None, "JSON.stringify([n.value,a1.value,c.value,z.value,t.value,cc.value,ex.value,cv.value,hn.value])")?; assert_eq!(got.as_str().unwrap(), r#"["王小明","信義路五段 7 號","台北市","110","0912345678","4111111111111111","08/39","123","WANG XIAOMING"]"#); Ok(()) }; let r = run(); let _ = crate::botbrowser::remove("vault-test"); let _ = std::fs::remove_file(dir.join("vault-test.key")); let _ = std::fs::remove_file(store::file_path()); r.unwrap(); } }