Mac permissions: ask at most every ten minutes; one fixed signing key keeps them across updates; open apps by their shown name

- Every untrusted step popped macOS's prompt and opened Settings; a remote brain's steps covered
  the screen. Now once per pane per ten minutes.
- Ad hoc signing made each build a new app to macOS, so 輔助使用/螢幕錄製 silently stopped
  working after updates (agent-desktop PERM_DENIED). deploy/mac/sign.sh signs with a local key
  kept in its own keychain under AnyBot's folder.
- computer_use_open_app finds 「計算機」 by display name and says the app opened even when its
  window can't be read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
王性驊 2026-11-05 16:57:44 +08:00
parent 6b0a0babf1
commit 5b2c974f3c
4 changed files with 126 additions and 10 deletions

View File

@ -2,7 +2,7 @@
# Make AnyBot.app from the release build and zip it (run from app/ on a Mac, after
# `cargo build --release`): deploy/mac/bundle.sh 1.2.0 → dist/AnyBot-1.2.0-macos-<arch>.zip
# With KEEP_APP=1 it leaves dist/AnyBot.app instead of zipping it (make install).
# Signed ad hoc (no Apple developer account): the first time, right-click › 打開.
# Signed with this Mac's own fixed key (no Apple developer account): the first time, right-click › 打開.
set -e
VERSION=${1:?version}
BIN=${CARGO_TARGET_DIR:-target}/release/anybot
@ -39,7 +39,8 @@ cat > "$APP/Contents/Info.plist" <<PLIST
<key>NSAppleEventsUsageDescription</key><string>讓 Bot 在你允許時操作其他 App。</string>
</dict></plist>
PLIST
codesign --force --deep -s - "$APP"
# The same signature every build, so macOS keeps the permissions it gave (see sign.sh).
sh "$(dirname "$0")/sign.sh" "$APP"
if [ "${KEEP_APP:-}" = 1 ]; then
exit 0
fi

57
deploy/mac/sign.sh Executable file
View File

@ -0,0 +1,57 @@
#!/bin/sh
# Sign AnyBot.app with this Mac's own signing key, the same one every build:
# deploy/mac/sign.sh dist/AnyBot.app
# macOS keeps 輔助使用 / 螢幕錄製 for an app by its signature. Signed "ad hoc", every build was a
# new app to it, so each update quietly lost those permissions and the bots could no longer read
# or operate other apps (agent-desktop answered PERM_DENIED). One fixed self-signed key, kept in
# its own keychain under AnyBot's folder (the login keychain is not touched), keeps them.
# Anything going wrong falls back to ad hoc signing.
set -u
APP=${1:?app}
DIR="$HOME/Library/Application Support/AnyBot/signing"
KC="$DIR/anybot-signing.keychain-db"
PWF="$DIR/keychain-password"
NAME="AnyBot Local Signing"
make_key() {
mkdir -p "$DIR" && chmod 700 "$DIR" || return 1
head -c 24 /dev/urandom | base64 > "$PWF" && chmod 600 "$PWF" || return 1
PW=$(cat "$PWF")
T=$(mktemp -d)
cat > "$T/cert.cnf" <<CNF
[req]
distinguished_name=dn
x509_extensions=ext
prompt=no
[dn]
CN=$NAME
[ext]
keyUsage=critical,digitalSignature
extendedKeyUsage=critical,codeSigning
basicConstraints=critical,CA:false
CNF
ok=1
/usr/bin/openssl req -x509 -newkey rsa:2048 -nodes -keyout "$T/key.pem" -out "$T/cert.pem" -days 7300 -config "$T/cert.cnf" >/dev/null 2>&1 \
&& /usr/bin/openssl pkcs12 -export -inkey "$T/key.pem" -in "$T/cert.pem" -out "$T/id.p12" -passout pass:"$PW" >/dev/null 2>&1 \
&& security create-keychain -p "$PW" "$KC" >/dev/null \
&& security set-keychain-settings "$KC" \
&& security unlock-keychain -p "$PW" "$KC" \
&& security import "$T/id.p12" -k "$KC" -P "$PW" -T /usr/bin/codesign >/dev/null \
&& security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$PW" "$KC" >/dev/null 2>&1 \
&& ok=0
rm -rf "$T"
return $ok
}
signed=1
if [ -f "$KC" ] || make_key; then
if security unlock-keychain -p "$(cat "$PWF")" "$KC" 2>/dev/null \
&& codesign --force --deep --keychain "$KC" -s "$NAME" "$APP" 2>/dev/null; then
signed=0
echo "已用這台 Mac 固定的簽章簽好(更新後「輔助使用」「螢幕錄製」權限會保留)"
fi
fi
if [ $signed -ne 0 ]; then
codesign --force --deep -s - "$APP"
echo "注意:用臨時簽章(更新後要重新允許「輔助使用」「螢幕錄製」)"
fi

View File

