feat: surface agent permissions and skills

This commit is contained in:
AI Bot
2026-05-12 18:27:10 +08:00
parent 7c371ed644
commit 315cc5cd54
3 changed files with 260 additions and 23 deletions

View File

@@ -245,7 +245,8 @@ open dist/boss-agent.app
说明:
- `boss-agent.app` 是本机 `local-agent` 的 macOS WebView 外壳,默认打开 `http://127.0.0.1:4317/boss-agent`
- 未绑定账号时会显示可扫码的 Boss APP 绑定二维码已绑定后显示账号、API、服务器、授权本机电脑权限状态
- 未绑定账号时会显示可扫码的 Boss APP 绑定二维码已绑定后显示账号、API、服务器、授权本机权限获取和本机 Skill 部署情况
- 本机权限会区分两级判断:`辅助功能 / 屏幕录制 / 自动化控制` 只代表核心桌面控制能力;完整接管还需要按业务场景补齐全磁盘访问、输入监控、通知、麦克风、摄像头和本地网络等权限
- 本机状态 JSON 可通过 `GET http://127.0.0.1:4317/api/v1/boss-agent/status` 查看,不会返回设备 token 明文
device-agent 当前职责:

View File

@@ -8,16 +8,58 @@ const PERMISSION_DEFS = [
key: "accessibility",
label: "辅助功能",
description: "用于点击、输入和读取可访问控件",
tier: "core",
},
{
key: "screenRecording",
label: "屏幕录制",
description: "用于识别桌面画面和系统弹窗",
tier: "core",
},
{
key: "automation",
label: "自动化控制",
description: "用于控制 Finder、浏览器和企业软件",
tier: "core",
},
];
const EXTENDED_PERMISSION_DEFS = [
{
key: "fullDiskAccess",
label: "全磁盘访问",
description: "用于读取和写入企业授权目录、日志与开发资产",
tier: "extended",
},
{
key: "inputMonitoring",
label: "输入监控",
description: "用于低层热键、复杂输入和部分不可访问控件兜底",
tier: "extended",
},
{
key: "notifications",
label: "通知权限",
description: "用于后台任务、接管结果和风险告警提醒",
tier: "extended",
},
{
key: "microphone",
label: "麦克风",
description: "用于语音指令、会议和音频协作场景",
tier: "extended",
},
{
key: "camera",
label: "摄像头",
description: "用于视觉协作、会议和现场画面确认",
tier: "extended",
},
{
key: "localNetwork",
label: "本地网络",
description: "用于发现和连接局域网设备、开发板与企业内网服务",
tier: "extended",
},
];
@@ -122,6 +164,60 @@ function resolveLicense(config, bound) {
};
}
function permissionItems(defs, permissions) {
return defs.map((item) => ({
...item,
status: normalizePermissionStatus(permissions[item.key]),
}));
}
function resolvePermissionReadiness(coreItems, extendedItems) {
const coreGrantedCount = coreItems.filter((item) => item.status === "granted").length;
const extendedGrantedCount = extendedItems.filter((item) => item.status === "granted").length;
const coreReady = coreGrantedCount === coreItems.length;
const fullControlReady = coreReady && extendedGrantedCount === extendedItems.length;
const summary = fullControlReady
? "完整接管权限已具备"
: coreReady
? "核心桌面控制已具备,完整接管待补齐"
: "核心桌面控制待授权,完整接管不可用";
return {
coreReady,
fullControlReady,
coreGrantedCount,
coreTotal: coreItems.length,
extendedGrantedCount,
extendedTotal: extendedItems.length,
summary,
detail:
"辅助功能、屏幕录制、自动化控制是桌面点击输入与画面识别的核心权限;完整接管还需要按业务场景补齐全磁盘访问、输入监控、通知、麦克风、摄像头和本地网络。",
};
}
function resolveSkills(runtime) {
const rawSkills = Array.isArray(runtime.lastSkills) ? runtime.lastSkills : [];
const items = rawSkills
.map((item) => ({
name: nonEmpty(item?.name),
category: nonEmpty(item?.category) ?? "本机",
path: nonEmpty(item?.path) ?? "",
description: nonEmpty(item?.description) ?? "",
status: nonEmpty(item?.status) ?? "deployed",
}))
.filter((item) => item.name);
const syncOk = runtime.lastSkillSyncOk === true;
return {
total: items.length,
items: items.slice(0, 8),
syncOk,
syncStatus: syncOk ? "已同步" : items.length > 0 ? "待确认" : "未同步",
syncAt: nonEmpty(runtime.lastSkillSyncAt) ?? "",
summary: items.length > 0 ? `已部署 ${items.length} 个 Skill` : "本机暂无已同步 Skill",
};
}
export function buildBossAgentStatus(config = {}, runtime = {}, options = {}) {
const now = nonEmpty(options.now) ?? new Date().toISOString();
const token = nonEmpty(runtime.issuedToken) ?? nonEmpty(config.token);
@@ -130,6 +226,9 @@ export function buildBossAgentStatus(config = {}, runtime = {}, options = {}) {
const permissions = options.permissions ?? {};
const serverOk = runtime.lastHeartbeatOk === true;
const qrPayload = bound ? "" : buildBindingPayload(config);
const corePermissionItems = permissionItems(PERMISSION_DEFS, permissions);
const extendedPermissionItems = permissionItems(EXTENDED_PERMISSION_DEFS, permissions);
const permissionReadiness = resolvePermissionReadiness(corePermissionItems, extendedPermissionItems);
return {
appName: "boss-agent",
@@ -161,14 +260,12 @@ export function buildBossAgentStatus(config = {}, runtime = {}, options = {}) {
api: resolveApiUsage(config),
license: resolveLicense(config, bound),
permissions: {
summary: PERMISSION_DEFS.every((item) => normalizePermissionStatus(permissions[item.key]) === "granted")
? "本机权限正常"
: "本机权限待处理",
items: PERMISSION_DEFS.map((item) => ({
...item,
status: normalizePermissionStatus(permissions[item.key]),
})),
summary: permissionReadiness.summary,
items: corePermissionItems,
extendedItems: extendedPermissionItems,
},
permissionReadiness,
skills: resolveSkills(runtime),
};
}
@@ -201,8 +298,8 @@ function permissionText(status) {
return "待确认";
}
function permissionRows(status) {
return status.permissions.items
function permissionRows(items) {
return items
.map((item) => {
const tone = statusTone(item.status);
return `<div class="permission-row">
@@ -216,6 +313,66 @@ function permissionRows(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 `<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>
<div class="sidebar-mini-list">${permissionRows(status.permissions.items)}</div>
</section>`;
}
function skillRows(status) {
const skills = status.skills?.items ?? [];
if (skills.length === 0) {
return `<div class="empty-state">本机暂无已同步 Skill。绑定账号后可由 Boss 后台下发或同步本机 Codex Skill。</div>`;
}
return skills
.map((skill) => {
const pathLabel = skill.path ? skill.path.replace(os.homedir(), "~") : "未记录路径";
return `<div class="skill-row">
<div>
<div class="skill-name">${escapeHtml(skill.name)}</div>
<div class="muted">${escapeHtml(skill.category)} · ${escapeHtml(pathLabel)}</div>
</div>
<span class="pill good">已部署</span>
</div>`;
})
.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);
}
@@ -297,7 +454,7 @@ function renderBossAgentHtmlBase(status, options = {}) {
width: min(1180px, calc(100vw - 56px));
min-height: 760px;
display: grid;
grid-template-columns: 218px 1fr;
grid-template-columns: 268px 1fr;
overflow: hidden;
border: 1px solid rgba(17, 20, 24, .08);
border-radius: 22px;
@@ -312,7 +469,7 @@ function renderBossAgentHtmlBase(status, options = {}) {
.traffic { display: flex; gap: 8px; margin-bottom: 30px; }
.traffic span { width: 12px; height: 12px; border-radius: 999px; display: block; }
.red { background: #ff5f57; } .yellow { background: #febc2e; } .green-light { background: #28c840; }
.brand { display: flex; align-items: center; gap: 12px; margin-bottom: 30px; }
.brand { display: flex; align-items: center; gap: 12px; margin-bottom: 24px; }
.brand-mark, .bound-mark {
width: 42px; height: 42px; border-radius: 16px;
display: grid; place-items: center;
@@ -321,8 +478,12 @@ function renderBossAgentHtmlBase(status, options = {}) {
}
.brand-title { font-weight: 800; font-size: 18px; letter-spacing: -.02em; }
.device-name { margin-top: 4px; color: var(--muted); font-size: 12px; }
.nav { display: grid; gap: 6px; }
.nav { display: grid; gap: 6px; margin-bottom: 16px; }
.nav a {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
padding: 12px 12px;
border-radius: 14px;
color: #394139;
@@ -331,6 +492,44 @@ function renderBossAgentHtmlBase(status, options = {}) {
font-weight: 650;
}
.nav a.active { background: var(--green-soft); color: #058743; }
.nav-badge {
min-width: 22px;
padding: 3px 7px;
border-radius: 999px;
background: rgba(17, 20, 24, .06);
color: var(--muted);
text-align: center;
font-size: 11px;
font-weight: 800;
}
.nav a.active .nav-badge { background: rgba(7, 193, 96, .14); color: #058743; }
.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; }
@@ -381,7 +580,7 @@ function renderBossAgentHtmlBase(status, options = {}) {
.timer { color: var(--muted); font-size: 13px; }
.panel { padding: 22px; }
.rows { display: grid; gap: 14px; margin-top: 18px; }
.row, .permission-row { display: flex; justify-content: space-between; gap: 18px; align-items: center; }
.row, .permission-row, .skill-row { display: flex; justify-content: space-between; gap: 18px; align-items: center; }
.label, .muted { color: var(--muted); font-size: 13px; }
.value { font-weight: 750; text-align: right; }
.cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 18px; }
@@ -401,6 +600,18 @@ function renderBossAgentHtmlBase(status, options = {}) {
.pill.warn { color: var(--warn); background: var(--warn-soft); }
.pill.bad { color: var(--bad); background: var(--bad-soft); }
.permission-name { font-weight: 780; margin-bottom: 3px; }
.skill-name { font-weight: 780; margin-bottom: 3px; }
.empty-state {
min-height: 120px;
display: grid;
place-items: center;
color: var(--muted);
line-height: 1.7;
text-align: center;
border: 1px dashed var(--line);
border-radius: 16px;
background: #fbfcfb;
}
.hint {
margin-top: 18px;
padding: 14px 16px;
@@ -431,11 +642,14 @@ function renderBossAgentHtmlBase(status, options = {}) {
</div>
</div>
<nav class="nav">
<a class="active" href="/">概览</a>
<a href="/">绑定</a>
<a href="/">授权</a>
<a href="/">日志</a>
<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>
</nav>
${sidebarPermissionBlock(status)}
${sidebarSkillBlock(status)}
</aside>
<section class="content">
<header class="topbar">
@@ -500,12 +714,12 @@ function renderBossAgentHtmlBase(status, options = {}) {
<div class="row"><span class="label">授权到期</span><span class="value">${escapeHtml(status.license.expiresAtLabel)}</span></div>
<div class="row"><span class="label">权限范围</span><span class="value">${escapeHtml(status.license.scope)}</span></div>
</div>
<div class="hint">授权状态由 Boss 企业后台统一下发;未绑定时只显示本机预检结果。</div>
<div class="hint">${escapeHtml(status.permissionReadiness.detail)}</div>
</div>
<div class="card panel">
<h2>本机电脑权限状态</h2>
<div class="rows">${permissionRows(status)}</div>
<div class="hint">${escapeHtml(status.permissions.summary)}。如未授权,请在 macOS 系统设置里为 boss-agent / Node 运行时开启对应权限。</div>
<h2>Skill 部署情况</h2>
<div class="rows">${skillRows(status)}</div>
<div class="hint">Skill 用于把本机可复用能力分发给 Boss APP 和主 Agent后续企业后台可按账号、设备和权限策略下发。</div>
</div>
</section>
</section>

View File

@@ -25,6 +25,12 @@ test("boss-agent status exposes unbound QR binding and local permission states",
lastHeartbeatOk: true,
lastHeartbeatStatus: 200,
lastHeartbeatAt: "2026-05-12T05:00:00.000Z",
lastSkills: [
{ name: "bb-browser", category: "MacBook Air", path: "/Users/jas/.codex/skills/bb-browser/SKILL.md" },
{ name: "skill-installer", category: ".system", path: "/Users/jas/.codex/skills/.system/skill-installer/SKILL.md" },
],
lastSkillSyncOk: true,
lastSkillSyncAt: "2026-05-12T05:30:00.000Z",
},
{
permissions: {
@@ -43,6 +49,11 @@ test("boss-agent status exposes unbound QR binding and local permission states",
assert.equal(status.api.primary, "DeepSeek V4");
assert.equal(status.api.backup, "未启用");
assert.equal(status.license.status, "pending_binding");
assert.equal(status.skills.total, 2);
assert.equal(status.skills.syncOk, true);
assert.equal(status.permissionReadiness.coreReady, false);
assert.equal(status.permissionReadiness.fullControlReady, false);
assert.match(status.permissionReadiness.summary, /完整接管/);
assert.deepEqual(
status.permissions.items.map((item) => [item.key, item.status]),
[
@@ -74,12 +85,19 @@ test("boss-agent status treats token-backed devices as bound and renders enterpr
lastHeartbeatOk: true,
lastHeartbeatStatus: 200,
lastHeartbeatAt: "2026-05-12T05:00:00.000Z",
lastSkills: [
{ name: "bb-browser", category: "MacBook Air", path: "/Users/jas/.codex/skills/bb-browser/SKILL.md" },
{ name: "skill-installer", category: ".system", path: "/Users/jas/.codex/skills/.system/skill-installer/SKILL.md" },
],
lastSkillSyncOk: true,
},
{
permissions: {
accessibility: "granted",
screenRecording: "granted",
automation: "granted",
fullDiskAccess: "missing",
inputMonitoring: "unknown",
},
now: "2026-05-12T06:00:00.000Z",
},
@@ -93,10 +111,14 @@ test("boss-agent status treats token-backed devices as bound and renders enterpr
const html = renderBossAgentHtml(status);
assert.match(html, /boss-agent/);
assert.match(html, /企业电脑接入端/);
assert.match(html, /本机电脑权限状态/);
assert.match(html, /本机权限获取/);
assert.match(html, /完整接管待补齐/);
assert.match(html, /Skill/);
assert.match(html, /bb-browser/);
assert.match(html, /DeepSeek V4/);
assert.match(html, /默认公司/);
assert.doesNotMatch(html, /boss-secret-token/);
assert.doesNotMatch(html, /<h2>本机电脑权限状态<\/h2>/);
});
test("boss-agent unbound HTML renders a real scannable QR image when qrcode is available", async () => {