feat: ship enterprise control and desktop governance

This commit is contained in:
AI Bot
2026-05-11 14:59:26 +08:00
parent 0757d07521
commit a311280238
285 changed files with 48574 additions and 2428 deletions

View File

@@ -0,0 +1,43 @@
import type { BossPermission } from "@/lib/boss-data";
export const BOSS_PERMISSION_TEMPLATES = [
{
templateId: "viewer",
name: "只读观察员",
description: "只能查看授权设备、项目和 Skill不允许聊天、接管或电脑控制。",
devicePermissions: ["device.view"],
projectPermissions: ["project.view"],
skillPermissions: ["skill.view"],
},
{
templateId: "developer",
name: "项目开发者",
description: "允许查看设备、参与项目聊天、问询主 Agent并调用已分配 Skill。",
devicePermissions: ["device.view"],
projectPermissions: ["project.view", "thread.chat", "master_agent.ask"],
skillPermissions: ["skill.view", "skill.use"],
},
{
templateId: "operator",
name: "设备操作者",
description: "允许项目聊天、主 Agent 接管、电脑控制和 Skill 调用,用于可信协作者。",
devicePermissions: ["device.view", "computer.control"],
projectPermissions: [
"project.view",
"thread.chat",
"master_agent.ask",
"master_agent.takeover",
"computer.control",
],
skillPermissions: ["skill.view", "skill.use"],
},
] satisfies Array<{
templateId: string;
name: string;
description: string;
devicePermissions: BossPermission[];
projectPermissions: BossPermission[];
skillPermissions: BossPermission[];
}>;
export type BossPermissionTemplate = (typeof BOSS_PERMISSION_TEMPLATES)[number];

View File

@@ -0,0 +1,132 @@
import type { AdminCompany, AdminNotification, AuthAccount, BossState } from "@/lib/boss-data";
import { deliverAdminPlainEmail } from "@/lib/boss-mail";
export type AdminNotificationDeliveryStatus = "pending" | "sent" | "failed" | "disabled";
export interface AdminNotificationDeliveryResult {
notificationId: string;
status: AdminNotificationDeliveryStatus;
target: string;
message: string;
deliveredAt: string;
}
function splitRecipients(value?: string) {
return (value ?? "")
.split(/[,\s;]+/)
.map((item) => item.trim())
.filter(Boolean);
}
function isEmail(value?: string) {
return Boolean(value && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value));
}
function emailAccount(account?: AuthAccount) {
if (!account) return undefined;
if (isEmail(account.verificationEmail)) return account.verificationEmail;
if (isEmail(account.account)) return account.account;
return undefined;
}
function companyForNotification(state: BossState, notification: AdminNotification) {
return state.adminCompanies.find((company) => company.companyId === notification.companyId);
}
function accountById(state: BossState, account?: string) {
if (!account) return undefined;
return state.authAccounts.find((item) => item.account === account);
}
function companyAdminRecipients(state: BossState, companyId: string) {
return state.authAccounts
.filter((account) => account.companyId === companyId && (account.role === "admin" || account.role === "highest_admin"))
.map(emailAccount)
.filter((value): value is string => Boolean(value));
}
export function resolveAdminNotificationRecipients(
state: BossState,
notification: AdminNotification,
) {
const company = companyForNotification(state, notification);
const recipients = [
emailAccount(accountById(state, company?.ownerAccount)),
emailAccount(accountById(state, company?.successOwnerAccount)),
...companyAdminRecipients(state, notification.companyId),
...splitRecipients(process.env.BOSS_ADMIN_NOTIFICATION_RECIPIENTS),
].filter((value): value is string => Boolean(value));
return [...new Set(recipients)];
}
function companyLabel(company?: AdminCompany) {
return company?.name || company?.companyId || "未归属公司";
}
export function buildAdminNotificationEmail(
state: BossState,
notification: AdminNotification,
) {
const company = companyForNotification(state, notification);
return {
subject: `[Boss 风险提醒] ${notification.title}`,
body: [
`公司:${companyLabel(company)}`,
`风险:${notification.riskId}`,
`级别:${notification.severity}`,
`标题:${notification.title}`,
"",
notification.body,
"",
"请登录 Boss 管理后台查看风险详情、分派负责人或更新 SLA。",
].join("\n"),
};
}
export async function deliverAdminNotification(
state: BossState,
notification: AdminNotification,
deliveredAt = new Date().toISOString(),
): Promise<AdminNotificationDeliveryResult> {
const recipients = resolveAdminNotificationRecipients(state, notification);
const target = recipients.join(",");
const mode = process.env.BOSS_ADMIN_NOTIFICATION_MODE?.trim().toLowerCase() || "disabled";
if (mode !== "email") {
return {
notificationId: notification.notificationId,
status: "disabled",
target,
deliveredAt,
message: "ADMIN_NOTIFICATION_EMAIL_DISABLED",
};
}
if (recipients.length === 0) {
return {
notificationId: notification.notificationId,
status: "failed",
target,
deliveredAt,
message: "ADMIN_NOTIFICATION_RECIPIENT_REQUIRED",
};
}
const email = buildAdminNotificationEmail(state, notification);
const results = await Promise.all(
recipients.map((recipient) =>
deliverAdminPlainEmail({
recipient,
subject: email.subject,
body: email.body,
}),
),
);
const failed = results.find((result) => !result.delivered);
return {
notificationId: notification.notificationId,
status: failed ? "failed" : "sent",
target,
deliveredAt,
message: failed?.message ?? "ADMIN_NOTIFICATION_EMAIL_SENT",
};
}

View File

@@ -0,0 +1,306 @@
import type { AuthAccount, BossState, Device, OpsSeverity } from "@/lib/boss-data";
export type AdminRiskSeverity = OpsSeverity;
export interface AdminRiskItem {
riskId: string;
severity: AdminRiskSeverity;
kind: "device_offline" | "ops_fault" | "thread_context_alert" | "master_agent_task_failed";
title: string;
detail: string;
companyId: string;
deviceId?: string;
projectId?: string;
ownerAccount?: string;
slaDueAt?: string;
lastSeenAt: string;
}
function fallbackCompanyIdForAccount(account?: string) {
const normalized = account?.trim().toLowerCase() ?? "";
const domain = normalized.includes("@") ? normalized.split("@").at(-1)?.trim() : "";
return domain || "default";
}
function companyIdForAccount(account: AuthAccount) {
return account.companyId || fallbackCompanyIdForAccount(account.account);
}
function companyName(companyNames: Map<string, string>, companyId: string) {
return companyNames.get(companyId) ?? (companyId === "default" ? "默认公司" : companyId);
}
function publicAccount(account: AuthAccount, companyNames: Map<string, string>) {
const companyId = companyIdForAccount(account);
return {
account: account.account,
displayName: account.displayName,
role: account.role,
status: account.status,
companyId,
companyName: companyName(companyNames, companyId),
createdAt: account.createdAt,
updatedAt: account.updatedAt,
lastLoginAt: account.lastLoginAt,
primaryDeviceId: account.primaryDeviceId,
};
}
function deviceCompanyId(state: BossState, device?: Pick<Device, "account" | "companyId"> | null) {
if (device?.companyId) return device.companyId;
const owner = state.authAccounts.find((account) => account.account === device?.account);
return owner ? companyIdForAccount(owner) : fallbackCompanyIdForAccount(device?.account);
}
function projectPrimaryDeviceId(state: BossState, projectId?: string) {
if (!projectId) return undefined;
return state.projects.find((project) => project.id === projectId)?.deviceIds[0];
}
function deviceForRisk(state: BossState, deviceId?: string, projectId?: string) {
const resolvedDeviceId = deviceId || projectPrimaryDeviceId(state, projectId);
return state.devices.find((device) => device.id === resolvedDeviceId) ?? null;
}
function severityRank(severity: AdminRiskSeverity) {
switch (severity) {
case "critical":
return 3;
case "warning":
return 2;
default:
return 1;
}
}
function isExpired(expiresAt?: string) {
return Boolean(expiresAt && new Date(expiresAt).getTime() <= Date.now());
}
function newerRisk(left: AdminRiskItem, right: AdminRiskItem) {
return left.lastSeenAt.localeCompare(right.lastSeenAt) >= 0 ? left : right;
}
function buildRisks(state: BossState): AdminRiskItem[] {
const risks: AdminRiskItem[] = [];
for (const device of state.devices) {
if (device.status === "online") continue;
risks.push({
riskId: `device-offline:${device.id}`,
severity: "warning",
kind: "device_offline",
title: `设备离线:${device.name}`,
detail: `${device.name} 最近心跳时间为 ${device.lastSeenAt}`,
companyId: deviceCompanyId(state, device),
deviceId: device.id,
lastSeenAt: device.lastSeenAt,
});
}
for (const fault of state.opsFaults) {
if (fault.status === "resolved") continue;
const device = deviceForRisk(state, fault.nodeId, fault.projectId);
risks.push({
riskId: `ops-fault:${fault.faultId}`,
severity: fault.severity,
kind: "ops_fault",
title: fault.faultKey,
detail: fault.summary || fault.suggestedNextAction,
companyId: deviceCompanyId(state, device),
deviceId: fault.nodeId,
projectId: fault.projectId,
ownerAccount: fault.ownerAccount,
slaDueAt: fault.slaDueAt,
lastSeenAt: fault.lastSeenAt,
});
}
for (const alert of state.threadContextAlerts) {
if (alert.alertStatus === "resolved") continue;
const device = deviceForRisk(state, undefined, alert.projectId);
risks.push({
riskId: `thread-alert:${alert.alertId}`,
severity: alert.alertType === "context_critical" ? "critical" : "warning",
kind: "thread_context_alert",
title: "线程上下文风险",
detail: alert.summary,
companyId: deviceCompanyId(state, device),
deviceId: device?.id,
projectId: alert.projectId,
ownerAccount: alert.ownerAccount,
slaDueAt: alert.slaDueAt,
lastSeenAt: alert.openedAt,
});
}
const failedMasterTaskRisks = new Map<string, { risk: AdminRiskItem; count: number }>();
for (const task of state.masterAgentTasks) {
if (task.status !== "failed") continue;
const device = deviceForRisk(state, task.deviceId, task.projectId);
const groupKey = [
task.deviceId || "unknown-device",
task.projectId || "unknown-project",
task.taskType,
task.errorMessage || "MASTER_AGENT_TASK_FAILED",
].join(":");
const risk: AdminRiskItem = {
riskId: `master-task:${task.taskId}`,
severity: "warning",
kind: "master_agent_task_failed",
title: "主 Agent 任务失败",
detail: task.errorMessage || task.requestText || "主 Agent 任务执行失败",
companyId: deviceCompanyId(state, device),
deviceId: task.deviceId,
projectId: task.projectId,
lastSeenAt: task.completedAt || task.requestedAt,
};
const existing = failedMasterTaskRisks.get(groupKey);
if (!existing) {
failedMasterTaskRisks.set(groupKey, { risk, count: 1 });
continue;
}
failedMasterTaskRisks.set(groupKey, {
risk: newerRisk(existing.risk, risk),
count: existing.count + 1,
});
}
for (const { risk, count } of failedMasterTaskRisks.values()) {
risks.push({
...risk,
detail: count > 1 ? `${risk.detail};已折叠 ${count - 1} 条同类失败。` : risk.detail,
});
}
return risks.sort((left, right) => {
const severityDiff = severityRank(right.severity) - severityRank(left.severity);
return severityDiff || right.lastSeenAt.localeCompare(left.lastSeenAt);
});
}
export function buildAdminOverview(state: BossState) {
const risks = buildRisks(state);
const companyNames = new Map(
state.adminCompanies.map((company) => [
company.companyId,
company.name || (company.companyId === "default" ? "默认公司" : company.companyId),
]),
);
const risksByCompany = new Map<string, number>();
const risksByDevice = new Map<string, number>();
for (const risk of risks) {
risksByCompany.set(risk.companyId, (risksByCompany.get(risk.companyId) ?? 0) + 1);
if (risk.deviceId) {
risksByDevice.set(risk.deviceId, (risksByDevice.get(risk.deviceId) ?? 0) + 1);
}
}
const companyMap = new Map<string, {
companyId: string;
name: string;
ownerAccount?: string;
successOwnerAccount?: string;
planTier?: string;
contractExpiresAt?: string;
status?: string;
accountCount: number;
adminCount: number;
deviceCount: number;
onlineDeviceCount: number;
openRiskCount: number;
}>();
const ensureCompany = (companyId: string) => {
const existing = companyMap.get(companyId);
if (existing) return existing;
const configured = state.adminCompanies.find((company) => company.companyId === companyId);
const company = {
companyId,
name: companyName(companyNames, companyId),
ownerAccount: configured?.ownerAccount,
successOwnerAccount: configured?.successOwnerAccount,
planTier: configured?.planTier,
contractExpiresAt: configured?.contractExpiresAt,
status: configured?.status,
accountCount: 0,
adminCount: 0,
deviceCount: 0,
onlineDeviceCount: 0,
openRiskCount: risksByCompany.get(companyId) ?? 0,
};
companyMap.set(companyId, company);
return company;
};
for (const company of state.adminCompanies) {
ensureCompany(company.companyId);
}
for (const account of state.authAccounts) {
const company = ensureCompany(companyIdForAccount(account));
company.accountCount += 1;
if (account.role === "admin" || account.role === "highest_admin") {
company.adminCount += 1;
}
}
for (const device of state.devices) {
const company = ensureCompany(deviceCompanyId(state, device));
company.deviceCount += 1;
if (device.status === "online") {
company.onlineDeviceCount += 1;
}
}
const devices = state.devices.map((device) => ({
companyId: deviceCompanyId(state, device),
companyName: companyName(companyNames, deviceCompanyId(state, device)),
id: device.id,
name: device.name,
account: device.account,
status: device.status,
lastSeenAt: device.lastSeenAt,
preferredExecutionMode: device.preferredExecutionMode,
projectCount: device.projects.length,
codexGuiOnline: Boolean(device.capabilities?.gui.connected),
codexCliOnline: Boolean(device.capabilities?.cli.connected),
openRiskCount: risksByDevice.get(device.id) ?? 0,
}));
const expiredGrants = [
...state.accountDeviceGrants,
...state.accountProjectGrants,
...state.accountSkillGrants,
].filter((grant) => isExpired(grant.expiresAt)).length;
const notifications = state.adminNotifications
.filter((notification) => notification.status === "open")
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))
.slice(0, 20);
return {
summary: {
companies: companyMap.size,
accounts: state.authAccounts.length,
devices: state.devices.length,
onlineDevices: state.devices.filter((device) => device.status === "online").length,
openRisks: risks.length,
openNotifications: notifications.length,
criticalRisks: risks.filter((risk) => risk.severity === "critical").length,
},
companies: [...companyMap.values()].sort((left, right) =>
right.openRiskCount - left.openRiskCount || left.name.localeCompare(right.name, "zh-CN"),
),
accounts: state.authAccounts.map((account) => publicAccount(account, companyNames)),
devices,
risks,
notifications,
riskTimeline: state.adminRiskTimeline
.slice()
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))
.slice(0, 50),
grantsSummary: {
deviceGrants: state.accountDeviceGrants.length,
projectGrants: state.accountProjectGrants.length,
skillGrants: state.accountSkillGrants.length,
expiredGrants,
},
};
}

View File

@@ -1,38 +1,10 @@
import type { AuthSession, BossState, Project } from "@/lib/boss-data";
function getAccountOwnedDeviceIds(state: BossState, account: string) {
return new Set(
state.devices
.filter((device) => device.account === account)
.map((device) => device.id),
);
}
import { canAccessProject } from "@/lib/boss-permissions";
export function canSessionAccessAttachmentProject(
state: BossState,
session: Pick<AuthSession, "account" | "role">,
project: Pick<Project, "deviceIds" | "groupMembers">,
session: Pick<AuthSession, "account" | "role" | "displayName">,
project: Pick<Project, "id" | "deviceIds" | "groupMembers">,
) {
if (session.role === "highest_admin") {
return true;
}
const ownedDeviceIds = getAccountOwnedDeviceIds(state, session.account);
if (ownedDeviceIds.size === 0) {
return false;
}
for (const deviceId of project.deviceIds) {
if (ownedDeviceIds.has(deviceId)) {
return true;
}
}
for (const member of project.groupMembers) {
if (ownedDeviceIds.has(member.deviceId)) {
return true;
}
}
return false;
return canAccessProject(state, session, project.id, "project.view");
}

268
src/lib/boss-audit.ts Normal file
View File

