feat: surface agent permissions and skills
This commit is contained in:
@@ -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 当前职责:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user