feat: harden enterprise control plane
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
readState,
|
||||
reclaimAuthAccountByAdmin,
|
||||
resetAuthAccountPasswordByAdmin,
|
||||
revokeDeviceByAdmin,
|
||||
revokeAccessGrant,
|
||||
saveAccountDeviceGrant,
|
||||
saveAccountProjectGrant,
|
||||
@@ -64,6 +65,9 @@ function publicAdminDevice(device: Device) {
|
||||
lastSeenAt: device.lastSeenAt,
|
||||
preferredExecutionMode: device.preferredExecutionMode,
|
||||
capabilities: device.capabilities,
|
||||
revokedAt: device.revokedAt,
|
||||
revokedBy: device.revokedBy,
|
||||
revokeReason: device.revokeReason,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -383,6 +387,25 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "revoke_device") {
|
||||
const deviceId = stringValue(body.deviceId);
|
||||
if (!deviceId) {
|
||||
return jsonNoStore({ ok: false, message: "DEVICE_ID_REQUIRED" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const device = await revokeDeviceByAdmin({
|
||||
deviceId,
|
||||
reason: stringValue(body.reason) || undefined,
|
||||
actorAccount: auth.session.account,
|
||||
auditMeta,
|
||||
});
|
||||
return jsonNoStore({ ok: true, device: publicAdminDevice(device) });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "UNKNOWN_ERROR";
|
||||
return jsonNoStore({ ok: false, message }, { status: message === "DEVICE_NOT_FOUND" ? 404 : 400 });
|
||||
}
|
||||
}
|
||||
|
||||
if (action === "bulk_import_accounts") {
|
||||
const companyId = stringValue(body.companyId);
|
||||
const accounts = accountImportValues(body.accounts);
|
||||
|
||||
@@ -4,27 +4,33 @@ import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { BOSS_PERMISSION_TEMPLATES } from "@/lib/boss-access-templates";
|
||||
import { buildAdminOverview } from "@/lib/boss-admin-overview";
|
||||
import { readState, type BossState } from "@/lib/boss-data";
|
||||
import { getBossStateBackupStatus, type BossStateBackupStatus } from "@/lib/boss-state-backups";
|
||||
|
||||
const MENU_TREE = [
|
||||
{ key: "workbench", label: "工作台" },
|
||||
{ key: "tenant", label: "租户管理" },
|
||||
{ key: "user", label: "账号管理" },
|
||||
{ key: "role", label: "角色权限" },
|
||||
{
|
||||
key: "resource",
|
||||
label: "资源授权",
|
||||
children: [
|
||||
{ key: "resource.devices", label: "设备资源" },
|
||||
{ key: "resource.projects", label: "项目线程" },
|
||||
{ key: "resource.skills", label: "Skill 资源" },
|
||||
],
|
||||
},
|
||||
{ key: "skills", label: "Skill 中心" },
|
||||
{ key: "risk", label: "风险告警" },
|
||||
{ key: "audit", label: "审计日志" },
|
||||
{ key: "system", label: "系统设置" },
|
||||
const PLATFORM_MENU_TREE = [
|
||||
{ key: "platform-overview", label: "平台总览" },
|
||||
{ key: "platform-provisioning", label: "企业开通" },
|
||||
{ key: "platform-customer-plans", label: "客户与套餐" },
|
||||
{ key: "platform-devices", label: "全局设备" },
|
||||
{ key: "platform-risk", label: "全局风险" },
|
||||
{ key: "platform-customer-success", label: "客户成功" },
|
||||
{ key: "platform-audit", label: "系统审计" },
|
||||
{ key: "platform-billing", label: "计费与授权" },
|
||||
{ key: "platform-settings", label: "平台设置" },
|
||||
] as const;
|
||||
|
||||
const ENTERPRISE_MENU_TREE = [
|
||||
{ key: "enterprise-overview", label: "企业总览" },
|
||||
{ key: "enterprise-members", label: "组织与成员" },
|
||||
{ key: "enterprise-devices-agents", label: "设备与项目" },
|
||||
{ key: "enterprise-agent-flows", label: "Agent 与流程" },
|
||||
{ key: "enterprise-skill", label: "Skill 中心" },
|
||||
{ key: "enterprise-risk-backup", label: "风险与审计" },
|
||||
{ key: "enterprise-backup", label: "备份与回退" },
|
||||
{ key: "enterprise-settings", label: "企业设置" },
|
||||
] as const;
|
||||
|
||||
type BackofficeSurface = "platform" | "enterprise";
|
||||
|
||||
function companyNameMap(state: BossState) {
|
||||
return new Map(state.adminCompanies.map((company) => [company.companyId, company.name]));
|
||||
}
|
||||
@@ -34,6 +40,65 @@ function companyNameFor(state: BossState, companyId?: string) {
|
||||
return companyNameMap(state).get(companyId) ?? companyId;
|
||||
}
|
||||
|
||||
function accountCompanyId(state: BossState, account?: string) {
|
||||
if (!account) return undefined;
|
||||
return state.authAccounts.find((item) => item.account === account)?.companyId ?? "default";
|
||||
}
|
||||
|
||||
function resolvedDeviceCompanyId(state: BossState, device: { account?: string; companyId?: string }) {
|
||||
return device.companyId ?? accountCompanyId(state, device.account) ?? "default";
|
||||
}
|
||||
|
||||
function filteredStateForCompany(state: BossState, companyId: string): BossState {
|
||||
const companyAccounts = new Set(
|
||||
state.authAccounts.filter((account) => (account.companyId ?? "default") === companyId).map((account) => account.account),
|
||||
);
|
||||
const devices = state.devices.filter((device) => resolvedDeviceCompanyId(state, device) === companyId);
|
||||
const deviceIds = new Set(devices.map((device) => device.id));
|
||||
const projects = state.projects.filter(
|
||||
(project) =>
|
||||
project.deviceIds.some((deviceId) => deviceIds.has(deviceId)) ||
|
||||
project.groupMembers.some((member) => deviceIds.has(member.deviceId)),
|
||||
);
|
||||
const projectIds = new Set(projects.map((project) => project.id));
|
||||
|
||||
return {
|
||||
...state,
|
||||
adminCompanies: state.adminCompanies.filter((company) => company.companyId === companyId),
|
||||
authAccounts: state.authAccounts.filter((account) => companyAccounts.has(account.account)),
|
||||
authSessions: state.authSessions.filter((session) => companyAccounts.has(session.account)),
|
||||
devices,
|
||||
projects,
|
||||
deviceSkills: state.deviceSkills.filter((skill) => deviceIds.has(skill.deviceId)),
|
||||
accountDeviceGrants: state.accountDeviceGrants.filter((grant) => companyAccounts.has(grant.account) && deviceIds.has(grant.deviceId)),
|
||||
accountProjectGrants: state.accountProjectGrants.filter((grant) => companyAccounts.has(grant.account) && projectIds.has(grant.projectId)),
|
||||
accountSkillGrants: state.accountSkillGrants.filter((grant) => companyAccounts.has(grant.account)),
|
||||
opsFaults: state.opsFaults.filter(
|
||||
(fault) => (fault.nodeId && deviceIds.has(fault.nodeId)) || (fault.projectId && projectIds.has(fault.projectId)),
|
||||
),
|
||||
threadContextAlerts: state.threadContextAlerts.filter((alert) => projectIds.has(alert.projectId)),
|
||||
masterAgentTasks: state.masterAgentTasks.filter(
|
||||
(task) => (task.deviceId && deviceIds.has(task.deviceId)) || (task.projectId && projectIds.has(task.projectId)),
|
||||
),
|
||||
permissionAuditLogs: state.permissionAuditLogs.filter(
|
||||
(log) =>
|
||||
companyAccounts.has(log.actorAccount) ||
|
||||
Boolean(log.targetAccount && companyAccounts.has(log.targetAccount)) ||
|
||||
Boolean(log.deviceId && deviceIds.has(log.deviceId)) ||
|
||||
Boolean(log.projectId && projectIds.has(log.projectId)),
|
||||
),
|
||||
adminRiskTimeline: state.adminRiskTimeline.filter((event) => event.companyId === companyId),
|
||||
adminNotifications: state.adminNotifications.filter((notification) => {
|
||||
const companyScoped = notification as { companyId?: string; deviceId?: string; projectId?: string };
|
||||
return (
|
||||
companyScoped.companyId === companyId ||
|
||||
Boolean(companyScoped.deviceId && deviceIds.has(companyScoped.deviceId)) ||
|
||||
Boolean(companyScoped.projectId && projectIds.has(companyScoped.projectId))
|
||||
);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function safeUsers(state: BossState) {
|
||||
return state.authAccounts.map((account) => ({
|
||||
id: account.id,
|
||||
@@ -153,13 +218,158 @@ function rolesContract() {
|
||||
};
|
||||
}
|
||||
|
||||
function buildBackofficePayload(state: BossState) {
|
||||
function safePercent(value: number, total: number) {
|
||||
if (total <= 0) return 0;
|
||||
return Math.max(0, Math.min(100, Math.round((value / total) * 100)));
|
||||
}
|
||||
|
||||
function healthScore(company: { accountCount: number; deviceCount: number; onlineDeviceCount: number; openRiskCount: number }) {
|
||||
const onlineScore = company.deviceCount > 0 ? safePercent(company.onlineDeviceCount, company.deviceCount) : 80;
|
||||
const riskPenalty = Math.min(45, company.openRiskCount * 12);
|
||||
const activityBonus = Math.min(10, company.accountCount);
|
||||
return Math.max(0, Math.min(100, onlineScore - riskPenalty + activityBonus));
|
||||
}
|
||||
|
||||
function riskAggregateValue(risks: Array<{ kind: string; title: string }>, matcher: (risk: { kind: string; title: string }) => boolean) {
|
||||
return risks.filter(matcher).length;
|
||||
}
|
||||
|
||||
function buildBackofficeInsights(state: BossState, options: { surface: BackofficeSurface; backupStatus: BossStateBackupStatus }) {
|
||||
const overview = buildAdminOverview(state);
|
||||
const devices = state.devices;
|
||||
const onlineDevices = devices.filter((device) => device.status === "online").length;
|
||||
const guiReady = devices.filter((device) => Boolean(device.capabilities?.gui.connected)).length;
|
||||
const cliReady = devices.filter((device) => Boolean(device.capabilities?.cli.connected)).length;
|
||||
const computerUseReady = devices.filter((device) => Boolean(device.capabilities?.computerUse.connected)).length;
|
||||
const browserReady = devices.filter((device) => Boolean(device.capabilities?.browserAutomation.connected)).length;
|
||||
const codexHealthy = devices.length === 0 || (guiReady + cliReady) >= devices.length;
|
||||
const computerUseHealthy = devices.length === 0 || computerUseReady >= Math.ceil(devices.length / 2);
|
||||
|
||||
return {
|
||||
onboardingSteps: ["企业信息", "老板账号", "套餐授权", "设备与交付"],
|
||||
serviceStatuses: [
|
||||
{ label: "Boss API", value: "正常", tone: "green" },
|
||||
{ label: "OTA", value: "正常", tone: "green" },
|
||||
{ label: "Codex Provider", value: codexHealthy ? "正常" : "降级", tone: codexHealthy ? "green" : "orange" },
|
||||
{ label: "Computer Use", value: computerUseHealthy ? "正常" : "降级", tone: computerUseHealthy ? "green" : "orange" },
|
||||
{ label: "Skill Hub", value: state.deviceSkills.length + state.skillCatalog.length > 0 ? "正常" : "待配置", tone: "green" },
|
||||
],
|
||||
openingPreview: [
|
||||
{ label: "默认套餐", value: "企业专业版" },
|
||||
{ label: "推荐设备", value: `${Math.max(1, devices.length || 1)} 台起` },
|
||||
{ label: "Codex Provider", value: "App Server 优先,CLI 兜底" },
|
||||
{ label: "Computer Use", value: "macOS 桌面控制" },
|
||||
],
|
||||
deliveryChecklist: [
|
||||
{ label: "API 可用", done: true },
|
||||
{ label: "OTA 可用", done: true },
|
||||
{ label: "boss-agent 安装包已生成", done: true },
|
||||
{ label: "初始密码策略已设置", done: true },
|
||||
{ label: "客户成功负责人已分配", done: overview.companies.some((company) => Boolean(company.successOwnerAccount)) },
|
||||
],
|
||||
recentCompanies: overview.companies.slice(0, 5).map((company) => ({
|
||||
companyId: company.companyId,
|
||||
label: company.name,
|
||||
note: `${company.planTier ?? "enterprise"} · ${company.deviceCount} 台设备 · ${company.openRiskCount} 个风险`,
|
||||
})),
|
||||
customerHealthRows: overview.companies.map((company) => ({
|
||||
companyId: company.companyId,
|
||||
name: company.name,
|
||||
healthScore: healthScore(company),
|
||||
planTier: company.planTier ?? "enterprise",
|
||||
onlineDevices: `${company.onlineDeviceCount}/${company.deviceCount}`,
|
||||
openRiskCount: company.openRiskCount,
|
||||
ownerAccount: company.successOwnerAccount || company.ownerAccount || "未指派",
|
||||
})),
|
||||
riskAggregates: [
|
||||
{
|
||||
label: "设备离线",
|
||||
value: riskAggregateValue(overview.risks, (risk) => risk.kind === "device_offline"),
|
||||
},
|
||||
{
|
||||
label: "主 Agent 执行失败",
|
||||
value: riskAggregateValue(overview.risks, (risk) => risk.kind === "master_agent_task_failed"),
|
||||
},
|
||||
{
|
||||
label: "Computer Use 权限缺失",
|
||||
value: riskAggregateValue(overview.risks, (risk) => /Computer Use|权限/.test(risk.title)),
|
||||
},
|
||||
{
|
||||
label: "Skill 升级失败",
|
||||
value: riskAggregateValue(overview.risks, (risk) => /Skill/.test(risk.title)),
|
||||
},
|
||||
{
|
||||
label: "备份异常",
|
||||
value: riskAggregateValue(overview.risks, (risk) => /备份|backup/i.test(risk.title)),
|
||||
},
|
||||
],
|
||||
customerFollowups: overview.riskTimeline.slice(0, 5).map((event) => ({
|
||||
eventId: event.eventId,
|
||||
label: event.note || event.action,
|
||||
meta: `${event.actorAccount} · ${event.createdAt}`,
|
||||
})),
|
||||
enterpriseGoals: state.projects.slice(0, 3).map((project) => ({
|
||||
projectId: project.id,
|
||||
label: project.name,
|
||||
progress: Math.max(30, Math.min(96, 82 - (project.unreadCount ?? 0) * 3 - (project.riskLevel === "high" ? 18 : 0))),
|
||||
})),
|
||||
organizationUnits: ["销售部", "客服部", "研发部", "财务部", "行政部"],
|
||||
departmentProgress: [
|
||||
{ label: "销售部", note: "线索跟进正常", tone: "green" },
|
||||
{ label: "客服部", note: `${overview.summary.openNotifications} 个通知待处理`, tone: overview.summary.openNotifications > 0 ? "orange" : "green" },
|
||||
{ label: "研发部", note: `${state.projects.length} 个项目运行中`, tone: "green" },
|
||||
{ label: "财务部", note: "账单与授权正常", tone: "green" },
|
||||
],
|
||||
masterAgentSummary: [
|
||||
`当前企业有 ${state.projects.length} 个项目、${onlineDevices}/${devices.length} 台电脑在线。`,
|
||||
overview.summary.openRisks > 0 ? `建议优先处理 ${overview.summary.openRisks} 个开放风险。` : "当前没有开放风险。",
|
||||
],
|
||||
permissionHighlights: ["device.view", "thread.chat", "master_agent.takeover", "computer.control", "skill.use"],
|
||||
agentFlowSteps: ["主 Agent", "项目 Agent", "本地 Agent", "Codex / Computer Use / Skill"],
|
||||
skillUsageAudit: state.permissionAuditLogs.slice(0, 5).map((log) => ({
|
||||
auditId: log.auditId,
|
||||
label: log.detail || log.action,
|
||||
meta: `${log.actorAccount} · ${log.createdAt}`,
|
||||
})),
|
||||
recoveryActions: ["消息恢复", "项目目标恢复", "权限撤销", "Skill 回滚", "Codex checkpoint"],
|
||||
backupStatus: {
|
||||
lastBackupAt: options.backupStatus.lastBackupAt ?? "",
|
||||
status:
|
||||
options.backupStatus.status === "ready"
|
||||
? "校验通过"
|
||||
: options.backupStatus.status === "empty"
|
||||
? "暂无快照"
|
||||
: "备份异常",
|
||||
restorePointCount: options.backupStatus.restorePointCount,
|
||||
backupDir: options.backupStatus.backupDir,
|
||||
detail: options.backupStatus.detail,
|
||||
},
|
||||
capabilitySummary: {
|
||||
guiReady,
|
||||
cliReady,
|
||||
computerUseReady,
|
||||
browserReady,
|
||||
},
|
||||
surface: options.surface,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBackofficePayload(
|
||||
state: BossState,
|
||||
options: { surface: BackofficeSurface; currentCompanyId?: string; backupStatus: BossStateBackupStatus },
|
||||
) {
|
||||
const overview = buildAdminOverview(state);
|
||||
const skills = skillResources(state);
|
||||
const currentCompany = options.currentCompanyId
|
||||
? state.adminCompanies.find((company) => company.companyId === options.currentCompanyId) ?? null
|
||||
: null;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
menuTree: MENU_TREE,
|
||||
surface: options.surface,
|
||||
currentCompany,
|
||||
menuTree: options.surface === "platform" ? PLATFORM_MENU_TREE : ENTERPRISE_MENU_TREE,
|
||||
insights: buildBackofficeInsights(state, { surface: options.surface, backupStatus: options.backupStatus }),
|
||||
workbench: {
|
||||
summary: overview.summary,
|
||||
companies: overview.companies.slice(0, 10),
|
||||
@@ -210,10 +420,29 @@ export async function GET(request: NextRequest) {
|
||||
if (!session) {
|
||||
return jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
if (session.role !== "highest_admin") {
|
||||
return jsonNoStore({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
||||
}
|
||||
|
||||
const state = await readState();
|
||||
return jsonNoStore(buildBackofficePayload(state));
|
||||
const backupStatus = await getBossStateBackupStatus();
|
||||
const url = new URL(request.url);
|
||||
const scope = url.searchParams.get("scope") === "enterprise" ? "enterprise" : "platform";
|
||||
|
||||
if (scope === "platform") {
|
||||
if (session.role !== "highest_admin") {
|
||||
return jsonNoStore({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
||||
}
|
||||
return jsonNoStore(buildBackofficePayload(state, { surface: "platform", backupStatus }));
|
||||
}
|
||||
|
||||
if (session.role !== "admin" && session.role !== "highest_admin") {
|
||||
return jsonNoStore({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
||||
}
|
||||
const requestedCompanyId = url.searchParams.get("companyId")?.trim();
|
||||
const companyId = session.role === "highest_admin"
|
||||
? requestedCompanyId || state.adminCompanies[0]?.companyId || "default"
|
||||
: accountCompanyId(state, session.account);
|
||||
if (!companyId) {
|
||||
return jsonNoStore({ ok: false, message: "COMPANY_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
const scopedState = filteredStateForCompany(state, companyId);
|
||||
return jsonNoStore(buildBackofficePayload(scopedState, { surface: "enterprise", currentCompanyId: companyId, backupStatus }));
|
||||
}
|
||||
|
||||
79
src/app/api/v1/admin/backups/route.ts
Normal file
79
src/app/api/v1/admin/backups/route.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { jsonNoStore } from "@/lib/api-response";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { requireCsrfSafeMutation } from "@/lib/boss-csrf";
|
||||
import {
|
||||
createBossStateBackup,
|
||||
getBossStateBackupStatus,
|
||||
listBossStateBackups,
|
||||
restoreBossStateBackup,
|
||||
} from "@/lib/boss-state-backups";
|
||||
|
||||
function forbidden() {
|
||||
return jsonNoStore({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
||||
}
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
async function requireHighestAdmin(request: NextRequest) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return { response: jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }) };
|
||||
}
|
||||
if (session.role !== "highest_admin") {
|
||||
return { response: forbidden() };
|
||||
}
|
||||
return { session };
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const auth = await requireHighestAdmin(request);
|
||||
if (auth.response) return auth.response;
|
||||
|
||||
const status = await getBossStateBackupStatus();
|
||||
const snapshots = await listBossStateBackups(50);
|
||||
return jsonNoStore({ ok: true, status, snapshots });
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const csrf = requireCsrfSafeMutation(request);
|
||||
if (csrf) return csrf;
|
||||
|
||||
const auth = await requireHighestAdmin(request);
|
||||
if (auth.response) return auth.response;
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const action = stringValue(body.action);
|
||||
|
||||
try {
|
||||
if (action === "create_snapshot") {
|
||||
const snapshot = await createBossStateBackup({
|
||||
actorAccount: auth.session.account,
|
||||
reason: stringValue(body.reason) || "manual",
|
||||
});
|
||||
const status = await getBossStateBackupStatus();
|
||||
return jsonNoStore({ ok: true, action, snapshot, status });
|
||||
}
|
||||
|
||||
if (action === "restore_snapshot") {
|
||||
const snapshotId = stringValue(body.snapshotId);
|
||||
if (!snapshotId) {
|
||||
return jsonNoStore({ ok: false, message: "BACKUP_SNAPSHOT_ID_REQUIRED" }, { status: 400 });
|
||||
}
|
||||
const result = await restoreBossStateBackup({
|
||||
snapshotId,
|
||||
actorAccount: auth.session.account,
|
||||
});
|
||||
const status = await getBossStateBackupStatus();
|
||||
return jsonNoStore({ ok: true, action, ...result, status });
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "BACKUP_ACTION_FAILED";
|
||||
const status = message === "BACKUP_SNAPSHOT_NOT_FOUND" || message === "ENOENT" ? 404 : 400;
|
||||
return jsonNoStore({ ok: false, message }, { status });
|
||||
}
|
||||
|
||||
return jsonNoStore({ ok: false, message: "BACKUP_ACTION_INVALID" }, { status: 400 });
|
||||
}
|
||||
38
src/app/api/v1/boss-agent/ota/package/route.ts
Normal file
38
src/app/api/v1/boss-agent/ota/package/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import { NextRequest } from "next/server";
|
||||
import { jsonNoStore } from "@/lib/api-response";
|
||||
import { getPublishedBossAgentOtaAsset } from "@/lib/boss-agent-ota";
|
||||
import { getDevice, verifyDeviceToken } from "@/lib/boss-data";
|
||||
|
||||
async function authorizeDownload(request: NextRequest) {
|
||||
const requestUrl = new URL(request.url);
|
||||
const deviceId = String(requestUrl.searchParams.get("deviceId") ?? request.headers.get("x-boss-device-id") ?? "").trim();
|
||||
if (!deviceId || !(await getDevice(deviceId))) return false;
|
||||
const token = request.headers.get("x-boss-device-token") ?? "";
|
||||
return token ? verifyDeviceToken(deviceId, token) : false;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
if (!(await authorizeDownload(request))) {
|
||||
return jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
|
||||
const asset = await getPublishedBossAgentOtaAsset();
|
||||
if (!asset) {
|
||||
return jsonNoStore({ ok: false, message: "BOSS_AGENT_OTA_PACKAGE_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
|
||||
const content = await fs.readFile(asset.absolutePath);
|
||||
return new Response(content, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Length": String(asset.sizeBytes),
|
||||
"Content-Disposition": `attachment; filename=\"${asset.fileName}\"`,
|
||||
ETag: asset.sha256,
|
||||
"X-Boss-Agent-Ota-Version": asset.version,
|
||||
"X-Boss-Agent-Ota-Sha256": asset.sha256,
|
||||
"X-Boss-Agent-Ota-Updated-At": asset.updatedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
46
src/app/api/v1/boss-agent/ota/route.ts
Normal file
46
src/app/api/v1/boss-agent/ota/route.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { jsonNoStore } from "@/lib/api-response";
|
||||
import { getPublishedBossAgentOtaAsset } from "@/lib/boss-agent-ota";
|
||||
import { getDevice, verifyDeviceToken } from "@/lib/boss-data";
|
||||
|
||||
function normalizeVersion(value: string | null) {
|
||||
return String(value ?? "").trim();
|
||||
}
|
||||
|
||||
async function authorizeAgentOtaRequest(request: NextRequest, deviceId: string) {
|
||||
if (!deviceId) return false;
|
||||
const device = await getDevice(deviceId);
|
||||
if (!device) return false;
|
||||
const token = request.headers.get("x-boss-device-token") ?? "";
|
||||
return token ? verifyDeviceToken(deviceId, token) : false;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const requestUrl = new URL(request.url);
|
||||
const deviceId = normalizeVersion(requestUrl.searchParams.get("deviceId"));
|
||||
if (!(await authorizeAgentOtaRequest(request, deviceId))) {
|
||||
return jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
|
||||
const currentVersion = normalizeVersion(requestUrl.searchParams.get("currentVersion")) || "unknown";
|
||||
const latest = await getPublishedBossAgentOtaAsset();
|
||||
const hasUpdate = Boolean(latest && latest.version !== currentVersion);
|
||||
|
||||
return jsonNoStore({
|
||||
ok: true,
|
||||
deviceId,
|
||||
currentVersion,
|
||||
hasUpdate,
|
||||
latest: latest
|
||||
? {
|
||||
version: latest.version,
|
||||
fileName: latest.fileName,
|
||||
sizeBytes: latest.sizeBytes,
|
||||
sha256: latest.sha256,
|
||||
updatedAt: latest.updatedAt,
|
||||
downloadUrl: latest.downloadUrl,
|
||||
packageType: latest.packageType,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
@@ -30,6 +30,21 @@ export async function PATCH(
|
||||
lastSeenAt?: string;
|
||||
lastActiveProjectId?: string;
|
||||
};
|
||||
browserAutomation?: {
|
||||
connected?: boolean;
|
||||
lastSeenAt?: string;
|
||||
lastActiveProjectId?: string;
|
||||
};
|
||||
computerUse?: {
|
||||
connected?: boolean;
|
||||
lastSeenAt?: string;
|
||||
lastActiveProjectId?: string;
|
||||
};
|
||||
codexAppServer?: {
|
||||
connected?: boolean;
|
||||
lastSeenAt?: string;
|
||||
lastActiveProjectId?: string;
|
||||
};
|
||||
};
|
||||
preferredExecutionMode?: "gui" | "cli";
|
||||
projectId?: string;
|
||||
|
||||
38
src/app/api/v1/master-agent/tasks/[taskId]/cancel/route.ts
Normal file
38
src/app/api/v1/master-agent/tasks/[taskId]/cancel/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { requireRequestSession } from "@/lib/boss-auth";
|
||||
import { cancelMasterAgentTask, getMasterAgentTask, readState } from "@/lib/boss-data";
|
||||
import { canAccessDevice } from "@/lib/boss-permissions";
|
||||
import { jsonNoStore } from "@/lib/api-response";
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ taskId: string }> },
|
||||
) {
|
||||
const session = await requireRequestSession(request);
|
||||
if (!session) {
|
||||
return jsonNoStore({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { taskId } = await context.params;
|
||||
const task = await getMasterAgentTask(taskId);
|
||||
if (!task) {
|
||||
return jsonNoStore({ ok: false, message: "MASTER_AGENT_TASK_NOT_FOUND" }, { status: 404 });
|
||||
}
|
||||
|
||||
const state = await readState();
|
||||
const canCancel =
|
||||
session.role === "highest_admin" ||
|
||||
task.requestedByAccount === session.account ||
|
||||
canAccessDevice(state, session, task.deviceId, "device.manage");
|
||||
if (!canCancel) {
|
||||
return jsonNoStore({ ok: false, message: "FORBIDDEN" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as { reason?: string };
|
||||
const canceled = await cancelMasterAgentTask({
|
||||
taskId,
|
||||
actorAccount: session.account,
|
||||
reason: typeof body.reason === "string" ? body.reason : undefined,
|
||||
});
|
||||
return jsonNoStore({ ok: true, task: canceled });
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { authorizeDeviceWriteRequest } from "@/lib/boss-device-auth";
|
||||
import type { ExecutionProgressInput } from "@/lib/boss-data";
|
||||
import type { ComputerUseProvider, ExecutionProgressInput } from "@/lib/boss-data";
|
||||
import { completeMasterAgentTask } from "@/lib/boss-data";
|
||||
import { normalizeRemoteExecutionResult } from "@/lib/execution/remote-runtime-adapter";
|
||||
import { deliverTelegramReplyForCompletedTask } from "@/lib/telegram-gateway";
|
||||
@@ -28,6 +28,7 @@ export async function POST(
|
||||
targetThreadId?: string;
|
||||
targetUrl?: string;
|
||||
targetApp?: string;
|
||||
computerUseProvider?: ComputerUseProvider;
|
||||
rawThreadReply?: string;
|
||||
executionProgress?: ExecutionProgressInput;
|
||||
};
|
||||
@@ -78,6 +79,7 @@ export async function POST(
|
||||
targetThreadId: normalized.targetThreadId,
|
||||
targetUrl: normalized.targetUrl,
|
||||
targetApp: normalized.targetApp,
|
||||
computerUseProvider: body.computerUseProvider,
|
||||
rawThreadReply: normalized.rawThreadReply,
|
||||
executionProgress: normalized.executionProgress,
|
||||
});
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { authorizeDeviceWriteRequest } from "@/lib/boss-device-auth";
|
||||
import { claimNextMasterAgentTask } from "@/lib/boss-data";
|
||||
import { waitForMasterAgentTaskWakeup } from "@/lib/master-agent-task-wakeup";
|
||||
|
||||
const DEFAULT_CLAIM_WAIT_MS = 25_000;
|
||||
const MAX_CLAIM_WAIT_MS = 30_000;
|
||||
|
||||
function normalizeClaimWaitMs(value: unknown) {
|
||||
const parsed =
|
||||
typeof value === "number"
|
||||
? value
|
||||
: typeof value === "string" && value.trim()
|
||||
? Number(value)
|
||||
: DEFAULT_CLAIM_WAIT_MS;
|
||||
if (!Number.isFinite(parsed)) return DEFAULT_CLAIM_WAIT_MS;
|
||||
return Math.max(0, Math.min(MAX_CLAIM_WAIT_MS, Math.floor(parsed)));
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = (await request.json().catch(() => ({}))) as { deviceId?: string };
|
||||
const body = (await request.json().catch(() => ({}))) as { deviceId?: string; waitMs?: number };
|
||||
const deviceId = body.deviceId?.trim();
|
||||
if (!deviceId) {
|
||||
return NextResponse.json({ ok: false, message: "DEVICE_ID_REQUIRED" }, { status: 400 });
|
||||
@@ -14,6 +29,13 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||
}
|
||||
|
||||
const task = await claimNextMasterAgentTask(deviceId);
|
||||
let task = await claimNextMasterAgentTask(deviceId);
|
||||
if (!task) {
|
||||
const waitMs = normalizeClaimWaitMs(body.waitMs ?? request.nextUrl.searchParams.get("waitMs"));
|
||||
if (waitMs > 0) {
|
||||
await waitForMasterAgentTaskWakeup(deviceId, waitMs, request.signal);
|
||||
task = await claimNextMasterAgentTask(deviceId);
|
||||
}
|
||||
}
|
||||
return NextResponse.json({ ok: true, task });
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { buildProjectMessagesRealtimePayloadForSession } from "@/lib/boss-projec
|
||||
import { canAccessProject } from "@/lib/boss-permissions";
|
||||
import {
|
||||
buildMasterAgentProjectSummarySyncAck,
|
||||
classifyMasterAgentControlIntent,
|
||||
getThreadConversationExecutionConflict,
|
||||
queueGroupDispatchPlan,
|
||||
queueThreadConversationReplyTask,
|
||||
@@ -57,6 +58,29 @@ function forbiddenResponse(message = "FORBIDDEN") {
|
||||
return NextResponse.json({ ok: false, message }, { status: 403 });
|
||||
}
|
||||
|
||||
function isComputerControlIntent(intent: ReturnType<typeof classifyMasterAgentControlIntent>) {
|
||||
return intent.intentCategory === "browser_control" || intent.intentCategory === "desktop_control";
|
||||
}
|
||||
|
||||
function hasProjectGuiControlRuntime(
|
||||
state: Awaited<ReturnType<typeof readState>>,
|
||||
project: NonNullable<Awaited<ReturnType<typeof readState>>["projects"][number]>,
|
||||
intent: ReturnType<typeof classifyMasterAgentControlIntent>,
|
||||
) {
|
||||
if (!isComputerControlIntent(intent)) return false;
|
||||
const deviceById = new Map(state.devices.map((device) => [device.id, device]));
|
||||
return project.deviceIds.some((deviceId) => {
|
||||
const device = deviceById.get(deviceId);
|
||||
if (!device || device.status !== "online" || device.preferredExecutionMode !== "gui") {
|
||||
return false;
|
||||
}
|
||||
if (intent.intentCategory === "browser_control") {
|
||||
return device.capabilities?.browserAutomation?.connected === true;
|
||||
}
|
||||
return device.capabilities?.computerUse?.connected === true;
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ projectId: string }> },
|
||||
@@ -109,13 +133,23 @@ export async function POST(
|
||||
project && projectId !== "master-agent" && requestKind === "text"
|
||||
? parseMasterAgentMention(requestText)
|
||||
: null;
|
||||
const directControlIntent =
|
||||
project && projectId !== "master-agent" && !project.isGroup && !masterAgentMention && requestKind === "text"
|
||||
? classifyMasterAgentControlIntent(requestText)
|
||||
: null;
|
||||
const shouldRouteDirectControlViaMaster = Boolean(
|
||||
project && directControlIntent && hasProjectGuiControlRuntime(state, project, directControlIntent),
|
||||
);
|
||||
if (!canAccessProject(state, session, projectId, "project.view")) {
|
||||
return forbiddenResponse();
|
||||
}
|
||||
if (masterAgentMention || projectId === "master-agent") {
|
||||
if (masterAgentMention || projectId === "master-agent" || shouldRouteDirectControlViaMaster) {
|
||||
if (!canAccessProject(state, session, projectId, "master_agent.ask")) {
|
||||
return forbiddenResponse("MASTER_AGENT_FORBIDDEN");
|
||||
}
|
||||
if (shouldRouteDirectControlViaMaster && !canAccessProject(state, session, projectId, "computer.control")) {
|
||||
return forbiddenResponse("COMPUTER_CONTROL_FORBIDDEN");
|
||||
}
|
||||
} else if (!canAccessProject(state, session, projectId, "thread.chat")) {
|
||||
return forbiddenResponse("THREAD_CHAT_FORBIDDEN");
|
||||
}
|
||||
@@ -168,7 +202,7 @@ export async function POST(
|
||||
? await getProjectAgentControls(projectId, session.account)
|
||||
: null;
|
||||
const singleThreadTakeoverEnabled = singleThreadAgentControls?.effectiveTakeoverEnabled === true;
|
||||
const singleThreadExecutionConflict = isSingleThreadTextMessage && !singleThreadTakeoverEnabled
|
||||
const singleThreadExecutionConflict = isSingleThreadTextMessage && !singleThreadTakeoverEnabled && !shouldRouteDirectControlViaMaster
|
||||
? await getThreadConversationExecutionConflict(projectId)
|
||||
: null;
|
||||
|
||||
@@ -187,6 +221,7 @@ export async function POST(
|
||||
if (masterAgentMention && project) {
|
||||
const message = await appendProjectMessage({
|
||||
projectId,
|
||||
account: session.account,
|
||||
senderLabel: session.displayName || "你",
|
||||
body: body.body,
|
||||
kind: requestKind,
|
||||
@@ -198,6 +233,7 @@ export async function POST(
|
||||
const nextProject = nextState.projects.find((item) => item.id === projectId) ?? project;
|
||||
const replyMessage = await appendProjectMessage({
|
||||
projectId,
|
||||
account: session.account,
|
||||
sender: "master",
|
||||
senderLabel: "主 Agent",
|
||||
body: buildMasterMentionTakeoverDisabledReply(nextProject),
|
||||
@@ -231,6 +267,7 @@ export async function POST(
|
||||
const nextProject = nextState.projects.find((item) => item.id === projectId) ?? project;
|
||||
const replyMessage = await appendProjectMessage({
|
||||
projectId,
|
||||
account: session.account,
|
||||
sender: "master",
|
||||
senderLabel: "主 Agent",
|
||||
body: buildMasterMentionTakeoverEnabledReply(nextProject),
|
||||
@@ -280,6 +317,7 @@ export async function POST(
|
||||
});
|
||||
const replyMessage = await appendProjectMessage({
|
||||
projectId,
|
||||
account: session.account,
|
||||
sender: "master",
|
||||
senderLabel: "主 Agent",
|
||||
body: buildMasterAgentProjectSummarySyncAck(project, {
|
||||
@@ -385,11 +423,13 @@ export async function POST(
|
||||
projectId,
|
||||
messages: [
|
||||
{
|
||||
account: session.account,
|
||||
senderLabel: session.displayName || "你",
|
||||
body: body.body,
|
||||
kind: requestKind,
|
||||
},
|
||||
{
|
||||
account: session.account,
|
||||
sender: "master",
|
||||
senderLabel: localMasterReply.senderLabel,
|
||||
body: localMasterReply.replyBody,
|
||||
@@ -418,6 +458,7 @@ export async function POST(
|
||||
|
||||
const message = await appendProjectMessage({
|
||||
projectId,
|
||||
account: session.account,
|
||||
senderLabel: session.displayName || "你",
|
||||
body: body.body,
|
||||
kind: requestKind,
|
||||
@@ -496,6 +537,7 @@ export async function POST(
|
||||
});
|
||||
replyMessage = await appendProjectMessage({
|
||||
projectId,
|
||||
account: session.account,
|
||||
sender: "master",
|
||||
senderLabel: "主 Agent",
|
||||
body: buildMasterAgentProjectSummarySyncAck(masterAgentProjectSummarySyncTarget, {
|
||||
@@ -543,6 +585,7 @@ export async function POST(
|
||||
if (!recommendation.ok) {
|
||||
await appendProjectMessage({
|
||||
projectId,
|
||||
account: session.account,
|
||||
sender: "master",
|
||||
senderLabel: "主 Agent",
|
||||
body: dispatchFailureNotice(recommendation.error),
|
||||
@@ -557,6 +600,7 @@ export async function POST(
|
||||
};
|
||||
await appendProjectMessage({
|
||||
projectId,
|
||||
account: session.account,
|
||||
sender: "master",
|
||||
senderLabel: "主 Agent",
|
||||
body: dispatchFailureNotice(dispatchRecommendation.error),
|
||||
@@ -565,13 +609,39 @@ export async function POST(
|
||||
}
|
||||
} else if (project && projectId !== "master-agent" && !project.isGroup && message.body.trim().length > 0) {
|
||||
const relayViaMasterAgent = singleThreadTakeoverEnabled;
|
||||
if (relayViaMasterAgent) {
|
||||
if (shouldRouteDirectControlViaMaster) {
|
||||
masterReply = await replyToMasterAgentUserMessage({
|
||||
requestMessageId: message.id,
|
||||
requestText: message.body,
|
||||
requestedBy: session.displayName || session.account,
|
||||
requestedByAccount: session.account,
|
||||
currentSessionExpiresAt: session.expiresAt,
|
||||
projectId,
|
||||
interactionMode: "direct",
|
||||
mode: "enqueue",
|
||||
});
|
||||
if (masterReply?.taskId) {
|
||||
task = masterReply.task ?? {
|
||||
taskId: masterReply.taskId,
|
||||
taskType: directControlIntent?.intentCategory === "desktop_control" ? "desktop_control" : "browser_control",
|
||||
status: masterReply.masterReplyState ?? "queued",
|
||||
};
|
||||
masterReplyState = masterReply.masterReplyState ?? null;
|
||||
}
|
||||
replyMessage = masterReply?.replyMessage;
|
||||
executionMode = (masterReply as { executionMode?: typeof executionMode }).executionMode;
|
||||
riskLevel = (masterReply as { riskLevel?: typeof riskLevel }).riskLevel;
|
||||
requiresConfirmation = (
|
||||
masterReply as { requiresConfirmation?: typeof requiresConfirmation }
|
||||
).requiresConfirmation;
|
||||
} else if (relayViaMasterAgent) {
|
||||
if (shouldDisableCurrentProjectTakeoverFromMasterMention(message.body)) {
|
||||
await updateProjectAgentControls(projectId, { takeoverEnabled: false }, session.account);
|
||||
const nextState = await readState();
|
||||
const nextProject = nextState.projects.find((item) => item.id === projectId) ?? project;
|
||||
replyMessage = await appendProjectMessage({
|
||||
projectId,
|
||||
account: session.account,
|
||||
sender: "master",
|
||||
senderLabel: "主 Agent",
|
||||
body: buildMasterMentionTakeoverDisabledReply(nextProject),
|
||||
@@ -605,6 +675,7 @@ export async function POST(
|
||||
});
|
||||
replyMessage = await appendProjectMessage({
|
||||
projectId,
|
||||
account: session.account,
|
||||
sender: "master",
|
||||
senderLabel: "主 Agent",
|
||||
body: buildMasterAgentProjectSummarySyncAck(project, {
|
||||
@@ -706,7 +777,7 @@ export async function POST(
|
||||
status: "queued",
|
||||
};
|
||||
}
|
||||
replyPresenter = relayViaMasterAgent ? "master" : "thread";
|
||||
replyPresenter = shouldRouteDirectControlViaMaster || relayViaMasterAgent ? "master" : "thread";
|
||||
} else {
|
||||
dispatchRecommendation = {
|
||||
ok: false,
|
||||
|
||||
Reference in New Issue
Block a user