feat: harden enterprise control plane

This commit is contained in:
AI Bot
2026-05-17 02:20:08 +08:00
parent 67511c31f4
commit e1aed590f8
112 changed files with 10977 additions and 2004 deletions

View File

@@ -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);

View File

@@ -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 }));
}

View 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 });
}

View 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,
},
});
}

View 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,
});
}

View File

@@ -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;

View 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 });
}

View File

@@ -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,
});

View File

@@ -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 });
}

View File

@@ -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,