From 29740f35c776e513d7026337a08eb6e1fe6ddbf3 Mon Sep 17 00:00:00 2001 From: AI Bot Date: Tue, 12 May 2026 23:22:27 +0800 Subject: [PATCH] fix: align agent permissions with native app --- README.md | 2 +- .../boss-agent-mac/Sources/BossAgentApp.swift | 177 ++++++++++++++++++ local-agent/boss-agent-status.mjs | 118 ++---------- scripts/build-boss-agent-mac-app.sh | 21 ++- tests/boss-agent-status.test.mjs | 24 +++ 5 files changed, 233 insertions(+), 109 deletions(-) diff --git a/README.md b/README.md index f94d4ea..858bba8 100644 --- a/README.md +++ b/README.md @@ -247,7 +247,7 @@ open dist/boss-agent.app - `boss-agent.app` 是本机 `local-agent` 的 macOS WebView 外壳,默认打开 `http://127.0.0.1:4317/boss-agent` - 未绑定账号时会显示可扫码的 Boss APP 绑定二维码;已绑定后显示账号、API、服务器、授权、本机权限获取和本机 Skill 部署情况 - 本机权限会区分两级判断:`辅助功能 / 屏幕录制 / 自动化控制` 只代表核心桌面控制能力;完整接管还需要按业务场景补齐全磁盘访问、输入监控、通知、麦克风、摄像头和本地网络等权限 -- 本机权限页提供“一次完整授权”入口,点击后通过 `GET /api/v1/boss-agent/permissions/open?target=all` 打开 macOS 隐私设置;各权限项也有独立设置入口。授权完成后由系统持久保存,后续控制过程只静默校验并使用,不在任务执行中临时申请 +- 本机权限页提供“一次完整授权”入口;在 `boss-agent.app` 内点击时会先由应用本体触发辅助功能、屏幕录制、自动化、输入监控、通知、麦克风、摄像头和本地网络等原生权限预检,再打开对应 macOS 隐私设置页,确保权限列表里出现 `boss-agent` 供用户直接开启。授权完成后由系统持久保存,后续控制过程只静默校验并使用,不在任务执行中临时申请 - 本机状态 JSON 可通过 `GET http://127.0.0.1:4317/api/v1/boss-agent/status` 查看,不会返回设备 token 明文 device-agent 当前职责: diff --git a/apps/boss-agent-mac/Sources/BossAgentApp.swift b/apps/boss-agent-mac/Sources/BossAgentApp.swift index 5456a13..ba98b19 100644 --- a/apps/boss-agent-mac/Sources/BossAgentApp.swift +++ b/apps/boss-agent-mac/Sources/BossAgentApp.swift @@ -1,9 +1,19 @@ import Cocoa import WebKit +import ApplicationServices +import AVFoundation +import Network +import UserNotifications + +private let bossInputMonitoringTapCallback: CGEventTapCallBack = { _, _, event, _ in + Unmanaged.passUnretained(event) +} final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate { private var window: NSWindow? private var webView: WKWebView? + private var inputMonitoringTap: CFMachPort? + private var localNetworkBrowser: NWBrowser? func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.regular) @@ -48,6 +58,173 @@ final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate { loadFallback() } + func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { + guard let url = navigationAction.request.url else { + decisionHandler(.allow) + return + } + + if isPermissionSetupUrl(url) { + decisionHandler(.cancel) + handlePermissionSetupNavigation(url) + return + } + + decisionHandler(.allow) + } + + private func isPermissionSetupUrl(_ url: URL) -> Bool { + url.path == "/api/v1/boss-agent/permissions/open" + } + + private func handlePermissionSetupNavigation(_ url: URL) { + let target = + URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems? + .first(where: { $0.name == "target" })? + .value ?? "all" + + requestNativePermission(for: target) + if let settingsUrl = systemSettingsUrl(for: target) { + NSWorkspace.shared.open(settingsUrl) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak self] in + self?.loadAgentPanel() + } + } + + private func requestNativePermission(for target: String) { + let targets = target == "all" + ? [ + "accessibility", + "screenRecording", + "automation", + "fullDiskAccess", + "inputMonitoring", + "notifications", + "microphone", + "camera", + "localNetwork", + ] + : [target] + + for permission in targets { + requestSingleNativePermission(permission) + } + } + + private func requestSingleNativePermission(_ permission: String) { + switch permission { + case "accessibility": + requestAccessibilityPermission() + case "screenRecording": + requestScreenRecordingPermission() + case "automation": + requestAutomationPermission() + case "fullDiskAccess": + preflightFullDiskAccess() + case "inputMonitoring": + requestInputMonitoringPermission() + case "notifications": + requestNotificationPermission() + case "microphone": + AVCaptureDevice.requestAccess(for: .audio) { _ in } + case "camera": + AVCaptureDevice.requestAccess(for: .video) { _ in } + case "localNetwork": + requestLocalNetworkPermission() + default: + break + } + } + + private func requestAccessibilityPermission() { + let promptKey = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String + let options = [promptKey: true] as CFDictionary + _ = AXIsProcessTrustedWithOptions(options) + } + + private func requestScreenRecordingPermission() { + if #available(macOS 10.15, *) { + _ = CGRequestScreenCaptureAccess() + } else { + _ = CGPreflightScreenCaptureAccess() + } + } + + private func requestAutomationPermission() { + let script = NSAppleScript(source: "tell application \"Finder\" to get name") + var error: NSDictionary? + _ = script?.executeAndReturnError(&error) + } + + private func preflightFullDiskAccess() { + let candidatePaths = [ + "\(NSHomeDirectory())/Library/Safari/Bookmarks.plist", + "\(NSHomeDirectory())/Library/Mail", + "/Library/Application Support/com.apple.TCC/TCC.db", + ] + for path in candidatePaths { + if FileManager.default.fileExists(atPath: path) { + _ = try? Data(contentsOf: URL(fileURLWithPath: path), options: .mappedIfSafe) + } + } + } + + private func requestInputMonitoringPermission() { + let eventMask = CGEventMask(1 << CGEventType.keyDown.rawValue) + inputMonitoringTap = CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: .headInsertEventTap, + options: .listenOnly, + eventsOfInterest: eventMask, + callback: bossInputMonitoringTapCallback, + userInfo: nil + ) + if let tap = inputMonitoringTap { + CFMachPortInvalidate(tap) + inputMonitoringTap = nil + } + } + + private func requestNotificationPermission() { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { _, _ in } + } + + private func requestLocalNetworkPermission() { + let parameters = NWParameters.tcp + parameters.includePeerToPeer = true + let browser = NWBrowser(for: .bonjour(type: "_http._tcp", domain: nil), using: parameters) + browser.stateUpdateHandler = { _ in } + localNetworkBrowser = browser + browser.start(queue: .main) + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { [weak self] in + self?.localNetworkBrowser?.cancel() + self?.localNetworkBrowser = nil + } + } + + private func systemSettingsUrl(for target: String) -> URL? { + let mapping = [ + "all": "x-apple.systempreferences:com.apple.preference.security?Privacy", + "accessibility": "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility", + "screenRecording": "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture", + "automation": "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation", + "fullDiskAccess": "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles", + "inputMonitoring": "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent", + "notifications": "x-apple.systempreferences:com.apple.Notifications-Settings.extension", + "microphone": "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone", + "camera": "x-apple.systempreferences:com.apple.preference.security?Privacy_Camera", + "localNetwork": "x-apple.systempreferences:com.apple.preference.security?Privacy_LocalNetwork", + ] + return URL(string: mapping[target] ?? mapping["all"]!) + } + private func loadFallback() { let html = """ diff --git a/local-agent/boss-agent-status.mjs b/local-agent/boss-agent-status.mjs index d96f2ad..a92e633 100644 --- a/local-agent/boss-agent-status.mjs +++ b/local-agent/boss-agent-status.mjs @@ -221,7 +221,7 @@ function buildPermissionSetupPlan(coreItems, extendedItems, readiness) { 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)}`, - owner: "boss-agent / local-agent", + owner: "boss-agent.app", })); const missingActions = actions.filter((action) => action.status !== "granted"); @@ -350,46 +350,6 @@ function permissionText(status) { return "待确认"; } -function permissionRows(items) { - return items - .map((item) => { - const tone = statusTone(item.status); - return `
-
-
${escapeHtml(item.label)}
-
${escapeHtml(item.description)}
-
- ${escapeHtml(permissionText(item.status))} -
`; - }) - .join(""); -} - -function sidebarPermissionBlock(status) { - const readiness = status.permissionReadiness; - const coreTone = readiness.coreReady ? "good" : "bad"; - const fullTone = readiness.fullControlReady ? "good" : "warn"; - const missingExtended = status.permissions.extendedItems - .filter((item) => item.status !== "granted") - .map((item) => item.label) - .join("、"); - return ``; -} - function setupActionRows(status) { return status.permissionSetup.actions .map((action) => { @@ -429,22 +389,6 @@ function skillRows(status) { .join(""); } -function sidebarSkillBlock(status) { - const syncTone = status.skills?.syncOk ? "good" : "warn"; - return ``; -} - export function renderBossAgentHtml(status) { return renderBossAgentHtmlBase(status); } @@ -575,44 +519,6 @@ function renderBossAgentHtmlBase(status, options = {}) { font-weight: 800; } .nav a.active .nav-badge { background: rgba(7, 193, 96, .14); color: #058743; } - .sidebar-action { - display: block; - padding: 9px 10px; - border-radius: 12px; - background: var(--green); - color: #fff; - text-align: center; - text-decoration: none; - font-size: 12px; - font-weight: 850; - } - .sidebar-card { - display: grid; - gap: 10px; - margin-top: 12px; - padding: 14px; - border: 1px solid var(--line); - border-radius: 18px; - background: rgba(255, 255, 255, .82); - box-shadow: 0 8px 24px rgba(21, 35, 27, .04); - } - .sidebar-title { font-size: 14px; font-weight: 850; letter-spacing: -.02em; } - .sidebar-note { color: var(--muted); font-size: 12px; line-height: 1.5; } - .sidebar-kv { display: flex; justify-content: space-between; align-items: center; gap: 12px; color: var(--muted); font-size: 12px; } - .sidebar-kv b { color: var(--ink); font-size: 13px; } - .sidebar-kv b.good { color: #058743; } - .sidebar-kv b.warn { color: var(--warn); } - .sidebar-kv b.bad { color: var(--bad); } - .sidebar-mini-list { - display: grid; - gap: 8px; - padding-top: 4px; - border-top: 1px solid var(--line); - } - .sidebar-mini-list .permission-row { gap: 10px; align-items: flex-start; } - .sidebar-mini-list .permission-name { font-size: 12px; } - .sidebar-mini-list .muted { display: none; } - .sidebar-mini-list .pill { padding: 4px 8px; font-size: 11px; } .content { padding: 26px 32px 32px; } .topbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 28px; } h1 { margin: 0; font-size: 28px; letter-spacing: -.04em; } @@ -744,17 +650,15 @@ function renderBossAgentHtmlBase(status, options = {}) { - ${sidebarPermissionBlock(status)} - ${sidebarSkillBlock(status)}
-
+

boss-agent

企业电脑接入端
@@ -808,11 +712,11 @@ function renderBossAgentHtmlBase(status, options = {}) {
-
+

${escapeHtml(status.permissionSetup.title)}

-

${escapeHtml(status.permissionSetup.goal)} 当前状态:${escapeHtml(status.permissionSetup.summary)} 后续静默使用依赖系统持久授权。

+

${escapeHtml(status.permissionSetup.goal)} 权限结论:${escapeHtml(status.permissions.summary)}。当前状态:${escapeHtml(status.permissionSetup.summary)} 后续静默使用依赖系统持久授权。

${escapeHtml(status.permissionSetup.primaryAction.label)}
@@ -821,7 +725,7 @@ function renderBossAgentHtmlBase(status, options = {}) {
-
+

授权信息

企业${escapeHtml(status.license.enterpriseName)}
@@ -830,7 +734,7 @@ function renderBossAgentHtmlBase(status, options = {}) {
${escapeHtml(status.permissionReadiness.detail)}
-
+

Skill 部署情况

${skillRows(status)}
Skill 用于把本机可复用能力分发给 Boss APP 和主 Agent;后续企业后台可按账号、设备和权限策略下发。
diff --git a/scripts/build-boss-agent-mac-app.sh b/scripts/build-boss-agent-mac-app.sh index c74b0a8..2e0e63b 100644 --- a/scripts/build-boss-agent-mac-app.sh +++ b/scripts/build-boss-agent-mac-app.sh @@ -20,7 +20,11 @@ mkdir -p "$MACOS_DIR" "$RESOURCES_DIR" swiftc "$SOURCE_FILE" \ -o "$BINARY_PATH" \ -framework Cocoa \ - -framework WebKit + -framework WebKit \ + -framework ApplicationServices \ + -framework AVFoundation \ + -framework Network \ + -framework UserNotifications chmod +x "$BINARY_PATH" @@ -51,6 +55,21 @@ cat > "$CONTENTS_DIR/Info.plist" <<'PLIST' 13.0 NSHighResolutionCapable + NSAppleEventsUsageDescription + boss-agent 需要通过自动化控制 Finder、浏览器和授权的企业应用,以完成远程桌面级任务。 + NSScreenCaptureUsageDescription + boss-agent 需要读取屏幕画面,用于识别桌面状态、系统弹窗和远程控制结果。 + NSMicrophoneUsageDescription + boss-agent 需要麦克风权限,用于语音协作和远程指令输入。 + NSCameraUsageDescription + boss-agent 需要摄像头权限,用于视觉协作和现场画面确认。 + NSLocalNetworkUsageDescription + boss-agent 需要访问本地网络,用于发现和连接局域网设备、开发板和企业内网服务。 + NSBonjourServices + + _http._tcp + _boss-agent._tcp + PLIST diff --git a/tests/boss-agent-status.test.mjs b/tests/boss-agent-status.test.mjs index e7af92f..c2a1653 100644 --- a/tests/boss-agent-status.test.mjs +++ b/tests/boss-agent-status.test.mjs @@ -1,5 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import { buildBossAgentStatus, @@ -122,6 +123,10 @@ test("boss-agent status treats token-backed devices as bound and renders enterpr assert.match(html, /完整接管待补齐/); assert.match(html, /一次完整授权/); assert.match(html, /后续静默使用/); + assert.match(html, /href="#permissions">本机权限获取<\/span>/); + assert.match(html, /href="#skills">Skill<\/span>/); + assert.doesNotMatch(html, /