fix: align agent permissions with native app

This commit is contained in:
AI Bot
2026-05-12 23:22:27 +08:00
parent 5b3f43014d
commit 29740f35c7
5 changed files with 233 additions and 109 deletions

View File

@@ -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 当前职责:

View File

@@ -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 = """
<!doctype html>

View File

@@ -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 `<div class="permission-row">
<div>
<div class="permission-name">${escapeHtml(item.label)}</div>
<div class="muted">${escapeHtml(item.description)}</div>
</div>
<span class="pill ${tone}">${escapeHtml(permissionText(item.status))}</span>
</div>`;
})
.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 `<section class="sidebar-card">
<div class="sidebar-title">本机权限获取</div>
<div class="sidebar-note">${escapeHtml(readiness.summary)}</div>
<div class="sidebar-kv">
<span>核心桌面控制</span>
<b class="${coreTone}">${escapeHtml(`${readiness.coreGrantedCount}/${readiness.coreTotal}`)}</b>
</div>
<div class="sidebar-kv">
<span>完整接管权限</span>
<b class="${fullTone}">${escapeHtml(`${readiness.extendedGrantedCount}/${readiness.extendedTotal}`)}</b>
</div>
<div class="sidebar-note">${escapeHtml(missingExtended ? `完整接管还需:${missingExtended}` : "完整接管权限已满足")}</div>
<a class="sidebar-action" href="${escapeHtml(status.permissionSetup.primaryAction.href)}">${escapeHtml(status.permissionSetup.title)}</a>
<div class="sidebar-mini-list">${permissionRows(status.permissions.items)}</div>
</section>`;
}
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 `<section class="sidebar-card">
<div class="sidebar-title">Skill</div>
<div class="sidebar-note">${escapeHtml(status.skills?.summary ?? "本机暂无已同步 Skill")}</div>
<div class="sidebar-kv">
<span>同步状态</span>
<b class="${syncTone}">${escapeHtml(status.skills?.syncStatus ?? "未同步")}</b>
</div>
<div class="sidebar-kv">
<span>部署数量</span>
<b>${escapeHtml(status.skills?.total ?? 0)}</b>
</div>
</section>`;
}
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 = {}) {
</div>
</div>
<nav class="nav">
<a class="active" href="/"><span>概览</span><span class="nav-badge">当前</span></a>
<a href="/"><span>本机权限获取</span><span class="nav-badge">${escapeHtml(status.permissionReadiness.coreReady ? "OK" : "待")}</span></a>
<a href="/"><span>Skill</span><span class="nav-badge">${escapeHtml(status.skills.total)}</span></a>
<a href="/"><span>绑定与授权</span></a>
<a href="/"><span>日志</span></a>
<a class="active" href="#overview"><span>概览</span><span class="nav-badge">当前</span></a>
<a href="#permissions"><span>本机权限获取</span><span class="nav-badge">${escapeHtml(status.permissionReadiness.coreReady ? "OK" : "待")}</span></a>
<a href="#skills"><span>Skill</span><span class="nav-badge">${escapeHtml(status.skills.total)}</span></a>
<a href="#license"><span>绑定与授权</span></a>
<a href="#logs"><span>日志</span></a>
</nav>
${sidebarPermissionBlock(status)}
${sidebarSkillBlock(status)}
</aside>
<section class="content">
<header class="topbar">
<header class="topbar" id="overview">
<div>
<h1>boss-agent</h1>
<div class="subtitle">企业电脑接入端</div>
@@ -808,11 +712,11 @@ function renderBossAgentHtmlBase(status, options = {}) {
</div>
</section>
<section class="card panel setup-panel">
<section class="card panel setup-panel" id="permissions">
<div class="setup-head">
<div>
<h2>${escapeHtml(status.permissionSetup.title)}</h2>
<p>${escapeHtml(status.permissionSetup.goal)} 当前状态:${escapeHtml(status.permissionSetup.summary)} 后续静默使用依赖系统持久授权。</p>
<p>${escapeHtml(status.permissionSetup.goal)} 权限结论:${escapeHtml(status.permissions.summary)}当前状态:${escapeHtml(status.permissionSetup.summary)} 后续静默使用依赖系统持久授权。</p>
</div>
<a class="button" href="${escapeHtml(status.permissionSetup.primaryAction.href)}">${escapeHtml(status.permissionSetup.primaryAction.label)}</a>
</div>
@@ -821,7 +725,7 @@ function renderBossAgentHtmlBase(status, options = {}) {
</section>
<section class="lower">
<div class="card panel">
<div class="card panel" id="license">
<h2>授权信息</h2>
<div class="rows">
<div class="row"><span class="label">企业</span><span class="value">${escapeHtml(status.license.enterpriseName)}</span></div>
@@ -830,7 +734,7 @@ function renderBossAgentHtmlBase(status, options = {}) {
</div>
<div class="hint">${escapeHtml(status.permissionReadiness.detail)}</div>
</div>
<div class="card panel">
<div class="card panel" id="skills">
<h2>Skill 部署情况</h2>
<div class="rows">${skillRows(status)}</div>
<div class="hint">Skill 用于把本机可复用能力分发给 Boss APP 和主 Agent后续企业后台可按账号、设备和权限策略下发。</div>

View File

@@ -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'
<string>13.0</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSAppleEventsUsageDescription</key>
<string>boss-agent 需要通过自动化控制 Finder、浏览器和授权的企业应用以完成远程桌面级任务。</string>
<key>NSScreenCaptureUsageDescription</key>
<string>boss-agent 需要读取屏幕画面,用于识别桌面状态、系统弹窗和远程控制结果。</string>
<key>NSMicrophoneUsageDescription</key>
<string>boss-agent 需要麦克风权限,用于语音协作和远程指令输入。</string>
<key>NSCameraUsageDescription</key>
<string>boss-agent 需要摄像头权限,用于视觉协作和现场画面确认。</string>
<key>NSLocalNetworkUsageDescription</key>
<string>boss-agent 需要访问本地网络,用于发现和连接局域网设备、开发板和企业内网服务。</string>
<key>NSBonjourServices</key>
<array>
<string>_http._tcp</string>
<string>_boss-agent._tcp</string>
</array>
</dict>
</plist>
PLIST

View File

@@ -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>本机权限获取<\/span>/);
assert.match(html, /href="#skills"><span>Skill<\/span>/);
assert.doesNotMatch(html, /<section class="sidebar-card">/);
assert.doesNotMatch(html, /<div class="sidebar-title">本机权限获取<\/div>/);
assert.match(html, /api\/v1\/boss-agent\/permissions\/open\?target=all/);
assert.match(html, /api\/v1\/boss-agent\/permissions\/open\?target=fullDiskAccess/);
assert.match(html, /Skill/);
@@ -153,3 +158,22 @@ test("boss-agent unbound HTML renders a real scannable QR image when qrcode is a
assert.match(html, /data:image\/png;base64/);
assert.match(html, /Boss APP 绑定二维码/);
});
test("boss-agent mac app intercepts permission links and triggers native app permission requests", () => {
const swiftSource = readFileSync("apps/boss-agent-mac/Sources/BossAgentApp.swift", "utf8");
const buildScript = readFileSync("scripts/build-boss-agent-mac-app.sh", "utf8");
assert.match(swiftSource, /decidePolicyFor navigationAction/);
assert.match(swiftSource, /api\/v1\/boss-agent\/permissions\/open/);
assert.match(swiftSource, /AXIsProcessTrustedWithOptions/);
assert.match(swiftSource, /CGRequestScreenCaptureAccess/);
assert.match(swiftSource, /AVCaptureDevice\.requestAccess\(for: \.audio/);
assert.match(swiftSource, /AVCaptureDevice\.requestAccess\(for: \.video/);
assert.match(swiftSource, /UNUserNotificationCenter\.current\(\)\.requestAuthorization/);
assert.match(swiftSource, /CGEvent\.tapCreate/);
assert.match(swiftSource, /NSWorkspace\.shared\.open/);
assert.match(buildScript, /NSMicrophoneUsageDescription/);
assert.match(buildScript, /NSCameraUsageDescription/);
assert.match(buildScript, /NSAppleEventsUsageDescription/);
assert.match(buildScript, /NSLocalNetworkUsageDescription/);
});