@ -303,8 +303,8 @@ pub fn read_person_browser(exe: &str, max_chars: usize) -> Result<Page, String>
if !cfg!(target_os = "macos") {
return Err("只支援 macOS".into());
}
if !trusted(true) {
let _ = crate::computer::spawn_reaped(&mut std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"));
if !trusted(false) {
crate::computer::ask_permission("Privacy_Accessibility");
return Err("macOS 還沒讓 AnyBot 使用「輔助使用」,所以看不到你的瀏覽器。我已經打開「系統設定 → 隱私權與安全性 → 輔助使用」:把 AnyBot(或「終端機」)打開,然後再叫我試一次。只要開一次。".into());
}
let ps = std::process::Command::new("ps").args(["-axo", "pid=,args="]).output().map(|o| String::from_utf8_lossy(&o.stdout).into_owned()).unwrap_or_default();

View File

@ -394,6 +394,53 @@ fn run_text_mode_name(name: &str, v: &Value) -> Option<()> {
}
}
/// Ask macOS for a permission (its own prompt, then 系統設定 at that pane) at most once every ten
/// minutes. Asking on every step meant a bot working through several steps — or a brain far away
/// working on this computer — covered the screen with prompts and Settings windows, and the
/// person could do nothing else.
pub fn ask_permission(pane: &str) {
static LAST: std::sync::Mutex<Vec<(String, std::time::Instant)>> = std::sync::Mutex::new(Vec::new());
{
let mut l = LAST.lock().unwrap_or_else(|e| e.into_inner());
if l.iter().any(|(p, t)| p == pane && t.elapsed() < std::time::Duration::from_secs(600)) {
return;
}
l.retain(|(p, _)| p != pane);
l.push((pane.to_string(), std::time::Instant::now()));
}
if !cfg!(target_os = "macos") {
return;
}
match pane {
"Privacy_Accessibility" => {
let _ = crate::axread::trusted(true);
}
#[cfg(target_os = "macos")]
"Privacy_ScreenCapture" => {
let _ = mac::screen_capture_allowed();
}
_ => {}
}
let _ = spawn_reaped(&mut std::process::Command::new("open").arg(format!("x-apple.systempreferences:com.apple.preference.security?{pane}")));
}
/// The app a shown name stands for (「計算機」 → Calculator), from the apps' display names.
#[cfg(target_os = "macos")]
fn app_named(name: &str) -> Option<String> {
if name.is_empty() || name.contains(['"', '\\', '\'']) {
return None;
}
let q = format!("kMDItemDisplayName == \"{name}\"cd && kMDItemContentType == \"com.apple.application-bundle\"");
let out = std::process::Command::new("mdfind").arg(q).output().ok()?;
let path = String::from_utf8_lossy(&out.stdout).lines().next()?.trim().to_string();
std::path::Path::new(&path).file_stem().map(|s| s.to_string_lossy().to_string())
}
#[cfg(not(target_os = "macos"))]
fn app_named(_name: &str) -> Option<String> {
None
}
/// The accessibility (text) way: agent-desktop, by element number, in the background.
fn run_text_mode(name: &str, v: &Value) -> Option<Result<(String, Option<String>), String>> {
let rf = v["ref"].as_str().unwrap_or("").trim().to_string();
@ -407,9 +454,20 @@ fn run_text_mode(name: &str, v: &Value) -> Option<Result<(String, Option<String>
}),
"computer_use_open_app" => {
let app = v["app"].as_str().unwrap_or("").trim().to_string();
crate::desktop::run("launch", &json!({"app": app})).and_then(|_| {
// A name as the person sees it (「計算機」 on a Chinese Mac) isn't one the system
// launches by; find the app it is and launch that.
let launched = crate::desktop::run("launch", &json!({"app": app})).or_else(|e| match app_named(&app) {
Some(real) if real != app => crate::desktop::run("launch", &json!({"app": real})),
_ => Err(e),
});
launched.map(|_| {
std::thread::sleep(std::time::Duration::from_millis(800));
crate::desktop::read_app(&app).map(|(a, lines)| format!("已打開「{a}」。\n{}", lines.join("\n")))
// Open is open, even when its window can't be read (that's a separate problem,
// and the bot said "couldn't open" while the app sat there on the screen).
match crate::desktop::read_app(&app) {
Ok((a, lines)) => format!("已打開「{a}」。\n{}", lines.join("\n")),
Err(e) => format!("已打開「{app}」,但讀不到它的畫面:{e}"),
}
})
}
"computer_use_select" => crate::desktop::act("select", &rf, &json!({"value": v["value"]})),
@ -464,8 +522,8 @@ fn run_inner(name: &str, v: &Value) -> Result<(String, Option<String>), String>
if HELD_BY_PERSON.load(std::sync::atomic::Ordering::SeqCst) {
return Err("使用者正在自己操作這台電腦(已取得控制權)。請等他交還控制權後再操作;可以先截圖看看。".into());
}
if cfg!(target_os = "macos") && !crate::axread::trusted(true) {
let _ = spawn_reaped(&mut std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"));
if cfg!(target_os = "macos") && !crate::axread::trusted(false) {
ask_permission("Privacy_Accessibility");
return Err("macOS 還沒讓 AnyBot 使用「輔助使用」,所以不能動滑鼠和鍵盤。我已經打開「系統設定 → 隱私權與安全性 → 輔助使用」:把 AnyBot(或「終端機」)打開,再叫我試一次。只要開一次。".into());
}
let scale = || SCALE.lock().unwrap_or_else(|e| e.into_inner()).ok_or_else(|| "請先用 computer_use_screenshot 看一下畫面,座標要用截圖上的位置。".to_string());
@ -631,8 +689,8 @@ pub fn app_at_point(x: f64, y: f64) -> Option<String> {
}
fn screenshot() -> Result<(String, String), String> {
if !mac::screen_capture_allowed() {
let _ = spawn_reaped(&mut std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture"));
if !mac::screen_capture_preflight() {
ask_permission("Privacy_ScreenCapture");
return Err("macOS 還沒讓 AnyBot 錄製螢幕,所以看不到畫面。我已經打開「系統設定 → 隱私權與安全性 → 螢幕與系統錄音」:把 AnyBot(或「終端機」)打開,再叫我試一次。只要開一次。".into());
}
let img = capture_screen()?;