@@ -0,0 +1,268 @@
import type { BossState, PermissionAuditLog } from "@/lib/boss-data";
import type { NextRequest } from "next/server";
export interface PermissionAuditQuery {
action?: string;
actorAccount?: string;
targetAccount?: string;
deviceId?: string;
projectId?: string;
skillId?: string;
cursor?: string;
limit?: number;
}
export interface PermissionAuditRiskAlert {
kind:
| "rapid_permission_grants"
| "skill_lifecycle_failed"
| "expired_grant_present"
| "admin_route_denied";
severity: "medium" | "high";
title: string;
detail: string;
count: number;
auditIds: string[];
targetAccount?: string;
actorAccount?: string;
deviceId?: string;
projectId?: string;
skillId?: string;
}
const DEFAULT_LIMIT = 50;
const MAX_LIMIT = 200;
const RAPID_GRANT_WINDOW_MS = 10 * 60 * 1000;
const RAPID_GRANT_THRESHOLD = 5;
export function buildRequestAuditMeta(request: NextRequest) {
const forwardedFor = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
const realIp = request.headers.get("x-real-ip")?.trim();
return {
ipAddress: forwardedFor || realIp || undefined,
userAgent: nonEmpty(request.headers.get("user-agent")),
requestId: nonEmpty(request.headers.get("x-request-id")) ?? nonEmpty(request.headers.get("x-correlation-id")),
};
}
function nonEmpty(value?: string | null) {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function timestampMs(value?: string) {
const parsed = value ? Date.parse(value) : Number.NaN;
return Number.isFinite(parsed) ? parsed : 0;
}
function newestFirst(logs: PermissionAuditLog[]) {
return [...logs].sort((left, right) => {
const byTime = timestampMs(right.createdAt) - timestampMs(left.createdAt);
return byTime || right.auditId.localeCompare(left.auditId);
});
}
function normalizedLimit(limit?: number) {
if (!Number.isFinite(limit ?? Number.NaN)) {
return DEFAULT_LIMIT;
}
return Math.min(Math.max(Math.trunc(limit ?? DEFAULT_LIMIT), 1), MAX_LIMIT);
}
function matchesQuery(log: PermissionAuditLog, query: PermissionAuditQuery) {
return (
(!query.action || log.action === query.action) &&
(!query.actorAccount || log.actorAccount === query.actorAccount) &&
(!query.targetAccount || log.targetAccount === query.targetAccount) &&
(!query.deviceId || log.deviceId === query.deviceId) &&
(!query.projectId || log.projectId === query.projectId) &&
(!query.skillId || log.skillId === query.skillId)
);
}
export function queryPermissionAuditLogs(logs: PermissionAuditLog[], query: PermissionAuditQuery) {
const filtered = newestFirst(logs).filter((log) => matchesQuery(log, query));
const startIndex = query.cursor ? filtered.findIndex((log) => log.auditId === query.cursor) + 1 : 0;
const safeStart = startIndex > 0 ? startIndex : 0;
const page = filtered.slice(safeStart, safeStart + normalizedLimit(query.limit));
const nextCursor = safeStart + page.length < filtered.length ? page.at(-1)?.auditId ?? null : null;
return {
logs: page,
nextCursor,
total: filtered.length,
};
}
function knownReferenceNow(state: BossState) {
const candidates = [
Date.now(),
...state.permissionAuditLogs.map((log) => timestampMs(log.createdAt)),
...state.accountDeviceGrants.map((grant) => timestampMs(grant.expiresAt)),
...state.accountProjectGrants.map((grant) => timestampMs(grant.expiresAt)),
...state.accountSkillGrants.map((grant) => timestampMs(grant.expiresAt)),
];
return Math.max(...candidates);
}
function isGrantLike(log: PermissionAuditLog) {
return log.action === "grant.created" || log.action === "grant.updated" || log.action === "skill.assigned";
}
function detectRapidGrants(logs: PermissionAuditLog[]): PermissionAuditRiskAlert[] {
const groups = new Map<string, PermissionAuditLog[]>();
for (const log of logs.filter(isGrantLike)) {
const key = `${log.actorAccount}\u0000${log.targetAccount ?? ""}`;
groups.set(key, [...(groups.get(key) ?? []), log]);
}
const alerts: PermissionAuditRiskAlert[] = [];
for (const groupLogs of groups.values()) {
const ordered = newestFirst(groupLogs).reverse();
for (let index = 0; index < ordered.length; index += 1) {
const windowStart = timestampMs(ordered[index]?.createdAt);
const windowLogs = ordered.filter((log) => {
const createdAt = timestampMs(log.createdAt);
return createdAt >= windowStart && createdAt <= windowStart + RAPID_GRANT_WINDOW_MS;
});
if (windowLogs.length >= RAPID_GRANT_THRESHOLD) {
const sample = windowLogs.at(0);
alerts.push({
kind: "rapid_permission_grants",
severity: "high",
title: "短时间大量授权",
detail: `${windowLogs.length} 条授权类审计在 10 分钟内集中发生`,
count: windowLogs.length,
auditIds: newestFirst(windowLogs).map((log) => log.auditId),
actorAccount: sample?.actorAccount,
targetAccount: sample?.targetAccount,
});
break;
}
}
}
return alerts;
}
function detectSkillLifecycleFailures(logs: PermissionAuditLog[]): PermissionAuditRiskAlert[] {
return logs
.filter((log) =>
(log.action as string) === "skill.lifecycle.failed" ||
(log.action === "skill.lifecycle.completed" && /\bfailed\b/i.test(log.detail ?? "")),
)
.map((log) => ({
kind: "skill_lifecycle_failed" as const,
severity: "high" as const,
title: "Skill lifecycle 执行失败",
detail: log.detail ?? "skill lifecycle failed",
count: 1,
auditIds: [log.auditId],
actorAccount: log.actorAccount,
deviceId: log.deviceId,
skillId: log.skillId,
}));
}
function detectDeniedAdminRoutes(logs: PermissionAuditLog[]): PermissionAuditRiskAlert[] {
return logs
.filter((log) => log.action === "task.denied" && /admin[_ -]?route|\/api\/v1\/admin\//i.test(log.detail ?? ""))
.map((log) => ({
kind: "admin_route_denied" as const,
severity: "medium" as const,
title: "非最高管理员访问管理入口被拒",
detail: log.detail ?? "admin route denied",
count: 1,
auditIds: [log.auditId],
actorAccount: log.actorAccount,
targetAccount: log.targetAccount,
deviceId: log.deviceId,
projectId: log.projectId,
}));
}
function detectExpiredGrants(state: BossState): PermissionAuditRiskAlert[] {
const now = knownReferenceNow(state);
const alerts: PermissionAuditRiskAlert[] = [];
for (const grant of state.accountDeviceGrants) {
if (grant.expiresAt && timestampMs(grant.expiresAt) < now) {
alerts.push({
kind: "expired_grant_present",
severity: "high",
title: "过期设备授权仍存在",
detail: `${grant.account} -> ${grant.deviceId} 已于 ${grant.expiresAt} 过期`,
count: 1,
auditIds: [],
targetAccount: grant.account,
actorAccount: grant.grantedBy,
deviceId: grant.deviceId,
});
}
}
for (const grant of state.accountProjectGrants) {
if (grant.expiresAt && timestampMs(grant.expiresAt) < now) {
alerts.push({
kind: "expired_grant_present",
severity: "high",
title: "过期项目授权仍存在",
detail: `${grant.account} -> ${grant.projectId} 已于 ${grant.expiresAt} 过期`,
count: 1,
auditIds: [],
targetAccount: grant.account,
actorAccount: grant.grantedBy,
deviceId: grant.deviceId,
projectId: grant.projectId,
});
}
}
for (const grant of state.accountSkillGrants) {
if (grant.expiresAt && timestampMs(grant.expiresAt) < now) {
alerts.push({
kind: "expired_grant_present",
severity: "high",
title: "过期 Skill 授权仍存在",
detail: `${grant.account} -> ${grant.skillId} 已于 ${grant.expiresAt} 过期`,
count: 1,
auditIds: [],
targetAccount: grant.account,
actorAccount: grant.grantedBy,
deviceId: grant.deviceId,
projectId: grant.projectId,
skillId: grant.skillId,
});
}
}
return alerts;
}
export function summarizePermissionAuditRisks(state: BossState) {
const logs = newestFirst(state.permissionAuditLogs);
const alerts = [
...detectRapidGrants(logs),
...detectSkillLifecycleFailures(logs),
...detectExpiredGrants(state),
...detectDeniedAdminRoutes(logs),
].sort((left, right) => {
const severityDelta = (right.severity === "high" ? 1 : 0) - (left.severity === "high" ? 1 : 0);
return severityDelta || left.kind.localeCompare(right.kind);
});
return {
totalAlerts: alerts.length,
highAlerts: alerts.filter((alert) => alert.severity === "high").length,
mediumAlerts: alerts.filter((alert) => alert.severity === "medium").length,
alerts,
};
}
export function permissionAuditQueryFromSearchParams(searchParams: URLSearchParams): PermissionAuditQuery {
const limit = Number(searchParams.get("limit"));
return {
action: nonEmpty(searchParams.get("action")),
actorAccount: nonEmpty(searchParams.get("actorAccount")),
targetAccount: nonEmpty(searchParams.get("targetAccount")),
deviceId: nonEmpty(searchParams.get("deviceId")),
projectId: nonEmpty(searchParams.get("projectId")),
skillId: nonEmpty(searchParams.get("skillId")),
cursor: nonEmpty(searchParams.get("cursor")),
limit: Number.isFinite(limit) ? limit : undefined,
};
}

View File

@@ -0,0 +1,116 @@
export type BossCapabilityGroupId =
| "computer_control"
| "codex_development"
| "browser_automation"
| "skill_operations"
| "admin_ops";
export type BossCapabilityGroup = {
id: BossCapabilityGroupId;
label: string;
requiredPermissions: string[];
allowedSkillIds: string[];
requiredDeviceScopes: string[];
};
export type BossCapabilityDecisionReason =
| "allowed"
| "unknown_group"
| "permission_missing"
| "skill_scope_missing"
| "device_scope_missing";
export type BossCapabilityDecision = {
allowed: boolean;
reason: BossCapabilityDecisionReason;
group?: BossCapabilityGroup;
missing?: string[];
};
export const DEFAULT_CAPABILITY_GROUPS: BossCapabilityGroup[] = [
{
id: "computer_control",
label: "Computer Control",
requiredPermissions: ["computer.control"],
allowedSkillIds: ["computer-use:computer-use", "computer-use"],
requiredDeviceScopes: ["computerUse"],
},
{
id: "codex_development",
label: "Codex Development",
requiredPermissions: ["thread.chat", "master_agent.takeover"],
allowedSkillIds: ["codex", "github:github", "github:yeet"],
requiredDeviceScopes: ["codexCli"],
},
{
id: "browser_automation",
label: "Browser Automation",
requiredPermissions: ["computer.control"],
allowedSkillIds: ["browser-use:browser", "playwright"],
requiredDeviceScopes: ["browserAutomation"],
},
{
id: "skill_operations",
label: "Skill Operations",
requiredPermissions: ["skill.use"],
allowedSkillIds: ["skill-installer", "skill-creator", "writing-skills"],
requiredDeviceScopes: ["skillLifecycle"],
},
{
id: "admin_ops",
label: "Admin Operations",
requiredPermissions: ["admin.manage"],
allowedSkillIds: ["boss-server-debug", "gitea-version-upload"],
requiredDeviceScopes: ["adminOps"],
},
];
export function canUseCapabilityGroup(input: {
groupId: string;
accountPermissions: string[];
skillIds: string[];
deviceScopes: string[];
requestedSkillId?: string;
requestedDeviceScope?: string;
groups?: BossCapabilityGroup[];
}): BossCapabilityDecision {
const groups = input.groups ?? DEFAULT_CAPABILITY_GROUPS;
const group = groups.find((candidate) => candidate.id === input.groupId);
if (!group) {
return { allowed: false, reason: "unknown_group" };
}
const missingPermissions = group.requiredPermissions.filter(
(permission) => !input.accountPermissions.includes(permission),
);
if (missingPermissions.length > 0) {
return { allowed: false, reason: "permission_missing", group, missing: missingPermissions };
}
if (input.requestedSkillId) {
const skillAllowedByGroup = group.allowedSkillIds.includes(input.requestedSkillId);
const skillAllowedByAccount = input.skillIds.includes(input.requestedSkillId);
if (!skillAllowedByGroup || !skillAllowedByAccount) {
return { allowed: false, reason: "skill_scope_missing", group, missing: [input.requestedSkillId] };
}
}
const requestedDeviceScope = input.requestedDeviceScope ?? group.requiredDeviceScopes[0];
if (requestedDeviceScope) {
const scopeAllowedByGroup = group.requiredDeviceScopes.includes(requestedDeviceScope);
const scopeAllowedByDevice = input.deviceScopes.includes(requestedDeviceScope);
if (!scopeAllowedByGroup || !scopeAllowedByDevice) {
return { allowed: false, reason: "device_scope_missing", group, missing: [requestedDeviceScope] };
}
}
return { allowed: true, reason: "allowed", group };
}
export function explainCapabilityGroupDecision(decision: BossCapabilityDecision): string {
if (decision.allowed) {
return `${decision.group?.id ?? "capability"} allowed`;
}
const missing = decision.missing?.length ? `: ${decision.missing.join(", ")}` : "";
return `${decision.group?.id ?? "capability"} denied: ${decision.reason}${missing}`;
}

36
src/lib/boss-csrf.ts Normal file
View File

@@ -0,0 +1,36 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
function expectedOrigin(request: NextRequest) {
const host = request.headers.get("x-forwarded-host") || request.headers.get("host") || request.nextUrl.host;
const proto = request.headers.get("x-forwarded-proto") || request.nextUrl.protocol.replace(":", "") || "http";
return `${proto}://${host}`;
}
export function csrfFailureResponse() {
return NextResponse.json({ ok: false, message: "CSRF_CHECK_FAILED" }, { status: 403 });
}
export function requireCsrfSafeMutation(request: NextRequest) {
if (request.method === "GET" || request.method === "HEAD" || request.method === "OPTIONS") {
return null;
}
if (request.headers.get("x-boss-native-app") === "1") {
return null;
}
const fetchSite = request.headers.get("sec-fetch-site")?.toLowerCase();
if (fetchSite === "cross-site") {
return csrfFailureResponse();
}
const origin = request.headers.get("origin");
if (!origin) {
return null;
}
if (origin !== expectedOrigin(request)) {
return csrfFailureResponse();
}
return null;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
import type { NextRequest } from "next/server";
import { requireRequestSession } from "@/lib/boss-auth";
import { getDevice, verifyDeviceToken } from "@/lib/boss-data";
import { getDevice, readState, verifyDeviceToken } from "@/lib/boss-data";
import { canAccessDevice } from "@/lib/boss-permissions";
export async function authorizeDeviceWriteRequest(
request: NextRequest,
@@ -9,7 +10,18 @@ export async function authorizeDeviceWriteRequest(
const device = await getDevice(deviceId);
const session = await requireRequestSession(request);
if (device && session && (session.role === "highest_admin" || device.account === session.account)) {
if (device && session) {
const state = await readState();
if (canAccessDevice(state, session, deviceId, "device.manage")) {
return {
ok: true as const,
device,
principal: "session" as const,
};
}
}
if (device && session && session.role === "highest_admin") {
return {
ok: true as const,
device,
@@ -58,7 +70,8 @@ export async function authorizeDeviceSessionRequest(
};
}
if (session.role === "highest_admin" || device.account === session.account) {
const state = await readState();
if (canAccessDevice(state, session, deviceId, "device.view")) {
return {
ok: true as const,
status: 200 as const,

View File

@@ -0,0 +1,151 @@
export type BossDeviceTrustTier = "untrusted" | "paired" | "verified" | "managed" | "federated";
export type BossDeviceTrustProfile = {
deviceId: string;
trustTier: BossDeviceTrustTier;
publicKeyFingerprint: string;
maxMessageBudget: number;
maxHopCount: number;
allowedCapabilities: string[];
};
export type BossDeviceTrustDecisionReason =
| "allowed"
| "device_mismatch"
| "trust_tier_too_low"
| "capability_not_allowed"
| "message_budget_exceeded"
| "hop_limit_exceeded";
export type BossDeviceTrustDecision = {
allowed: boolean;
reason: BossDeviceTrustDecisionReason;
};
export type BossDeviceTrustEnvelope = {
deviceId: string;
payload: unknown;
signature: string;
keyFingerprint: string;
timestamp: string;
nonce: string;
};
export type BossDeviceTrustEnvelopeDecisionReason =
| "valid"
| "device_mismatch"
| "key_fingerprint_mismatch"
| "clock_skew_exceeded"
| "invalid_signature";
export type BossDeviceTrustEnvelopeDecision = {
valid: boolean;
reason: BossDeviceTrustEnvelopeDecisionReason;
canonicalPayload: string;
};
const TRUST_TIER_RANK: Record<BossDeviceTrustTier, number> = {
untrusted: 0,
paired: 1,
verified: 2,
managed: 3,
federated: 4,
};
export function evaluateDeviceTrust(input: {
device: BossDeviceTrustProfile;
requiredTrustTier: BossDeviceTrustTier;
capabilityGroupId: string;
messageBudgetUsed: number;
hopCount: number;
deviceId?: string;
}): BossDeviceTrustDecision {
if (input.deviceId && input.device.deviceId !== input.deviceId) {
return { allowed: false, reason: "device_mismatch" };
}
if (TRUST_TIER_RANK[input.device.trustTier] < TRUST_TIER_RANK[input.requiredTrustTier]) {
return { allowed: false, reason: "trust_tier_too_low" };
}
if (!input.device.allowedCapabilities.includes(input.capabilityGroupId)) {
return { allowed: false, reason: "capability_not_allowed" };
}
if (input.messageBudgetUsed > input.device.maxMessageBudget) {
return { allowed: false, reason: "message_budget_exceeded" };
}
if (input.hopCount > input.device.maxHopCount) {
return { allowed: false, reason: "hop_limit_exceeded" };
}
return { allowed: true, reason: "allowed" };
}
export function verifyDeviceTrustEnvelope(
envelope: BossDeviceTrustEnvelope,
options: {
device: BossDeviceTrustProfile;
now: string;
maxClockSkewMs: number;
verifySignature: (input: {
canonicalPayload: string;
signature: string;
publicKeyFingerprint: string;
deviceId: string;
}) => boolean;
},
): BossDeviceTrustEnvelopeDecision {
const canonicalPayload = canonicalizeEnvelopePayload(envelope);
if (envelope.deviceId !== options.device.deviceId) {
return { valid: false, reason: "device_mismatch", canonicalPayload };
}
if (envelope.keyFingerprint !== options.device.publicKeyFingerprint) {
return { valid: false, reason: "key_fingerprint_mismatch", canonicalPayload };
}
const skewMs = Math.abs(new Date(options.now).getTime() - new Date(envelope.timestamp).getTime());
if (!Number.isFinite(skewMs) || skewMs > options.maxClockSkewMs) {
return { valid: false, reason: "clock_skew_exceeded", canonicalPayload };
}
const valid = options.verifySignature({
canonicalPayload,
signature: envelope.signature,
publicKeyFingerprint: envelope.keyFingerprint,
deviceId: envelope.deviceId,
});
if (!valid) {
return { valid: false, reason: "invalid_signature", canonicalPayload };
}
return { valid: true, reason: "valid", canonicalPayload };
}
export function assertDeviceTrustEnvelope(
envelope: BossDeviceTrustEnvelope,
options: Parameters<typeof verifyDeviceTrustEnvelope>[1],
): BossDeviceTrustEnvelopeDecision {
const decision = verifyDeviceTrustEnvelope(envelope, options);
if (!decision.valid) {
throw new Error(`Device trust envelope rejected: ${decision.reason}`);
}
return decision;
}
function canonicalizeEnvelopePayload(envelope: BossDeviceTrustEnvelope): string {
return stableStringify({
deviceId: envelope.deviceId,
keyFingerprint: envelope.keyFingerprint,
nonce: envelope.nonce,
payload: envelope.payload,
timestamp: envelope.timestamp,
});
}
function stableStringify(value: unknown): string {
if (value === null || typeof value !== "object") {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return `[${value.map((item) => stableStringify(item)).join(",")}]`;
}
const record = value as Record<string, unknown>;
return `{${Object.keys(record)
.sort()
.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`)
.join(",")}}`;
}

View File

@@ -4,6 +4,8 @@ export type BossEventName =
| "conversation.updated"
| "project.messages.updated"
| "project.context_risk.updated"
| "desktop.dialog_guard.intervention_required"
| "desktop.dialog_guard.intervention_resolved"
| "app.logs.updated"
| "master_agent.task.updated"
| "master_agent.settings.updated"
@@ -19,6 +21,16 @@ export interface BossEventPayload {
projectId?: string;
deviceId?: string;
taskId?: string;
interventionId?: string;
dialogId?: string;
requestId?: string;
appName?: string;
platform?: string;
risk?: string;
summary?: string;
recommendedAction?: string;
availableActions?: string[];
decision?: string;
status?: string;
note?: string;
conversationItem?: unknown;

View File

@@ -11,6 +11,12 @@ export interface VerificationDeliveryResult {
status: number;
}
export interface AdminMailDeliveryResult {
delivered: boolean;
status: number;
message: string;
}
function purposeLabel(purpose: VerificationPurpose) {
switch (purpose) {
case "login":
@@ -88,11 +94,48 @@ function buildVerificationMessage({
return lines.join("\n");
}
async function sendMail(message: string) {
function buildPlainMessage({
recipient,
subject,
body,
}: {
recipient: string;
subject: string;
body: string;
}) {
const domain = process.env.BOSS_MAIL_DOMAIN?.trim() || "boss.hyzq.net";
const fromAddress = process.env.BOSS_MAIL_FROM_ADDRESS?.trim() || `notify@${domain}`;
const fromName = process.env.BOSS_MAIL_FROM_NAME?.trim() || "Boss Notify";
const messageId = `<${Date.now()}.${randomBytes(4).toString("hex")}@${domain}>`;
return [
`From: ${fromName} <${fromAddress}>`,
`To: ${recipient}`,
`Subject: ${subject}`,
`Date: ${new Date().toUTCString()}`,
`Message-ID: ${messageId}`,
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=UTF-8",
"Content-Transfer-Encoding: 8bit",
"",
body,
"",
].join("\n");
}
export function resolveSendmailSpawnCommand() {
const sendmailPath = process.env.BOSS_SENDMAIL_PATH?.trim() || "/usr/sbin/sendmail";
return {
executable: "/usr/bin/env",
args: ["--", sendmailPath, "-t", "-i"],
};
}
async function sendMail(message: string) {
const { args } = resolveSendmailSpawnCommand();
await new Promise<void>((resolve, reject) => {
const child = spawn(sendmailPath, ["-t", "-i"], { stdio: ["pipe", "ignore", "pipe"] });
const child = spawn("/usr/bin/env", args, { stdio: ["pipe", "ignore", "pipe"] });
let stderr = "";
child.on("error", (error) => {
@@ -169,3 +212,38 @@ export async function deliverVerificationCode({
};
}
}
export async function deliverAdminPlainEmail({
recipient,
subject,
body,
}: {
recipient: string;
subject: string;
body: string;
}): Promise<AdminMailDeliveryResult> {
const finalRecipient = recipient.trim();
if (!isLikelyEmailAccount(finalRecipient)) {
return {
delivered: false,
status: 400,
message: "ADMIN_NOTIFICATION_RECIPIENT_INVALID",
};
}
try {
await sendMail(buildPlainMessage({ recipient: finalRecipient, subject, body }));
return {
delivered: true,
status: 200,
message: "ADMIN_NOTIFICATION_EMAIL_SENT",
};
} catch (error) {
const message = error instanceof Error ? error.message : "ADMIN_NOTIFICATION_EMAIL_FAILED";
return {
delivered: false,
status: 502,
message,
};
}
}

View File

@@ -25,7 +25,12 @@ import {
import type {
AiAccount,
AiProvider,
AuthRole,
BossPermission,
ComputerControlIntentCategory,
ComputerControlRiskLevel,
DispatchPlanTarget,
ExternalReplyTarget,
Project,
ProjectExecutionPolicy,
ProjectAgentControls,
@@ -52,6 +57,7 @@ import { buildExecutionPrompt } from "@/lib/execution/prompt-assembler";
import { readAliyunOssObjectBuffer } from "@/lib/boss-storage-aliyun-oss";
import { readServerFileAttachmentBuffer } from "@/lib/boss-storage-server-file";
import {
getAuthorizedStateSnapshot,
getMasterAgentPromptPolicyView,
getUserMasterPromptView,
listUserMasterMemoriesView,
@@ -139,6 +145,169 @@ const API_EXECUTION_PROVIDER_PRIORITY: ApiCompatibleProvider[] = [
"custom_api",
];
type BossRuntimeState = Awaited<ReturnType<typeof readState>>;
type MasterAgentPermissionSession = {
account: string;
role: AuthRole;
displayName: string;
};
type MasterAgentTaskAuthorizationPayload = {
authorizedDeviceIds: string[];
authorizedProjectIds: string[];
authorizedSkillIds: string[];
requiredPermissions: BossPermission[];
};
const MASTER_AGENT_BASE_REQUIRED_PERMISSIONS: BossPermission[] = ["master_agent.ask"];
function resolveMasterAgentPermissionSession(
state: BossRuntimeState,
account: string,
displayName: string,
): MasterAgentPermissionSession {
const authAccount = state.authAccounts.find((item) => item.account === account);
if (authAccount) {
return {
account,
role: authAccount.role,
displayName: authAccount.displayName || displayName || account,
};
}
if (state.user.account === account) {
return {
account,
role: state.user.role,
displayName: state.user.name || displayName || account,
};
}
return {
account,
role: "member",
displayName: displayName || account,
};
}
function buildAuthorizedMasterAgentScope(
state: BossRuntimeState,
session: MasterAgentPermissionSession,
) {
const scopedState = getAuthorizedStateSnapshot(state, session);
return {
state: scopedState,
authorizedDeviceIds: scopedState.devices.map((device) => device.id),
authorizedProjectIds: scopedState.projects.map((project) => project.id),
authorizedSkillIds: scopedState.deviceSkills.map((skill) => skill.skillId),
requiredPermissions: [...MASTER_AGENT_BASE_REQUIRED_PERMISSIONS],
};
}
export function buildAuthorizedMasterAgentPromptForTest(params: {
state: BossRuntimeState;
session: MasterAgentPermissionSession;
projectId: string;
requestText: string;
}) {
const scope = buildAuthorizedMasterAgentScope(params.state, params.session);
return {
...scope,
prompt: buildMasterCodexNodePrompt(scope.state, params.projectId, params.requestText),
};
}
export interface MasterAgentControlIntentClassification {
intentCategory: ComputerControlIntentCategory;
executionMode: "discussion" | "thread" | "development" | "browser" | "desktop";
riskLevel: ComputerControlRiskLevel;
}
function includesAny(text: string, keywords: string[]) {
return keywords.some((keyword) => text.includes(keyword));
}
export function classifyMasterAgentControlIntent(
requestText: string,
): MasterAgentControlIntentClassification {
const normalized = requestText.trim().toLowerCase();
if (!normalized) {
return {
intentCategory: "discussion_only",
executionMode: "discussion",
riskLevel: "low",
};
}
const browserSignals = [
"chrome",
"浏览器",
"网页",
"网站",
"表单",
"登录网站",
"打开网站",
"打开后台",
"打开页面",
"提交表单",
];
const desktopSignals = [
"桌面",
"系统设置",
"finder",
"微信",
"飞书",
"telegram",
"打开应用",
"打开软件",
"app",
"应用",
"窗口",
];
const developmentSignals = [
"开发",
"改代码",
"修复",
"跑测试",
"联调",
"实现",
"提交",
"构建",
"回归测试",
"debug",
"编译",
];
if (includesAny(normalized, browserSignals)) {
return {
intentCategory: "browser_control",
executionMode: "browser",
riskLevel: "medium",
};
}
if (includesAny(normalized, desktopSignals)) {
return {
intentCategory: "desktop_control",
executionMode: "desktop",
riskLevel: "medium",
};
}
if (includesAny(normalized, developmentSignals)) {
return {
intentCategory: "project_development",
executionMode: "development",
riskLevel: "medium",
};
}
return {
intentCategory: "discussion_only",
executionMode: "discussion",
riskLevel: "low",
};
}
export const classifyMasterAgentControlIntentForTesting = classifyMasterAgentControlIntent;
const GENERIC_COMPATIBLE_MODEL_OPTIONS = ["gpt-5.4-mini", "gpt-5.4", "gpt-5.1", "gpt-4.1"];
type QueuedMasterAgentReplyEnvelope = {
@@ -148,7 +317,7 @@ type QueuedMasterAgentReplyEnvelope = {
masterReplyState: MasterAgentReplyState;
task: {
taskId: string;
taskType: "conversation_reply";
taskType: "conversation_reply" | "browser_control" | "desktop_control";
status: MasterAgentReplyState;
};
};
@@ -172,6 +341,11 @@ type LocalMasterAgentFastReplyResolution = {
reasoningEffortOverride?: ReasoningEffort | null;
};
modeResolutionOverride?: MasterAgentExecutionModeResolution;
projectTakeoverPatch?: {
projectId: string;
takeoverEnabled: boolean;
projectName: string;
};
};
const DEFAULT_FAST_MODEL = "gpt-5.4-mini";
@@ -230,6 +404,50 @@ const MASTER_AGENT_COMPLEX_CONTEXT_KEYWORDS = [
"数据库",
"迁移",
];
const MASTER_AGENT_PROJECT_SUMMARY_KEYWORDS = [
"项目目标",
"版本记录",
"版本迭代",
"项目总结",
"版本总结",
"阶段总结",
"汇总",
"概括",
"梳理",
"总结",
];
const MASTER_AGENT_TAKEOVER_KEYWORDS = ["托管", "接管", "协同接管"];
const MASTER_AGENT_OPERATIONAL_RUNTIME_KEYWORDS = [
"ota",
"升级",
"更新包",
"版本包",
"发布",
"推送",
"设备",
"在线",
"离线",
"掉线",
"连接",
"日志",
"app日志",
"告警",
"崩溃",
"闪退",
"卡顿",
"超时",
"报错",
"异常",
"同步失败",
"上下文预算",
"must_finish_before_compaction",
"must_finish",
"quota",
"认证",
"登录",
"cookie",
"会话到期",
];
export class ThreadConversationExecutionConflictError extends Error {
conflict: ThreadConversationExecutionConflict;
@@ -616,13 +834,13 @@ function buildLocalMasterAgentFastReply(params: {
};
}
if (/^(你好|在吗|你是谁)[。!?!?\s]*$/i.test(compact)) {
if (/^(你好|在吗|你是谁|hello|hi|hey)[。!?!?\s]*$/i.test(compact)) {
const model = params.modeResolution.effectiveModelOverride || params.fallbackModel || "默认主控模型";
return {
replyBody: [
"在,我是主 Agent。",
"在,主 Agent 可以开始协调。",
`当前模式:${masterAgentModeLabel(params.modeResolution.effectiveMode)},模型:${model}`,
"简单问题我会快速回复;涉及开发、排查、方案或长上下文时,我会自动升档到深度思考。",
"你给目标后,我会先给你结论,再安排线程开发、调研或回归验证;需要我协调线程或直接推进,直接说任务即可。",
].join("\n"),
};
}
@@ -630,6 +848,50 @@ function buildLocalMasterAgentFastReply(params: {
return null;
}
function isMasterAgentTakeoverIntent(requestText: string) {
const normalized = requestText.trim().replace(/\s+/g, "");
if (!normalized) {
return false;
}
return MASTER_AGENT_TAKEOVER_KEYWORDS.some((keyword) => normalized.includes(keyword));
}
function resolveMasterAgentTakeoverTarget(
state: Awaited<ReturnType<typeof readState>>,
requestText: string,
) {
if (!isMasterAgentTakeoverIntent(requestText)) {
return null;
}
const ranked = state.projects
.filter((project) => isDispatchableThreadProject(project))
.map((project) => ({
project,
score: scoreMasterAgentDispatchCandidate(project, requestText),
}))
.filter((item) => item.score > 0)
.sort((left, right) => {
if (right.score !== left.score) {
return right.score - left.score;
}
return Date.parse(right.project.updatedAt || "") - Date.parse(left.project.updatedAt || "");
});
if (ranked.length === 0) {
return null;
}
if (ranked.length > 1 && ranked[0]?.score === ranked[1]?.score) {
return null;
}
const normalized = requestText.trim().replace(/\s+/g, "");
return {
project: ranked[0]!.project,
takeoverEnabled: !/(关闭|取消|退出|停止|禁用|撤销|解除)/.test(normalized),
};
}
function buildAgentControlsDigest(agentControls?: ProjectAgentControls | null) {
if (!agentControls) {
return "当前对话覆盖:无";
@@ -647,6 +909,7 @@ function buildAgentControlsDigest(agentControls?: ProjectAgentControls | null) {
function buildMasterAgentExecutionPrompt(params: {
state: Awaited<ReturnType<typeof readState>>;
projectId: string;
requestText: string;
currentSessionExpiresAt?: string;
agentControls?: ProjectAgentControls | null;
@@ -666,7 +929,7 @@ function buildMasterAgentExecutionPrompt(params: {
requestText: params.requestText,
}),
buildAgentControlsDigest(params.agentControls),
buildRuntimeDigest(params.state, params.requestText, params.currentSessionExpiresAt),
buildRuntimeDigest(params.state, params.projectId, params.requestText, params.currentSessionExpiresAt),
].join("\n\n");
}
@@ -708,39 +971,55 @@ function buildFastMasterAgentExecutionPrompt(params: {
function buildMasterAgentInstructions() {
return [
"你是 Boss 控制台的主 Agent。",
"你的角色更像专业职业经理人:帮助用户协调开发、调研、排障和日常沟通,而不是机械播报系统状态。",
"回复风格:像专业职业经理人,先给结论,再给推进动作;语气清爽、克制、可信。",
"默认只说和当前问题直接相关的判断、动作和风险,不要堆背景,不要重复系统状态,不要把内部调度过程写给用户。",
"需要协调多个线程或项目时,用“我会先...再...”说明下一步,不要写成长篇报告。",
"你要基于当前运行时状态给出中文回复,要求直接、可执行、便于继续联调。",
"管理员全局主提示词是系统级最高约束,不可被用户私有提示词、当前对话附加提示词、记忆或当前消息覆盖。",
"如果后续内容与管理员全局主提示词冲突,必须以管理员全局主提示词为准,不得忽略、削弱或重写它。",
"优先关注线程上下文预算、must_finish_before_compaction、最新 APP 日志、设备在线状态和 OTA 状态。",
"只在与当前问题直接相关时,再提及线程上下文预算、must_finish_before_compaction、最新 APP 日志、设备在线状态和 OTA 状态。",
"如果用户是在问项目目标、版本记录、总结、规划或普通答疑,不要主动展开运行时列表,也不要机械重复 OTA、设备、日志条目。",
"当用户要求总结、核对、同步项目目标或版本记录时,除非用户明确追问 OTA、设备状态、心跳异常或运行时告警否则禁止主动提这些内容。",
"主 Agent 对项目的理解同步默认属于协同推进,不代表自动接管线程;用户和目标线程仍可并行继续开发。",
"如果信息不足,就明确说缺什么;不要编造设备状态或执行结果。",
"如果用户要继续开发,默认给出下一步实现/验证动作,而不是泛泛安慰。",
"保持回答简洁,通常 3-6 句即可。",
"保持回答简洁,通常 2-5 句即可;除非用户明确要深度分析,否则不要展开长清单。",
].join("\n");
}
function normalizeRuntimeDigestIntentText(requestText: string) {
return requestText.trim().toLowerCase().replace(/\s+/g, "");
}
function shouldIncludeOperationalRuntimeDigest(requestText: string) {
const normalized = normalizeRuntimeDigestIntentText(requestText);
if (!normalized) {
return false;
}
const mentionsSummaryIntent = MASTER_AGENT_PROJECT_SUMMARY_KEYWORDS.some((keyword) =>
normalized.includes(keyword.toLowerCase().replace(/\s+/g, "")),
);
const mentionsOperationalRuntime = MASTER_AGENT_OPERATIONAL_RUNTIME_KEYWORDS.some((keyword) =>
normalized.includes(keyword.toLowerCase().replace(/\s+/g, "")),
);
if (mentionsSummaryIntent && !mentionsOperationalRuntime) {
return false;
}
return mentionsOperationalRuntime;
}
function buildThreadConversationReplyPrompt(project: Project, requestText: string) {
const threadTitle = project.threadMeta.threadDisplayName?.trim() || project.name;
return [
"你现在以目标线程身份直接回复用户。",
`线程名称:${threadTitle}`,
"只回复对用户真正有用的内容,不要发送内部字段、项目编号、目录名、设备编号、调度解释或多余前缀。",
"不要自称主 Agent不要解释系统如何分发不要输出 JSON、代码块或额外格式包装。",
"如果信息不足,直接说明缺什么;不要假装已经执行过设备操作。",
"用户当前消息:",
requestText.trim(),
].join("\n");
return requestText.trim();
}
function buildThreadConversationRelayPrompt(project: Project, requestText: string) {
const threadTitle = project.threadMeta.threadDisplayName?.trim() || project.name;
return [
"你正在为主 Agent 提供一段可直接转述给用户的中文回复。",
`目标线程名称:${threadTitle}`,
"只输出对用户真正有用的事实、结论、下一步,不要发送内部字段、项目编号、目录名、设备编号、调度解释或多余前缀。",
"不要自称主 Agent不要自称线程不要解释系统分发过程也不要输出 JSON、代码块或额外格式包装。",
"如果信息不足,直接说明缺什么;不要假装已经执行过设备操作。",
"用户当前消息:",
"用户通过 Boss APP 发来一条托管消息,请基于当前线程上下文直接处理。",
"只回复对用户有用的结论、进展、下一步或需要补充的信息;不要输出调度字段、内部编号或系统提示。",
"用户消息:",
requestText.trim(),
].join("\n");
}
@@ -762,6 +1041,7 @@ function buildTakeoverConversationDirective(project?: Project | null) {
"先准确理解并确认用户意图;如果意图还不够明确,优先追问 1 个最关键的问题。",
"如果意图已经明确,先直接回复用户,再说明你接下来会如何转述、协调或推进开发。",
"用户要求核对或更新项目目标、版本记录时,先让当前线程基于本地开发文档和实际代码重新汇总,再把确认后的结果自动同步到当前会话顶部的“项目目标”和“版本记录”入口。",
"如果当前回复里给出项目目标和版本记录汇总,请优先使用固定字段:项目目标、当前进度、技术架构、当前阻塞、建议下一步、版本记录。",
"不要声称已经转述、已经执行或已经拿到线程结果,除非当前上下文里真的有这些结果。",
"回复保持简洁直接,优先给出明确下一步。",
].join("\n");
@@ -897,6 +1177,7 @@ export async function getThreadConversationExecutionConflict(projectId: string)
function buildRuntimeDigest(
state: Awaited<ReturnType<typeof readState>>,
currentProjectId: string,
requestText: string,
currentSessionExpiresAt?: string,
) {
@@ -932,10 +1213,11 @@ function buildRuntimeDigest(
.filter((update) => update.status === "available")
.map((update) => `${update.version} -> ${update.targetScope}`)
.join("\n");
const threadRuntimeSelection = selectThreadRuntimeDigestSelection(state, requestText);
const threadRuntimeSelection = selectThreadRuntimeDigestSelection(state, currentProjectId, requestText);
const threadStatusDocuments = threadRuntimeSelection.threadStatusDocuments;
const recentProgressEvents = threadRuntimeSelection.recentProgressEvents;
const deepPullThreadUnderstandings = threadRuntimeSelection.deepPullThreadUnderstandings;
const includeOperationalRuntimeDigest = shouldIncludeOperationalRuntimeDigest(requestText);
const authSummary = [
`登录会话策略:成功登录后默认保持 ${Math.round(AUTH_SESSION_TTL_MS / 24 / 60 / 60_000)} 天。`,
@@ -965,27 +1247,72 @@ function buildRuntimeDigest(
"最近主 Agent 对话:",
recentMessages || "无",
"",
"最新 APP 日志:",
recentLogs || "无",
"",
"高风险线程:",
riskyThreads || "",
"",
"在线设备:",
devices || "",
"",
"认证状态:",
authSummary,
"",
"可用 OTA",
ota || "",
...(includeOperationalRuntimeDigest
? [
"最新 APP 日志:",
recentLogs || "无",
"",
"高风险线程:",
riskyThreads || "无",
"",
"在线设备:",
devices || "无",
"",
"认证状态:",
authSummary,
"",
"可用 OTA",
ota || "无",
]
: []),
].join("\n");
}
function selectThreadRuntimeDigestSelection(
state: Awaited<ReturnType<typeof readState>>,
currentProjectId: string,
requestText: string,
) {
if (currentProjectId !== "master-agent") {
const currentProject = state.projects.find((project) => project.id === currentProjectId);
const threadStatusDocuments = [...state.threadStatusDocuments]
.filter((document) => document.projectId === currentProjectId)
.sort((left, right) => {
const updatedDelta = Date.parse(right.updatedAt) - Date.parse(left.updatedAt);
if (updatedDelta !== 0) {
return updatedDelta;
}
return right.documentId.localeCompare(left.documentId);
});
const recentProgressEvents = [...state.threadProgressEvents]
.filter((event) => event.projectId === currentProjectId)
.sort((left, right) => {
const createdDelta = Date.parse(right.createdAt) - Date.parse(left.createdAt);
if (createdDelta !== 0) {
return createdDelta;
}
return right.eventId.localeCompare(left.eventId);
});
const deepPullThreadUnderstandings =
threadStatusDocuments.length === 0 &&
recentProgressEvents.length === 0 &&
currentProject?.projectUnderstanding
? [buildDeepPullThreadUnderstandingDigest(currentProject)].filter(
(entry): entry is string => Boolean(entry),
)
: [];
return {
threadStatusDocuments: threadStatusDocuments
.slice(0, 6)
.map((document) => buildThreadStatusDocumentDigest(state, document)),
recentProgressEvents: recentProgressEvents
.slice(0, 8)
.map((event) => buildThreadProgressEventDigest(state, event)),
deepPullThreadUnderstandings,
};
}
const projectsWithRuntimeEvidence = state.projects
.filter((project) =>
state.threadStatusDocuments.some((document) => document.projectId === project.id) ||
@@ -1374,7 +1701,7 @@ function isUsableMasterNodeAccount(account: AiAccount) {
return (
account.enabled &&
account.provider === "master_codex_node" &&
account.status === "ready" &&
(account.status === "ready" || account.status === "degraded") &&
Boolean(account.nodeId?.trim())
);
}
@@ -1555,6 +1882,33 @@ export async function tryBuildLocalMasterAgentFastReply(params: {
return null;
}
const takeoverTarget = resolveMasterAgentTakeoverTarget(state, params.requestText);
if (takeoverTarget) {
await updateProjectAgentControls(
takeoverTarget.project.id,
{ takeoverEnabled: takeoverTarget.takeoverEnabled },
params.requestedByAccount,
);
const projectName = takeoverTarget.project.threadMeta.threadDisplayName?.trim() || takeoverTarget.project.name;
return {
senderLabel: `主 Agent · ${runtime.account.model || runtime.summary.roleLabel}`,
replyBody: takeoverTarget.takeoverEnabled
? `已为《${projectName}》开启主 Agent 协同接管。后续这个线程里你直接发消息,我会先确认意图,再协调对应线程推进。`
: `已为《${projectName}》关闭主 Agent 协同接管。后续这个线程会恢复为你直接和对应 Codex 线程对话。`,
masterReply: {
ok: true as const,
accountId: runtime.account.accountId,
requestId: "local-fast-path",
masterReplyState: "completed" as const,
activeMode: "default" as const,
effectiveMode: "default" as const,
effectiveModel: runtime.account.model,
effectiveReasoningEffort: "medium" as const,
autoEscalated: false,
},
};
}
const storedAgentControls = resolveStoredAgentControlsFromState(
state,
replyProjectId,
@@ -1710,6 +2064,7 @@ async function generateApiProviderReply(params: {
params.executionPromptOverride ??
buildMasterAgentExecutionPrompt({
state,
projectId: executionProjectId,
requestText: params.requestText,
currentSessionExpiresAt: params.currentSessionExpiresAt,
agentControls: params.agentControls,
@@ -1786,6 +2141,7 @@ async function generateApiProviderReply(params: {
function buildMasterOpenAiReplyPrompt(
state: Awaited<ReturnType<typeof readState>>,
projectId: string,
requestText: string,
currentSessionExpiresAt?: string,
agentControls?: ProjectAgentControls | null,
@@ -1796,6 +2152,7 @@ function buildMasterOpenAiReplyPrompt(
) {
return buildMasterAgentExecutionPrompt({
state,
projectId,
requestText,
currentSessionExpiresAt,
agentControls,
@@ -1946,6 +2303,8 @@ async function enqueueOpenAiMasterAgentReply(params: {
userMemories?: RelevantMemory[];
executionPromptOverride?: string;
relayViaMasterAgent?: boolean;
taskAuthorization?: MasterAgentTaskAuthorizationPayload;
externalReplyTarget?: ExternalReplyTarget;
masterFallback?: {
account: AiAccount;
executionPrompt: string;
@@ -1964,6 +2323,7 @@ async function enqueueOpenAiMasterAgentReply(params: {
params.executionPromptOverride ??
buildMasterOpenAiReplyPrompt(
state,
params.projectId ?? "master-agent",
params.requestText,
params.currentSessionExpiresAt,
params.agentControls,
@@ -1977,7 +2337,9 @@ async function enqueueOpenAiMasterAgentReply(params: {
deviceId: primaryCandidate.deviceId,
accountId: primaryCandidate.account.accountId,
accountLabel: primaryCandidate.account.label,
...params.taskAuthorization,
relayViaMasterAgent: params.relayViaMasterAgent,
externalReplyTarget: params.externalReplyTarget,
});
void queueAndStartOpenAiMasterAgentReply({
candidates: params.candidates,
@@ -2018,6 +2380,8 @@ async function enqueueClawMasterAgentReply(params: {
projectId?: string;
agentControls?: ProjectAgentControls | null;
relayViaMasterAgent?: boolean;
taskAuthorization?: MasterAgentTaskAuthorizationPayload;
externalReplyTarget?: ExternalReplyTarget;
apiFallbackCandidates: ApiExecutionCandidate[];
masterFallback?: {
account: AiAccount;
@@ -2034,7 +2398,9 @@ async function enqueueClawMasterAgentReply(params: {
deviceId: CLAW_RUNTIME_DEVICE_ID,
accountId: CLAW_BACKEND_ID,
accountLabel: "Claw Runtime",
...params.taskAuthorization,
relayViaMasterAgent: params.relayViaMasterAgent,
externalReplyTarget: params.externalReplyTarget,
});
const timer = setTimeout(() => {
@@ -2208,6 +2574,7 @@ async function appendMasterAgentSystemReply(body: string, senderLabel = "主 Age
function buildMasterCodexNodePrompt(
state: Awaited<ReturnType<typeof readState>>,
projectId: string,
requestText: string,
currentSessionExpiresAt?: string,
agentControls?: ProjectAgentControls | null,
@@ -2218,6 +2585,7 @@ function buildMasterCodexNodePrompt(
) {
return buildMasterAgentExecutionPrompt({
state,
projectId,
requestText,
currentSessionExpiresAt,
agentControls,
@@ -2314,6 +2682,18 @@ const MASTER_AGENT_DISPATCH_KEYWORDS = [
"让",
"继续",
];
const MASTER_AGENT_PROJECT_SUMMARY_SYNC_KEYWORDS = [
"项目目标",
"版本记录",
"版本迭代",
"汇总",
"总结",
"同步",
"更新",
"核对",
"确认",
"梳理",
];
function normalizeDispatchLookupText(value: string) {
return value.trim().toLowerCase();
@@ -2362,6 +2742,69 @@ export function shouldRecommendMasterAgentDispatchPlan(
.some((project) => scoreMasterAgentDispatchCandidate(project, requestText) > 0);
}
function isMasterAgentProjectSummarySyncRequest(requestText: string) {
const request = normalizeDispatchLookupText(requestText);
if (!request) {
return false;
}
const mentionsGoal = request.includes("项目目标") || request.includes("目标");
const mentionsVersion = request.includes("版本记录") || request.includes("版本迭代") || request.includes("版本");
const mentionsSyncVerb = MASTER_AGENT_PROJECT_SUMMARY_SYNC_KEYWORDS.some((keyword) =>
request.includes(keyword),
);
return mentionsSyncVerb && (mentionsGoal || mentionsVersion);
}
export function resolveMasterAgentProjectSummarySyncTarget(
state: Awaited<ReturnType<typeof readState>>,
requestText: string,
) {
if (!isMasterAgentProjectSummarySyncRequest(requestText)) {
return null;
}
const ranked = state.projects
.filter((project) => isDispatchableThreadProject(project))
.map((project) => ({
project,
score: scoreMasterAgentDispatchCandidate(project, requestText),
}))
.filter((item) => item.score > 0)
.sort((left, right) => {
if (right.score !== left.score) {
return right.score - left.score;
}
return Date.parse(right.project.updatedAt || "") - Date.parse(left.project.updatedAt || "");
});
if (ranked.length === 0) {
return null;
}
if (ranked.length > 1 && ranked[0]?.score === ranked[1]?.score) {
return null;
}
return ranked[0]?.project ?? null;
}
export function buildMasterAgentProjectSummarySyncAck(
project: Project,
options?: { notifyOnCompletion?: boolean; rememberedPreference?: boolean },
) {
const threadTitle = project.threadMeta.threadDisplayName?.trim() || project.name;
const lines = [
`已开始同步《${threadTitle}》的项目目标和版本记录。`,
"我会先让对应 Codex 线程基于本地文档和实际代码重新汇总。",
"汇总完成后,会自动写回该线程会话顶部的“项目目标”和“版本记录”入口。",
];
if (options?.notifyOnCompletion) {
lines.push("同步完成后,我会在这里回你一条结果。");
}
if (options?.rememberedPreference) {
lines.push("这条提醒规则我也记住了,后续同类同步默认这样处理。");
}
return lines.join("\n");
}
function collectGroupDispatchTargets(
state: Awaited<ReturnType<typeof readState>>,
project: Project,
@@ -2664,6 +3107,9 @@ export async function queueThreadConversationReplyTask(params: {
projectId: string;
requestMessageId: string;
requestText: string;
sourceMessageId?: string;
sourceMessageBody?: string;
sourceMessageSentAt?: string;
requestedBy: string;
requestedByAccount: string;
relayViaMasterAgent?: boolean;
@@ -2681,6 +3127,9 @@ export async function queueThreadConversationReplyTask(params: {
executionPrompt: params.relayViaMasterAgent
? buildThreadConversationRelayPrompt(project, params.requestText)
: buildThreadConversationReplyPrompt(project, params.requestText),
sourceMessageId: params.sourceMessageId,
sourceMessageBody: params.sourceMessageBody,
sourceMessageSentAt: params.sourceMessageSentAt,
requestedBy: params.requestedBy,
requestedByAccount: params.requestedByAccount,
deviceId,
@@ -2690,6 +3139,8 @@ export async function queueThreadConversationReplyTask(params: {
targetCodexThreadRef: project.threadMeta.codexThreadRef,
targetCodexFolderRef: project.threadMeta.codexFolderRef,
relayViaMasterAgent: params.relayViaMasterAgent,
mirrorBossUserMessageToCodexDesktop:
params.sourceMessageId?.trim() && params.sourceMessageBody?.trim() ? true : undefined,
});
}
@@ -3105,6 +3556,7 @@ export async function replyToMasterAgentUserMessage(params: {
projectId?: string;
interactionMode?: "direct" | "takeover_single_thread";
mode?: "wait" | "enqueue" | "smart";
externalReplyTarget?: ExternalReplyTarget;
}) {
const runtime = await getMasterAgentRuntimeAccount();
const replyProjectId = params.projectId ?? "master-agent";
@@ -3124,7 +3576,28 @@ export async function replyToMasterAgentUserMessage(params: {
params.requestText,
);
const state = await readState();
const replyProject = state.projects.find((project) => project.id === replyProjectId);
const permissionSession = resolveMasterAgentPermissionSession(
state,
params.requestedByAccount,
params.requestedBy,
);
const authorizedScope = buildAuthorizedMasterAgentScope(state, permissionSession);
const authorizedState = authorizedScope.state;
const masterTaskAuthorization = (requiredPermissions = authorizedScope.requiredPermissions) => ({
authorizedDeviceIds: authorizedScope.authorizedDeviceIds,
authorizedProjectIds: authorizedScope.authorizedProjectIds,
authorizedSkillIds: authorizedScope.authorizedSkillIds,
requiredPermissions,
});
if (!authorizedScope.authorizedProjectIds.includes(replyProjectId)) {
await appendMasterAgentSystemReply(
"这个会话还没有授权给当前账号,主 Agent 不能读取或接管它。请让超级管理员先分配项目权限。",
"主 Agent",
replyProjectId,
);
return { ok: false as const, reason: "FORBIDDEN" };
}
const replyProject = authorizedState.projects.find((project) => project.id === replyProjectId);
const primaryDeviceId = runtime.account.nodeId || state.user.boundDeviceId || "mac-studio";
const primaryDevice = state.devices.find((device) => device.id === primaryDeviceId);
const primaryBackendStatus =
@@ -3152,6 +3625,7 @@ export async function replyToMasterAgentUserMessage(params: {
const agentControls = executionConfig.agentControls;
const modeResolution = executionConfig.modeResolution;
const replyMetadata = buildMasterAgentModeMetadata(modeResolution);
const controlIntent = classifyMasterAgentControlIntent(params.requestText);
const relayViaMasterAgent = params.interactionMode === "takeover_single_thread";
const selectedMasterAccount = await resolveMasterNodeExecutionCandidate({
backendChoices,
@@ -3194,7 +3668,8 @@ export async function replyToMasterAgentUserMessage(params: {
userPrompt: executionConfig.userPrompt,
})
: buildMasterCodexNodePrompt(
state,
authorizedState,
replyProjectId,
params.requestText,
params.currentSessionExpiresAt,
agentControls,
@@ -3215,7 +3690,7 @@ export async function replyToMasterAgentUserMessage(params: {
requestText: params.requestText,
requestedByAccount: params.requestedByAccount,
projectId: replyProjectId,
state,
state: authorizedState,
})
: null;
@@ -3231,6 +3706,52 @@ export async function replyToMasterAgentUserMessage(params: {
};
}
if (
controlIntent.intentCategory === "browser_control" || controlIntent.intentCategory === "desktop_control"
) {
const deviceId = runtime.account.nodeId || state.user.boundDeviceId || "mac-studio";
const taskType = controlIntent.intentCategory;
const task = await queueMasterAgentTask({
projectId: replyProjectId,
taskType,
requestMessageId: params.requestMessageId ?? "master-agent-manual",
requestText: params.requestText,
executionPrompt: baseMasterExecutionPrompt,
requestedBy: params.requestedBy,
requestedByAccount: params.requestedByAccount,
deviceId,
accountId: runtime.account.accountId,
accountLabel: runtime.summary.roleLabel,
...masterTaskAuthorization(["master_agent.ask", "computer.control"]),
intentCategory: controlIntent.intentCategory,
runtimeKind:
controlIntent.intentCategory === "browser_control"
? "browser-automation-runtime"
: "computer-use-runtime",
riskLevel: controlIntent.riskLevel,
confirmationPolicy: controlIntent.riskLevel === "high" ? "strong_confirm" : "light_confirm",
requiresUserConfirmation: false,
confirmationScopeKey: `${deviceId}:${replyProjectId}`,
externalReplyTarget: params.externalReplyTarget,
});
return {
ok: true as const,
accountId: runtime.account.accountId,
taskId: task.taskId,
masterReplyState: "queued" as const,
task: {
taskId: task.taskId,
taskType,
status: "queued" as const,
},
executionMode: controlIntent.executionMode,
riskLevel: controlIntent.riskLevel,
requiresConfirmation: false,
...replyMetadata,
};
}
const runMasterNodeExecution = async () => {
if (!selectedMasterAccount) {
await appendMasterAgentSystemReply(
@@ -3267,6 +3788,62 @@ export async function replyToMasterAgentUserMessage(params: {
return { ok: false as const, reason: "MASTER_NODE_OFFLINE" };
}
await updateAiAccountHealth({
accountId: selectedMasterAccount.accountId,
status: "ready",
lastValidatedAt: new Date().toISOString(),
lastUsedAt: new Date().toISOString(),
});
if (
controlIntent.intentCategory === "browser_control" || controlIntent.intentCategory === "desktop_control"
) {
const runtimeKind =
controlIntent.intentCategory === "browser_control"
? "browser-automation-runtime"
: "computer-use-runtime";
const taskType = controlIntent.intentCategory;
const task = await queueMasterAgentTask({
projectId: replyProjectId,
taskType,
requestMessageId: params.requestMessageId ?? "master-agent-manual",
requestText: params.requestText,
executionPrompt: masterExecutionPrompt,
requestedBy: params.requestedBy,
requestedByAccount: params.requestedByAccount,
deviceId,
accountId: selectedMasterAccount.accountId,
accountLabel: selectedMasterAccount.label || runtime.summary.roleLabel,
...masterTaskAuthorization(["master_agent.ask", "computer.control"]),
intentCategory: controlIntent.intentCategory,
runtimeKind,
riskLevel: controlIntent.riskLevel,
confirmationPolicy: controlIntent.riskLevel === "high" ? "strong_confirm" : "light_confirm",
requiresUserConfirmation: false,
confirmationScopeKey: `${deviceId}:${replyProjectId}`,
externalReplyTarget: params.externalReplyTarget,
});
const queuedReply: QueuedMasterAgentReplyEnvelope = {
ok: true as const,
accountId: selectedMasterAccount.accountId,
taskId: task.taskId,
masterReplyState: "queued" as const,
task: {
taskId: task.taskId,
taskType,
status: "queued" as const,
},
};
return {
...queuedReply,
executionMode: controlIntent.executionMode,
riskLevel: controlIntent.riskLevel,
requiresConfirmation: false,
...replyMetadata,
};
}
const task = await queueMasterAgentTask({
projectId: replyProjectId,
requestMessageId: params.requestMessageId ?? "master-agent-manual",
@@ -3277,7 +3854,9 @@ export async function replyToMasterAgentUserMessage(params: {
deviceId,
accountId: selectedMasterAccount.accountId,
accountLabel: selectedMasterAccount.label || runtime.summary.roleLabel,
...masterTaskAuthorization(),
relayViaMasterAgent,
externalReplyTarget: params.externalReplyTarget,
});
if (replyMode === "enqueue") {
@@ -3351,6 +3930,8 @@ export async function replyToMasterAgentUserMessage(params: {
projectId: replyProjectId,
agentControls,
relayViaMasterAgent,
taskAuthorization: masterTaskAuthorization(),
externalReplyTarget: params.externalReplyTarget,
apiFallbackCandidates: apiExecutionCandidates,
masterFallback: hasMasterFallback && selectedMasterAccount
? {
@@ -3385,6 +3966,8 @@ export async function replyToMasterAgentUserMessage(params: {
userMemories: executionConfig.userMemories,
executionPromptOverride: masterExecutionPrompt,
relayViaMasterAgent,
taskAuthorization: masterTaskAuthorization(),
externalReplyTarget: params.externalReplyTarget,
masterFallback: hasMasterFallback && selectedMasterAccount
? {
account: selectedMasterAccount,

207
src/lib/boss-permissions.ts Normal file
View File

@@ -0,0 +1,207 @@
import type {
AuthSession,
BossPermission,
BossState,
Device,
Project,
} from "@/lib/boss-data";
export type PermissionSession = Pick<AuthSession, "account" | "role" | "displayName">;
function isExpired(expiresAt?: string) {
return Boolean(expiresAt && new Date(expiresAt).getTime() <= Date.now());
}
export function isHighestAdmin(session: Pick<PermissionSession, "role">) {
return session.role === "highest_admin";
}
function permissionSetIncludes(permissions: BossPermission[], required: BossPermission) {
return permissions.includes(required);
}
function projectUsesDevice(project: Project, deviceId: string) {
if (project.deviceIds.includes(deviceId)) return true;
return project.groupMembers.some((member) => member.deviceId === deviceId);
}
function accountOwnsDevice(device: Device | undefined, account: string) {
return Boolean(device && device.account === account);
}
function accountCompanyId(state: BossState, account: string) {
return state.authAccounts.find((item) => item.account === account)?.companyId;
}
function deviceCompanyId(state: BossState, device: Device | undefined) {
if (!device) return undefined;
return device.companyId ?? accountCompanyId(state, device.account);
}
function tenantAllowsCompanies(
state: BossState,
session: PermissionSession,
targetCompanyIds: Array<string | undefined>,
) {
if (isHighestAdmin(session)) return true;
const concreteTargets = [...new Set(targetCompanyIds.filter((item): item is string => Boolean(item)))];
if (concreteTargets.length === 0) return true;
const actorCompanyId = accountCompanyId(state, session.account);
return Boolean(actorCompanyId && concreteTargets.includes(actorCompanyId));
}
function projectCompanyIds(state: BossState, project: Project | undefined) {
if (!project) return [];
const deviceIds = new Set([
...project.deviceIds,
...project.groupMembers.map((member) => member.deviceId),
]);
return [...deviceIds].map((deviceId) =>
deviceCompanyId(state, state.devices.find((device) => device.id === deviceId)),
);
}
export function canAccessDevice(
state: BossState,
session: PermissionSession,
deviceId: string,
permission: BossPermission = "device.view",
) {
if (isHighestAdmin(session)) return true;
const device = state.devices.find((item) => item.id === deviceId);
if (!device) return false;
if (!tenantAllowsCompanies(state, session, [deviceCompanyId(state, device)])) return false;
if (permission === "device.view" && accountOwnsDevice(device, session.account)) {
return true;
}
return state.accountDeviceGrants.some(
(grant) =>
grant.account === session.account &&
grant.deviceId === deviceId &&
!isExpired(grant.expiresAt) &&
permissionSetIncludes(grant.permissions, permission),
);
}
export function canAccessProject(
state: BossState,
session: PermissionSession,
projectId: string,
permission: BossPermission = "project.view",
) {
if (isHighestAdmin(session)) return true;
const project = state.projects.find((item) => item.id === projectId);
if (!project) return false;
if (!tenantAllowsCompanies(state, session, projectCompanyIds(state, project))) return false;
const directProjectGrant = state.accountProjectGrants.some(
(grant) =>
grant.account === session.account &&
grant.projectId === projectId &&
!isExpired(grant.expiresAt) &&
permissionSetIncludes(grant.permissions, permission),
);
if (directProjectGrant) return true;
if (permission === "project.view") {
return state.devices.some(
(device) =>
projectUsesDevice(project, device.id) &&
(accountOwnsDevice(device, session.account) ||
canAccessDevice(state, session, device.id, "device.view")),
);
}
return state.accountDeviceGrants.some(
(grant) =>
grant.account === session.account &&
!isExpired(grant.expiresAt) &&
projectUsesDevice(project, grant.deviceId) &&
permissionSetIncludes(grant.permissions, permission),
);
}
function grantMatchesScope(
grant: { deviceId?: string; projectId?: string },
scope: { deviceId?: string; projectId?: string },
) {
if (grant.deviceId && scope.deviceId && grant.deviceId !== scope.deviceId) return false;
if (grant.projectId && scope.projectId && grant.projectId !== scope.projectId) return false;
return true;
}
export function canUseSkill(
state: BossState,
session: PermissionSession,
skillId: string,
scope: { deviceId?: string; projectId?: string } = {},
) {
if (isHighestAdmin(session)) return true;
return state.accountSkillGrants.some(
(grant) =>
grant.account === session.account &&
grant.skillId === skillId &&
!isExpired(grant.expiresAt) &&
permissionSetIncludes(grant.permissions, "skill.use") &&
grantMatchesScope(grant, scope),
);
}
export function canViewSkill(
state: BossState,
session: PermissionSession,
skillId: string,
scope: { deviceId?: string; projectId?: string } = {},
) {
if (isHighestAdmin(session)) return true;
return state.accountSkillGrants.some(
(grant) =>
grant.account === session.account &&
grant.skillId === skillId &&
!isExpired(grant.expiresAt) &&
(permissionSetIncludes(grant.permissions, "skill.view") ||
permissionSetIncludes(grant.permissions, "skill.use")) &&
grantMatchesScope(grant, scope),
);
}
export function filterDevicesForSession(state: BossState, session: PermissionSession) {
if (isHighestAdmin(session)) return state.devices;
return state.devices.filter((device) =>
canAccessDevice(state, session, device.id, "device.view"),
);
}
export function filterProjectsForSession(state: BossState, session: PermissionSession) {
if (isHighestAdmin(session)) return state.projects;
return state.projects.filter((project) =>
canAccessProject(state, session, project.id, "project.view"),
);
}
export function filterProjectDevicesForSession(
state: BossState,
session: PermissionSession,
project: Pick<Project, "deviceIds">,
) {
if (isHighestAdmin(session)) {
return state.devices.filter((device) => project.deviceIds.includes(device.id));
}
return state.devices.filter(
(device) =>
project.deviceIds.includes(device.id) &&
canAccessDevice(state, session, device.id, "device.view"),
);
}
export function assertProjectPermission(
state: BossState,
session: PermissionSession,
projectId: string,
permission: BossPermission,
) {
if (!canAccessProject(state, session, projectId, permission)) {
return { ok: false as const, status: 403 as const, message: "FORBIDDEN" };
}
return { ok: true as const };
}

View File

@@ -3,6 +3,7 @@ import type {
AiProvider,
AiAccountStatus,
AppLogEntry,
AuthSession,
AuditTaskRequest,
AuditTaskResult,
BossState,
@@ -28,6 +29,15 @@ import type {
ThreadHandoffPackage,
UserMasterPrompt,
} from "@/lib/boss-data";
import {
canAccessDevice,
canAccessProject,
canViewSkill,
filterDevicesForSession,
filterProjectDevicesForSession,
filterProjectsForSession,
type PermissionSession,
} from "@/lib/boss-permissions";
export interface ContextIndicator {
visible: boolean;
@@ -74,6 +84,10 @@ export interface ConversationItem {
mustFinishBeforeCompaction: boolean;
}
function conversationHistoryWasCleared(state: BossState) {
return Boolean(state.conversationHistoryClearedAt?.trim());
}
export interface ThreadContextView {
snapshot: ThreadContextSnapshot;
handoffPackage?: ThreadHandoffPackage;
@@ -182,6 +196,101 @@ export function formatTimestampLabel(value?: string, fallback = "刚刚") {
const STALE_CONTEXT_SYNC_LABEL = "待同步";
const STALE_CONTEXT_REPLY_THRESHOLD_MS = 7 * 24 * 60 * 60_000;
const PROCESS_PREVIEW_PREFIXES = [
"我先",
"我现在",
"我会先",
"我发现",
"我准备",
"接下来",
"正在",
"先看",
"先读",
"我把",
"我再",
"目前在",
"现在在",
"补一组",
"处理一下",
"先确认",
"准备",
"同步一下",
"我这边已经",
];
const PROCESS_PREVIEW_CONTAINS = [
"我继续",
"我已经在",
"正在跑",
"正在检查",
"正在处理",
"正在同步",
"我会直接",
"我先把",
"先补",
"再接",
];
const PROCESS_PREVIEW_NUMBERED_HINTS = [
"先",
"再",
"接下来",
"然后",
"检查",
"确认",
"处理",
"同步",
"补",
"排查",
"推进",
"回你",
"回传",
"会把",
"我会",
];
const PROCESS_PREVIEW_BLOCK_MARKERS = [
"失败",
"报错",
"错误",
"阻塞",
"不能",
"无法",
"崩溃",
"超时",
"exception",
"error",
"fatal",
"结论",
"最终",
"总结",
"已完成",
"已经完成",
"验证通过",
"测试通过",
"已修复",
"修好了",
"已部署",
"已安装",
"可以直接",
];
const LEAKED_TITLE_PREFIXES = [
"你当前接手的项目根目录是",
"你现在接手的项目根目录是",
"你现在以目标线程身份直接回复用户",
"你正在向主 Agent 同步当前项目状态",
"只回复对用户真正有用的内容",
"只输出 JSON",
];
const LEAKED_TITLE_CONTAINS = [
"不要发送内部字段",
"不要自称主 Agent",
"不要解释系统如何分发",
"不要输出 JSON",
"项目名称:",
"线程名称:",
"文件夹:",
"同步原因:",
"当前消息:",
"用户当前消息:",
];
function formatConversationLatestReplyLabel(value: string, hasVisibleContext: boolean) {
if (hasVisibleContext && value.includes("T")) {
@@ -369,8 +478,7 @@ function buildConversationItem(state: BossState, project: Project): Conversation
const devices = state.devices.filter((device) => project.deviceIds.includes(device.id));
const threadViews = threadViewsForProject(state, project.id);
const topThread = threadViews[0]?.snapshot;
const threadTitle = trimLocalWorkspacePrefix(project.threadMeta?.threadDisplayName ?? project.name);
const folderLabel = project.threadMeta?.folderName ?? "";
const { folderLabel, threadTitle, projectTitle } = buildProjectDisplayTitles(project);
const activityIconCount = deriveConversationActivityIconCount(state, project);
const topPinnedLabel = isTopPinnedConversation(project) ? "置顶" : undefined;
const latestConversationActivityAt = deriveLatestConversationActivityAt(project);
@@ -390,7 +498,7 @@ function buildConversationItem(state: BossState, project: Project): Conversation
conversationId: `conv-${project.id}`,
conversationType: projectType(project),
projectId: project.id,
projectTitle: project.name,
projectTitle,
threadTitle,
folderLabel,
folderKey: buildFolderKey(project),
@@ -426,18 +534,56 @@ function buildConversationItem(state: BossState, project: Project): Conversation
} satisfies ConversationItem;
}
function buildProjectDisplayTitles(project: Project) {
const folderLabel = normalizeConversationTitle(project.threadMeta?.folderName ?? "");
const folderFallback = pickConversationTitleFallback([
folderLabel,
project.threadMeta?.codexFolderRef,
project.name,
]);
const threadTitle = sanitizeConversationTitle(project.threadMeta?.threadDisplayName ?? project.name, [
folderFallback,
project.name,
project.threadMeta?.codexFolderRef,
]);
const projectTitle = projectType(project) === "single_device"
? threadTitle ||
sanitizeConversationTitle(project.name, [folderFallback, project.threadMeta?.codexFolderRef])
: sanitizeConversationTitle(project.name, [
threadTitle,
folderFallback,
project.threadMeta?.codexFolderRef,
]);
return {
folderLabel,
threadTitle,
projectTitle,
};
}
function cloneProjectWithDisplayTitles(project: Project): Project {
const { folderLabel, threadTitle, projectTitle } = buildProjectDisplayTitles(project);
return {
...project,
name: projectTitle || project.name,
threadMeta: {
...project.threadMeta,
threadDisplayName: threadTitle || project.threadMeta.threadDisplayName,
folderName: folderLabel || project.threadMeta.folderName,
},
};
}
function deriveLatestConversationActivityAt(project: Project) {
const candidates = [
const messageCandidates = [
project.lastMessageAt,
project.threadMeta?.lastObservedCodexActivityAt,
project.projectUnderstanding?.updatedAt,
project.updatedAt,
...project.messages.map((message) => message.sentAt),
].filter(Boolean) as string[];
let latest = candidates[0];
let latest = messageCandidates[0];
let latestTs = latest ? Date.parse(latest) : Number.NEGATIVE_INFINITY;
for (const candidate of candidates.slice(1)) {
for (const candidate of messageCandidates.slice(1)) {
const candidateTs = Date.parse(candidate);
if (!Number.isFinite(candidateTs)) {
continue;
@@ -448,7 +594,7 @@ function deriveLatestConversationActivityAt(project: Project) {
}
}
return latest ?? project.lastMessageAt;
return latest ?? project.lastMessageAt ?? project.updatedAt;
}
function deriveConversationActivityIconCount(state: BossState, project: Project): number {
@@ -512,7 +658,174 @@ function compactImportedThreadPreview(preview?: string) {
if (/^已从设备.+导入线程《.+》[。.]?$/.test(value)) {
return "已导入线程";
}
return value;
if (isLikelyProcessPreview(value)) {
return "";
}
return compactConversationPreview(value);
}
function isLikelyProcessPreview(preview: string) {
const normalized = compactProcessPreview(preview);
if (!normalized) {
return false;
}
if (containsProcessPreviewMarker(normalized, PROCESS_PREVIEW_BLOCK_MARKERS)) {
return false;
}
if (isStructuredNumberedProcessPreview(preview)) {
return true;
}
return (
PROCESS_PREVIEW_PREFIXES.some((marker) => normalized.startsWith(marker)) ||
containsProcessPreviewMarker(normalized, PROCESS_PREVIEW_CONTAINS)
);
}
function compactProcessPreview(value: string) {
return value
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n")
.replace(/\n{2,}/g, "\n")
.trim()
.toLowerCase();
}
function containsProcessPreviewMarker(value: string, markers: string[]) {
return markers.some((marker) => value.includes(marker));
}
function isStructuredNumberedProcessPreview(preview: string) {
const numberedLines = preview
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n")
.split("\n")
.map((line) => compactProcessPreview(line))
.filter((line) => /^\d+[.)\u3001]\s*/.test(line));
if (numberedLines.length < 2) {
return false;
}
return containsProcessPreviewMarker(
numberedLines.join(" "),
PROCESS_PREVIEW_NUMBERED_HINTS,
);
}
function compactConversationPreview(preview: string) {
const structuredPreview = compactStructuredSummaryPreview(preview);
const flattened = (structuredPreview || preview)
.replace(/\[[^\]]+\]\(([^)]+)\)/g, "$1")
.replace(/`([^`]+)`/g, "$1")
.replace(/\s+/g, " ")
.trim();
if (!flattened) {
return "";
}
return flattened.length <= 72 ? flattened : `${flattened.slice(0, 72).trimEnd()}`;
}
function compactStructuredSummaryPreview(preview: string) {
const raw = preview.trim();
if (!raw.startsWith("{") || !raw.endsWith("}")) {
return "";
}
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
if (!parsed || Array.isArray(parsed)) {
return "";
}
const segments = [
formatStructuredSummarySegment("目标", parsed.projectGoal),
formatStructuredSummarySegment("进度", parsed.currentProgress),
formatStructuredSummarySegment("版本", parsed.versionRecord),
formatStructuredSummarySegment("下一步", parsed.recommendedNextStep),
].filter(Boolean);
return segments.join(" ");
} catch {
return "";
}
}
function formatStructuredSummarySegment(label: string, value: unknown) {
const normalized = typeof value === "string" ? value.trim() : "";
return normalized ? `${label}${normalized}` : "";
}
function normalizeConversationTitle(value?: string) {
const source = value?.replace(/\u0000/g, "") ?? "";
const firstLine = source
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean);
if (!firstLine) {
return "";
}
return firstLine.replace(/\s+/g, " ").trim();
}
function stripTrailingConversationTitleNoise(value: string) {
return value.replace(/['"}\]]{2,}$/g, "").trimEnd();
}
function looksLikeLeakedConversationTitle(value?: string) {
const normalized = normalizeConversationTitle(value);
if (!normalized) {
return false;
}
return (
LEAKED_TITLE_PREFIXES.some((marker) => normalized.startsWith(marker)) ||
LEAKED_TITLE_CONTAINS.some((marker) => normalized.includes(marker))
);
}
function extractWorkspaceProjectName(value?: string) {
const normalized = normalizeConversationTitle(value).replaceAll("\\", "/");
if (!normalized) {
return "";
}
const patterns = [
/\/Users\/[^/]+\/code\/([^/\s"'`,。;!?]+)/i,
/\/home\/[^/]+\/code\/([^/\s"'`,。;!?]+)/i,
/[A-Za-z]:\/Users\/[^/]+\/code\/([^/\s"'`,。;!?]+)/i,
];
for (const pattern of patterns) {
const match = normalized.match(pattern);
if (match?.[1]) {
return match[1].split("/")[0]?.trim() ?? "";
}
}
return "";
}
function pickConversationTitleFallback(candidates: Array<string | undefined>) {
for (const candidate of candidates) {
const extractedProjectName = extractWorkspaceProjectName(candidate);
if (extractedProjectName && !looksLikeLeakedConversationTitle(extractedProjectName)) {
return extractedProjectName;
}
const normalized = stripTrailingConversationTitleNoise(
trimLocalWorkspacePrefix(normalizeConversationTitle(candidate)),
);
if (normalized && !looksLikeLeakedConversationTitle(normalized)) {
return normalized;
}
}
return "";
}
function sanitizeConversationTitle(value: string | undefined, fallbackCandidates: Array<string | undefined> = []) {
const normalized = normalizeConversationTitle(value);
const trimmed = stripTrailingConversationTitleNoise(trimLocalWorkspacePrefix(normalized));
if (trimmed && !looksLikeLeakedConversationTitle(normalized) && !looksLikeLeakedConversationTitle(trimmed)) {
return trimmed;
}
const extractedProjectName = extractWorkspaceProjectName(normalized);
if (extractedProjectName && !looksLikeLeakedConversationTitle(extractedProjectName)) {
return extractedProjectName;
}
const fallback = pickConversationTitleFallback(fallbackCandidates);
return fallback || trimmed;
}
function trimLocalWorkspacePrefix(label?: string) {
@@ -539,6 +852,87 @@ export function getConversationItems(state: BossState): ConversationItem[] {
return sortConversationItems(conversations);
}
function stateForSession(state: BossState, session: PermissionSession): BossState {
const visibleDevices = filterDevicesForSession(state, session);
const visibleDeviceIds = new Set(visibleDevices.map((device) => device.id));
const visibleProjects = filterProjectsForSession(state, session).map((project) => ({
...project,
deviceIds: project.deviceIds.filter((deviceId) => visibleDeviceIds.has(deviceId)),
groupMembers: project.groupMembers.filter((member) => visibleDeviceIds.has(member.deviceId)),
}));
const visibleProjectIds = new Set(visibleProjects.map((project) => project.id));
const canSeeThreadOnDevice = (projectId: string, deviceId: string) =>
visibleProjectIds.has(projectId) && visibleDeviceIds.has(deviceId);
return {
...state,
devices: visibleDevices,
projects: visibleProjects,
deviceSkills: state.deviceSkills.filter((skill) =>
visibleDeviceIds.has(skill.deviceId) &&
(session.role === "highest_admin" ||
canViewSkill(state, session, skill.skillId, { deviceId: skill.deviceId })),
),
threadStatusDocuments: state.threadStatusDocuments.filter((document) =>
canSeeThreadOnDevice(document.projectId, document.deviceId),
),
threadProgressEvents: state.threadProgressEvents.filter((event) =>
canSeeThreadOnDevice(event.projectId, event.deviceId),
),
threadContextSnapshots: state.threadContextSnapshots.filter((snapshot) =>
canSeeThreadOnDevice(snapshot.projectId, snapshot.nodeId),
),
threadHandoffPackages: state.threadHandoffPackages.filter((item) =>
visibleProjectIds.has(item.projectId),
),
threadContextAlerts: state.threadContextAlerts.filter((alert) =>
visibleProjectIds.has(alert.projectId),
),
dispatchPlans: state.dispatchPlans
.filter((plan) => visibleProjectIds.has(plan.groupProjectId))
.map((plan) => ({
...plan,
targets: plan.targets.filter(
(target) =>
visibleProjectIds.has(target.projectId) &&
visibleDeviceIds.has(target.deviceId),
),
confirmedTargetProjectIds: plan.confirmedTargetProjectIds?.filter((projectId) =>
visibleProjectIds.has(projectId),
),
})),
dispatchExecutions: state.dispatchExecutions.filter(
(execution) =>
visibleProjectIds.has(execution.groupProjectId) &&
visibleProjectIds.has(execution.targetProjectId) &&
visibleDeviceIds.has(execution.deviceId),
),
masterAgentTasks: state.masterAgentTasks.filter(
(task) =>
task.requestedByAccount === session.account ||
visibleProjectIds.has(task.projectId) ||
Boolean(task.targetProjectId && visibleProjectIds.has(task.targetProjectId)),
),
appLogs: state.appLogs.filter((log) =>
visibleDeviceIds.has(log.deviceId) ||
Boolean(log.projectId && visibleProjectIds.has(log.projectId)),
),
};
}
export function getAuthorizedStateSnapshot(
state: BossState,
session: Pick<AuthSession, "account" | "role" | "displayName">,
): BossState {
return stateForSession(state, session);
}
export function getConversationItemsForSession(
state: BossState,
session: Pick<AuthSession, "account" | "role" | "displayName">,
): ConversationItem[] {
return getConversationItems(stateForSession(state, session));
}
export interface ConversationFolderView {
folderKey: string;
folderLabel: string;
@@ -605,6 +999,7 @@ export function getConversationHomeItems(state: BossState): ConversationItem[] {
const latestMessagePreview = compactImportedThreadPreview(
latestItem.lastMessagePreview || latestItem.preview,
);
const historyCleared = conversationHistoryWasCleared(state);
passthrough.push({
conversationId: `folder-${folderKey}`,
conversationType: "folder_archive",
@@ -626,11 +1021,13 @@ export function getConversationHomeItems(state: BossState): ConversationItem[] {
searchTargetProjectIds: searchAliases.targetProjectIds,
}
: {}),
preview: latestPreview || `包含 ${items.length} 个线程,最近活跃:《${recentThreadLabel || latestItem.threadTitle}`,
preview:
latestPreview ||
(historyCleared ? "" : `包含 ${items.length} 个线程,最近活跃:《${recentThreadLabel || latestItem.threadTitle}`),
lastMessagePreview:
latestMessagePreview ||
latestPreview ||
`包含 ${items.length} 个线程,最近活跃:《${recentThreadLabel || latestItem.threadTitle}`,
(historyCleared ? "" : `包含 ${items.length} 个线程,最近活跃:《${recentThreadLabel || latestItem.threadTitle}`),
activityIconCount: Math.max(0, Math.min(4, items.reduce((sum, entry) => sum + entry.activityIconCount, 0))),
latestReplyAt: latestItem.latestReplyAt,
latestReplyLabel: latestItem.latestReplyLabel,
@@ -660,6 +1057,13 @@ export function getConversationHomeItems(state: BossState): ConversationItem[] {
return sortConversationItems(passthrough);
}
export function getConversationHomeItemsForSession(
state: BossState,
session: Pick<AuthSession, "account" | "role" | "displayName">,
): ConversationItem[] {
return getConversationHomeItems(stateForSession(state, session));
}
export function getConversationWebItems(state: BossState): ConversationItem[] {
return getConversationHomeItems(state).map((item) => ({
...item,
@@ -716,6 +1120,14 @@ export function getConversationFolderView(
};
}
export function getConversationFolderViewForSession(
state: BossState,
session: Pick<AuthSession, "account" | "role" | "displayName">,
folderKey: string,
): ConversationFolderView | null {
return getConversationFolderView(stateForSession(state, session), folderKey);
}
export function buildProjectMessagesRealtimePayload(
state: BossState,
projectId: string,
@@ -730,11 +1142,30 @@ export function buildProjectMessagesRealtimePayload(
}
return {
ok: true,
project,
project: cloneProjectWithDisplayTitles(project),
devices: state.devices.filter((device) => project.deviceIds.includes(device.id)),
};
}
export function buildProjectMessagesRealtimePayloadForSession(
state: BossState,
session: Pick<AuthSession, "account" | "role" | "displayName">,
projectId: string,
): ProjectMessagesRealtimePayload | null {
if (!canAccessProject(state, session, projectId, "project.view")) {
return null;
}
const project = state.projects.find((item) => item.id === projectId);
if (!project) {
return null;
}
return {
ok: true,
project: cloneProjectWithDisplayTitles(project),
devices: filterProjectDevicesForSession(state, session, project),
};
}
function resolveProjectAgentControls(
state: BossState,
projectId: string,
@@ -779,6 +1210,7 @@ function resolveProjectAgentControls(
export function getProjectDetailView(state: BossState, projectId: string, account?: string): ProjectDetailView | null {
const project = state.projects.find((item) => item.id === projectId);
if (!project) return null;
const displayProject = cloneProjectWithDisplayTitles(project);
const activeThreadContexts = threadViewsForProject(state, projectId);
const threadsRequiringHandoff = activeThreadContexts.filter(
@@ -812,7 +1244,7 @@ export function getProjectDetailView(state: BossState, projectId: string, accoun
.slice(0, 6);
return {
project,
project: displayProject,
agentControls: resolveProjectAgentControls(state, projectId, account),
devices: state.devices.filter((device) => project.deviceIds.includes(device.id)),
masterIdentity: projectId === "master-agent" ? getProjectMasterIdentity(state) : undefined,
@@ -826,6 +1258,36 @@ export function getProjectDetailView(state: BossState, projectId: string, accoun
};
}
export function getProjectDetailViewForSession(
state: BossState,
projectId: string,
session: Pick<AuthSession, "account" | "role" | "displayName">,
): ProjectDetailView | null {
if (!canAccessProject(state, session, projectId, "project.view")) {
return null;
}
const detail = getProjectDetailView(state, projectId, session.account);
if (!detail) {
return null;
}
const visibleProjectIds = new Set(filterProjectsForSession(state, session).map((project) => project.id));
const visibleDeviceIds = new Set(filterDevicesForSession(state, session).map((device) => device.id));
return {
...detail,
devices: filterProjectDevicesForSession(state, session, detail.project),
activeThreadContexts: detail.activeThreadContexts.filter((item) =>
visibleProjectIds.has(item.snapshot.projectId) && visibleDeviceIds.has(item.snapshot.nodeId),
),
threadsRequiringHandoff: detail.threadsRequiringHandoff.filter((item) =>
visibleProjectIds.has(item.snapshot.projectId) && visibleDeviceIds.has(item.snapshot.nodeId),
),
recentAppLogs: detail.recentAppLogs.filter((log) =>
visibleDeviceIds.has(log.deviceId) ||
Boolean(log.projectId && visibleProjectIds.has(log.projectId)),
),
};
}
export function getThreadContextDetailView(
state: BossState,
threadId: string,
@@ -869,6 +1331,17 @@ export function getDeviceWorkspaceView(
};
}
export function getDeviceWorkspaceViewForSession(
state: BossState,
session: Pick<AuthSession, "account" | "role" | "displayName">,
deviceId?: string,
): DeviceWorkspaceView {
if (!deviceId || !canAccessDevice(state, session, deviceId, "device.view")) {
return { relatedThreads: [] };
}
return getDeviceWorkspaceView(stateForSession(state, session), deviceId);
}
export function getOpsSummaryView(state: BossState): OpsSummaryView {
const tickets = state.opsRepairTickets.map((ticket) => ({
...ticket,
@@ -939,3 +1412,33 @@ export function getSkillInventoryViewForAccount(
.filter((group) => group.skills.length > 0),
};
}
export function getSkillInventoryViewForSession(
state: BossState,
session: Pick<AuthSession, "account" | "role" | "displayName">,
boundDeviceId?: string,
): SkillInventoryView {
const devices = filterDevicesForSession(state, session)
.filter((device) => !boundDeviceId || device.id === boundDeviceId || session.role === "highest_admin")
.sort((a, b) => {
if (a.id === boundDeviceId) return -1;
if (b.id === boundDeviceId) return 1;
return b.lastSeenAt.localeCompare(a.lastSeenAt);
});
return {
boundDeviceId,
groups: devices
.map((device) => ({
device,
skills: state.deviceSkills
.filter((skill) => skill.deviceId === device.id)
.filter((skill) =>
session.role === "highest_admin" ||
canViewSkill(state, session, skill.skillId, { deviceId: device.id }),
)
.sort((a, b) => a.name.localeCompare(b.name, "zh-CN")),
}))
.filter((group) => group.skills.length > 0),
};
}

View File

@@ -0,0 +1,131 @@
import type {
AdminNotification,
AuthAccount,
BossState,
Device,
OpsFault,
OpsSeverity,
ThreadContextAlert,
} from "@/lib/boss-data";
function fallbackCompanyIdForAccount(account?: string) {
const normalized = account?.trim().toLowerCase() ?? "";
const domain = normalized.includes("@") ? normalized.split("@").at(-1)?.trim() : "";
return domain || "default";
}
function companyIdForAccount(account?: AuthAccount) {
return account?.companyId || fallbackCompanyIdForAccount(account?.account);
}
function accountCompanyId(state: BossState, account?: string) {
const owner = state.authAccounts.find((item) => item.account === account);
return companyIdForAccount(owner);
}
function deviceCompanyId(state: BossState, device?: Pick<Device, "account" | "companyId"> | null) {
if (device?.companyId) return device.companyId;
return accountCompanyId(state, device?.account);
}
function projectPrimaryDevice(state: BossState, projectId?: string) {
if (!projectId) return null;
const project = state.projects.find((item) => item.id === projectId);
const deviceId = project?.deviceIds[0];
return state.devices.find((device) => device.id === deviceId) ?? null;
}
function deviceForRisk(state: BossState, deviceId?: string, projectId?: string) {
return state.devices.find((device) => device.id === deviceId) ?? projectPrimaryDevice(state, projectId);
}
function isOverdue(slaDueAt: string | undefined, nowMs: number) {
if (!slaDueAt) return false;
const dueMs = Date.parse(slaDueAt);
return Number.isFinite(dueMs) && dueMs < nowMs;
}
function notificationIdForRisk(riskId: string) {
return `risk-sla-overdue:${riskId}`;
}
function existingNotificationIds(state: BossState) {
return new Set(state.adminNotifications.map((notification) => notification.notificationId));
}
function opsFaultCompanyId(state: BossState, fault: OpsFault) {
const device = deviceForRisk(state, fault.nodeId, fault.projectId);
return deviceCompanyId(state, device) || accountCompanyId(state, fault.ownerAccount);
}
function threadAlertCompanyId(state: BossState, alert: ThreadContextAlert) {
const device = deviceForRisk(state, undefined, alert.projectId);
return deviceCompanyId(state, device) || accountCompanyId(state, alert.ownerAccount);
}
function threadAlertSeverity(alert: ThreadContextAlert): OpsSeverity {
return alert.alertType === "context_critical" ? "critical" : "warning";
}
function buildBody(summary: string, slaDueAt: string) {
return `${summary || "风险已超过 SLA需要平台协助跟进"}SLA 截止 ${slaDueAt}`;
}
export function buildRiskSlaNotificationDrafts(
state: BossState,
now: Date = new Date(),
): AdminNotification[] {
const nowMs = now.getTime();
const createdAt = now.toISOString();
const existingIds = existingNotificationIds(state);
const drafts: AdminNotification[] = [];
for (const fault of state.opsFaults) {
if (fault.status === "resolved" || !isOverdue(fault.slaDueAt, nowMs)) {
continue;
}
const riskId = `ops-fault:${fault.faultId}`;
const notificationId = notificationIdForRisk(riskId);
if (existingIds.has(notificationId)) {
continue;
}
drafts.push({
notificationId,
kind: "risk_sla_overdue",
severity: fault.severity,
companyId: opsFaultCompanyId(state, fault),
riskId,
title: `风险 SLA 已超时:${fault.faultKey}`,
body: buildBody(fault.summary || fault.suggestedNextAction, fault.slaDueAt ?? ""),
status: "open",
createdAt,
});
}
for (const alert of state.threadContextAlerts) {
if (alert.alertStatus === "resolved" || !isOverdue(alert.slaDueAt, nowMs)) {
continue;
}
const riskId = `thread-alert:${alert.alertId}`;
const notificationId = notificationIdForRisk(riskId);
if (existingIds.has(notificationId)) {
continue;
}
drafts.push({
notificationId,
kind: "risk_sla_overdue",
severity: threadAlertSeverity(alert),
companyId: threadAlertCompanyId(state, alert),
riskId,
title: "线程上下文风险 SLA 已超时",
body: buildBody(alert.summary || alert.riskNote || "线程上下文风险未按 SLA 处理", alert.slaDueAt ?? ""),
status: "open",
createdAt,
});
}
return drafts.sort((left, right) => {
const severityRank = { critical: 3, warning: 2, info: 1 };
return severityRank[right.severity] - severityRank[left.severity] || right.createdAt.localeCompare(left.createdAt);
});
}

153
src/lib/boss-state-store.ts Normal file
View File

@@ -0,0 +1,153 @@
import { randomBytes } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import type { Client as PgClient } from "pg";
export type BossStateStoreMode = "file" | "postgres";
export type BossStateStoreSummary = {
mode: BossStateStoreMode;
ready: boolean;
reason?: string;
};
export type BossStateStorePaths = {
dataFile: string;
backupFile: string;
};
export type BossStateStore = {
mode: BossStateStoreMode;
ensure(initialText: string): Promise<void>;
readText(): Promise<string>;
readBackupText(): Promise<string | null>;
writeText(text: string): Promise<void>;
};
const postgresSnapshotKey = process.env.BOSS_STATE_POSTGRES_KEY?.trim() || "default";
function configuredMode(): BossStateStoreMode {
return process.env.BOSS_STATE_STORE?.trim().toLowerCase() === "postgres" ? "postgres" : "file";
}
export function describeBossStateStore(): BossStateStoreSummary {
const mode = configuredMode();
if (mode === "file") {
return { mode, ready: true };
}
if (!process.env.BOSS_DATABASE_URL?.trim()) {
return { mode, ready: false, reason: "BOSS_DATABASE_URL is required when BOSS_STATE_STORE=postgres." };
}
return { mode, ready: true };
}
function createFileStateStore(paths: BossStateStorePaths): BossStateStore {
const dataDir = path.dirname(paths.dataFile);
return {
mode: "file",
async ensure(initialText: string) {
await fs.mkdir(dataDir, { recursive: true });
try {
await fs.access(paths.dataFile);
} catch {
await fs.writeFile(paths.dataFile, initialText, "utf8");
await fs.writeFile(paths.backupFile, initialText, "utf8");
}
},
async readText() {
return fs.readFile(paths.dataFile, "utf8");
},
async readBackupText() {
return fs.readFile(paths.backupFile, "utf8").catch(() => null);
},
async writeText(text: string) {
await fs.mkdir(dataDir, { recursive: true });
const tempFile = `${paths.dataFile}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
await fs.writeFile(tempFile, text, "utf8");
await fs.rename(tempFile, paths.dataFile);
await fs.writeFile(paths.backupFile, text, "utf8");
},
};
}
async function postgresClient() {
const connectionString = process.env.BOSS_DATABASE_URL?.trim();
if (!connectionString) {
throw new Error("BOSS_DATABASE_URL_REQUIRED");
}
const { Client } = await import("pg");
return new Client({ connectionString });
}
async function withPostgresClient<T>(handler: (client: PgClient) => Promise<T>) {
const client = await postgresClient();
await client.connect();
try {
return await handler(client);
} finally {
await client.end();
}
}
function createPostgresStateStore(): BossStateStore {
if (!process.env.BOSS_DATABASE_URL?.trim()) {
throw new Error("BOSS_DATABASE_URL_REQUIRED");
}
return {
mode: "postgres",
async ensure(initialText: string) {
await withPostgresClient(async (client) => {
await client.query(`
CREATE TABLE IF NOT EXISTS boss_state_snapshots (
snapshot_key TEXT PRIMARY KEY,
state JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
`);
await client.query(
`
INSERT INTO boss_state_snapshots (snapshot_key, state)
VALUES ($1, $2::jsonb)
ON CONFLICT (snapshot_key) DO NOTHING
`,
[postgresSnapshotKey, initialText],
);
});
},
async readText() {
return withPostgresClient(async (client) => {
const result = await client.query<{ state: unknown }>(
"SELECT state FROM boss_state_snapshots WHERE snapshot_key = $1",
[postgresSnapshotKey],
);
const state = result.rows[0]?.state;
if (!state) {
throw new Error("BOSS_POSTGRES_STATE_NOT_FOUND");
}
return JSON.stringify(state, null, 2);
});
},
async readBackupText() {
return null;
},
async writeText(text: string) {
await withPostgresClient(async (client) => {
await client.query(
`
INSERT INTO boss_state_snapshots (snapshot_key, state, updated_at)
VALUES ($1, $2::jsonb, now())
ON CONFLICT (snapshot_key)
DO UPDATE SET state = EXCLUDED.state, updated_at = now()
`,
[postgresSnapshotKey, text],
);
});
},
};
}
export function createBossStateStore(paths: BossStateStorePaths): BossStateStore {
return configuredMode() === "postgres" ? createPostgresStateStore() : createFileStateStore(paths);
}

View File

@@ -1,6 +1,5 @@
import { createHash } from "node:crypto";
import { createHash, createHmac } from "node:crypto";
import path from "node:path";
import OSS from "ali-oss";
import type { UserAttachmentStorageConfig } from "@/lib/boss-data";
import type { AttachmentStorageProvider, StoreAttachmentParams, StoredAttachmentRecord } from "@/lib/boss-storage";
import { sanitizeFileName } from "@/lib/boss-attachments";
@@ -31,6 +30,214 @@ function buildObjectKey(params: StoreAttachmentParams, prefix?: string) {
);
}
function encodeObjectKey(objectKey: string) {
return objectKey
.replace(/^\/+/, "")
.split("/")
.map((segment) => encodeURIComponent(segment).replace(/\+/g, "%2B"))
.join("/");
}
function normalizeEndpoint(endpoint: string) {
const trimmed = endpoint.trim().replace(/\/+$/g, "");
return new URL(/^[a-z][a-z\d+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`);
}
function isIpAddress(hostname: string) {
return /^(\d{1,3}\.){3}\d{1,3}$/.test(hostname) || hostname.includes(":");
}
function buildOssUrl(config: AliyunOssClientConfig, objectKey?: string, subresource?: string) {
const url = normalizeEndpoint(config.endpoint);
if (!isIpAddress(url.hostname) && !url.hostname.startsWith(`${config.bucket}.`)) {
url.hostname = `${config.bucket}.${url.hostname}`;
}
url.pathname = objectKey ? `/${encodeObjectKey(objectKey)}` : "/";
if (subresource) {
url.search = `?${encodeURIComponent(subresource)}`;
}
return url.toString();
}
function buildCanonicalizedResource(
bucket: string,
objectKey?: string,
parameters?: Record<string, string | number | undefined>,
) {
let canonicalizedResource = `/${bucket}/`;
if (objectKey) {
canonicalizedResource += objectKey.replace(/^\/+/, "");
}
let separator = "?";
Object.keys(parameters ?? {})
.sort()
.forEach((key) => {
canonicalizedResource += separator + key;
const value = parameters?.[key];
if (value || value === 0) {
canonicalizedResource += `=${value}`;
}
separator = "&";
});
return canonicalizedResource;
}
function normalizeHeaders(headers: Record<string, string | undefined>) {
return Object.fromEntries(
Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value ?? ""]),
);
}
function buildCanonicalString({
method,
bucket,
objectKey,
headers,
parameters,
expires,
}: {
method: string;
bucket: string;
objectKey?: string;
headers: Record<string, string | undefined>;
parameters?: Record<string, string | number | undefined>;
expires?: number;
}) {
const lowerHeaders = normalizeHeaders(headers);
const ossHeaders = Object.keys(lowerHeaders)
.filter((key) => key.startsWith("x-oss-"))
.sort()
.map((key) => `${key}:${String(lowerHeaders[key]).trim()}`);
return [
method.toUpperCase(),
lowerHeaders["content-md5"] || "",
lowerHeaders["content-type"] || "",
expires ? String(expires) : lowerHeaders["x-oss-date"] || "",
...ossHeaders,
buildCanonicalizedResource(bucket, objectKey, parameters),
].join("\n");
}
function createOssAuthorization(
accessKeyId: string,
accessKeySecret: string,
canonicalString: string,
) {
const signature = createHmac("sha1", accessKeySecret).update(canonicalString, "utf8").digest("base64");
return `OSS ${accessKeyId}:${signature}`;
}
async function readErrorBody(response: Response) {
try {
const text = await response.text();
return text.trim().slice(0, 300);
} catch {
return "";
}
}
type AliyunOssClientConfig = {
accessKeyId: string;
accessKeySecret: string;
bucket: string;
endpoint: string;
region: string;
};
type OssRequestOptions = {
body?: BodyInit;
contentType?: string;
subresource?: string;
};
class AliyunOssRestClient {
constructor(private readonly config: AliyunOssClientConfig) {}
private async request(method: string, objectKey?: string, options: OssRequestOptions = {}) {
const parameters = options.subresource ? { [options.subresource]: "" } : undefined;
const headers: Record<string, string> = {
"x-oss-date": new Date().toUTCString(),
};
if (options.contentType) {
headers["content-type"] = options.contentType;
}
const canonicalString = buildCanonicalString({
method,
bucket: this.config.bucket,
objectKey,
headers,
parameters,
});
headers.authorization = createOssAuthorization(
this.config.accessKeyId,
this.config.accessKeySecret,
canonicalString,
);
const response = await fetch(buildOssUrl(this.config, objectKey, options.subresource), {
method,
headers,
body: options.body,
});
if (!response.ok) {
const detail = await readErrorBody(response);
throw new Error(`ALIYUN_OSS_REQUEST_FAILED_${response.status}${detail ? `: ${detail}` : ""}`);
}
return response;
}
async put(objectKey: string, body: Buffer, options?: { headers?: Record<string, string> }) {
await this.request("PUT", objectKey, {
body: body as BodyInit,
contentType: options?.headers?.["Content-Type"] || options?.headers?.["content-type"],
});
}
signatureUrl(objectKey: string, options?: { expires?: number; method?: string }) {
const method = options?.method || "GET";
const expires = Math.floor(Date.now() / 1000) + (options?.expires || 1800);
const canonicalString = buildCanonicalString({
method,
bucket: this.config.bucket,
objectKey,
headers: {},
expires,
});
const signature = createHmac("sha1", this.config.accessKeySecret)
.update(canonicalString, "utf8")
.digest("base64");
const url = new URL(buildOssUrl(this.config, objectKey));
url.searchParams.set("OSSAccessKeyId", this.config.accessKeyId);
url.searchParams.set("Expires", String(expires));
url.searchParams.set("Signature", signature);
return url.toString();
}
async get(objectKey: string) {
const response = await this.request("GET", objectKey);
return {
content: Buffer.from(await response.arrayBuffer()),
};
}
async getBucketInfo(name = this.config.bucket) {
const bucketClient = new AliyunOssRestClient({ ...this.config, bucket: name });
const response = await bucketClient.request("GET", undefined, { subresource: "bucketInfo" });
const text = await response.text();
const bucketName = text.match(/<Name>([^<]+)<\/Name>/)?.[1] || name;
return {
bucket: {
Name: bucketName,
},
res: response,
};
}
}
export async function createAliyunOssClient(config: AliyunOssConfig) {
if (!config.enabled) {
throw new Error("ALIYUN_OSS_NOT_ENABLED");
@@ -46,7 +253,7 @@ export async function createAliyunOssClient(config: AliyunOssConfig) {
}
const accessKeySecret = await decryptStorageSecret(config.accessKeySecretEncrypted);
return new OSS({
return new AliyunOssRestClient({
accessKeyId: config.accessKeyId,
accessKeySecret,
bucket: config.bucket,
@@ -92,7 +299,7 @@ export async function readAliyunOssObjectBuffer(
): Promise<Buffer<ArrayBufferLike>> {
const client = await createAliyunOssClient(config);
const response = await client.get(objectKey);
const content = response.content;
const content: unknown = response.content;
if (Buffer.isBuffer(content)) {
return content;
}

258
src/lib/boss-work-claims.ts Normal file
View File

@@ -0,0 +1,258 @@
export type BossWorkActorKind = "user" | "agent" | "device" | "thread";
export type BossWorkClaimStatus = "active" | "stealable" | "released";
export type BossWorkClaim = {
claimId: string;
resourceId: string;
actorId: string;
actorKind: BossWorkActorKind;
acquiredAt: string;
expiresAt: string;
status: BossWorkClaimStatus;
stealableAfter?: string;
handoffFromActorId?: string;
reason?: string;
};
export type BossWorkClaimEventType =
| "claim_acquired"
| "claim_conflict"
| "claim_released"
| "claim_handoff_released"
| "claim_handoff_acquired"
| "claim_marked_stealable"
| "claim_stale_detected";
export type BossWorkClaimEvent = {
type: BossWorkClaimEventType;
resourceId: string;
claimId: string;
actorId: string;
at: string;
nextActorId?: string;
reason?: string;
};
export type BossWorkClaimResult = {
ok: boolean;
reason?: "claim_conflict" | "actor_mismatch";
claim?: BossWorkClaim;
conflictingClaim?: BossWorkClaim;
events: BossWorkClaimEvent[];
};
export function claimWork(input: {
claims: BossWorkClaim[];
resourceId: string;
actorId: string;
actorKind: BossWorkActorKind;
now: string;
ttlMs: number;
claimId?: string;
}): BossWorkClaimResult {
const conflictingClaim = input.claims.find(
(claim) =>
claim.resourceId === input.resourceId &&
claim.status === "active" &&
new Date(claim.expiresAt).getTime() > new Date(input.now).getTime() &&
claim.actorId !== input.actorId,
);
if (conflictingClaim) {
return {
ok: false,
reason: "claim_conflict",
conflictingClaim,
events: [
{
type: "claim_conflict",
resourceId: input.resourceId,
claimId: conflictingClaim.claimId,
actorId: input.actorId,
at: input.now,
reason: "claim_conflict",
},
],
};
}
const claim: BossWorkClaim = {
claimId: input.claimId ?? makeClaimId(input.resourceId, input.actorId, input.now),
resourceId: input.resourceId,
actorId: input.actorId,
actorKind: input.actorKind,
acquiredAt: input.now,
expiresAt: new Date(new Date(input.now).getTime() + input.ttlMs).toISOString(),
status: "active",
};
return {
ok: true,
claim,
events: [
{
type: "claim_acquired",
resourceId: claim.resourceId,
claimId: claim.claimId,
actorId: claim.actorId,
at: input.now,
},
],
};
}
export function releaseClaim(input: {
claim: BossWorkClaim;
actorId: string;
now: string;
reason?: string;
}): BossWorkClaimResult {
if (input.claim.actorId !== input.actorId) {
return {
ok: false,
reason: "actor_mismatch",
claim: input.claim,
events: [],
};
}
const claim = { ...input.claim, status: "released" as const, reason: input.reason };
return {
ok: true,
claim,
events: [
{
type: "claim_released",
resourceId: claim.resourceId,
claimId: claim.claimId,
actorId: input.actorId,
at: input.now,
reason: input.reason,
},
],
};
}
export function handoffWork(input: {
claim: BossWorkClaim;
fromActorId: string;
toActorId: string;
toActorKind: BossWorkActorKind;
now: string;
ttlMs: number;
reason?: string;
}): BossWorkClaimResult {
if (input.claim.actorId !== input.fromActorId) {
return {
ok: false,
reason: "actor_mismatch",
claim: input.claim,
events: [],
};
}
const claim: BossWorkClaim = {
...input.claim,
claimId: makeClaimId(input.claim.resourceId, input.toActorId, input.now),
actorId: input.toActorId,
actorKind: input.toActorKind,
acquiredAt: input.now,
expiresAt: new Date(new Date(input.now).getTime() + input.ttlMs).toISOString(),
status: "active",
handoffFromActorId: input.fromActorId,
reason: input.reason,
};
return {
ok: true,
claim,
events: [
{
type: "claim_handoff_released",
resourceId: input.claim.resourceId,
claimId: input.claim.claimId,
actorId: input.fromActorId,
at: input.now,
nextActorId: input.toActorId,
reason: input.reason,
},
{
type: "claim_handoff_acquired",
resourceId: claim.resourceId,
claimId: claim.claimId,
actorId: input.toActorId,
at: input.now,
reason: input.reason,
},
],
};
}
export function markClaimStealable(input: {
claim: BossWorkClaim;
actorId: string;
now: string;
reason?: string;
}): BossWorkClaimResult {
if (input.claim.actorId !== input.actorId) {
return {
ok: false,
reason: "actor_mismatch",
claim: input.claim,
events: [],
};
}
const claim = {
...input.claim,
status: "stealable" as const,
stealableAfter: input.now,
reason: input.reason,
};
return {
ok: true,
claim,
events: [
{
type: "claim_marked_stealable",
resourceId: claim.resourceId,
claimId: claim.claimId,
actorId: input.actorId,
at: input.now,
reason: input.reason,
},
],
};
}
export function detectStaleClaims(input: { claims: BossWorkClaim[]; now: string }): {
staleClaims: BossWorkClaim[];
events: BossWorkClaimEvent[];
} {
const nowMs = new Date(input.now).getTime();
const staleClaims = input.claims.filter(
(claim) => claim.status !== "released" && new Date(claim.expiresAt).getTime() <= nowMs,
);
return {
staleClaims,
events: staleClaims.map((claim) => ({
type: "claim_stale_detected",
resourceId: claim.resourceId,
claimId: claim.claimId,
actorId: claim.actorId,
at: input.now,
reason: "claim_expired",
})),
};
}
function makeClaimId(resourceId: string, actorId: string, now: string): string {
return `claim_${slug(resourceId)}_${slug(actorId)}_${new Date(now).getTime()}`;
}
function slug(value: string): string {
return value.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "").toLowerCase();
}

View File

@@ -1,4 +1,5 @@
import { listExecutionTools, type ExecutionToolName } from "@/lib/execution/tool-registry";
import type { ComputerControlRiskLevel } from "@/lib/boss-data";
type CollaborationMode = "development" | "approval_required";
@@ -70,9 +71,36 @@ function buildAllowedPolicy(mode: CollaborationMode, requiresApproval: boolean):
export function evaluatePermissionPolicy(input: {
project?: PermissionPolicyProject;
hasPendingDispatchPlan?: boolean;
requestedTool?: ExecutionToolName;
requestedRiskLevel?: ComputerControlRiskLevel;
}): PermissionPolicyResult {
const project = input.project;
if (input.requestedTool === "desktop_control" && input.requestedRiskLevel === "high") {
return {
allowed: false,
requiresApproval: true,
reason: "当前操作属于高风险桌面控制,需先明确确认后再执行。",
toolPolicy: {
allowedTools: [],
deniedTools: ["desktop_control"],
},
collaborationPolicy: buildCollaborationPolicy(project?.collaborationMode ?? "development"),
};
}
if (input.requestedTool === "browser_control" && input.requestedRiskLevel === "medium") {
return {
allowed: true,
requiresApproval: true,
toolPolicy: {
allowedTools: ["browser_control"],
deniedTools: [],
},
collaborationPolicy: buildCollaborationPolicy(project?.collaborationMode ?? "development"),
};
}
if (!project) {
return {
allowed: true,

View File

@@ -1,12 +1,21 @@
import type { ExecutionProgressInput } from "@/lib/boss-data";
import {
MASTER_CODEX_NODE_OUTPUT_LEAKED,
shouldBlockSensitiveMasterAgentOutput,
} from "@/lib/execution/sensitive-output-guard";
export interface RemoteExecutionResultInput {
status: "completed" | "failed";
dispatchExecutionId?: string;
targetProjectId?: string;
targetThreadId?: string;
targetUrl?: string;
targetApp?: string;
rawThreadReply?: string;
replyBody?: string;
errorMessage?: string;
requestId?: string;
executionProgress?: ExecutionProgressInput;
}
export interface NormalizedRemoteExecutionResult {
@@ -14,10 +23,13 @@ export interface NormalizedRemoteExecutionResult {
dispatchExecutionId?: string;
targetProjectId?: string;
targetThreadId?: string;
targetUrl?: string;
targetApp?: string;
rawThreadReply?: string;
replyBody?: string;
errorMessage?: string;
requestId?: string;
executionProgress?: ExecutionProgressInput;
}
function trimToDefined(value: string | undefined) {
@@ -56,6 +68,10 @@ function buildThreadEnvironmentErrorMessage() {
return "THREAD_ENVIRONMENT_INVALID: 线程返回了内部环境提示,已拦截,请检查线程绑定或工作目录。";
}
function buildCodexEnvelopeLeakErrorMessage() {
return MASTER_CODEX_NODE_OUTPUT_LEAKED;
}
export function normalizeRemoteExecutionResult(
input: RemoteExecutionResultInput,
): NormalizedRemoteExecutionResult {
@@ -65,6 +81,10 @@ export function normalizeRemoteExecutionResult(
const hasEnvironmentDiagnostic =
looksLikeThreadEnvironmentDiagnostic(rawThreadReply) ||
looksLikeThreadEnvironmentDiagnostic(replyBody);
const hasCodexEnvelopeLeak =
shouldBlockSensitiveMasterAgentOutput(rawThreadReply) ||
shouldBlockSensitiveMasterAgentOutput(replyBody) ||
shouldBlockSensitiveMasterAgentOutput(errorMessage);
if (hasEnvironmentDiagnostic) {
return {
@@ -72,8 +92,25 @@ export function normalizeRemoteExecutionResult(
dispatchExecutionId: trimToDefined(input.dispatchExecutionId),
targetProjectId: trimToDefined(input.targetProjectId),
targetThreadId: trimToDefined(input.targetThreadId),
targetUrl: trimToDefined(input.targetUrl),
targetApp: trimToDefined(input.targetApp),
errorMessage: errorMessage || buildThreadEnvironmentErrorMessage(),
requestId: trimToDefined(input.requestId),
executionProgress: input.executionProgress,
};
}
if (hasCodexEnvelopeLeak) {
return {
status: "failed",
dispatchExecutionId: trimToDefined(input.dispatchExecutionId),
targetProjectId: trimToDefined(input.targetProjectId),
targetThreadId: trimToDefined(input.targetThreadId),
targetUrl: trimToDefined(input.targetUrl),
targetApp: trimToDefined(input.targetApp),
errorMessage: buildCodexEnvelopeLeakErrorMessage(),
requestId: trimToDefined(input.requestId),
executionProgress: input.executionProgress,
};
}
@@ -82,10 +119,13 @@ export function normalizeRemoteExecutionResult(
dispatchExecutionId: trimToDefined(input.dispatchExecutionId),
targetProjectId: trimToDefined(input.targetProjectId),
targetThreadId: trimToDefined(input.targetThreadId),
targetUrl: trimToDefined(input.targetUrl),
targetApp: trimToDefined(input.targetApp),
rawThreadReply,
replyBody,
errorMessage,
requestId: trimToDefined(input.requestId),
executionProgress: input.executionProgress,
};
}

View File

@@ -0,0 +1,82 @@
export const MASTER_CODEX_NODE_OUTPUT_LEAKED = "MASTER_CODEX_NODE_OUTPUT_LEAKED";
const EXECUTION_PROMPT_SECTION_LABELS = [
"管理员全局主提示词:",
"用户私有主提示词:",
"当前对话附加提示词:",
"当前消息:",
"项目记忆:",
"用户通用记忆:",
];
function trimToDefined(value: string | undefined | null) {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
export function looksLikeCodexCliEnvelopeLeak(value: string | undefined | null) {
const text = trimToDefined(value);
if (!text) {
return false;
}
const hasCodexHeader = /OpenAI Codex v[\d.]+/i.test(text);
const hasExecutionMetadata =
/^workdir:\s+/m.test(text) &&
/^model:\s+/m.test(text) &&
/^provider:\s+/m.test(text);
const hasRuntimePolicy = /^approval:\s+/m.test(text) || /^sandbox:\s+/m.test(text);
const hasSessionOrMcp = /^session id:\s+/m.test(text) || /^mcp:\s+/m.test(text);
return hasCodexHeader && hasExecutionMetadata && hasRuntimePolicy && hasSessionOrMcp;
}
export function looksLikeExecutionPromptLeak(value: string | undefined | null) {
const text = trimToDefined(value);
if (!text) {
return false;
}
const sectionHitCount = EXECUTION_PROMPT_SECTION_LABELS.filter((label) => text.includes(label)).length;
if (sectionHitCount >= 2) {
return true;
}
return (
text.includes("管理员全局主提示词") &&
text.includes("系统级最高约束") &&
text.includes("不可被用户私有提示词")
);
}
export function shouldBlockSensitiveMasterAgentOutput(value: string | undefined | null) {
return looksLikeCodexCliEnvelopeLeak(value) || looksLikeExecutionPromptLeak(value);
}
export function sanitizeSensitiveTaskFailureDetailForTransport(value: string | undefined | null) {
const text = trimToDefined(value);
if (!text) {
return undefined;
}
return shouldBlockSensitiveMasterAgentOutput(text) ? MASTER_CODEX_NODE_OUTPUT_LEAKED : text;
}
export function sanitizeSensitiveTaskFailureDetailForLog(value: string | undefined | null) {
const text = trimToDefined(value);
if (!text) {
return undefined;
}
if (!shouldBlockSensitiveMasterAgentOutput(text)) {
return text;
}
return "已拦截内部执行日志,原始内容不再展示。";
}
export function sanitizeSensitiveUserVisibleText(value: string | undefined | null) {
const text = trimToDefined(value);
if (!text) {
return undefined;
}
if (!shouldBlockSensitiveMasterAgentOutput(text)) {
return text;
}
return "已拦截内部执行日志,原始内容已隐藏。";
}

View File

@@ -4,7 +4,9 @@ export type ExecutionToolName =
| "conversation_reply"
| "group_dispatch_plan"
| "dispatch_execution"
| "attachment_analysis";
| "attachment_analysis"
| "browser_control"
| "desktop_control";
export interface ExecutionToolDefinition {
name: ExecutionToolName;
@@ -15,6 +17,8 @@ const EXECUTION_TOOLS: readonly ExecutionToolDefinition[] = [
{ name: "conversation_reply", kind: "execution" },
{ name: "group_dispatch_plan", kind: "execution" },
{ name: "dispatch_execution", kind: "execution" },
{ name: "browser_control", kind: "execution" },
{ name: "desktop_control", kind: "execution" },
{ name: "attachment_analysis", kind: "analysis" },
] as const;

View File

@@ -4,7 +4,9 @@ export type ExecutionRequestKind =
| "master_agent_reply"
| "thread_reply"
| "dispatch_execution"
| "attachment_analysis";
| "attachment_analysis"
| "browser_control"
| "desktop_control";
export interface ExecutionRequest {
kind: ExecutionRequestKind;

627
src/lib/telegram-gateway.ts Normal file
View File

@@ -0,0 +1,627 @@
import { NextRequest } from "next/server";
import { jsonNoStore } from "@/lib/api-response";
import {
appendProjectMessages,
getAuthSession,
type AuthRole,
type AuthSession,
type BossState,
type ExternalReplyTarget,
type TelegramGroupProjectRoute,
type TelegramIntegrationState,
readState,
writeState,
} from "@/lib/boss-data";
import { replyToMasterAgentUserMessage, tryBuildLocalMasterAgentFastReply } from "@/lib/boss-master-agent";
const TELEGRAM_API_BASE = "https://api.telegram.org";
const TELEGRAM_TEXT_LIMIT = 4000;
const TELEGRAM_BRIDGE_ACCOUNT = "telegram-bridge";
const TELEGRAM_BRIDGE_LABEL = "Telegram";
type TelegramChatType = "private" | "group" | "supergroup" | "channel";
interface TelegramUser {
id: number;
is_bot?: boolean;
first_name?: string;
last_name?: string;
username?: string;
}
interface TelegramChat {
id: number;
type: TelegramChatType;
title?: string;
}
interface TelegramMessage {
message_id: number;
message_thread_id?: number;
date: number;
chat: TelegramChat;
from?: TelegramUser;
text?: string;
reply_to_message?: {
from?: TelegramUser;
};
}
interface TelegramUpdate {
update_id: number;
message?: TelegramMessage;
}
interface NormalizedTelegramMessage {
updateId: number;
messageId: number;
chatId: string;
chatType: TelegramChatType;
chatTitle?: string;
threadId?: number;
senderId?: string;
senderName: string;
text: string;
repliedToBot: boolean;
repliedToBotUsername?: string;
}
interface TelegramGatewayView {
enabled: boolean;
mode: "webhook" | "polling";
botTokenConfigured: boolean;
botUsername?: string;
dmPolicy: "allowlist" | "open" | "disabled";
allowFrom: string[];
groupPolicy: "allowlist" | "open" | "disabled";
groups: string[];
requireMentionInGroups: boolean;
defaultProjectId: string;
groupProjectRoutes: TelegramGroupProjectRoute[];
webhookSecretConfigured: boolean;
webhookUrl?: string;
lastConfiguredAt?: string;
lastConfiguredBy?: string;
lastError?: string;
processedUpdateCount: number;
}
function trimToDefined(value?: string | null) {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function toSenderName(message: TelegramMessage) {
const firstName = trimToDefined(message.from?.first_name);
const lastName = trimToDefined(message.from?.last_name);
const username = trimToDefined(message.from?.username);
return [firstName, lastName].filter(Boolean).join(" ") || username || `用户${message.from?.id ?? "未知"}`;
}
function normalizeTelegramUpdate(update: TelegramUpdate): NormalizedTelegramMessage | null {
const message = update.message;
const text = trimToDefined(message?.text);
if (!message || !text) {
return null;
}
return {
updateId: update.update_id,
messageId: message.message_id,
chatId: String(message.chat.id),
chatType: message.chat.type,
chatTitle: trimToDefined(message.chat.title),
threadId: typeof message.message_thread_id === "number" ? message.message_thread_id : undefined,
senderId: message.from?.id != null ? String(message.from.id) : undefined,
senderName: toSenderName(message),
text,
repliedToBot: message.reply_to_message?.from?.is_bot === true,
repliedToBotUsername: trimToDefined(message.reply_to_message?.from?.username),
};
}
function buildTelegramSessionKey(message: NormalizedTelegramMessage) {
if (message.chatType === "private") {
return `telegram:dm:${message.chatId}`;
}
if (message.threadId != null) {
return `telegram:group:${message.chatId}:topic:${message.threadId}`;
}
return `telegram:group:${message.chatId}`;
}
function chunkTelegramText(text: string) {
if (text.length <= TELEGRAM_TEXT_LIMIT) {
return [text];
}
const chunks: string[] = [];
let remaining = text.trim();
while (remaining.length > TELEGRAM_TEXT_LIMIT) {
let index = remaining.lastIndexOf("\n", TELEGRAM_TEXT_LIMIT);
if (index < Math.floor(TELEGRAM_TEXT_LIMIT * 0.6)) {
index = TELEGRAM_TEXT_LIMIT;
}
chunks.push(remaining.slice(0, index).trim());
remaining = remaining.slice(index).trim();
}
if (remaining) {
chunks.push(remaining);
}
return chunks;
}
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function resolveTelegramMentionRegex(botUsername?: string) {
const username = trimToDefined(botUsername);
if (!username) {
return null;
}
return new RegExp(`(^|\\s)@${escapeRegExp(username)}\\b`, "ig");
}
function hasTelegramBotMention(text: string, botUsername?: string) {
const mentionRegex = resolveTelegramMentionRegex(botUsername);
if (!mentionRegex) {
return /@\S+/.test(text);
}
return mentionRegex.test(text);
}
function isReplyToConfiguredTelegramBot(message: NormalizedTelegramMessage, botUsername?: string) {
if (!message.repliedToBot) {
return false;
}
const expectedUsername = trimToDefined(botUsername)?.toLowerCase();
if (!expectedUsername) {
return true;
}
return message.repliedToBotUsername?.toLowerCase() === expectedUsername;
}
function stripTelegramBotMention(text: string, botUsername?: string) {
const mentionRegex = resolveTelegramMentionRegex(botUsername);
if (!mentionRegex) {
return text.trim();
}
return text.replace(mentionRegex, " ").replace(/\s+/g, " ").trim();
}
function ensureTelegramIntegration(state: BossState): TelegramIntegrationState {
const integration = state.telegramIntegration;
if (integration) {
integration.groupProjectRoutes ??= [];
return integration;
}
const next: TelegramIntegrationState = {
enabled: false,
mode: "webhook",
dmPolicy: "allowlist",
allowFrom: [],
groupPolicy: "allowlist",
groups: [],
requireMentionInGroups: true,
defaultProjectId: "master-agent",
groupProjectRoutes: [],
processedUpdateIds: [],
};
state.telegramIntegration = next;
return next;
}
function getTelegramConfigView(integration: TelegramIntegrationState | undefined): TelegramGatewayView {
const config = integration ?? {
enabled: false,
mode: "webhook",
dmPolicy: "allowlist",
allowFrom: [],
groupPolicy: "allowlist",
groups: [],
requireMentionInGroups: true,
defaultProjectId: "master-agent",
groupProjectRoutes: [],
processedUpdateIds: [],
};
return {
enabled: config.enabled,
mode: config.mode,
botTokenConfigured: Boolean(trimToDefined(config.botToken)),
botUsername: trimToDefined(config.botUsername),
dmPolicy: config.dmPolicy,
allowFrom: config.allowFrom,
groupPolicy: config.groupPolicy,
groups: config.groups,
requireMentionInGroups: config.requireMentionInGroups,
defaultProjectId: config.defaultProjectId,
groupProjectRoutes: config.groupProjectRoutes ?? [],
webhookSecretConfigured: Boolean(trimToDefined(config.webhookSecret)),
webhookUrl: trimToDefined(config.webhookUrl),
lastConfiguredAt: trimToDefined(config.lastConfiguredAt),
lastConfiguredBy: trimToDefined(config.lastConfiguredBy),
lastError: trimToDefined(config.lastError),
processedUpdateCount: config.processedUpdateIds.length,
};
}
function checkTelegramAccess(
config: TelegramIntegrationState,
message: NormalizedTelegramMessage,
): { ok: true } | { ok: false; message: string; status: number } {
if (!config.enabled) {
return { ok: false, message: "TELEGRAM_DISABLED", status: 403 };
}
if (message.chatType === "private") {
if (config.dmPolicy === "disabled") {
return { ok: false, message: "TELEGRAM_DM_DISABLED", status: 403 };
}
if (config.dmPolicy === "allowlist") {
if (!message.senderId || !config.allowFrom.includes(message.senderId)) {
return { ok: false, message: "TELEGRAM_SENDER_FORBIDDEN", status: 403 };
}
}
return { ok: true };
}
if (config.groupPolicy === "disabled") {
return { ok: false, message: "TELEGRAM_GROUP_DISABLED", status: 403 };
}
if (config.groupPolicy === "allowlist" && !config.groups.includes(message.chatId)) {
return { ok: false, message: "TELEGRAM_GROUP_FORBIDDEN", status: 403 };
}
if (
config.requireMentionInGroups &&
!hasTelegramBotMention(message.text, config.botUsername) &&
!isReplyToConfiguredTelegramBot(message, config.botUsername)
) {
return { ok: false, message: "TELEGRAM_GROUP_MENTION_REQUIRED", status: 400 };
}
return { ok: true };
}
function buildTelegramSenderLabel(message: NormalizedTelegramMessage) {
if (message.chatType === "private") {
return `Telegram · ${message.senderName}`;
}
const groupLabel = message.chatTitle || message.chatId;
return `Telegram · ${groupLabel} · ${message.senderName}`;
}
function resolveTelegramTargetProjectId(
state: BossState,
config: TelegramIntegrationState,
message: NormalizedTelegramMessage,
) {
const routes = config.groupProjectRoutes ?? [];
const exactRoute = routes.find(
(route) => route.chatId === message.chatId && route.threadId != null && route.threadId === message.threadId,
);
const chatRoute =
exactRoute ??
routes.find((route) => route.chatId === message.chatId && route.threadId == null);
const requestedProjectId = chatRoute?.projectId || config.defaultProjectId || "master-agent";
const projectExists = state.projects.some((project) => project.id === requestedProjectId);
if (projectExists) {
return requestedProjectId;
}
if (state.projects.some((project) => project.id === config.defaultProjectId)) {
return config.defaultProjectId;
}
return "master-agent";
}
function markTelegramUpdateProcessed(state: BossState, updateId: number) {
const integration = ensureTelegramIntegration(state);
integration.processedUpdateIds = Array.from(new Set([...integration.processedUpdateIds, updateId]))
.sort((left, right) => left - right)
.slice(-256);
}
async function persistTelegramUpdateProcessed(updateId: number) {
const state = await readState();
markTelegramUpdateProcessed(state, updateId);
await writeState(state);
}
function hasProcessedTelegramUpdate(state: BossState, updateId: number) {
return ensureTelegramIntegration(state).processedUpdateIds.includes(updateId);
}
async function sendTelegramMessage(config: TelegramIntegrationState, target: ExternalReplyTarget, text: string) {
const botToken = trimToDefined(config.botToken);
if (!botToken) {
throw new Error("TELEGRAM_BOT_TOKEN_REQUIRED");
}
const chunks = chunkTelegramText(text);
for (const chunk of chunks) {
const body: Record<string, unknown> = {
chat_id: Number(target.chatId),
text: chunk,
};
if (typeof target.messageId === "number") {
body.reply_to_message_id = target.messageId;
}
if (typeof target.threadId === "number") {
body.message_thread_id = target.threadId;
}
const response = await fetch(`${TELEGRAM_API_BASE}/bot${botToken}/sendMessage`, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`TELEGRAM_SEND_FAILED_${response.status}`);
}
}
}
function buildTelegramBridgeSession(): AuthSession {
const now = new Date().toISOString();
return {
sessionId: "telegram-bridge-session",
sessionToken: "telegram-bridge-session",
restoreToken: "telegram-bridge-session",
account: TELEGRAM_BRIDGE_ACCOUNT,
role: "highest_admin" satisfies AuthRole,
displayName: TELEGRAM_BRIDGE_LABEL,
loginMethod: "password",
createdAt: now,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
lastSeenAt: now,
};
}
export async function handleTelegramWebhookRequest(params: { request: NextRequest }) {
const state = await readState();
const integration = ensureTelegramIntegration(state);
const secret = trimToDefined(integration.webhookSecret);
if (secret) {
const received = trimToDefined(params.request.headers.get("x-telegram-bot-api-secret-token"));
if (received !== secret) {
return jsonNoStore({ ok: false, message: "TELEGRAM_WEBHOOK_SECRET_INVALID" }, { status: 401 });
}
}
const update = (await params.request.json().catch(() => null)) as TelegramUpdate | null;
const normalized = update ? normalizeTelegramUpdate(update) : null;
if (!update || !normalized) {
return jsonNoStore({ ok: true, delivery: "ignored" });
}
if (hasProcessedTelegramUpdate(state, normalized.updateId)) {
return jsonNoStore({ ok: true, delivery: "duplicate" });
}
const access = checkTelegramAccess(integration, normalized);
if (!access.ok) {
return jsonNoStore({ ok: false, message: access.message }, { status: access.status });
}
const replyTarget: ExternalReplyTarget = {
provider: "telegram",
chatId: normalized.chatId,
messageId: normalized.messageId,
threadId: normalized.threadId,
sessionKey: buildTelegramSessionKey(normalized),
};
const bridgeSession = buildTelegramBridgeSession();
const bridgeAccount = state.user.account || bridgeSession.account;
const projectId = resolveTelegramTargetProjectId(state, integration, normalized);
const requestText =
normalized.chatType === "private"
? normalized.text
: stripTelegramBotMention(normalized.text, integration.botUsername);
const localFastReply = await tryBuildLocalMasterAgentFastReply({
requestText,
requestedByAccount: bridgeSession.account,
projectId,
state,
});
if (localFastReply) {
await appendProjectMessages({
projectId,
messages: [
{
senderLabel: buildTelegramSenderLabel(normalized),
body: requestText,
kind: "text",
},
{
sender: "master",
senderLabel: localFastReply.senderLabel,
body: localFastReply.replyBody,
kind: "text",
},
],
});
await persistTelegramUpdateProcessed(normalized.updateId);
await sendTelegramMessage(integration, replyTarget, localFastReply.replyBody);
return jsonNoStore({ ok: true, delivery: "sent" });
}
const [message] = await appendProjectMessages({
projectId,
messages: [
{
senderLabel: buildTelegramSenderLabel(normalized),
body: requestText,
kind: "text",
},
],
});
const reply = await replyToMasterAgentUserMessage({
requestMessageId: message.id,
requestText,
requestedBy: buildTelegramSenderLabel(normalized),
requestedByAccount: bridgeAccount,
currentSessionExpiresAt: bridgeSession.expiresAt,
projectId,
mode: "smart",
externalReplyTarget: replyTarget,
});
await persistTelegramUpdateProcessed(normalized.updateId);
const replyState = "masterReplyState" in reply ? reply.masterReplyState : undefined;
if (reply.ok && replyState === "completed" && "replyMessage" in reply && reply.replyMessage?.body) {
await sendTelegramMessage(integration, replyTarget, reply.replyMessage.body);
return jsonNoStore({ ok: true, delivery: "sent", taskId: "taskId" in reply ? reply.taskId : undefined });
}
await sendTelegramMessage(integration, replyTarget, "已收到,我先继续协调,整理好结果后会自动回到这里。");
return jsonNoStore({ ok: true, delivery: "queued", taskId: "taskId" in reply ? reply.taskId : undefined });
}
export async function deliverTelegramReplyForCompletedTask(taskId: string) {
const state = await readState();
const integration = ensureTelegramIntegration(state);
const task = state.masterAgentTasks.find((item) => item.taskId === taskId);
if (!task?.externalReplyTarget || task.externalReplyTarget.provider !== "telegram") {
return { delivered: false, reason: "NO_EXTERNAL_TARGET" as const };
}
if (!task.replyBody?.trim()) {
return { delivered: false, reason: "NO_REPLY_BODY" as const };
}
if (task.externalReplyTarget.deliveredAt) {
return { delivered: false, reason: "ALREADY_DELIVERED" as const };
}
try {
await sendTelegramMessage(integration, task.externalReplyTarget, task.replyBody);
} catch (error) {
task.externalReplyTarget.deliveryError = error instanceof Error ? error.message : "TELEGRAM_DELIVERY_FAILED";
await writeState(state);
return { delivered: false, reason: "SEND_FAILED" as const };
}
task.externalReplyTarget.deliveredAt = new Date().toISOString();
task.externalReplyTarget.deliveryError = undefined;
await writeState(state);
return { delivered: true as const };
}
export async function getTelegramIntegrationView() {
const state = await readState();
return getTelegramConfigView(state.telegramIntegration);
}
export async function saveTelegramIntegrationConfig(input: {
enabled: boolean;
mode?: "webhook" | "polling";
botToken?: string;
botUsername?: string;
dmPolicy?: "allowlist" | "open" | "disabled";
allowFrom?: string[];
groupPolicy?: "allowlist" | "open" | "disabled";
groups?: string[];
requireMentionInGroups?: boolean;
defaultProjectId?: string;
groupProjectRoutes?: TelegramGroupProjectRoute[];
webhookSecret?: string;
webhookUrl?: string;
configuredBy: string;
}) {
const state = await readState();
const integration = ensureTelegramIntegration(state);
integration.enabled = input.enabled;
integration.mode = input.mode ?? integration.mode;
integration.botToken = trimToDefined(input.botToken) ?? integration.botToken;
integration.botUsername = trimToDefined(input.botUsername);
integration.dmPolicy = input.dmPolicy ?? integration.dmPolicy;
integration.allowFrom = (input.allowFrom ?? integration.allowFrom).map((item) => item.trim()).filter(Boolean);
integration.groupPolicy = input.groupPolicy ?? integration.groupPolicy;
integration.groups = (input.groups ?? integration.groups).map((item) => item.trim()).filter(Boolean);
integration.requireMentionInGroups =
input.requireMentionInGroups ?? integration.requireMentionInGroups;
integration.defaultProjectId = trimToDefined(input.defaultProjectId) ?? integration.defaultProjectId;
integration.groupProjectRoutes = (input.groupProjectRoutes ?? integration.groupProjectRoutes)
.map((route) => ({
chatId: String(route.chatId ?? "").trim(),
threadId: typeof route.threadId === "number" ? route.threadId : undefined,
projectId: String(route.projectId ?? "").trim(),
label: trimToDefined(route.label),
}))
.filter((route) => route.chatId && route.projectId);
integration.webhookSecret = trimToDefined(input.webhookSecret) ?? integration.webhookSecret;
integration.webhookUrl = trimToDefined(input.webhookUrl);
integration.lastConfiguredAt = new Date().toISOString();
integration.lastConfiguredBy = input.configuredBy;
integration.lastError = undefined;
await writeState(state);
return getTelegramConfigView(integration);
}
export async function probeTelegramBot(config?: TelegramIntegrationState | null) {
const integration = config ?? (await readState()).telegramIntegration;
const botToken = trimToDefined(integration?.botToken);
if (!botToken) {
throw new Error("TELEGRAM_BOT_TOKEN_REQUIRED");
}
const response = await fetch(`${TELEGRAM_API_BASE}/bot${botToken}/getMe`);
if (!response.ok) {
throw new Error(`TELEGRAM_GET_ME_FAILED_${response.status}`);
}
const payload = (await response.json()) as { ok?: boolean; result?: { username?: string } };
return {
ok: payload.ok === true,
username: trimToDefined(payload.result?.username),
};
}
export async function syncTelegramWebhookRegistration(config?: TelegramIntegrationState | null) {
const integration = config ?? (await readState()).telegramIntegration;
const botToken = trimToDefined(integration?.botToken);
if (!integration?.enabled || integration.mode === "polling") {
if (!botToken) {
return { ok: true as const, action: "skipped" as const, reason: "BOT_TOKEN_NOT_CONFIGURED" };
}
const response = await fetch(`${TELEGRAM_API_BASE}/bot${botToken}/deleteWebhook`, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({ drop_pending_updates: false }),
});
if (!response.ok) {
throw new Error(`TELEGRAM_DELETE_WEBHOOK_FAILED_${response.status}`);
}
return { ok: true as const, action: "delete_webhook" as const };
}
if (!botToken) {
return { ok: true as const, action: "skipped" as const, reason: "BOT_TOKEN_NOT_CONFIGURED" };
}
const webhookUrl = trimToDefined(integration.webhookUrl);
if (!webhookUrl) {
return { ok: true as const, action: "skipped" as const, reason: "WEBHOOK_URL_NOT_CONFIGURED" };
}
const body: Record<string, unknown> = {
url: webhookUrl,
drop_pending_updates: false,
};
const secret = trimToDefined(integration.webhookSecret);
if (secret) {
body.secret_token = secret;
}
const response = await fetch(`${TELEGRAM_API_BASE}/bot${botToken}/setWebhook`, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`TELEGRAM_SET_WEBHOOK_FAILED_${response.status}`);
}
return { ok: true as const, action: "set_webhook" as const };
}
export async function getAuthorizedTelegramConfigSession(request: NextRequest) {
const session = await getAuthSession(request.cookies.get("boss_session")?.value);
if (!session || session.role !== "highest_admin") {
return null;
}
return session;
}