From 73327be8b0d18a43ca6b0b5c751bf89085199972 Mon Sep 17 00:00:00 2001 From: AI Bot Date: Wed, 13 May 2026 00:18:19 +0800 Subject: [PATCH] fix: sync native agent permission states --- .../boss-agent-mac/Sources/BossAgentApp.swift | 128 ++++++++++++++++-- local-agent/boss-agent-status.mjs | 30 +++- local-agent/server.mjs | 9 +- tests/boss-agent-status.test.mjs | 40 +++++- 4 files changed, 191 insertions(+), 16 deletions(-) diff --git a/apps/boss-agent-mac/Sources/BossAgentApp.swift b/apps/boss-agent-mac/Sources/BossAgentApp.swift index ba98b19..35a0896 100644 --- a/apps/boss-agent-mac/Sources/BossAgentApp.swift +++ b/apps/boss-agent-mac/Sources/BossAgentApp.swift @@ -14,9 +14,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate { private var webView: WKWebView? private var inputMonitoringTap: CFMachPort? private var localNetworkBrowser: NWBrowser? + private var activeTab = "overview" func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.regular) + NotificationCenter.default.addObserver( + self, + selector: #selector(handleApplicationDidBecomeActive), + name: NSApplication.didBecomeActiveNotification, + object: nil + ) let webConfiguration = WKWebViewConfiguration() let webView = WKWebView(frame: .zero, configuration: webConfiguration) @@ -38,18 +45,29 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate { window.makeKeyAndOrderFront(nil) self.window = window - loadAgentPanel() + loadAgentPanel(tab: activeTab) NSApp.activate(ignoringOtherApps: true) } - private func loadAgentPanel() { - guard let url = URL(string: "http://127.0.0.1:4317/boss-agent") else { + private func loadAgentPanel(tab: String? = nil) { + activeTab = normalizedTab(tab ?? activeTab) + var components = URLComponents() + components.scheme = "http" + components.host = "127.0.0.1" + components.port = 4317 + components.path = "/boss-agent" + components.queryItems = [URLQueryItem(name: "tab", value: activeTab)] + nativePermissionQueryItems() + guard let url = components.url else { loadFallback() return } webView?.load(URLRequest(url: url, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData)) } + @objc private func handleApplicationDidBecomeActive() { + loadAgentPanel(tab: activeTab) + } + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { loadFallback() } @@ -74,6 +92,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate { return } + if isAgentPanelUrl(url) && !hasNativePermissionQuery(url) { + decisionHandler(.cancel) + loadAgentPanel(tab: queryValue("tab", in: url)) + return + } + + if isAgentPanelUrl(url), let tab = queryValue("tab", in: url) { + activeTab = normalizedTab(tab) + } + decisionHandler(.allow) } @@ -81,12 +109,38 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate { url.path == "/api/v1/boss-agent/permissions/open" } + private func isAgentPanelUrl(_ url: URL) -> Bool { + let host = url.host ?? "" + return (host == "127.0.0.1" || host == "localhost") && url.port == 4317 && (url.path == "/boss-agent" || url.path == "/") + } + + private func hasNativePermissionQuery(_ url: URL) -> Bool { + URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems? + .contains(where: { $0.name.hasPrefix("native") }) == true + } + + private func queryValue(_ name: String, in url: URL) -> String? { + URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems? + .first(where: { $0.name == name })? + .value + } + + private func normalizedTab(_ value: String?) -> String { + switch value { + case "permissions", "skills", "license", "logs", "overview": + return value ?? "overview" + default: + return "overview" + } + } + private func handlePermissionSetupNavigation(_ url: URL) { let target = - URLComponents(url: url, resolvingAgainstBaseURL: false)? - .queryItems? - .first(where: { $0.name == "target" })? - .value ?? "all" + queryValue("target", in: url) ?? "all" + let returnTab = normalizedTab(queryValue("returnTab", in: url) ?? activeTab) + activeTab = returnTab requestNativePermission(for: target) if let settingsUrl = systemSettingsUrl(for: target) { @@ -94,7 +148,49 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate { } DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak self] in - self?.loadAgentPanel() + self?.loadAgentPanel(tab: returnTab) + } + } + + private func nativePermissionQueryItems() -> [URLQueryItem] { + [ + URLQueryItem(name: "nativeAccessibility", value: AXIsProcessTrusted() ? "granted" : "missing"), + URLQueryItem(name: "nativeScreenRecording", value: screenRecordingStatus()), + URLQueryItem(name: "nativeMicrophone", value: microphonePermissionStatus()), + URLQueryItem(name: "nativeCamera", value: cameraPermissionStatus()), + ] + } + + private func screenRecordingStatus() -> String { + if #available(macOS 10.15, *) { + return CGPreflightScreenCaptureAccess() ? "granted" : "missing" + } + return "unknown" + } + + private func microphonePermissionStatus() -> String { + switch AVCaptureDevice.authorizationStatus(for: .audio) { + case .authorized: + return "granted" + case .denied, .restricted: + return "missing" + case .notDetermined: + return "unknown" + @unknown default: + return "unknown" + } + } + + private func cameraPermissionStatus() -> String { + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + return "granted" + case .denied, .restricted: + return "missing" + case .notDetermined: + return "unknown" + @unknown default: + return "unknown" } } @@ -133,9 +229,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate { case "notifications": requestNotificationPermission() case "microphone": - AVCaptureDevice.requestAccess(for: .audio) { _ in } + requestMicrophonePermission() case "camera": - AVCaptureDevice.requestAccess(for: .video) { _ in } + requestCameraPermission() case "localNetwork": requestLocalNetworkPermission() default: @@ -149,6 +245,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate { _ = AXIsProcessTrustedWithOptions(options) } + private func requestMicrophonePermission() { + if AVCaptureDevice.authorizationStatus(for: .audio) == .notDetermined { + AVCaptureDevice.requestAccess(for: .audio) { _ in } + } + } + + private func requestCameraPermission() { + if AVCaptureDevice.authorizationStatus(for: .video) == .notDetermined { + AVCaptureDevice.requestAccess(for: .video) { _ in } + } + } + private func requestScreenRecordingPermission() { if #available(macOS 10.15, *) { _ = CGRequestScreenCaptureAccess() diff --git a/local-agent/boss-agent-status.mjs b/local-agent/boss-agent-status.mjs index 6672b54..232fa0b 100644 --- a/local-agent/boss-agent-status.mjs +++ b/local-agent/boss-agent-status.mjs @@ -78,6 +78,13 @@ const MACOS_PERMISSION_SETTINGS = { const AUTO_PREFLIGHT_PERMISSION_KEYS = new Set(["accessibility", "screenRecording", "automation"]); +const NATIVE_PERMISSION_QUERY_PARAMS = { + accessibility: "nativeAccessibility", + screenRecording: "nativeScreenRecording", + microphone: "nativeMicrophone", + camera: "nativeCamera", +}; + function nonEmpty(value) { const text = String(value ?? "").trim(); return text || undefined; @@ -103,6 +110,10 @@ function normalizePermissionStatus(value) { return value === "granted" || value === "missing" || value === "unknown" ? value : "unknown"; } +function isPermissionStatus(value) { + return value === "granted" || value === "missing" || value === "unknown"; +} + function statusTone(status) { if (status === "granted" || status === "valid" || status === "connected" || status === "bound") { return "good"; @@ -220,7 +231,7 @@ function buildPermissionSetupPlan(coreItems, extendedItems, readiness) { requiredForSilentControl: true, canPreflight: AUTO_PREFLIGHT_PERMISSION_KEYS.has(item.key), settingsUrl: MACOS_PERMISSION_SETTINGS[item.key] ?? MACOS_PERMISSION_SETTINGS.all, - openUrl: `/api/v1/boss-agent/permissions/open?target=${encodeURIComponent(item.key)}`, + openUrl: `/api/v1/boss-agent/permissions/open?target=${encodeURIComponent(item.key)}&returnTab=permissions`, owner: "boss-agent.app", })); const missingActions = actions.filter((action) => action.status !== "granted"); @@ -232,7 +243,7 @@ function buildPermissionSetupPlan(coreItems, extendedItems, readiness) { silentUseReady: missingActions.length === 0, primaryAction: { label: "打开完整授权向导", - href: "/api/v1/boss-agent/permissions/open?target=all", + href: "/api/v1/boss-agent/permissions/open?target=all&returnTab=permissions", settingsUrl: MACOS_PERMISSION_SETTINGS.all, }, actions, @@ -245,6 +256,21 @@ function buildPermissionSetupPlan(coreItems, extendedItems, readiness) { }; } +export function mergeBossAgentNativePermissionOverrides(permissions = {}, queryParams = {}) { + const getQueryValue = (name) => { + if (typeof queryParams.get === "function") return queryParams.get(name); + return queryParams[name]; + }; + const merged = { ...permissions }; + for (const [permissionKey, queryKey] of Object.entries(NATIVE_PERMISSION_QUERY_PARAMS)) { + const value = getQueryValue(queryKey); + if (isPermissionStatus(value)) { + merged[permissionKey] = value; + } + } + return merged; +} + function resolveSkills(runtime) { const rawSkills = Array.isArray(runtime.lastSkills) ? runtime.lastSkills : []; const items = rawSkills diff --git a/local-agent/server.mjs b/local-agent/server.mjs index 4d398eb..f1e42b9 100755 --- a/local-agent/server.mjs +++ b/local-agent/server.mjs @@ -41,6 +41,7 @@ import { import { buildBossAgentStatus, detectLocalComputerPermissions, + mergeBossAgentNativePermissionOverrides, normalizeBossAgentTab, openBossAgentPermissionSettings, renderBossAgentHtmlWithQr, @@ -1010,7 +1011,10 @@ const server = createServer(async (request, response) => { const requestUrl = new URL(request.url || "/", `http://${config.bindHost || "127.0.0.1"}`); if (requestUrl.pathname === "/" || requestUrl.pathname === "/boss-agent") { - const permissions = await detectLocalComputerPermissions(); + const permissions = mergeBossAgentNativePermissionOverrides( + await detectLocalComputerPermissions(), + requestUrl.searchParams, + ); const status = buildBossAgentStatus(config, runtime, { permissions }); const activeTab = normalizeBossAgentTab(requestUrl.searchParams.get("tab") ?? "overview"); response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); @@ -1034,6 +1038,7 @@ const server = createServer(async (request, response) => { if (requestUrl.pathname === "/api/v1/boss-agent/permissions/open") { const target = requestUrl.searchParams.get("target") || "all"; + const returnTab = normalizeBossAgentTab(requestUrl.searchParams.get("returnTab") ?? "permissions"); const result = await openBossAgentPermissionSettings(target); const wantsJson = String(request.headers.accept || "").includes("application/json"); if (wantsJson) { @@ -1041,7 +1046,7 @@ const server = createServer(async (request, response) => { response.end(JSON.stringify(result)); return; } - response.writeHead(302, { Location: "/boss-agent" }); + response.writeHead(302, { Location: `/boss-agent?tab=${encodeURIComponent(returnTab)}` }); response.end(); return; } diff --git a/tests/boss-agent-status.test.mjs b/tests/boss-agent-status.test.mjs index 6fb446e..6879746 100644 --- a/tests/boss-agent-status.test.mjs +++ b/tests/boss-agent-status.test.mjs @@ -4,6 +4,7 @@ import { readFileSync } from "node:fs"; import { buildBossAgentStatus, + mergeBossAgentNativePermissionOverrides, renderBossAgentHtml, renderBossAgentHtmlWithQr, } from "../local-agent/boss-agent-status.mjs"; @@ -169,8 +170,8 @@ test("boss-agent permission and skill menu entries render as separate tab pages" assert.match(permissionsHtml, /

一次完整授权<\/h2>/); assert.match(permissionsHtml, /完整接管待补齐/); assert.match(permissionsHtml, /后续静默使用/); - assert.match(permissionsHtml, /api\/v1\/boss-agent\/permissions\/open\?target=all/); - assert.match(permissionsHtml, /api\/v1\/boss-agent\/permissions\/open\?target=fullDiskAccess/); + assert.match(permissionsHtml, /api\/v1\/boss-agent\/permissions\/open\?target=all&returnTab=permissions/); + assert.match(permissionsHtml, /api\/v1\/boss-agent\/permissions\/open\?target=fullDiskAccess&returnTab=permissions/); assert.doesNotMatch(permissionsHtml, /

Skill 部署情况<\/h2>/); const skillsHtml = renderBossAgentHtml(status, { activeTab: "skills" }); @@ -180,6 +181,33 @@ test("boss-agent permission and skill menu entries render as separate tab pages" assert.doesNotMatch(skillsHtml, /

一次完整授权<\/h2>/); }); +test("boss-agent native permission overrides update app-owned camera and microphone state", () => { + const merged = mergeBossAgentNativePermissionOverrides( + { + accessibility: "missing", + screenRecording: "missing", + automation: "granted", + camera: "unknown", + microphone: "unknown", + }, + new URLSearchParams({ + nativeAccessibility: "granted", + nativeCamera: "granted", + nativeMicrophone: "missing", + nativeScreenRecording: "granted", + nativeInputMonitoring: "invalid", + }), + ); + + assert.deepEqual(merged, { + accessibility: "granted", + screenRecording: "granted", + automation: "granted", + camera: "granted", + microphone: "missing", + }); +}); + test("boss-agent unbound HTML renders a real scannable QR image when qrcode is available", async () => { const status = buildBossAgentStatus( { @@ -215,6 +243,14 @@ test("boss-agent mac app intercepts permission links and triggers native app per assert.match(swiftSource, /UNUserNotificationCenter\.current\(\)\.requestAuthorization/); assert.match(swiftSource, /CGEvent\.tapCreate/); assert.match(swiftSource, /NSWorkspace\.shared\.open/); + assert.match(swiftSource, /loadAgentPanel\(tab:/); + assert.match(swiftSource, /returnTab/); + assert.match(swiftSource, /nativePermissionQueryItems/); + assert.match(swiftSource, /nativeCamera/); + assert.match(swiftSource, /nativeMicrophone/); + assert.match(swiftSource, /AVCaptureDevice\.authorizationStatus\(for: \.video/); + assert.match(swiftSource, /AVCaptureDevice\.authorizationStatus\(for: \.audio/); + assert.match(swiftSource, /NSApplication\.didBecomeActiveNotification/); assert.match(buildScript, /NSMicrophoneUsageDescription/); assert.match(buildScript, /NSCameraUsageDescription/); assert.match(buildScript, /NSAppleEventsUsageDescription/);