fix: reconcile boss agent screen permission state

This commit is contained in:
AI Bot
2026-05-13 10:10:58 +08:00
parent a2d6dbd012
commit 04505da747
3 changed files with 210 additions and 5 deletions

View File

@@ -221,19 +221,65 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate {
}
private func nativePermissionQueryItems() -> [URLQueryItem] {
[
URLQueryItem(name: "nativeAccessibility", value: AXIsProcessTrusted() ? "granted" : "missing"),
URLQueryItem(name: "nativeScreenRecording", value: screenRecordingStatus()),
let accessibility = AXIsProcessTrusted() ? "granted" : "missing"
let screenRecording = screenRecordingStatus()
UserDefaults.standard.set(accessibility, forKey: "native.accessibility")
UserDefaults.standard.set(screenRecording, forKey: "native.screenRecording")
return [
URLQueryItem(name: "nativeAccessibility", value: accessibility),
URLQueryItem(name: "nativeScreenRecording", value: screenRecording),
]
}
private func screenRecordingStatus() -> String {
if #available(macOS 10.15, *) {
return CGPreflightScreenCaptureAccess() ? "granted" : "missing"
if CGPreflightScreenCaptureAccess() {
return "granted"
}
if CGRequestScreenCaptureAccess() {
return "granted"
}
return tccPermissionStatus(service: "kTCCServiceScreenCapture") ?? "missing"
}
return "unknown"
}
private func tccPermissionStatus(service: String) -> String? {
let query = "select auth_value from access where client='com.hyzq.boss.agent' and service='\(service)' limit 1;"
let databasePaths = [
"/Library/Application Support/com.apple.TCC/TCC.db",
"\(NSHomeDirectory())/Library/Application Support/com.apple.TCC/TCC.db",
]
for databasePath in databasePaths where FileManager.default.fileExists(atPath: databasePath) {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/sqlite3")
process.arguments = [databasePath, query]
let output = Pipe()
process.standardOutput = output
process.standardError = Pipe()
do {
try process.run()
process.waitUntilExit()
} catch {
continue
}
let data = output.fileHandleForReading.readDataToEndOfFile()
let value = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
if value == "2" {
return "granted"
}
if value == "0" {
return "missing"
}
}
return nil
}
private func requestNativePermission(for target: String) {
let targets: [String]
if target == "core" {

View File

@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
import { rm, stat } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
const PERMISSION_DEFS = [
{
@@ -31,6 +32,17 @@ const NATIVE_PERMISSION_QUERY_PARAMS = {
screenRecording: "nativeScreenRecording",
};
const BOSS_AGENT_BUNDLE_ID = "com.hyzq.boss.agent";
const BOSS_AGENT_DEFAULTS_DOMAIN = "com.hyzq.boss.agent";
const TCC_PERMISSION_SERVICES = {
kTCCServiceAccessibility: "accessibility",
kTCCServiceScreenCapture: "screenRecording",
};
const TCC_PERMISSION_DATABASES = [
"/Library/Application Support/com.apple.TCC/TCC.db",
path.join(os.homedir(), "Library/Application Support/com.apple.TCC/TCC.db"),
];
function nonEmpty(value) {
const text = String(value ?? "").trim();
return text || undefined;
@@ -216,12 +228,46 @@ export function mergeBossAgentNativePermissionOverrides(permissions = {}, queryP
for (const [permissionKey, queryKey] of Object.entries(NATIVE_PERMISSION_QUERY_PARAMS)) {
const value = getQueryValue(queryKey);
if (isPermissionStatus(value)) {
if (merged[permissionKey] === "granted" && value !== "granted") {
continue;
}
merged[permissionKey] = value;
}
}
return merged;
}
export function mergeBossAgentStoredNativePermissions(permissions = {}, storedPermissions = {}) {
const merged = { ...permissions };
for (const permissionKey of Object.keys(NATIVE_PERMISSION_QUERY_PARAMS)) {
if (storedPermissions[permissionKey] === "granted") {
merged[permissionKey] = "granted";
}
}
return merged;
}
export function mergeBossAgentAppTccPermissions(permissions = {}, tccRows = "") {
const merged = { ...permissions };
const granted = new Set();
for (const rawLine of String(tccRows ?? "").split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
const [service, authValue] = line.split("|");
const permissionKey = TCC_PERMISSION_SERVICES[service];
if (!permissionKey) continue;
if (authValue === "2") {
merged[permissionKey] = "granted";
granted.add(permissionKey);
} else if (!granted.has(permissionKey) && authValue === "0") {
merged[permissionKey] = "missing";
}
}
return merged;
}
function resolveSkills(runtime) {
const rawSkills = Array.isArray(runtime.lastSkills) ? runtime.lastSkills : [];
const items = rawSkills
@@ -927,8 +973,66 @@ export async function detectLocalComputerPermissions(platform = process.platform
}
}
return {
const localProcessPermissions = {
accessibility: accessibility.ok && /true/i.test(accessibility.stdout) ? "granted" : "missing",
screenRecording,
};
const appTccPermissions = mergeBossAgentAppTccPermissions(localProcessPermissions, await readBossAgentAppTccRows());
return mergeBossAgentStoredNativePermissions(appTccPermissions, await readBossAgentStoredNativePermissions());
}
async function readBossAgentAppTccRows() {
const outputs = [];
for (const dbPath of TCC_PERMISSION_DATABASES) {
try {
await stat(dbPath);
} catch {
continue;
}
for (const service of Object.keys(TCC_PERMISSION_SERVICES)) {
const query = [
"select service,auth_value from access",
`where client='${BOSS_AGENT_BUNDLE_ID}'`,
`and service='${service}'`,
"limit 1",
].join(" ");
try {
const db = new DatabaseSync(dbPath, { readonly: true });
try {
const rows = db.prepare(query).all();
if (rows.length > 0) {
outputs.push(rows.map((row) => `${row.service}|${row.auth_value}`).join("\n"));
}
} finally {
db.close();
}
} catch {
// Keep the command-line reader below as a second source of truth.
}
const result = await runCommand("/usr/bin/sqlite3", [dbPath, `${query};`], 2500);
if (result.ok && result.stdout) {
outputs.push(result.stdout);
}
}
}
return outputs.join("\n");
}
async function readBossAgentStoredNativePermissions() {
const entries = await Promise.all(
Object.keys(NATIVE_PERMISSION_QUERY_PARAMS).map(async (permissionKey) => {
const result = await runCommand(
"/usr/bin/defaults",
["read", BOSS_AGENT_DEFAULTS_DOMAIN, `native.${permissionKey}`],
1500,
);
return [permissionKey, result.ok ? normalizePermissionStatus(result.stdout) : "unknown"];
}),
);
return Object.fromEntries(entries);
}

View File

@@ -4,6 +4,8 @@ import { readFileSync } from "node:fs";
import {
buildBossAgentStatus,
mergeBossAgentAppTccPermissions,
mergeBossAgentStoredNativePermissions,
mergeBossAgentNativePermissionOverrides,
renderBossAgentHtml,
renderBossAgentHtmlWithQr,
@@ -254,6 +256,57 @@ test("boss-agent native permission overrides only update core desktop-control pe
});
});
test("boss-agent native missing values do not downgrade confirmed app grants", () => {
const merged = mergeBossAgentNativePermissionOverrides(
{
accessibility: "granted",
screenRecording: "granted",
},
new URLSearchParams({
nativeAccessibility: "missing",
nativeScreenRecording: "missing",
}),
);
assert.deepEqual(merged, {
accessibility: "granted",
screenRecording: "granted",
});
});
test("boss-agent permission detection trusts app bundle TCC grants over node process checks", () => {
const merged = mergeBossAgentAppTccPermissions(
{
accessibility: "missing",
screenRecording: "missing",
},
"kTCCServiceAccessibility|2\nkTCCServiceScreenCapture|2",
);
assert.deepEqual(merged, {
accessibility: "granted",
screenRecording: "granted",
});
});
test("boss-agent permission detection trusts stored native app permission grants", () => {
const merged = mergeBossAgentStoredNativePermissions(
{
accessibility: "missing",
screenRecording: "missing",
},
{
accessibility: "granted",
screenRecording: "granted",
},
);
assert.deepEqual(merged, {
accessibility: "granted",
screenRecording: "granted",
});
});
test("boss-agent unbound HTML renders a real scannable QR image when qrcode is available", async () => {
const status = buildBossAgentStatus(
{
@@ -292,6 +345,8 @@ test("boss-agent mac app intercepts permission links and triggers native app per
assert.match(swiftSource, /lastPermissionRequestTarget/);
assert.match(swiftSource, /AXIsProcessTrustedWithOptions/);
assert.match(swiftSource, /CGRequestScreenCaptureAccess/);
assert.match(swiftSource, /tccPermissionStatus/);
assert.match(swiftSource, /kTCCServiceScreenCapture/);
assert.doesNotMatch(swiftSource, /AVCaptureDevice\.requestAccess|UNUserNotificationCenter|import IOKit\.hid|CGRequestListenEventAccess|IOHIDRequestAccess|CGEvent\.tapCreate|NWBrowser/);
assert.match(swiftSource, /NSWorkspace\.shared\.open/);
assert.match(swiftSource, /deadline: \.now\(\) \+ 0\.45/);