feat: ship enterprise control and desktop governance
This commit is contained in:
103
tests/admin-access-panel-source.test.ts
Normal file
103
tests/admin-access-panel-source.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
const panelPath = new URL("../src/components/admin/admin-access-panel.tsx", import.meta.url);
|
||||
|
||||
async function readPanelSource() {
|
||||
return readFile(panelPath, "utf8");
|
||||
}
|
||||
|
||||
test("AdminAccessPanel is a client antd component without refine antd", async () => {
|
||||
const source = await readPanelSource();
|
||||
|
||||
assert.match(source, /["']use client["']/);
|
||||
assert.match(source, /export function AdminAccessPanel/);
|
||||
assert.doesNotMatch(source, /@refinedev\/antd/);
|
||||
for (const component of ["Form", "Card", "Table", "Button", "Select", "Checkbox", "Alert"]) {
|
||||
assert.match(source, new RegExp(`\\b${component}\\b`));
|
||||
}
|
||||
});
|
||||
|
||||
test("AdminAccessPanel calls the access endpoint for refresh and mutations", async () => {
|
||||
const source = await readPanelSource();
|
||||
|
||||
assert.match(source, /\/api\/v1\/admin\/access/);
|
||||
assert.match(source, /method:\s*["']POST["']/);
|
||||
assert.match(source, /cache:\s*["']no-store["']/);
|
||||
for (const action of [
|
||||
"upsert_company",
|
||||
"set_company_status",
|
||||
"assign_account_company",
|
||||
"assign_device_company",
|
||||
"preview_bulk_import_accounts",
|
||||
"bulk_import_accounts",
|
||||
"reclaim_account",
|
||||
"reset_account_password",
|
||||
"set_account_mfa_required",
|
||||
"upsert_account",
|
||||
"apply_template",
|
||||
"grant_device",
|
||||
"grant_project",
|
||||
"grant_skill",
|
||||
"revoke_grant",
|
||||
"set_account_status",
|
||||
]) {
|
||||
assert.match(source, new RegExp(action));
|
||||
}
|
||||
});
|
||||
|
||||
test("AdminAccessPanel exposes company lifecycle controls", async () => {
|
||||
const source = await readPanelSource();
|
||||
|
||||
assert.match(source, /公司管理/);
|
||||
assert.match(source, /套餐等级/);
|
||||
assert.match(source, /合同到期/);
|
||||
assert.match(source, /客户成功/);
|
||||
assert.match(source, /批量导入账号/);
|
||||
assert.match(source, /预览导入/);
|
||||
assert.match(source, /CSV/);
|
||||
assert.match(source, /parseBulkAccountsCsv/);
|
||||
assert.match(source, /离职回收/);
|
||||
assert.match(source, /重置密码/);
|
||||
assert.match(source, /所属公司/);
|
||||
});
|
||||
|
||||
test("AdminAccessPanel exposes hifi access workspace structure", async () => {
|
||||
const source = await readPanelSource();
|
||||
|
||||
assert.match(source, /权限概览/);
|
||||
assert.match(source, /关键风险/);
|
||||
assert.match(source, /配置负责人\/账号/);
|
||||
assert.match(source, /权限删除/);
|
||||
assert.match(source, /设备绑定 \/ 范围授权 \/ SLA 授权/);
|
||||
assert.match(source, /adminDense/);
|
||||
});
|
||||
|
||||
test("AdminAccessPanel wraps dangerous mutations with explicit confirmation", async () => {
|
||||
const source = await readPanelSource();
|
||||
|
||||
assert.match(source, /confirmDangerousAction/);
|
||||
assert.match(source, /window\.confirm/);
|
||||
for (const action of ["set_company_status", "reclaim_account", "reset_account_password", "revoke_grant"]) {
|
||||
assert.match(source, new RegExp(`confirmDangerousAction[\\s\\S]+${action}|${action}[\\s\\S]+confirmDangerousAction`));
|
||||
}
|
||||
});
|
||||
|
||||
test("AdminAccessPanel exposes account status controls", async () => {
|
||||
const source = await readPanelSource();
|
||||
|
||||
assert.match(source, /账号列表/);
|
||||
assert.match(source, /停用/);
|
||||
assert.match(source, /启用/);
|
||||
assert.match(source, /status:\s*["']disabled["']/);
|
||||
assert.match(source, /status:\s*["']active["']/);
|
||||
});
|
||||
|
||||
test("AdminAccessPanel only exposes member and admin account roles", async () => {
|
||||
const source = await readPanelSource();
|
||||
|
||||
assert.match(source, /member/);
|
||||
assert.match(source, /admin/);
|
||||
assert.doesNotMatch(source, /highest_admin/);
|
||||
});
|
||||
145
tests/admin-account-status-route.test.ts
Normal file
145
tests/admin-account-status-route.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
let runtimeRoot = "";
|
||||
let data: typeof import("../src/lib/boss-data");
|
||||
let authCookie = "";
|
||||
let postAdminAccess: (typeof import("../src/app/api/v1/admin/access/route"))["POST"];
|
||||
let baseState: Awaited<ReturnType<typeof import("../src/lib/boss-data")["readState"]>>;
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) return;
|
||||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-admin-account-status-"));
|
||||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||||
const [dataModule, authModule, routeModule] = await Promise.all([
|
||||
import("../src/lib/boss-data.ts"),
|
||||
import("../src/lib/boss-auth.ts"),
|
||||
import("../src/app/api/v1/admin/access/route.ts"),
|
||||
]);
|
||||
data = dataModule;
|
||||
authCookie = authModule.AUTH_SESSION_COOKIE;
|
||||
postAdminAccess = routeModule.POST;
|
||||
baseState = structuredClone(await data.readState());
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
if (runtimeRoot) await rm(runtimeRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await setup();
|
||||
const state = structuredClone(baseState);
|
||||
const now = "2026-04-27T12:00:00+08:00";
|
||||
state.authAccounts = [
|
||||
{
|
||||
id: "account-owner",
|
||||
account: "owner@acme.com",
|
||||
passwordHash: "secret",
|
||||
displayName: "Acme 老板",
|
||||
role: "highest_admin",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "account-worker",
|
||||
account: "worker@acme.com",
|
||||
passwordHash: "secret",
|
||||
displayName: "Worker",
|
||||
role: "member",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
];
|
||||
state.authSessions = [];
|
||||
state.permissionAuditLogs = [];
|
||||
await data.writeState(state);
|
||||
});
|
||||
|
||||
async function authedRequest(
|
||||
account: string,
|
||||
role: "member" | "admin" | "highest_admin",
|
||||
body: Record<string, unknown>,
|
||||
) {
|
||||
const session = await data.createAuthSession({
|
||||
account,
|
||||
role,
|
||||
displayName: account,
|
||||
loginMethod: "password",
|
||||
});
|
||||
return new NextRequest("http://127.0.0.1:3000/api/v1/admin/access", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
cookie: `${authCookie}=${session.sessionToken}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function adminPost(body: Record<string, unknown>) {
|
||||
return postAdminAccess(await authedRequest("owner@acme.com", "highest_admin", body));
|
||||
}
|
||||
|
||||
test("highest admin can disable a child account and revoke its active sessions", async () => {
|
||||
const workerSession = await data.createAuthSession({
|
||||
account: "worker@acme.com",
|
||||
role: "member",
|
||||
displayName: "Worker",
|
||||
loginMethod: "password",
|
||||
});
|
||||
|
||||
const response = await adminPost({
|
||||
action: "set_account_status",
|
||||
account: "worker@acme.com",
|
||||
status: "disabled",
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.account.status, "disabled");
|
||||
assert.equal(payload.account.passwordHash, undefined);
|
||||
|
||||
const state = await data.readState();
|
||||
assert.equal(state.authAccounts.find((account) => account.account === "worker@acme.com")?.status, "disabled");
|
||||
assert.equal(
|
||||
state.authSessions.find((session) => session.sessionToken === workerSession.sessionToken)?.revokedAt !== undefined,
|
||||
true,
|
||||
);
|
||||
assert.equal(await data.getAuthSession(workerSession.sessionToken), null);
|
||||
assert.equal(state.permissionAuditLogs.at(0)?.action, "account.updated");
|
||||
});
|
||||
|
||||
test("highest admin can re-enable a disabled child account", async () => {
|
||||
await adminPost({
|
||||
action: "set_account_status",
|
||||
account: "worker@acme.com",
|
||||
status: "disabled",
|
||||
});
|
||||
|
||||
const response = await adminPost({
|
||||
action: "set_account_status",
|
||||
account: "worker@acme.com",
|
||||
status: "active",
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.account.status, "active");
|
||||
|
||||
const state = await data.readState();
|
||||
assert.equal(state.authAccounts.find((account) => account.account === "worker@acme.com")?.status, "active");
|
||||
});
|
||||
|
||||
test("highest admin cannot disable a highest admin account", async () => {
|
||||
const response = await adminPost({
|
||||
action: "set_account_status",
|
||||
account: "owner@acme.com",
|
||||
status: "disabled",
|
||||
});
|
||||
assert.equal(response.status, 400);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.message, "CANNOT_DISABLE_HIGHEST_ADMIN");
|
||||
});
|
||||
264
tests/admin-backoffice-bff-route.test.ts
Normal file
264
tests/admin-backoffice-bff-route.test.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
let runtimeRoot = "";
|
||||
let data: typeof import("../src/lib/boss-data.ts");
|
||||
let authCookie = "";
|
||||
let getBackoffice: (typeof import("../src/app/api/v1/admin/backoffice/route.ts"))["GET"];
|
||||
let baseState: Awaited<ReturnType<typeof import("../src/lib/boss-data.ts")["readState"]>>;
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) return;
|
||||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-admin-backoffice-"));
|
||||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||||
|
||||
const [dataModule, authModule, routeModule] = await Promise.all([
|
||||
import("../src/lib/boss-data.ts"),
|
||||
import("../src/lib/boss-auth.ts"),
|
||||
import("../src/app/api/v1/admin/backoffice/route.ts"),
|
||||
]);
|
||||
data = dataModule;
|
||||
authCookie = authModule.AUTH_SESSION_COOKIE;
|
||||
getBackoffice = routeModule.GET;
|
||||
baseState = structuredClone(await data.readState());
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
if (runtimeRoot) {
|
||||
await rm(runtimeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await setup();
|
||||
const state = structuredClone(baseState);
|
||||
const now = "2026-04-30T10:00:00+08:00";
|
||||
state.adminCompanies = [
|
||||
{
|
||||
companyId: "acme",
|
||||
name: "Acme 科技",
|
||||
ownerAccount: "owner@acme.com",
|
||||
successOwnerAccount: "cs@boss.com",
|
||||
planTier: "enterprise",
|
||||
contractExpiresAt: "2027-04-30T00:00:00+08:00",
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
];
|
||||
state.authAccounts = [
|
||||
{
|
||||
id: "account-owner",
|
||||
account: "owner@acme.com",
|
||||
passwordHash: "do-not-leak-owner-password-hash",
|
||||
displayName: "Acme 老板",
|
||||
role: "highest_admin",
|
||||
status: "active",
|
||||
companyId: "acme",
|
||||
mfaSecret: "do-not-leak-mfa-secret",
|
||||
primaryDeviceId: "mac-1",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastLoginAt: now,
|
||||
},
|
||||
{
|
||||
id: "account-dev",
|
||||
account: "dev@acme.com",
|
||||
passwordHash: "do-not-leak-dev-password-hash",
|
||||
displayName: "开发同事",
|
||||
role: "member",
|
||||
status: "active",
|
||||
companyId: "acme",
|
||||
primaryDeviceId: "win-1",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
];
|
||||
state.authSessions = [];
|
||||
state.devices = [
|
||||
{
|
||||
id: "mac-1",
|
||||
name: "Acme Mac Studio",
|
||||
avatar: "A",
|
||||
account: "owner@acme.com",
|
||||
companyId: "acme",
|
||||
source: "production",
|
||||
status: "online",
|
||||
projects: ["project-acme"],
|
||||
quota5h: 0,
|
||||
quota7d: 0,
|
||||
lastSeenAt: "2026-04-30T09:58:00+08:00",
|
||||
preferredExecutionMode: "cli",
|
||||
capabilities: {
|
||||
gui: { connected: true, lastSeenAt: "2026-04-30T09:58:00+08:00" },
|
||||
cli: { connected: true, lastSeenAt: "2026-04-30T09:58:00+08:00" },
|
||||
browserAutomation: { connected: true, lastSeenAt: "2026-04-30T09:57:00+08:00" },
|
||||
computerUse: { connected: true, lastSeenAt: "2026-04-30T09:57:00+08:00" },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "win-1",
|
||||
name: "Acme Windows",
|
||||
avatar: "W",
|
||||
account: "dev@acme.com",
|
||||
companyId: "acme",
|
||||
source: "production",
|
||||
status: "offline",
|
||||
projects: ["project-acme"],
|
||||
quota5h: 0,
|
||||
quota7d: 0,
|
||||
lastSeenAt: "2026-04-30T08:00:00+08:00",
|
||||
preferredExecutionMode: "gui",
|
||||
capabilities: {
|
||||
gui: { connected: false },
|
||||
cli: { connected: false },
|
||||
browserAutomation: { connected: false },
|
||||
computerUse: { connected: false },
|
||||
},
|
||||
},
|
||||
];
|
||||
state.projects = [
|
||||
{
|
||||
id: "project-acme",
|
||||
name: "Acme 生产项目",
|
||||
pinned: false,
|
||||
deviceIds: ["mac-1", "win-1"],
|
||||
preview: "企业生产项目",
|
||||
updatedAt: now,
|
||||
lastMessageAt: now,
|
||||
isGroup: false,
|
||||
threadMeta: {
|
||||
projectId: "project-acme",
|
||||
threadId: "thread-acme",
|
||||
threadDisplayName: "Acme 线程",
|
||||
folderName: "acme",
|
||||
activityIconCount: 0,
|
||||
updatedAt: now,
|
||||
},
|
||||
groupMembers: [],
|
||||
createdByAgent: false,
|
||||
collaborationMode: "development",
|
||||
approvalState: "not_required",
|
||||
unreadCount: 0,
|
||||
riskLevel: "low",
|
||||
messages: [],
|
||||
goals: [],
|
||||
versions: [],
|
||||
},
|
||||
];
|
||||
state.deviceSkills = [
|
||||
{
|
||||
skillId: "mac-1:boss-server-debug",
|
||||
deviceId: "mac-1",
|
||||
name: "boss-server-debug",
|
||||
description: "Boss 服务器调试",
|
||||
path: "/Users/kris/.codex/skills/boss-server-debug/SKILL.md",
|
||||
invocation: "$boss-server-debug",
|
||||
category: "运维",
|
||||
updatedAt: now,
|
||||
},
|
||||
];
|
||||
state.opsFaults = [
|
||||
{
|
||||
faultId: "fault-1",
|
||||
faultKey: "LOCAL_AGENT.HEARTBEAT_FAILED",
|
||||
severity: "warning",
|
||||
status: "opened",
|
||||
nodeId: "win-1",
|
||||
serviceName: "local-agent",
|
||||
projectId: "project-acme",
|
||||
traceId: "trace-1",
|
||||
runbookId: "runbook-agent",
|
||||
firstSeenAt: "2026-04-30T08:10:00+08:00",
|
||||
lastSeenAt: "2026-04-30T08:30:00+08:00",
|
||||
summary: "Windows 节点心跳失败",
|
||||
suggestedNextAction: "检查 local-agent",
|
||||
autoRepairable: true,
|
||||
},
|
||||
];
|
||||
state.permissionAuditLogs = [
|
||||
{
|
||||
auditId: "audit-1",
|
||||
actorAccount: "owner@acme.com",
|
||||
action: "grant.created",
|
||||
targetAccount: "dev@acme.com",
|
||||
deviceId: "mac-1",
|
||||
permissions: ["device.view"],
|
||||
detail: "授权设备只读",
|
||||
createdAt: now,
|
||||
},
|
||||
];
|
||||
state.adminRiskTimeline = [
|
||||
{
|
||||
eventId: "risk-event-1",
|
||||
riskId: "ops-fault:fault-1",
|
||||
companyId: "acme",
|
||||
action: "risk.created",
|
||||
actorAccount: "system",
|
||||
note: "发现节点风险",
|
||||
createdAt: now,
|
||||
},
|
||||
];
|
||||
await data.writeState(state);
|
||||
});
|
||||
|
||||
async function authedRequest(account: string, role: "member" | "admin" | "highest_admin") {
|
||||
const session = await data.createAuthSession({
|
||||
account,
|
||||
role,
|
||||
displayName: account,
|
||||
loginMethod: "password",
|
||||
});
|
||||
return new NextRequest("http://127.0.0.1:3000/api/v1/admin/backoffice", {
|
||||
headers: {
|
||||
cookie: `${authCookie}=${session.sessionToken}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test("backoffice bff rejects non highest admin accounts", async () => {
|
||||
await setup();
|
||||
const response = await getBackoffice(await authedRequest("dev@acme.com", "member"));
|
||||
assert.equal(response.status, 403);
|
||||
});
|
||||
|
||||
test("backoffice bff exposes yudao style management contract without secrets", async () => {
|
||||
await setup();
|
||||
const response = await getBackoffice(await authedRequest("owner@acme.com", "highest_admin"));
|
||||
assert.equal(response.status, 200);
|
||||
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.ok, true);
|
||||
assert.deepEqual(
|
||||
payload.menuTree.map((item: { label: string }) => item.label),
|
||||
["工作台", "租户管理", "账号管理", "角色权限", "资源授权", "Skill 中心", "风险告警", "审计日志", "系统设置"],
|
||||
);
|
||||
assert.equal(payload.yudaoMapping.tenant, "adminCompanies");
|
||||
assert.equal(payload.yudaoMapping.user, "authAccounts");
|
||||
assert.equal(payload.yudaoMapping.role, "BOSS_PERMISSION_TEMPLATES");
|
||||
assert.equal(payload.workbench.summary.companies >= 1, true);
|
||||
assert.equal(
|
||||
payload.tenants.some((tenant: { name: string }) => tenant.name === "Acme 科技"),
|
||||
true,
|
||||
);
|
||||
assert.equal(payload.users[0].passwordHash, undefined);
|
||||
assert.equal(payload.users[0].mfaSecret, undefined);
|
||||
assert.equal(payload.roles.permissionTemplates.length >= 3, true);
|
||||
assert.equal(payload.resourceGroups.devices.length, 2);
|
||||
assert.equal(
|
||||
payload.resourceGroups.projects.some((project: { name: string }) => project.name === "Acme 生产项目"),
|
||||
true,
|
||||
);
|
||||
assert.equal(payload.resourceGroups.skills[0].name, "boss-server-debug");
|
||||
assert.equal(payload.audit.permissionLogs.length, 1);
|
||||
assert.equal(payload.audit.risks.length >= 1, true);
|
||||
|
||||
const serialized = JSON.stringify(payload);
|
||||
assert.equal(serialized.includes("do-not-leak-owner-password-hash"), false);
|
||||
assert.equal(serialized.includes("do-not-leak-mfa-secret"), false);
|
||||
});
|
||||
288
tests/admin-company-lifecycle-route.test.ts
Normal file
288
tests/admin-company-lifecycle-route.test.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
let runtimeRoot = "";
|
||||
let data: typeof import("../src/lib/boss-data");
|
||||
let authCookie = "";
|
||||
let getAdminAccess: (typeof import("../src/app/api/v1/admin/access/route"))["GET"];
|
||||
let postAdminAccess: (typeof import("../src/app/api/v1/admin/access/route"))["POST"];
|
||||
let baseState: Awaited<ReturnType<typeof import("../src/lib/boss-data")["readState"]>>;
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) return;
|
||||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-admin-company-"));
|
||||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||||
const [dataModule, authModule, routeModule] = await Promise.all([
|
||||
import("../src/lib/boss-data.ts"),
|
||||
import("../src/lib/boss-auth.ts"),
|
||||
import("../src/app/api/v1/admin/access/route.ts"),
|
||||
]);
|
||||
data = dataModule;
|
||||
authCookie = authModule.AUTH_SESSION_COOKIE;
|
||||
getAdminAccess = routeModule.GET;
|
||||
postAdminAccess = routeModule.POST;
|
||||
baseState = structuredClone(await data.readState());
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
if (runtimeRoot) await rm(runtimeRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await setup();
|
||||
const state = structuredClone(baseState);
|
||||
const now = "2026-04-27T14:00:00+08:00";
|
||||
state.authAccounts = [
|
||||
{
|
||||
id: "account-owner",
|
||||
account: "owner@platform.com",
|
||||
passwordHash: "secret",
|
||||
displayName: "平台老板",
|
||||
role: "highest_admin",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "account-worker",
|
||||
account: "worker@acme.com",
|
||||
passwordHash: "secret",
|
||||
displayName: "待分配成员",
|
||||
role: "member",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
];
|
||||
state.authSessions = [];
|
||||
state.devices = [
|
||||
{
|
||||
id: "mac-1",
|
||||
name: "客户 Mac",
|
||||
avatar: "M",
|
||||
account: "worker@acme.com",
|
||||
source: "production",
|
||||
status: "online",
|
||||
projects: [],
|
||||
quota5h: 0,
|
||||
quota7d: 0,
|
||||
lastSeenAt: now,
|
||||
preferredExecutionMode: "cli",
|
||||
capabilities: {
|
||||
gui: { connected: true, lastSeenAt: now },
|
||||
cli: { connected: true, lastSeenAt: now },
|
||||
browserAutomation: { connected: false },
|
||||
computerUse: { connected: false },
|
||||
},
|
||||
},
|
||||
];
|
||||
state.accountDeviceGrants = [
|
||||
{
|
||||
grantId: "grant-device",
|
||||
account: "worker@acme.com",
|
||||
deviceId: "mac-1",
|
||||
permissions: ["device.view"],
|
||||
grantedBy: "owner@platform.com",
|
||||
grantedAt: now,
|
||||
},
|
||||
];
|
||||
state.accountProjectGrants = [];
|
||||
state.accountSkillGrants = [];
|
||||
state.permissionAuditLogs = [];
|
||||
await data.writeState(state);
|
||||
});
|
||||
|
||||
async function authedRequest(body?: Record<string, unknown>, method = "POST") {
|
||||
const session = await data.createAuthSession({
|
||||
account: "owner@platform.com",
|
||||
role: "highest_admin",
|
||||
displayName: "平台老板",
|
||||
loginMethod: "password",
|
||||
});
|
||||
return new NextRequest("http://127.0.0.1:3000/api/v1/admin/access", {
|
||||
method,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
cookie: `${authCookie}=${session.sessionToken}`,
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function adminPost(body: Record<string, unknown>) {
|
||||
return postAdminAccess(await authedRequest(body));
|
||||
}
|
||||
|
||||
test("highest admin can manage companies and assign accounts and devices", async () => {
|
||||
const createCompany = await adminPost({
|
||||
action: "upsert_company",
|
||||
companyId: "acme",
|
||||
name: "Acme 客户",
|
||||
ownerAccount: "owner@acme.com",
|
||||
successOwnerAccount: "cs@platform.com",
|
||||
planTier: "enterprise",
|
||||
contractExpiresAt: "2026-12-31T23:59:59+08:00",
|
||||
note: "第一批 To B 客户",
|
||||
});
|
||||
assert.equal(createCompany.status, 200);
|
||||
const createPayload = await createCompany.json();
|
||||
assert.equal(createPayload.company.companyId, "acme");
|
||||
assert.equal(createPayload.company.name, "Acme 客户");
|
||||
assert.equal(createPayload.company.planTier, "enterprise");
|
||||
assert.equal(createPayload.company.successOwnerAccount, "cs@platform.com");
|
||||
assert.equal(createPayload.company.contractExpiresAt, "2026-12-31T23:59:59+08:00");
|
||||
|
||||
const assignAccount = await adminPost({
|
||||
action: "assign_account_company",
|
||||
account: "worker@acme.com",
|
||||
companyId: "acme",
|
||||
});
|
||||
assert.equal(assignAccount.status, 200);
|
||||
assert.equal((await assignAccount.json()).account.companyId, "acme");
|
||||
|
||||
const assignDevice = await adminPost({
|
||||
action: "assign_device_company",
|
||||
deviceId: "mac-1",
|
||||
companyId: "acme",
|
||||
});
|
||||
assert.equal(assignDevice.status, 200);
|
||||
assert.equal((await assignDevice.json()).device.companyId, "acme");
|
||||
|
||||
const getResponse = await getAdminAccess(await authedRequest(undefined, "GET"));
|
||||
const getPayload = await getResponse.json();
|
||||
assert.equal(getPayload.companies.some((company: { companyId: string }) => company.companyId === "acme"), true);
|
||||
assert.equal(getPayload.companies.find((company: { companyId: string }) => company.companyId === "acme")?.planTier, "enterprise");
|
||||
assert.equal(getPayload.accounts.find((account: { account: string }) => account.account === "worker@acme.com")?.companyId, "acme");
|
||||
assert.equal(getPayload.devices.find((device: { id: string }) => device.id === "mac-1")?.companyId, "acme");
|
||||
});
|
||||
|
||||
test("highest admin can toggle account MFA without exposing the secret in access GET", async () => {
|
||||
const response = await adminPost({
|
||||
action: "set_account_mfa_required",
|
||||
account: "worker@acme.com",
|
||||
required: true,
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.account.mfaRequired, true);
|
||||
assert.equal(typeof payload.mfaSetupSecret, "string");
|
||||
assert.equal(payload.account.mfaSecret, undefined);
|
||||
|
||||
const getResponse = await getAdminAccess(await authedRequest(undefined, "GET"));
|
||||
const getPayload = await getResponse.json();
|
||||
const account = getPayload.accounts.find((item: { account: string }) => item.account === "worker@acme.com");
|
||||
assert.equal(account.mfaRequired, true);
|
||||
assert.equal(account.mfaSecret, undefined);
|
||||
});
|
||||
|
||||
test("admin access GET returns safe project metadata without chat transcripts", async () => {
|
||||
const state = await data.readState();
|
||||
const now = "2026-04-27T14:05:00+08:00";
|
||||
state.projects = [
|
||||
{
|
||||
id: "project-secret",
|
||||
name: "敏感项目",
|
||||
pinned: false,
|
||||
deviceIds: ["mac-1"],
|
||||
preview: "passwordHash should not leave through admin access",
|
||||
updatedAt: now,
|
||||
lastMessageAt: now,
|
||||
isGroup: false,
|
||||
threadMeta: {
|
||||
projectId: "project-secret",
|
||||
threadId: "thread-secret",
|
||||
threadDisplayName: "敏感线程",
|
||||
folderName: "secret",
|
||||
activityIconCount: 1,
|
||||
updatedAt: now,
|
||||
},
|
||||
groupMembers: [],
|
||||
createdByAgent: false,
|
||||
collaborationMode: "development",
|
||||
approvalState: "not_required",
|
||||
unreadCount: 0,
|
||||
riskLevel: "low",
|
||||
messages: [
|
||||
{
|
||||
id: "msg-secret",
|
||||
role: "assistant",
|
||||
author: "线程",
|
||||
body: "passwordHash=secret should stay in project transcript only",
|
||||
createdAt: now,
|
||||
},
|
||||
],
|
||||
goals: [],
|
||||
versions: [],
|
||||
},
|
||||
];
|
||||
await data.writeState(state);
|
||||
|
||||
const response = await getAdminAccess(await authedRequest(undefined, "GET"));
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
const project = payload.projects.find((item: { id: string }) => item.id === "project-secret");
|
||||
|
||||
assert.equal(project.name, "敏感项目");
|
||||
assert.deepEqual(project.deviceIds, ["mac-1"]);
|
||||
assert.equal(project.messages, undefined);
|
||||
assert.equal(project.preview, undefined);
|
||||
assert.equal(JSON.stringify(payload.accounts).includes("passwordHash"), false);
|
||||
assert.equal(JSON.stringify(payload.projects).includes("passwordHash"), false);
|
||||
});
|
||||
|
||||
test("highest admin can bulk import accounts into a company", async () => {
|
||||
await adminPost({ action: "upsert_company", companyId: "acme", name: "Acme 客户" });
|
||||
|
||||
const response = await adminPost({
|
||||
action: "bulk_import_accounts",
|
||||
companyId: "acme",
|
||||
accounts: [
|
||||
{ account: "pm@acme.com", displayName: "产品负责人", role: "admin", password: "pass-123" },
|
||||
{ account: "dev2@acme.com", displayName: "开发二号", role: "member", password: "pass-456" },
|
||||
],
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.imported.length, 2);
|
||||
assert.equal(payload.imported.every((account: { passwordHash?: string }) => account.passwordHash === undefined), true);
|
||||
|
||||
const state = await data.readState();
|
||||
assert.deepEqual(
|
||||
state.authAccounts
|
||||
.filter((account) => account.companyId === "acme")
|
||||
.map((account) => account.account)
|
||||
.sort(),
|
||||
["dev2@acme.com", "pm@acme.com"],
|
||||
);
|
||||
});
|
||||
|
||||
test("highest admin can reclaim a leaving account and clear its grants and sessions", async () => {
|
||||
const workerSession = await data.createAuthSession({
|
||||
account: "worker@acme.com",
|
||||
role: "member",
|
||||
displayName: "待分配成员",
|
||||
loginMethod: "password",
|
||||
});
|
||||
|
||||
const response = await adminPost({
|
||||
action: "reclaim_account",
|
||||
account: "worker@acme.com",
|
||||
reason: "员工离职",
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.account.status, "disabled");
|
||||
assert.equal(payload.removedGrants.deviceGrants, 1);
|
||||
|
||||
const state = await data.readState();
|
||||
assert.equal(state.authAccounts.find((account) => account.account === "worker@acme.com")?.status, "disabled");
|
||||
assert.equal(state.accountDeviceGrants.some((grant) => grant.account === "worker@acme.com"), false);
|
||||
assert.equal(
|
||||
state.authSessions.find((session) => session.sessionToken === workerSession.sessionToken)?.revokedAt !== undefined,
|
||||
true,
|
||||
);
|
||||
assert.equal(state.permissionAuditLogs.at(0)?.action, "account.reclaimed");
|
||||
});
|
||||
35
tests/admin-domain-routing.test.ts
Normal file
35
tests/admin-domain-routing.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
const appRootPath = new URL("../src/app/page.tsx", import.meta.url);
|
||||
const appUiPath = new URL("../src/components/app-ui.tsx", import.meta.url);
|
||||
const caddyfilePath = new URL("../deployment/Caddyfile", import.meta.url);
|
||||
|
||||
async function readSource(path: URL) {
|
||||
return readFile(path, "utf8");
|
||||
}
|
||||
|
||||
test("admin host root redirects to the platform admin console", async () => {
|
||||
const source = await readSource(appRootPath);
|
||||
|
||||
assert.match(source, /headers/);
|
||||
assert.match(source, /admin\.boss\.hyzq\.net/);
|
||||
assert.match(source, /redirect\(session \? "\/admin" : "\/auth\/login"\)/);
|
||||
});
|
||||
|
||||
test("web login returns admin host users to the admin console", async () => {
|
||||
const source = await readSource(appUiPath);
|
||||
|
||||
assert.match(source, /admin\.boss\.hyzq\.net/);
|
||||
assert.match(source, /navigateAfterLogin/);
|
||||
assert.match(source, /\/admin/);
|
||||
});
|
||||
|
||||
test("Caddy serves the platform admin subdomain", async () => {
|
||||
const source = await readSource(caddyfilePath);
|
||||
|
||||
assert.match(source, /admin\.boss\.hyzq\.net/);
|
||||
assert.match(source, /redir \/ \/admin/);
|
||||
assert.match(source, /reverse_proxy 127\.0\.0\.1:3000/);
|
||||
});
|
||||
211
tests/admin-enterprise-ops-route.test.ts
Normal file
211
tests/admin-enterprise-ops-route.test.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
let runtimeRoot = "";
|
||||
let data: typeof import("../src/lib/boss-data");
|
||||
let authCookie = "";
|
||||
let postAdminAccess: (typeof import("../src/app/api/v1/admin/access/route"))["POST"];
|
||||
let baseState: Awaited<ReturnType<typeof import("../src/lib/boss-data")["readState"]>>;
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) return;
|
||||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-admin-enterprise-ops-"));
|
||||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||||
const [dataModule, authModule, routeModule] = await Promise.all([
|
||||
import("../src/lib/boss-data.ts"),
|
||||
import("../src/lib/boss-auth.ts"),
|
||||
import("../src/app/api/v1/admin/access/route.ts"),
|
||||
]);
|
||||
data = dataModule;
|
||||
authCookie = authModule.AUTH_SESSION_COOKIE;
|
||||
postAdminAccess = routeModule.POST;
|
||||
baseState = structuredClone(await data.readState());
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
if (runtimeRoot) await rm(runtimeRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await setup();
|
||||
const state = structuredClone(baseState);
|
||||
const now = "2026-04-27T18:00:00+08:00";
|
||||
state.adminCompanies = [
|
||||
{
|
||||
companyId: "tenant-a",
|
||||
name: "Tenant A",
|
||||
status: "active",
|
||||
ownerAccount: "admin@tenant-a.com",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
];
|
||||
state.authAccounts = [
|
||||
{
|
||||
id: "account-owner",
|
||||
account: "owner@platform.com",
|
||||
passwordHash: data.hashPassword("OwnerPass123"),
|
||||
displayName: "平台老板",
|
||||
role: "highest_admin",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "account-admin",
|
||||
account: "admin@tenant-a.com",
|
||||
passwordHash: data.hashPassword("OldPass123"),
|
||||
displayName: "客户管理员",
|
||||
role: "admin",
|
||||
companyId: "tenant-a",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "account-worker",
|
||||
account: "worker@tenant-a.com",
|
||||
passwordHash: data.hashPassword("WorkerPass123"),
|
||||
displayName: "客户成员",
|
||||
role: "member",
|
||||
companyId: "tenant-a",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
];
|
||||
state.authSessions = [];
|
||||
state.permissionAuditLogs = [];
|
||||
await data.writeState(state);
|
||||
});
|
||||
|
||||
async function authedRequest(body: Record<string, unknown>, extraHeaders: Record<string, string> = {}) {
|
||||
const session = await data.createAuthSession({
|
||||
account: "owner@platform.com",
|
||||
role: "highest_admin",
|
||||
displayName: "平台老板",
|
||||
loginMethod: "password",
|
||||
});
|
||||
return new NextRequest("http://127.0.0.1:3000/api/v1/admin/access", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
cookie: `${authCookie}=${session.sessionToken}`,
|
||||
...extraHeaders,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function adminPost(body: Record<string, unknown>) {
|
||||
return postAdminAccess(await authedRequest(body));
|
||||
}
|
||||
|
||||
async function adminPostWithHeaders(body: Record<string, unknown>, headers: Record<string, string>) {
|
||||
return postAdminAccess(await authedRequest(body, headers));
|
||||
}
|
||||
|
||||
test("bulk import preview reports create and update rows without mutating accounts", async () => {
|
||||
const response = await adminPost({
|
||||
action: "preview_bulk_import_accounts",
|
||||
companyId: "tenant-a",
|
||||
accounts: [
|
||||
{ account: "worker@tenant-a.com", displayName: "现有成员", role: "member" },
|
||||
{ account: "new@tenant-a.com", displayName: "新成员", role: "admin" },
|
||||
],
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
|
||||
assert.equal(payload.preview.summary.create, 1);
|
||||
assert.equal(payload.preview.summary.update, 1);
|
||||
assert.deepEqual(
|
||||
payload.preview.rows.map((row: { account: string; operation: string }) => [row.account, row.operation]),
|
||||
[
|
||||
["worker@tenant-a.com", "update"],
|
||||
["new@tenant-a.com", "create"],
|
||||
],
|
||||
);
|
||||
|
||||
const state = await data.readState();
|
||||
assert.equal(state.authAccounts.some((account) => account.account === "new@tenant-a.com"), false);
|
||||
});
|
||||
|
||||
test("highest admin can reset a child account password and revoke old sessions", async () => {
|
||||
const oldSession = await data.loginAccount({
|
||||
account: "worker@tenant-a.com",
|
||||
password: "WorkerPass123",
|
||||
method: "password",
|
||||
});
|
||||
|
||||
const response = await adminPostWithHeaders(
|
||||
{
|
||||
action: "reset_account_password",
|
||||
account: "worker@tenant-a.com",
|
||||
password: "NewWorkerPass456",
|
||||
},
|
||||
{
|
||||
"x-forwarded-for": "203.0.113.10, 10.0.0.1",
|
||||
"user-agent": "BossAdminTest/1.0",
|
||||
"x-request-id": "req-reset-001",
|
||||
},
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.account.account, "worker@tenant-a.com");
|
||||
assert.equal(payload.account.passwordHash, undefined);
|
||||
|
||||
await assert.rejects(
|
||||
data.loginAccount({ account: "worker@tenant-a.com", password: "WorkerPass123", method: "password" }),
|
||||
/INVALID_ACCOUNT_OR_PASSWORD/,
|
||||
);
|
||||
const login = await data.loginAccount({
|
||||
account: "worker@tenant-a.com",
|
||||
password: "NewWorkerPass456",
|
||||
method: "password",
|
||||
});
|
||||
assert.equal(login.account, "worker@tenant-a.com");
|
||||
assert.equal(await data.getAuthSession(oldSession.sessionToken), null);
|
||||
const audit = (await data.readState()).permissionAuditLogs.at(0);
|
||||
assert.equal(audit?.action, "account.password_reset");
|
||||
assert.equal(audit?.ipAddress, "203.0.113.10");
|
||||
assert.equal(audit?.userAgent, "BossAdminTest/1.0");
|
||||
assert.equal(audit?.requestId, "req-reset-001");
|
||||
assert.deepEqual(audit?.beforeJson, { account: "worker@tenant-a.com", status: "active", activeSessions: 1 });
|
||||
assert.deepEqual(audit?.afterJson, { account: "worker@tenant-a.com", status: "active", revokedSessions: 1 });
|
||||
});
|
||||
|
||||
test("disabling a company disables child accounts and revokes their sessions", async () => {
|
||||
const workerSession = await data.createAuthSession({
|
||||
account: "worker@tenant-a.com",
|
||||
role: "member",
|
||||
displayName: "客户成员",
|
||||
loginMethod: "password",
|
||||
});
|
||||
|
||||
const response = await adminPost({
|
||||
action: "set_company_status",
|
||||
companyId: "tenant-a",
|
||||
status: "disabled",
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.company.status, "disabled");
|
||||
assert.equal(payload.disabledAccounts, 2);
|
||||
|
||||
const state = await data.readState();
|
||||
assert.equal(state.adminCompanies.find((company) => company.companyId === "tenant-a")?.status, "disabled");
|
||||
assert.equal(
|
||||
state.authAccounts
|
||||
.filter((account) => account.companyId === "tenant-a")
|
||||
.every((account) => account.status === "disabled"),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
state.authSessions.find((session) => session.sessionToken === workerSession.sessionToken)?.revokedAt !== undefined,
|
||||
true,
|
||||
);
|
||||
assert.equal(state.permissionAuditLogs.at(0)?.action, "company.status_updated");
|
||||
});
|
||||
133
tests/admin-notification-dispatch-route.test.ts
Normal file
133
tests/admin-notification-dispatch-route.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
let runtimeRoot = "";
|
||||
let data: typeof import("../src/lib/boss-data");
|
||||
let authCookie = "";
|
||||
let postDispatch: (typeof import("../src/app/api/v1/admin/notifications/dispatch/route"))["POST"];
|
||||
let getOverview: (typeof import("../src/app/api/v1/admin/overview/route"))["GET"];
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) return;
|
||||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-admin-notification-dispatch-"));
|
||||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||||
process.env.BOSS_ADMIN_NOTIFICATION_MODE = "disabled";
|
||||
const [dataModule, authModule, dispatchRoute, overviewRoute] = await Promise.all([
|
||||
import("../src/lib/boss-data.ts"),
|
||||
import("../src/lib/boss-auth.ts"),
|
||||
import("../src/app/api/v1/admin/notifications/dispatch/route.ts"),
|
||||
import("../src/app/api/v1/admin/overview/route.ts"),
|
||||
]);
|
||||
data = dataModule;
|
||||
authCookie = authModule.AUTH_SESSION_COOKIE;
|
||||
postDispatch = dispatchRoute.POST;
|
||||
getOverview = overviewRoute.GET;
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
if (runtimeRoot) await rm(runtimeRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await setup();
|
||||
const now = "2026-04-27T18:20:00+08:00";
|
||||
const state = await data.readState();
|
||||
await data.writeState({
|
||||
...state,
|
||||
adminCompanies: [
|
||||
{
|
||||
companyId: "tenant-a",
|
||||
name: "Tenant A",
|
||||
ownerAccount: "owner@tenant-a.com",
|
||||
successOwnerAccount: "cs@platform.com",
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
],
|
||||
authAccounts: [
|
||||
{
|
||||
id: "account-platform",
|
||||
account: "platform@example.com",
|
||||
passwordHash: "hash",
|
||||
displayName: "平台管理员",
|
||||
role: "highest_admin",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "account-owner",
|
||||
account: "owner@tenant-a.com",
|
||||
passwordHash: "hash",
|
||||
displayName: "客户负责人",
|
||||
role: "admin",
|
||||
companyId: "tenant-a",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
],
|
||||
adminNotifications: [
|
||||
{
|
||||
notificationId: "risk-sla-overdue:ops-fault:fault-a",
|
||||
kind: "risk_sla_overdue",
|
||||
severity: "critical",
|
||||
companyId: "tenant-a",
|
||||
riskId: "ops-fault:fault-a",
|
||||
title: "风险 SLA 已超时:local-agent",
|
||||
body: "local-agent 离线超过 SLA",
|
||||
status: "open",
|
||||
createdAt: now,
|
||||
},
|
||||
],
|
||||
adminRiskTimeline: [],
|
||||
});
|
||||
});
|
||||
|
||||
async function adminRequest(url: string, init: RequestInit = {}) {
|
||||
const session = await data.createAuthSession({
|
||||
account: "platform@example.com",
|
||||
role: "highest_admin",
|
||||
displayName: "平台管理员",
|
||||
loginMethod: "password",
|
||||
});
|
||||
return new NextRequest(url, {
|
||||
...init,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(init.headers ?? {}),
|
||||
cookie: `${authCookie}=${session.sessionToken}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test("highest admin can dispatch open risk notifications and persist delivery status", async () => {
|
||||
const response = await postDispatch(await adminRequest("http://127.0.0.1:3000/api/v1/admin/notifications/dispatch", {
|
||||
method: "POST",
|
||||
}));
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.results.length, 1);
|
||||
assert.equal(payload.results[0].notificationId, "risk-sla-overdue:ops-fault:fault-a");
|
||||
assert.equal(payload.results[0].status, "disabled");
|
||||
|
||||
const state = await data.readState();
|
||||
assert.equal(state.adminNotifications[0]?.deliveryStatus, "disabled");
|
||||
assert.equal(state.adminNotifications[0]?.deliveryTarget?.includes("owner@tenant-a.com"), true);
|
||||
assert.equal(state.adminRiskTimeline.some((event) => event.action === "notification_dispatch_disabled"), true);
|
||||
});
|
||||
|
||||
test("admin overview exposes recent risk timeline events", async () => {
|
||||
await postDispatch(await adminRequest("http://127.0.0.1:3000/api/v1/admin/notifications/dispatch", { method: "POST" }));
|
||||
|
||||
const response = await getOverview(await adminRequest("http://127.0.0.1:3000/api/v1/admin/overview"));
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.riskTimeline.length > 0, true);
|
||||
assert.equal(payload.riskTimeline[0].riskId, "ops-fault:fault-a");
|
||||
});
|
||||
369
tests/admin-overview-route.test.ts
Normal file
369
tests/admin-overview-route.test.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
let runtimeRoot = "";
|
||||
let data: typeof import("../src/lib/boss-data.ts");
|
||||
let authCookie = "";
|
||||
let getAdminOverview: (typeof import("../src/app/api/v1/admin/overview/route.ts"))["GET"];
|
||||
let baseState: Awaited<ReturnType<typeof import("../src/lib/boss-data.ts")["readState"]>>;
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) return;
|
||||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-admin-overview-"));
|
||||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||||
|
||||
const [dataModule, authModule, routeModule] = await Promise.all([
|
||||
import("../src/lib/boss-data.ts"),
|
||||
import("../src/lib/boss-auth.ts"),
|
||||
import("../src/app/api/v1/admin/overview/route.ts"),
|
||||
]);
|
||||
data = dataModule;
|
||||
authCookie = authModule.AUTH_SESSION_COOKIE;
|
||||
getAdminOverview = routeModule.GET;
|
||||
baseState = structuredClone(await data.readState());
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
if (runtimeRoot) {
|
||||
await rm(runtimeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await setup();
|
||||
const state = structuredClone(baseState);
|
||||
const now = "2026-04-27T10:00:00+08:00";
|
||||
state.authAccounts = [
|
||||
{
|
||||
id: "account-owner",
|
||||
account: "owner@acme.com",
|
||||
passwordHash: "secret",
|
||||
displayName: "Acme 老板",
|
||||
role: "highest_admin",
|
||||
primaryDeviceId: "mac-1",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastLoginAt: now,
|
||||
},
|
||||
{
|
||||
id: "account-dev",
|
||||
account: "dev@acme.com",
|
||||
passwordHash: "secret",
|
||||
displayName: "开发同事",
|
||||
role: "member",
|
||||
primaryDeviceId: "win-1",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
];
|
||||
state.authSessions = [];
|
||||
state.devices = [
|
||||
{
|
||||
id: "mac-1",
|
||||
name: "Acme Mac Studio",
|
||||
avatar: "A",
|
||||
account: "owner@acme.com",
|
||||
source: "production",
|
||||
status: "online",
|
||||
projects: ["project-acme"],
|
||||
quota5h: 0,
|
||||
quota7d: 0,
|
||||
lastSeenAt: "2026-04-27T09:58:00+08:00",
|
||||
preferredExecutionMode: "cli",
|
||||
capabilities: {
|
||||
gui: { connected: true, lastSeenAt: "2026-04-27T09:58:00+08:00" },
|
||||
cli: { connected: true, lastSeenAt: "2026-04-27T09:58:00+08:00" },
|
||||
browserAutomation: { connected: false },
|
||||
computerUse: { connected: false },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "win-1",
|
||||
name: "Acme Windows",
|
||||
avatar: "W",
|
||||
account: "dev@acme.com",
|
||||
source: "production",
|
||||
status: "offline",
|
||||
projects: ["project-acme"],
|
||||
quota5h: 0,
|
||||
quota7d: 0,
|
||||
lastSeenAt: "2026-04-27T08:00:00+08:00",
|
||||
preferredExecutionMode: "gui",
|
||||
capabilities: {
|
||||
gui: { connected: false },
|
||||
cli: { connected: false },
|
||||
browserAutomation: { connected: false },
|
||||
computerUse: { connected: false },
|
||||
},
|
||||
},
|
||||
];
|
||||
state.projects = [
|
||||
{
|
||||
id: "project-acme",
|
||||
name: "Acme 生产项目",
|
||||
pinned: false,
|
||||
deviceIds: ["mac-1", "win-1"],
|
||||
preview: "",
|
||||
updatedAt: now,
|
||||
lastMessageAt: now,
|
||||
isGroup: false,
|
||||
threadMeta: {
|
||||
projectId: "project-acme",
|
||||
threadId: "thread-acme",
|
||||
threadDisplayName: "Acme 线程",
|
||||
folderName: "acme",
|
||||
activityIconCount: 0,
|
||||
updatedAt: now,
|
||||
},
|
||||
groupMembers: [],
|
||||
createdByAgent: false,
|
||||
collaborationMode: "development",
|
||||
approvalState: "not_required",
|
||||
unreadCount: 0,
|
||||
riskLevel: "low",
|
||||
messages: [],
|
||||
goals: [],
|
||||
versions: [],
|
||||
},
|
||||
];
|
||||
state.opsFaults = [
|
||||
{
|
||||
faultId: "fault-1",
|
||||
faultKey: "LOCAL_AGENT.HEARTBEAT_FAILED",
|
||||
severity: "critical",
|
||||
status: "opened",
|
||||
nodeId: "win-1",
|
||||
serviceName: "local-agent",
|
||||
projectId: "project-acme",
|
||||
traceId: "trace-1",
|
||||
runbookId: "runbook-agent",
|
||||
firstSeenAt: "2026-04-27T09:00:00+08:00",
|
||||
lastSeenAt: "2026-04-27T09:30:00+08:00",
|
||||
summary: "Windows 节点心跳失败",
|
||||
suggestedNextAction: "检查 local-agent",
|
||||
autoRepairable: true,
|
||||
},
|
||||
];
|
||||
state.threadContextAlerts = [
|
||||
{
|
||||
alertId: "alert-1",
|
||||
projectId: "project-acme",
|
||||
threadId: "thread-acme",
|
||||
alertType: "context_critical",
|
||||
alertStatus: "opened",
|
||||
openedAt: "2026-04-27T09:20:00+08:00",
|
||||
summary: "线程上下文接近耗尽",
|
||||
masterActions: ["先固化版本记录"],
|
||||
},
|
||||
];
|
||||
state.threadContextSnapshots = [
|
||||
{
|
||||
snapshotId: "snapshot-1",
|
||||
projectId: "project-acme",
|
||||
taskId: "task-context-1",
|
||||
threadId: "thread-acme",
|
||||
title: "Acme 线程",
|
||||
summary: "上下文接近耗尽",
|
||||
nodeId: "mac-1",
|
||||
workerId: "worker-1",
|
||||
sourceKind: "codex_sdk",
|
||||
status: "running",
|
||||
contextBudgetRemainingPct: 8,
|
||||
contextBudgetLevel: "critical",
|
||||
mustFinishBeforeCompaction: true,
|
||||
estimatedRemainingTurns: 1,
|
||||
estimatedRemainingLargeMessages: 0,
|
||||
compactionCount: 0,
|
||||
patchPending: true,
|
||||
testsPending: true,
|
||||
evidencePending: false,
|
||||
checklist: ["固化项目目标", "补版本记录"],
|
||||
capturedAt: "2026-04-27T09:19:00+08:00",
|
||||
},
|
||||
];
|
||||
state.masterAgentTasks = [
|
||||
{
|
||||
taskId: "task-failed-1",
|
||||
projectId: "project-acme",
|
||||
taskType: "conversation_reply",
|
||||
requestMessageId: "msg-1",
|
||||
requestText: "帮我继续开发",
|
||||
executionPrompt: "prompt",
|
||||
requestedBy: "owner@acme.com",
|
||||
requestedByAccount: "owner@acme.com",
|
||||
deviceId: "mac-1",
|
||||
status: "failed",
|
||||
requestedAt: "2026-04-27T09:10:00+08:00",
|
||||
completedAt: "2026-04-27T09:12:00+08:00",
|
||||
errorMessage: "Master Codex Node 执行失败",
|
||||
},
|
||||
];
|
||||
state.accountDeviceGrants = [
|
||||
{
|
||||
grantId: "grant-expired",
|
||||
account: "dev@acme.com",
|
||||
deviceId: "mac-1",
|
||||
permissions: ["device.view"],
|
||||
grantedBy: "owner@acme.com",
|
||||
grantedAt: "2026-04-20T10:00:00+08:00",
|
||||
expiresAt: "2026-04-21T10:00:00+08:00",
|
||||
},
|
||||
];
|
||||
state.accountProjectGrants = [];
|
||||
state.accountSkillGrants = [];
|
||||
await data.writeState(state);
|
||||
});
|
||||
|
||||
async function authedRequest(account: string, role: "member" | "admin" | "highest_admin") {
|
||||
const session = await data.createAuthSession({
|
||||
account,
|
||||
role,
|
||||
displayName: account,
|
||||
loginMethod: "password",
|
||||
});
|
||||
return new NextRequest("http://127.0.0.1:3000/api/v1/admin/overview", {
|
||||
headers: { cookie: `${authCookie}=${session.sessionToken}` },
|
||||
});
|
||||
}
|
||||
|
||||
test("admin overview requires highest admin", async () => {
|
||||
await setup();
|
||||
const unauthorized = await getAdminOverview(
|
||||
new NextRequest("http://127.0.0.1:3000/api/v1/admin/overview"),
|
||||
);
|
||||
assert.equal(unauthorized.status, 401);
|
||||
|
||||
const forbidden = await getAdminOverview(await authedRequest("dev@acme.com", "member"));
|
||||
assert.equal(forbidden.status, 403);
|
||||
});
|
||||
|
||||
test("highest admin can read companies devices risks and grant summary", async () => {
|
||||
await setup();
|
||||
const response = await getAdminOverview(await authedRequest("owner@acme.com", "highest_admin"));
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get("cache-control"), "private, no-store, max-age=0");
|
||||
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.ok, true);
|
||||
assert.equal(payload.summary.companies, 2);
|
||||
assert.equal(payload.summary.accounts, 3);
|
||||
assert.equal(payload.summary.devices, 2);
|
||||
assert.equal(payload.summary.onlineDevices, 1);
|
||||
assert.equal(payload.summary.openRisks, 4);
|
||||
assert.equal(payload.summary.criticalRisks, 2);
|
||||
|
||||
const acme = payload.companies.find((company: { companyId: string }) => company.companyId === "acme.com");
|
||||
assert.equal(acme.name, "acme.com");
|
||||
assert.equal(acme.accountCount, 2);
|
||||
assert.equal(acme.adminCount, 1);
|
||||
assert.equal(acme.deviceCount, 2);
|
||||
assert.equal(acme.onlineDeviceCount, 1);
|
||||
assert.equal(acme.openRiskCount, 4);
|
||||
|
||||
assert.equal(payload.accounts[0].passwordHash, undefined);
|
||||
const offlineRisk = payload.risks.find((risk: { kind: string }) => risk.kind === "device_offline");
|
||||
assert.equal(offlineRisk.deviceId, "win-1");
|
||||
assert.equal(offlineRisk.companyId, "acme.com");
|
||||
assert.match(offlineRisk.title, /设备离线/);
|
||||
|
||||
const failedTaskRisk = payload.risks.find((risk: { kind: string }) => risk.kind === "master_agent_task_failed");
|
||||
assert.match(failedTaskRisk.detail, /Master Codex Node/);
|
||||
|
||||
const winDevice = payload.devices.find((device: { id: string }) => device.id === "win-1");
|
||||
assert.equal(winDevice.projectCount, 1);
|
||||
assert.equal(winDevice.codexGuiOnline, false);
|
||||
assert.equal(winDevice.codexCliOnline, false);
|
||||
assert.equal(winDevice.openRiskCount, 2);
|
||||
|
||||
assert.deepEqual(payload.grantsSummary, {
|
||||
deviceGrants: 1,
|
||||
projectGrants: 0,
|
||||
skillGrants: 0,
|
||||
expiredGrants: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("admin overview folds repeated master-agent task failures for the same device and project", async () => {
|
||||
const state = await data.readState();
|
||||
const baseTask = state.masterAgentTasks[0];
|
||||
assert.ok(baseTask);
|
||||
state.masterAgentTasks = [
|
||||
baseTask,
|
||||
{
|
||||
...baseTask,
|
||||
taskId: "task-failed-2",
|
||||
requestMessageId: "msg-2",
|
||||
requestedAt: "2026-04-27T09:14:00+08:00",
|
||||
completedAt: "2026-04-27T09:15:00+08:00",
|
||||
errorMessage: "Master Codex Node 执行失败",
|
||||
},
|
||||
{
|
||||
...baseTask,
|
||||
taskId: "task-failed-3",
|
||||
requestMessageId: "msg-3",
|
||||
requestedAt: "2026-04-27T09:16:00+08:00",
|
||||
completedAt: "2026-04-27T09:17:00+08:00",
|
||||
errorMessage: "Master Codex Node 执行失败",
|
||||
},
|
||||
];
|
||||
await data.writeState(state);
|
||||
|
||||
const response = await getAdminOverview(await authedRequest("owner@acme.com", "highest_admin"));
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
const masterTaskRisks = payload.risks.filter((risk: { kind: string }) => risk.kind === "master_agent_task_failed");
|
||||
|
||||
assert.equal(masterTaskRisks.length, 1);
|
||||
assert.match(masterTaskRisks[0].detail, /已折叠 2 条同类失败/);
|
||||
assert.equal(payload.summary.openRisks, 4);
|
||||
});
|
||||
|
||||
test("admin overview prefers explicit company assignment over account domain", async () => {
|
||||
const state = await data.readState();
|
||||
const now = "2026-04-27T11:00:00+08:00";
|
||||
state.adminCompanies = [
|
||||
{
|
||||
companyId: "tenant-hyzq",
|
||||
name: "环宇智擎客户",
|
||||
ownerAccount: "owner@acme.com",
|
||||
status: "active",
|
||||
note: "企业客户",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
];
|
||||
state.authAccounts = state.authAccounts.map((account) => ({
|
||||
...account,
|
||||
companyId: account.account === "dev@acme.com" ? "tenant-hyzq" : account.companyId,
|
||||
}));
|
||||
state.devices = state.devices.map((device) => ({
|
||||
...device,
|
||||
companyId: device.id === "win-1" ? "tenant-hyzq" : device.companyId,
|
||||
}));
|
||||
await data.writeState(state);
|
||||
|
||||
const response = await getAdminOverview(await authedRequest("owner@acme.com", "highest_admin"));
|
||||
assert.equal(response.status, 200);
|
||||
|
||||
const payload = await response.json();
|
||||
const tenant = payload.companies.find((company: { companyId: string }) => company.companyId === "tenant-hyzq");
|
||||
assert.equal(tenant.name, "环宇智擎客户");
|
||||
assert.equal(tenant.accountCount, 1);
|
||||
assert.equal(tenant.deviceCount, 1);
|
||||
|
||||
const devAccount = payload.accounts.find((account: { account: string }) => account.account === "dev@acme.com");
|
||||
assert.equal(devAccount.companyId, "tenant-hyzq");
|
||||
assert.equal(devAccount.companyName, "环宇智擎客户");
|
||||
|
||||
const winDevice = payload.devices.find((device: { id: string }) => device.id === "win-1");
|
||||
assert.equal(winDevice.companyId, "tenant-hyzq");
|
||||
assert.equal(winDevice.companyName, "环宇智擎客户");
|
||||
|
||||
const offlineRisk = payload.risks.find((risk: { kind: string }) => risk.kind === "device_offline");
|
||||
assert.equal(offlineRisk.companyId, "tenant-hyzq");
|
||||
});
|
||||
68
tests/admin-refine-page.test.ts
Normal file
68
tests/admin-refine-page.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
const adminPagePath = new URL("../src/app/admin/page.tsx", import.meta.url);
|
||||
const adminAppPath = new URL("../src/components/admin/boss-admin-app.tsx", import.meta.url);
|
||||
const dataProviderPath = new URL("../src/components/admin/boss-admin-data-provider.ts", import.meta.url);
|
||||
|
||||
async function readSource(path: URL) {
|
||||
return readFile(path, "utf8");
|
||||
}
|
||||
|
||||
test("/admin page gates the refine shell behind highest_admin", async () => {
|
||||
const source = await readSource(adminPagePath);
|
||||
|
||||
assert.match(source, /requirePageSession/);
|
||||
assert.match(source, /BossAdminApp/);
|
||||
assert.match(source, /session\.role\s*!==\s*["']highest_admin["']/);
|
||||
assert.match(source, /仅最高管理员可用/);
|
||||
});
|
||||
|
||||
test("BossAdminApp wires refine resources and enterprise overview sections", async () => {
|
||||
const source = await readSource(adminAppPath);
|
||||
|
||||
assert.match(source, /['"]use client['"]/);
|
||||
assert.doesNotMatch(source, /@refinedev\/antd/);
|
||||
assert.match(source, /Refine/);
|
||||
assert.match(source, /@refinedev\/core/);
|
||||
assert.match(source, /ConfigProvider/);
|
||||
assert.match(source, /Table/);
|
||||
assert.match(source, /Alert/);
|
||||
for (const resource of ["companies", "accounts", "devices", "risks", "auditLogs"]) {
|
||||
assert.match(source, new RegExp(`name:\\s*["']${resource}["']`));
|
||||
}
|
||||
for (const title of ["平台运营驾驶舱", "客户与账号", "授权工作台", "风险与治理"]) {
|
||||
assert.match(source, new RegExp(title));
|
||||
}
|
||||
for (const title of ["今日待处理", "客户健康排行", "关键风险队列", "节点健康", "最近事件"]) {
|
||||
assert.match(source, new RegExp(title));
|
||||
}
|
||||
for (const riskAction of ["assign_owner", "set_sla", "负责人", "SLA"]) {
|
||||
assert.match(source, new RegExp(riskAction));
|
||||
}
|
||||
assert.doesNotMatch(source, /window\.prompt/);
|
||||
});
|
||||
|
||||
test("BossAdminApp uses the approved PC To B admin shell", async () => {
|
||||
const source = await readSource(adminAppPath);
|
||||
|
||||
assert.match(source, /adminShell/);
|
||||
assert.match(source, /adminSidebar/);
|
||||
assert.match(source, /adminHeader/);
|
||||
assert.match(source, /currentSubtitle/);
|
||||
assert.match(source, /bg-\[#F3F5F2\]/);
|
||||
assert.match(source, /highest_admin/);
|
||||
assert.match(source, /开放风险/);
|
||||
assert.match(source, /风险通知/);
|
||||
});
|
||||
|
||||
test("admin data provider reads the overview endpoint and supports initialOverview", async () => {
|
||||
const appSource = await readSource(adminAppPath);
|
||||
const providerSource = await readSource(dataProviderPath);
|
||||
|
||||
assert.match(appSource, /initialOverview/);
|
||||
assert.doesNotMatch(providerSource, /@refinedev\/antd/);
|
||||
assert.match(providerSource, /\/api\/v1\/admin\/overview/);
|
||||
assert.match(providerSource, /initialOverview/);
|
||||
});
|
||||
360
tests/admin-risk-actions-route.test.ts
Normal file
360
tests/admin-risk-actions-route.test.ts
Normal file
@@ -0,0 +1,360 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
let runtimeRoot = "";
|
||||
let data: typeof import("../src/lib/boss-data");
|
||||
let authCookie = "";
|
||||
let postRiskAction: (typeof import("../src/app/api/v1/admin/risks/actions/route"))["POST"];
|
||||
let subscribeBossEvents: (typeof import("../src/lib/boss-events"))["subscribeBossEvents"];
|
||||
let baseState: Awaited<ReturnType<typeof import("../src/lib/boss-data")["readState"]>>;
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) return;
|
||||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-admin-risk-actions-"));
|
||||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||||
|
||||
const [dataModule, authModule, routeModule, eventsModule] = await Promise.all([
|
||||
import("../src/lib/boss-data.ts"),
|
||||
import("../src/lib/boss-auth.ts"),
|
||||
import("../src/app/api/v1/admin/risks/actions/route.ts"),
|
||||
import("../src/lib/boss-events.ts"),
|
||||
]);
|
||||
data = dataModule;
|
||||
authCookie = authModule.AUTH_SESSION_COOKIE;
|
||||
postRiskAction = routeModule.POST;
|
||||
subscribeBossEvents = eventsModule.subscribeBossEvents;
|
||||
baseState = structuredClone(await data.readState());
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
if (runtimeRoot) {
|
||||
await rm(runtimeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await setup();
|
||||
const state = structuredClone(baseState);
|
||||
const now = "2026-04-27T10:00:00+08:00";
|
||||
|
||||
state.authAccounts = [
|
||||
{
|
||||
id: "account-owner",
|
||||
account: "owner@acme.com",
|
||||
passwordHash: "secret",
|
||||
displayName: "Acme 老板",
|
||||
role: "highest_admin",
|
||||
primaryDeviceId: "mac-1",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "account-admin",
|
||||
account: "admin@acme.com",
|
||||
passwordHash: "secret",
|
||||
displayName: "Acme 管理员",
|
||||
role: "admin",
|
||||
primaryDeviceId: "mac-1",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
];
|
||||
state.authSessions = [];
|
||||
state.devices = [
|
||||
{
|
||||
id: "mac-1",
|
||||
name: "Acme Mac",
|
||||
avatar: "A",
|
||||
account: "owner@acme.com",
|
||||
source: "production",
|
||||
status: "online",
|
||||
projects: ["project-acme"],
|
||||
quota5h: 0,
|
||||
quota7d: 0,
|
||||
lastSeenAt: "2026-04-27T09:58:00+08:00",
|
||||
preferredExecutionMode: "cli",
|
||||
capabilities: {
|
||||
gui: { connected: true, lastSeenAt: "2026-04-27T09:58:00+08:00" },
|
||||
cli: { connected: true, lastSeenAt: "2026-04-27T09:58:00+08:00" },
|
||||
browserAutomation: { connected: false },
|
||||
computerUse: { connected: false },
|
||||
},
|
||||
},
|
||||
];
|
||||
state.projects = [
|
||||
{
|
||||
id: "project-acme",
|
||||
name: "Acme 生产项目",
|
||||
pinned: false,
|
||||
deviceIds: ["mac-1"],
|
||||
preview: "",
|
||||
updatedAt: now,
|
||||
lastMessageAt: now,
|
||||
isGroup: false,
|
||||
threadMeta: {
|
||||
projectId: "project-acme",
|
||||
threadId: "thread-acme",
|
||||
threadDisplayName: "Acme 线程",
|
||||
folderName: "acme",
|
||||
activityIconCount: 0,
|
||||
updatedAt: now,
|
||||
},
|
||||
groupMembers: [],
|
||||
createdByAgent: false,
|
||||
collaborationMode: "development",
|
||||
approvalState: "not_required",
|
||||
unreadCount: 0,
|
||||
riskLevel: "low",
|
||||
messages: [],
|
||||
goals: [],
|
||||
versions: [],
|
||||
},
|
||||
];
|
||||
state.opsFaults = [
|
||||
{
|
||||
faultId: "fault-1",
|
||||
faultKey: "LOCAL_AGENT.HEARTBEAT_FAILED",
|
||||
severity: "critical",
|
||||
status: "opened",
|
||||
nodeId: "mac-1",
|
||||
serviceName: "local-agent",
|
||||
projectId: "project-acme",
|
||||
traceId: "trace-1",
|
||||
runbookId: "runbook-agent",
|
||||
firstSeenAt: "2026-04-27T09:00:00+08:00",
|
||||
lastSeenAt: "2026-04-27T09:30:00+08:00",
|
||||
summary: "Mac 节点心跳失败",
|
||||
suggestedNextAction: "检查 local-agent",
|
||||
autoRepairable: true,
|
||||
},
|
||||
];
|
||||
state.opsRepairTickets = [];
|
||||
state.opsRepairVerifications = [];
|
||||
state.threadContextAlerts = [
|
||||
{
|
||||
alertId: "alert-1",
|
||||
projectId: "project-acme",
|
||||
threadId: "thread-acme",
|
||||
alertType: "context_critical",
|
||||
alertStatus: "opened",
|
||||
openedAt: "2026-04-27T09:20:00+08:00",
|
||||
summary: "线程上下文接近耗尽",
|
||||
masterActions: ["先固化版本记录"],
|
||||
},
|
||||
];
|
||||
state.threadContextSnapshots = [
|
||||
{
|
||||
snapshotId: "snapshot-1",
|
||||
projectId: "project-acme",
|
||||
taskId: "task-context-1",
|
||||
threadId: "thread-acme",
|
||||
title: "Acme 线程",
|
||||
summary: "上下文接近耗尽",
|
||||
nodeId: "mac-1",
|
||||
workerId: "worker-1",
|
||||
sourceKind: "codex_sdk",
|
||||
status: "running",
|
||||
contextBudgetRemainingPct: 8,
|
||||
contextBudgetLevel: "critical",
|
||||
mustFinishBeforeCompaction: true,
|
||||
estimatedRemainingTurns: 1,
|
||||
estimatedRemainingLargeMessages: 0,
|
||||
compactionCount: 0,
|
||||
patchPending: true,
|
||||
testsPending: true,
|
||||
evidencePending: false,
|
||||
checklist: ["固化项目目标"],
|
||||
capturedAt: "2026-04-27T09:19:00+08:00",
|
||||
},
|
||||
];
|
||||
|
||||
await data.writeState(state);
|
||||
});
|
||||
|
||||
async function authedRequest(
|
||||
account: string,
|
||||
role: "member" | "admin" | "highest_admin",
|
||||
body: Record<string, unknown>,
|
||||
) {
|
||||
const session = await data.createAuthSession({
|
||||
account,
|
||||
role,
|
||||
displayName: account,
|
||||
loginMethod: "password",
|
||||
});
|
||||
return new NextRequest("http://127.0.0.1:3000/api/v1/admin/risks/actions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
cookie: `${authCookie}=${session.sessionToken}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function adminPost(body: Record<string, unknown>) {
|
||||
return postRiskAction(await authedRequest("owner@acme.com", "highest_admin", body));
|
||||
}
|
||||
|
||||
test("admin risk actions require highest admin", async () => {
|
||||
await setup();
|
||||
const unauthorized = await postRiskAction(
|
||||
new NextRequest("http://127.0.0.1:3000/api/v1/admin/risks/actions", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ riskId: "ops-fault:fault-1", action: "ack" }),
|
||||
}),
|
||||
);
|
||||
assert.equal(unauthorized.status, 401);
|
||||
|
||||
const forbidden = await postRiskAction(
|
||||
await authedRequest("admin@acme.com", "admin", { riskId: "ops-fault:fault-1", action: "ack" }),
|
||||
);
|
||||
assert.equal(forbidden.status, 403);
|
||||
});
|
||||
|
||||
test("highest admin can ack and resolve an ops fault", async () => {
|
||||
const events: Array<{ event: string; payload: { projectId?: string; deviceId?: string; status?: string; note?: string } }> = [];
|
||||
const unsubscribe = subscribeBossEvents((event, payload) => {
|
||||
events.push({ event, payload });
|
||||
});
|
||||
|
||||
const ackResponse = await adminPost({ riskId: "ops-fault:fault-1", action: "ack" });
|
||||
assert.equal(ackResponse.status, 200);
|
||||
const ackPayload = await ackResponse.json();
|
||||
assert.equal(ackPayload.ok, true);
|
||||
assert.equal(ackPayload.riskId, "ops-fault:fault-1");
|
||||
assert.equal(ackPayload.action, "ack");
|
||||
assert.equal(ackPayload.fault.status, "acked");
|
||||
|
||||
let state = await data.readState();
|
||||
assert.equal(state.opsFaults.find((fault) => fault.faultId === "fault-1")?.status, "acked");
|
||||
const ackEvent = events.at(-1);
|
||||
assert.equal(ackEvent?.event, "project.context_risk.updated");
|
||||
assert.equal(ackEvent?.payload.projectId, "project-acme");
|
||||
assert.equal(ackEvent?.payload.deviceId, "mac-1");
|
||||
assert.equal(ackEvent?.payload.status, "acked");
|
||||
assert.equal(ackEvent?.payload.note, "ops-fault:fault-1");
|
||||
|
||||
const resolveResponse = await adminPost({ riskId: "ops-fault:fault-1", action: "resolve" });
|
||||
unsubscribe();
|
||||
assert.equal(resolveResponse.status, 200);
|
||||
const resolvePayload = await resolveResponse.json();
|
||||
assert.equal(resolvePayload.fault.status, "resolved");
|
||||
|
||||
state = await data.readState();
|
||||
assert.equal(state.opsFaults.find((fault) => fault.faultId === "fault-1")?.status, "resolved");
|
||||
});
|
||||
|
||||
test("highest admin can create and reuse an ops repair ticket for a fault", async () => {
|
||||
const firstResponse = await adminPost({ riskId: "ops-fault:fault-1", action: "create_repair_ticket" });
|
||||
assert.equal(firstResponse.status, 200);
|
||||
const firstPayload = await firstResponse.json();
|
||||
assert.equal(firstPayload.ok, true);
|
||||
assert.equal(firstPayload.ticket.faultId, "fault-1");
|
||||
assert.equal(firstPayload.ticket.approvalStatus, "pending");
|
||||
assert.equal(firstPayload.ticket.executionStatus, "queued");
|
||||
assert.equal(firstPayload.ticket.targetNodeId, "mac-1");
|
||||
|
||||
let state = await data.readState();
|
||||
assert.equal(state.opsRepairTickets.length, 1);
|
||||
assert.equal(state.opsRepairTickets[0]?.ticketId, firstPayload.ticket.ticketId);
|
||||
|
||||
const secondResponse = await adminPost({ riskId: "ops-fault:fault-1", action: "create_repair_ticket" });
|
||||
assert.equal(secondResponse.status, 200);
|
||||
const secondPayload = await secondResponse.json();
|
||||
assert.equal(secondPayload.ticket.ticketId, firstPayload.ticket.ticketId);
|
||||
|
||||
state = await data.readState();
|
||||
assert.equal(state.opsRepairTickets.length, 1);
|
||||
});
|
||||
|
||||
test("highest admin can ack and resolve a thread context alert", async () => {
|
||||
const ackResponse = await adminPost({ riskId: "thread-alert:alert-1", action: "ack" });
|
||||
assert.equal(ackResponse.status, 200);
|
||||
const ackPayload = await ackResponse.json();
|
||||
assert.equal(ackPayload.alert.alertStatus, "acked");
|
||||
assert.equal(ackPayload.alert.resolvedAt, undefined);
|
||||
|
||||
let state = await data.readState();
|
||||
assert.equal(state.threadContextAlerts.find((alert) => alert.alertId === "alert-1")?.alertStatus, "acked");
|
||||
|
||||
const resolveResponse = await adminPost({ riskId: "thread-alert:alert-1", action: "resolve" });
|
||||
assert.equal(resolveResponse.status, 200);
|
||||
const resolvePayload = await resolveResponse.json();
|
||||
assert.equal(resolvePayload.alert.alertStatus, "resolved");
|
||||
assert.equal(typeof resolvePayload.alert.resolvedAt, "string");
|
||||
|
||||
state = await data.readState();
|
||||
const alert = state.threadContextAlerts.find((item) => item.alertId === "alert-1");
|
||||
assert.equal(alert?.alertStatus, "resolved");
|
||||
assert.equal(typeof alert?.resolvedAt, "string");
|
||||
});
|
||||
|
||||
test("highest admin can assign owners and SLA to risks", async () => {
|
||||
const assignFaultResponse = await adminPost({
|
||||
riskId: "ops-fault:fault-1",
|
||||
action: "assign_owner",
|
||||
ownerAccount: "admin@acme.com",
|
||||
note: "请先处理心跳链路",
|
||||
});
|
||||
assert.equal(assignFaultResponse.status, 200);
|
||||
const assignFaultPayload = await assignFaultResponse.json();
|
||||
assert.equal(assignFaultPayload.ok, true);
|
||||
assert.equal(assignFaultPayload.fault.ownerAccount, "admin@acme.com");
|
||||
assert.equal(assignFaultPayload.fault.riskNote, "请先处理心跳链路");
|
||||
|
||||
const setFaultSlaResponse = await adminPost({
|
||||
riskId: "ops-fault:fault-1",
|
||||
action: "set_sla",
|
||||
slaDueAt: "2026-04-27T18:00:00+08:00",
|
||||
});
|
||||
assert.equal(setFaultSlaResponse.status, 200);
|
||||
const setFaultSlaPayload = await setFaultSlaResponse.json();
|
||||
assert.equal(setFaultSlaPayload.fault.slaDueAt, "2026-04-27T18:00:00+08:00");
|
||||
|
||||
const assignAlertResponse = await adminPost({
|
||||
riskId: "thread-alert:alert-1",
|
||||
action: "assign_owner",
|
||||
ownerAccount: "admin@acme.com",
|
||||
});
|
||||
assert.equal(assignAlertResponse.status, 200);
|
||||
const assignAlertPayload = await assignAlertResponse.json();
|
||||
assert.equal(assignAlertPayload.alert.ownerAccount, "admin@acme.com");
|
||||
|
||||
const state = await data.readState();
|
||||
assert.equal(state.opsFaults.find((fault) => fault.faultId === "fault-1")?.ownerAccount, "admin@acme.com");
|
||||
assert.equal(state.opsFaults.find((fault) => fault.faultId === "fault-1")?.slaDueAt, "2026-04-27T18:00:00+08:00");
|
||||
assert.equal(state.threadContextAlerts.find((alert) => alert.alertId === "alert-1")?.ownerAccount, "admin@acme.com");
|
||||
});
|
||||
|
||||
test("risk owner and SLA actions require target fields", async () => {
|
||||
const assignResponse = await adminPost({ riskId: "ops-fault:fault-1", action: "assign_owner" });
|
||||
assert.equal(assignResponse.status, 400);
|
||||
const assignPayload = await assignResponse.json();
|
||||
assert.equal(assignPayload.message, "RISK_OWNER_REQUIRED");
|
||||
|
||||
const slaResponse = await adminPost({ riskId: "ops-fault:fault-1", action: "set_sla" });
|
||||
assert.equal(slaResponse.status, 400);
|
||||
const slaPayload = await slaResponse.json();
|
||||
assert.equal(slaPayload.message, "RISK_SLA_REQUIRED");
|
||||
});
|
||||
|
||||
test("unsupported risk actions return 400", async () => {
|
||||
const response = await adminPost({ riskId: "device-offline:mac-1", action: "ack" });
|
||||
assert.equal(response.status, 400);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.ok, false);
|
||||
assert.equal(payload.message, "RISK_ACTION_UNSUPPORTED");
|
||||
});
|
||||
|
||||
test("missing risk targets return 404", async () => {
|
||||
const response = await adminPost({ riskId: "ops-fault:missing", action: "ack" });
|
||||
assert.equal(response.status, 404);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.ok, false);
|
||||
});
|
||||
147
tests/admin-risk-sla-notifications-route.test.ts
Normal file
147
tests/admin-risk-sla-notifications-route.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
let runtimeRoot = "";
|
||||
let data: typeof import("../src/lib/boss-data");
|
||||
let authCookie = "";
|
||||
let postScan: (typeof import("../src/app/api/v1/admin/risks/scan/route"))["POST"];
|
||||
let getOverview: (typeof import("../src/app/api/v1/admin/overview/route"))["GET"];
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) return;
|
||||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-risk-sla-notifications-"));
|
||||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||||
const [dataModule, authModule, scanRoute, overviewRoute] = await Promise.all([
|
||||
import("../src/lib/boss-data.ts"),
|
||||
import("../src/lib/boss-auth.ts"),
|
||||
import("../src/app/api/v1/admin/risks/scan/route.ts"),
|
||||
import("../src/app/api/v1/admin/overview/route.ts"),
|
||||
]);
|
||||
data = dataModule;
|
||||
authCookie = authModule.AUTH_SESSION_COOKIE;
|
||||
postScan = scanRoute.POST;
|
||||
getOverview = overviewRoute.GET;
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
if (runtimeRoot) await rm(runtimeRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await setup();
|
||||
const now = "2026-04-27T17:30:00+08:00";
|
||||
const state = await data.readState();
|
||||
await data.writeState({
|
||||
...state,
|
||||
adminNotifications: [],
|
||||
authAccounts: [
|
||||
{
|
||||
id: "account-owner",
|
||||
account: "owner@example.com",
|
||||
passwordHash: "hash",
|
||||
displayName: "平台管理员",
|
||||
role: "highest_admin",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "account-customer",
|
||||
account: "customer@example.com",
|
||||
passwordHash: "hash",
|
||||
displayName: "客户负责人",
|
||||
role: "admin",
|
||||
companyId: "tenant-a",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
],
|
||||
devices: [
|
||||
{
|
||||
id: "mac-a",
|
||||
name: "客户 Mac",
|
||||
avatar: "M",
|
||||
account: "customer@example.com",
|
||||
companyId: "tenant-a",
|
||||
source: "production",
|
||||
status: "online",
|
||||
projects: ["project-a"],
|
||||
quota5h: 0,
|
||||
quota7d: 0,
|
||||
lastSeenAt: now,
|
||||
},
|
||||
],
|
||||
projects: [],
|
||||
opsFaults: [
|
||||
{
|
||||
faultId: "fault-overdue",
|
||||
faultKey: "LOCAL_AGENT.OFFLINE",
|
||||
severity: "critical",
|
||||
status: "opened",
|
||||
nodeId: "mac-a",
|
||||
serviceName: "local-agent",
|
||||
projectId: "project-a",
|
||||
traceId: "trace-overdue",
|
||||
runbookId: "runbook-local-agent",
|
||||
firstSeenAt: "2026-04-27T12:00:00+08:00",
|
||||
lastSeenAt: "2026-04-27T13:00:00+08:00",
|
||||
ownerAccount: "customer@example.com",
|
||||
slaDueAt: "2026-04-27T14:00:00+08:00",
|
||||
summary: "local-agent 离线超过 SLA",
|
||||
suggestedNextAction: "联系客户重启本地节点",
|
||||
autoRepairable: false,
|
||||
},
|
||||
],
|
||||
threadContextAlerts: [],
|
||||
});
|
||||
});
|
||||
|
||||
async function adminRequest(url: string, init: RequestInit = {}) {
|
||||
const session = await data.createAuthSession({
|
||||
account: "owner@example.com",
|
||||
role: "highest_admin",
|
||||
displayName: "平台管理员",
|
||||
loginMethod: "password",
|
||||
});
|
||||
return new NextRequest(url, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.headers ?? {}),
|
||||
cookie: `${authCookie}=${session.sessionToken}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test("risk SLA scan creates idempotent overdue notifications", async () => {
|
||||
const first = await postScan(await adminRequest("http://127.0.0.1:3000/api/v1/admin/risks/scan", {
|
||||
method: "POST",
|
||||
}));
|
||||
assert.equal(first.status, 200);
|
||||
const firstPayload = await first.json();
|
||||
assert.equal(firstPayload.created.length, 1);
|
||||
assert.equal(firstPayload.notifications[0].kind, "risk_sla_overdue");
|
||||
assert.equal(firstPayload.notifications[0].companyId, "tenant-a");
|
||||
|
||||
const second = await postScan(await adminRequest("http://127.0.0.1:3000/api/v1/admin/risks/scan", {
|
||||
method: "POST",
|
||||
}));
|
||||
assert.equal(second.status, 200);
|
||||
const secondPayload = await second.json();
|
||||
assert.equal(secondPayload.created.length, 0);
|
||||
assert.equal(secondPayload.notifications.length, 1);
|
||||
});
|
||||
|
||||
test("admin overview includes open risk notifications", async () => {
|
||||
await postScan(await adminRequest("http://127.0.0.1:3000/api/v1/admin/risks/scan", { method: "POST" }));
|
||||
|
||||
const response = await getOverview(await adminRequest("http://127.0.0.1:3000/api/v1/admin/overview"));
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
|
||||
assert.equal(payload.summary.openNotifications, 1);
|
||||
assert.equal(payload.notifications[0].riskId, "ops-fault:fault-overdue");
|
||||
});
|
||||
45
tests/admin-skill-lifecycle-panel-source.test.ts
Normal file
45
tests/admin-skill-lifecycle-panel-source.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const componentPath = path.join(
|
||||
process.cwd(),
|
||||
"src/components/admin/admin-skill-lifecycle-panel.tsx",
|
||||
);
|
||||
|
||||
test("admin skill lifecycle panel exposes the requested source contract", async () => {
|
||||
const source = await readFile(componentPath, "utf8");
|
||||
|
||||
assert.match(source, /export function AdminSkillLifecyclePanel/);
|
||||
assert.match(source, /\/api\/v1\/admin\/skills\/requests/);
|
||||
assert.match(source, /method:\s*"POST"/);
|
||||
assert.match(source, /method:\s*"GET"/);
|
||||
|
||||
for (const action of ["install", "update", "uninstall", "rollback", "version_lock"]) {
|
||||
assert.match(source, new RegExp(`["']${action}["']`));
|
||||
}
|
||||
|
||||
assert.doesNotMatch(source, /@refinedev\/antd/);
|
||||
});
|
||||
|
||||
test("admin skill lifecycle panel keeps the hifi governance summaries", async () => {
|
||||
const source = await readFile(componentPath, "utf8");
|
||||
|
||||
assert.match(source, /最近结果/);
|
||||
assert.match(source, /校验信息/);
|
||||
assert.match(source, /Skill 生命周期请求与执行结果/);
|
||||
assert.match(source, /adminDense/);
|
||||
});
|
||||
|
||||
test("admin skill lifecycle panel is catalog first instead of request form first", async () => {
|
||||
const source = await readFile(componentPath, "utf8");
|
||||
|
||||
for (const title of ["Skill 中心", "Skill 目录", "Skill 详情", "授权对象", "执行轨迹", "安装向导"]) {
|
||||
assert.match(source, new RegExp(title));
|
||||
}
|
||||
|
||||
assert.doesNotMatch(source, /title="创建 Skill 生命周期请求"/);
|
||||
assert.match(source, /selectedSkill/);
|
||||
assert.match(source, /activeDeviceIds/);
|
||||
});
|
||||
23
tests/admin-web-static-route.test.ts
Normal file
23
tests/admin-web-static-route.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
async function readSource(path: string) {
|
||||
return readFile(new URL(path, import.meta.url), "utf8");
|
||||
}
|
||||
|
||||
test("enterprise admin route gates static Vue admin behind highest_admin", async () => {
|
||||
const source = await readSource("../src/app/enterprise-admin/page.tsx");
|
||||
|
||||
assert.match(source, /requirePageSession/);
|
||||
assert.match(source, /session\.role\s*!==\s*["']highest_admin["']/);
|
||||
assert.match(source, /redirect\(["']\/admin-web\/index\.html["']\)/);
|
||||
});
|
||||
|
||||
test("independent admin build publishes static assets under Next public admin-web", async () => {
|
||||
const source = await readSource("../apps/boss-admin-web/vite.config.ts");
|
||||
|
||||
assert.match(source, /base:\s*["']\/admin-web\/["']/);
|
||||
assert.match(source, /outDir:\s*["']\.\.\/\.\.\/public\/admin-web["']/);
|
||||
assert.match(source, /emptyOutDir:\s*true/);
|
||||
});
|
||||
@@ -44,7 +44,7 @@ test.after(async () => {
|
||||
|
||||
async function createAuthedJsonRequest(url: string, body: Record<string, unknown>) {
|
||||
const session = await createAuthSession({
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
role: "highest_admin",
|
||||
displayName: "Boss 超级管理员",
|
||||
loginMethod: "password",
|
||||
@@ -126,7 +126,7 @@ test("POST /api/v1/accounts/onboard/master-node upserts a master node account an
|
||||
await createAuthedJsonRequest("http://127.0.0.1:3000/api/v1/accounts/onboard/master-node", {
|
||||
label: "主 GPT",
|
||||
displayName: "Mac 上的 Master Codex Node",
|
||||
accountIdentifier: "17600003315",
|
||||
accountIdentifier: "krisolo",
|
||||
nodeId: "mac-studio",
|
||||
nodeLabel: "Mac Studio",
|
||||
model: "gpt-5.4",
|
||||
|
||||
@@ -44,7 +44,7 @@ test.after(async () => {
|
||||
|
||||
async function createAuthedJsonRequest(url: string, method: "POST" | "PATCH", body: Record<string, unknown>) {
|
||||
const session = await createAuthSession({
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
role: "highest_admin",
|
||||
displayName: "Boss 超级管理员",
|
||||
loginMethod: "password",
|
||||
|
||||
101
tests/aliyun-oss-storage.test.ts
Normal file
101
tests/aliyun-oss-storage.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
createAliyunOssStorageProvider,
|
||||
getAliyunOssSignedDownloadUrl,
|
||||
readAliyunOssObjectBuffer,
|
||||
validateAliyunOssConfig,
|
||||
} from "../src/lib/boss-storage-aliyun-oss.ts";
|
||||
import { encryptStorageSecret } from "../src/lib/boss-storage-secrets.ts";
|
||||
|
||||
async function createConfig() {
|
||||
process.env.BOSS_STORAGE_SECRET_KEY = "aliyun-oss-storage-test-key";
|
||||
return {
|
||||
enabled: true,
|
||||
accessKeyId: "test-access-key",
|
||||
accessKeySecretEncrypted: await encryptStorageSecret("test-secret"),
|
||||
bucket: "boss-bucket",
|
||||
endpoint: "oss-cn-hangzhou.aliyuncs.com",
|
||||
region: "oss-cn-hangzhou",
|
||||
prefix: "uploads",
|
||||
};
|
||||
}
|
||||
|
||||
test("aliyun oss storage uploads attachments through signed REST requests", async () => {
|
||||
const config = await createConfig();
|
||||
const originalFetch = globalThis.fetch;
|
||||
const calls: Array<{ input: string; init?: RequestInit }> = [];
|
||||
globalThis.fetch = (async (input, init) => {
|
||||
calls.push({ input: String(input), init });
|
||||
return new Response("", { status: 200 });
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const provider = createAliyunOssStorageProvider(config);
|
||||
const record = await provider.storeAttachment({
|
||||
account: "kris@example.com",
|
||||
messageId: "msg-1",
|
||||
fileName: "report.txt",
|
||||
mimeType: "text/plain",
|
||||
buffer: Buffer.from("hello"),
|
||||
});
|
||||
|
||||
assert.equal(record.storageBackend, "aliyun_oss");
|
||||
assert.match(record.storagePath, /^uploads\/acct-[a-f0-9]{16}\/\d{4}\/\d{2}\/msg-1-report\.txt$/);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.match(calls[0].input, /^https:\/\/boss-bucket\.oss-cn-hangzhou\.aliyuncs\.com\/uploads\/acct-/);
|
||||
assert.equal(calls[0].init?.method, "PUT");
|
||||
const headers = calls[0].init?.headers as Record<string, string>;
|
||||
assert.equal(headers["content-type"], "text/plain");
|
||||
assert.match(headers.authorization, /^OSS test-access-key:/);
|
||||
assert.ok(headers["x-oss-date"]);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("aliyun oss signed download url is generated without network access", async () => {
|
||||
const config = await createConfig();
|
||||
|
||||
const signedUrl = await getAliyunOssSignedDownloadUrl(config, "uploads/demo file.txt", 600);
|
||||
const url = new URL(signedUrl);
|
||||
|
||||
assert.equal(url.hostname, "boss-bucket.oss-cn-hangzhou.aliyuncs.com");
|
||||
assert.equal(url.pathname, "/uploads/demo%20file.txt");
|
||||
assert.equal(url.searchParams.get("OSSAccessKeyId"), "test-access-key");
|
||||
assert.ok(url.searchParams.get("Expires"));
|
||||
assert.ok(url.searchParams.get("Signature"));
|
||||
});
|
||||
|
||||
test("aliyun oss storage reads objects and validates bucket config", async () => {
|
||||
const config = await createConfig();
|
||||
const originalFetch = globalThis.fetch;
|
||||
const calls: Array<{ input: string; init?: RequestInit }> = [];
|
||||
globalThis.fetch = (async (input, init) => {
|
||||
calls.push({ input: String(input), init });
|
||||
if (String(input).endsWith("?bucketInfo")) {
|
||||
return new Response("<BucketInfo><Bucket><Name>boss-bucket</Name></Bucket></BucketInfo>", {
|
||||
status: 200,
|
||||
});
|
||||
}
|
||||
return new Response("object-content", { status: 200 });
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const buffer = await readAliyunOssObjectBuffer(config, "uploads/a.txt");
|
||||
assert.equal(buffer.toString("utf8"), "object-content");
|
||||
|
||||
const validation = await validateAliyunOssConfig(config);
|
||||
assert.deepEqual(validation, {
|
||||
provider: "aliyun_oss",
|
||||
bucket: "boss-bucket",
|
||||
endpoint: "oss-cn-hangzhou.aliyuncs.com",
|
||||
region: "oss-cn-hangzhou",
|
||||
});
|
||||
assert.equal(calls.length, 2);
|
||||
assert.match(calls[1].input, /\/\?bucketInfo$/);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
@@ -13,10 +13,9 @@ test("MainActivity debounces root realtime refresh bursts", async () => {
|
||||
assert.match(source, /private boolean realtimeRefreshScheduled(?: = false)?;/);
|
||||
assert.match(source, /private final Runnable realtimeRefreshRunnable = new Runnable\(\)/);
|
||||
assert.match(source, /scheduleRealtimeRefresh\(\)/);
|
||||
assert.doesNotMatch(
|
||||
assert.match(
|
||||
source,
|
||||
/runOnUiThread\(this::refreshCurrentTab\)/,
|
||||
"root page should coalesce bursts instead of refreshing immediately for each event",
|
||||
/private void scheduleRealtimeRefresh\(\)\s*\{[\s\S]*?if \(realtimeRefreshScheduled\) \{[\s\S]*?return;[\s\S]*?\}[\s\S]*?realtimeRefreshScheduled = true;[\s\S]*?uiHandler\.postDelayed\(realtimeRefreshRunnable, REALTIME_REFRESH_DEBOUNCE_MS\);[\s\S]*?\}/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -28,9 +27,13 @@ test("ProjectDetailActivity debounces realtime chat reload bursts", async () =>
|
||||
assert.match(source, /private boolean realtimeReloadRequiresFullSnapshot;/);
|
||||
assert.match(source, /private final Runnable realtimeReloadRunnable = new Runnable\(\)/);
|
||||
assert.match(source, /scheduleRealtimeReload\(boolean requireFullSnapshot\)/);
|
||||
assert.match(
|
||||
source,
|
||||
/private void scheduleRealtimeReload\(boolean requireFullSnapshot\)\s*\{[\s\S]*?realtimeReloadRequiresFullSnapshot = realtimeReloadRequiresFullSnapshot \|\| requireFullSnapshot;[\s\S]*?if \(realtimeReloadScheduled\) \{[\s\S]*?return;[\s\S]*?\}[\s\S]*?realtimeReloadScheduled = true;[\s\S]*?uiHandler\.postDelayed\(realtimeReloadRunnable, REALTIME_REFRESH_DEBOUNCE_MS\);[\s\S]*?\}/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/runOnUiThread\(this::triggerRealtimeReload\)/,
|
||||
/private void scheduleRealtimeReload\(boolean requireFullSnapshot\)\s*\{[\s\S]*?triggerRealtimeReload\(requireFullSnapshot\);[\s\S]*?\}/,
|
||||
"chat page should debounce repeated realtime updates before reloading",
|
||||
);
|
||||
});
|
||||
|
||||
225
tests/audit-permission-logs-route.test.ts
Normal file
225
tests/audit-permission-logs-route.test.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
let runtimeRoot = "";
|
||||
let data: typeof import("../src/lib/boss-data");
|
||||
let authCookie = "";
|
||||
let getPermissionLogs: (typeof import("../src/app/api/v1/audits/permission-logs/route"))["GET"];
|
||||
let baseState: Awaited<ReturnType<typeof import("../src/lib/boss-data")["readState"]>>;
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) return;
|
||||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-audit-permission-logs-"));
|
||||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||||
const [dataModule, authModule, routeModule] = await Promise.all([
|
||||
import("../src/lib/boss-data.ts"),
|
||||
import("../src/lib/boss-auth.ts"),
|
||||
import("../src/app/api/v1/audits/permission-logs/route.ts"),
|
||||
]);
|
||||
data = dataModule;
|
||||
authCookie = authModule.AUTH_SESSION_COOKIE;
|
||||
getPermissionLogs = routeModule.GET;
|
||||
baseState = structuredClone(await data.readState());
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
if (runtimeRoot) await rm(runtimeRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await setup();
|
||||
const state = structuredClone(baseState);
|
||||
state.permissionAuditLogs = [
|
||||
{
|
||||
auditId: "audit-008",
|
||||
actorAccount: "krisolo",
|
||||
action: "task.denied",
|
||||
targetAccount: "member@example.com",
|
||||
detail: "admin_route:/api/v1/admin/access",
|
||||
createdAt: "2026-04-27T10:03:00.000Z",
|
||||
},
|
||||
{
|
||||
auditId: "audit-007",
|
||||
actorAccount: "krisolo",
|
||||
action: "skill.lifecycle.completed",
|
||||
deviceId: "mac-studio",
|
||||
skillId: "mac-studio:boss-server-debug",
|
||||
detail: "update:failed:git checkout failed",
|
||||
createdAt: "2026-04-27T10:02:00.000Z",
|
||||
},
|
||||
{
|
||||
auditId: "audit-006",
|
||||
actorAccount: "krisolo",
|
||||
action: "skill.assigned",
|
||||
targetAccount: "worker@example.com",
|
||||
deviceId: "mac-studio",
|
||||
projectId: "master-agent",
|
||||
skillId: "mac-studio:boss-server-debug",
|
||||
permissions: ["skill.view", "skill.use"],
|
||||
createdAt: "2026-04-27T10:01:05.000Z",
|
||||
},
|
||||
{
|
||||
auditId: "audit-005",
|
||||
actorAccount: "krisolo",
|
||||
action: "grant.created",
|
||||
targetAccount: "worker@example.com",
|
||||
projectId: "master-agent",
|
||||
permissions: ["project.view"],
|
||||
createdAt: "2026-04-27T10:01:04.000Z",
|
||||
},
|
||||
{
|
||||
auditId: "audit-004",
|
||||
actorAccount: "krisolo",
|
||||
action: "grant.created",
|
||||
targetAccount: "worker@example.com",
|
||||
deviceId: "mac-studio",
|
||||
permissions: ["device.view"],
|
||||
createdAt: "2026-04-27T10:01:03.000Z",
|
||||
},
|
||||
{
|
||||
auditId: "audit-003",
|
||||
actorAccount: "krisolo",
|
||||
action: "grant.created",
|
||||
targetAccount: "worker@example.com",
|
||||
deviceId: "mac-studio",
|
||||
permissions: ["device.view"],
|
||||
createdAt: "2026-04-27T10:01:02.000Z",
|
||||
},
|
||||
{
|
||||
auditId: "audit-002",
|
||||
actorAccount: "krisolo",
|
||||
action: "grant.created",
|
||||
targetAccount: "worker@example.com",
|
||||
deviceId: "mac-studio",
|
||||
permissions: ["device.view"],
|
||||
createdAt: "2026-04-27T10:01:01.000Z",
|
||||
},
|
||||
{
|
||||
auditId: "audit-001",
|
||||
actorAccount: "krisolo",
|
||||
action: "grant.created",
|
||||
targetAccount: "worker@example.com",
|
||||
deviceId: "mac-studio",
|
||||
permissions: ["device.view"],
|
||||
createdAt: "2026-04-27T10:01:00.000Z",
|
||||
},
|
||||
];
|
||||
state.accountDeviceGrants = [
|
||||
{
|
||||
grantId: "expired-device-grant",
|
||||
account: "worker@example.com",
|
||||
deviceId: "mac-studio",
|
||||
permissions: ["device.view"],
|
||||
grantedBy: "krisolo",
|
||||
grantedAt: "2026-04-26T10:00:00.000Z",
|
||||
expiresAt: "2026-04-27T09:00:00.000Z",
|
||||
},
|
||||
];
|
||||
state.accountProjectGrants = [];
|
||||
state.accountSkillGrants = [];
|
||||
await data.writeState(state);
|
||||
});
|
||||
|
||||
async function authedRequest(
|
||||
account: string,
|
||||
role: "member" | "admin" | "highest_admin",
|
||||
url: string,
|
||||
) {
|
||||
const session = await data.createAuthSession({
|
||||
account,
|
||||
role,
|
||||
displayName: account,
|
||||
loginMethod: "password",
|
||||
});
|
||||
return new NextRequest(url, {
|
||||
headers: {
|
||||
cookie: `${authCookie}=${session.sessionToken}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test("highest admin can filter permission audit logs and page with a cursor", async () => {
|
||||
const response = await getPermissionLogs(
|
||||
await authedRequest(
|
||||
"krisolo",
|
||||
"highest_admin",
|
||||
"http://127.0.0.1:3000/api/v1/audits/permission-logs?action=grant.created&actorAccount=krisolo&targetAccount=worker@example.com&deviceId=mac-studio&limit=2",
|
||||
),
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.deepEqual(
|
||||
payload.logs.map((log: { auditId: string }) => log.auditId),
|
||||
["audit-004", "audit-003"],
|
||||
);
|
||||
assert.equal(payload.nextCursor, "audit-003");
|
||||
assert.equal(payload.riskSummary.totalAlerts >= 1, true);
|
||||
|
||||
const nextResponse = await getPermissionLogs(
|
||||
await authedRequest(
|
||||
"krisolo",
|
||||
"highest_admin",
|
||||
`http://127.0.0.1:3000/api/v1/audits/permission-logs?action=grant.created&actorAccount=krisolo&targetAccount=worker@example.com&deviceId=mac-studio&limit=2&cursor=${payload.nextCursor}`,
|
||||
),
|
||||
);
|
||||
assert.equal(nextResponse.status, 200);
|
||||
const nextPayload = await nextResponse.json();
|
||||
assert.deepEqual(
|
||||
nextPayload.logs.map((log: { auditId: string }) => log.auditId),
|
||||
["audit-002", "audit-001"],
|
||||
);
|
||||
assert.equal(nextPayload.nextCursor, null);
|
||||
});
|
||||
|
||||
test("highest admin can filter permission audit logs by project and skill", async () => {
|
||||
const response = await getPermissionLogs(
|
||||
await authedRequest(
|
||||
"krisolo",
|
||||
"highest_admin",
|
||||
"http://127.0.0.1:3000/api/v1/audits/permission-logs?projectId=master-agent&skillId=mac-studio%3Aboss-server-debug",
|
||||
),
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.deepEqual(
|
||||
payload.logs.map((log: { auditId: string }) => log.auditId),
|
||||
["audit-006"],
|
||||
);
|
||||
});
|
||||
|
||||
test("permission audit risk summary is deterministic from current logs and grants", async () => {
|
||||
const response = await getPermissionLogs(
|
||||
await authedRequest(
|
||||
"krisolo",
|
||||
"highest_admin",
|
||||
"http://127.0.0.1:3000/api/v1/audits/permission-logs",
|
||||
),
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.deepEqual(
|
||||
payload.riskSummary.alerts.map((alert: { kind: string }) => alert.kind).sort(),
|
||||
[
|
||||
"admin_route_denied",
|
||||
"expired_grant_present",
|
||||
"rapid_permission_grants",
|
||||
"skill_lifecycle_failed",
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("ordinary accounts cannot read permission audit logs", async () => {
|
||||
const response = await getPermissionLogs(
|
||||
await authedRequest(
|
||||
"worker@example.com",
|
||||
"member",
|
||||
"http://127.0.0.1:3000/api/v1/audits/permission-logs",
|
||||
),
|
||||
);
|
||||
assert.equal(response.status, 403);
|
||||
});
|
||||
112
tests/auth-login-hardening-route.test.ts
Normal file
112
tests/auth-login-hardening-route.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
let runtimeRoot = "";
|
||||
let data: typeof import("../src/lib/boss-data");
|
||||
let postLogin: (typeof import("../src/app/api/auth/login/route"))["POST"];
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) return;
|
||||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-auth-login-hardening-"));
|
||||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||||
const [dataModule, routeModule] = await Promise.all([
|
||||
import("../src/lib/boss-data.ts"),
|
||||
import("../src/app/api/auth/login/route.ts"),
|
||||
]);
|
||||
data = dataModule;
|
||||
postLogin = routeModule.POST;
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
if (runtimeRoot) await rm(runtimeRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await setup();
|
||||
delete process.env.BOSS_AUTH_AUTO_LOGIN;
|
||||
const state = await data.readState();
|
||||
await data.writeState({
|
||||
...state,
|
||||
authSessions: [],
|
||||
authAccounts: [
|
||||
{
|
||||
id: "account-owner",
|
||||
account: "owner@example.com",
|
||||
passwordHash: data.hashPassword("StrongPass123"),
|
||||
displayName: "企业管理员",
|
||||
role: "highest_admin",
|
||||
status: "active",
|
||||
createdAt: "2026-04-27T16:00:00+08:00",
|
||||
updatedAt: "2026-04-27T16:00:00+08:00",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
function loginRequest(body: Record<string, unknown>) {
|
||||
return new NextRequest("http://127.0.0.1:3000/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
test("login does not allow temporary auto login unless explicitly enabled", async () => {
|
||||
const response = await postLogin(loginRequest({}));
|
||||
assert.equal(response.status, 400);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.ok, false);
|
||||
assert.match(payload.message, /账号/);
|
||||
});
|
||||
|
||||
test("login allows temporary auto login only with an explicit development switch", async () => {
|
||||
process.env.BOSS_AUTH_AUTO_LOGIN = "1";
|
||||
|
||||
const response = await postLogin(loginRequest({}));
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.ok, true);
|
||||
assert.equal(payload.role, "highest_admin");
|
||||
assert.match(payload.message, /临时免验证/);
|
||||
});
|
||||
|
||||
test("password login still creates a normal session when auto login is disabled", async () => {
|
||||
const response = await postLogin(loginRequest({
|
||||
account: "owner@example.com",
|
||||
password: "StrongPass123",
|
||||
method: "password",
|
||||
}));
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.ok, true);
|
||||
assert.equal(payload.account, "owner@example.com");
|
||||
assert.equal(payload.role, "highest_admin");
|
||||
assert.match(response.headers.get("set-cookie") ?? "", /boss_session=/);
|
||||
});
|
||||
|
||||
test("code login requires a previously issued verification code even in fixed mode", async () => {
|
||||
const direct = await postLogin(loginRequest({
|
||||
account: "owner@example.com",
|
||||
code: "000000",
|
||||
method: "code",
|
||||
}));
|
||||
assert.equal(direct.status, 400);
|
||||
assert.match((await direct.json()).message, /验证码/);
|
||||
|
||||
const issued = await data.issueVerificationCode("owner@example.com", "login");
|
||||
const response = await postLogin(loginRequest({
|
||||
account: "owner@example.com",
|
||||
code: issued.code,
|
||||
method: "code",
|
||||
}));
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.ok, true);
|
||||
assert.equal(payload.loginMethod, "code");
|
||||
});
|
||||
33
tests/auth-pages-copy.test.ts
Normal file
33
tests/auth-pages-copy.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
const authHelpPagePath = new URL("../src/app/auth/help/page.tsx", import.meta.url);
|
||||
const loginPagePath = new URL("../src/app/auth/login/page.tsx", import.meta.url);
|
||||
const securityPagePath = new URL("../src/app/me/security/page.tsx", import.meta.url);
|
||||
const bossDataPath = new URL("../src/lib/boss-data.ts", import.meta.url);
|
||||
|
||||
async function readSource(path: URL) {
|
||||
return readFile(path, "utf8");
|
||||
}
|
||||
|
||||
test("auth-facing pages do not advertise temporary auto login", async () => {
|
||||
const sources = await Promise.all([
|
||||
readSource(authHelpPagePath),
|
||||
readSource(loginPagePath),
|
||||
readSource(securityPagePath),
|
||||
]);
|
||||
const combined = sources.join("\n");
|
||||
|
||||
assert.doesNotMatch(combined, /免验证|一键进入|直接创建最高管理员会话/);
|
||||
assert.match(combined, /账号密码/);
|
||||
assert.match(combined, /验证码/);
|
||||
assert.match(combined, /krisolo/);
|
||||
});
|
||||
|
||||
test("seeded OTA release notes do not describe login as one-click entry", async () => {
|
||||
const source = await readSource(bossDataPath);
|
||||
|
||||
assert.doesNotMatch(source, /登录页改为原生一键进入/);
|
||||
assert.match(source, /登录页改为原生账号登录/);
|
||||
});
|
||||
144
tests/auth-security-hardening-route.test.ts
Normal file
144
tests/auth-security-hardening-route.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
let runtimeRoot = "";
|
||||
let data: typeof import("../src/lib/boss-data");
|
||||
let authCookie = "";
|
||||
let postLogin: (typeof import("../src/app/api/auth/login/route"))["POST"];
|
||||
let postRestore: (typeof import("../src/app/api/auth/restore/route"))["POST"];
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) return;
|
||||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-auth-security-"));
|
||||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||||
const [dataModule, authModule, loginRoute, restoreRoute] = await Promise.all([
|
||||
import("../src/lib/boss-data.ts"),
|
||||
import("../src/lib/boss-auth.ts"),
|
||||
import("../src/app/api/auth/login/route.ts"),
|
||||
import("../src/app/api/auth/restore/route.ts"),
|
||||
]);
|
||||
data = dataModule;
|
||||
authCookie = authModule.AUTH_SESSION_COOKIE;
|
||||
postLogin = loginRoute.POST;
|
||||
postRestore = restoreRoute.POST;
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
if (runtimeRoot) await rm(runtimeRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await setup();
|
||||
delete process.env.BOSS_AUTH_AUTO_LOGIN;
|
||||
const now = "2026-04-27T18:00:00+08:00";
|
||||
const state = await data.readState();
|
||||
await data.writeState({
|
||||
...state,
|
||||
authSessions: [],
|
||||
authAccounts: [
|
||||
{
|
||||
id: "account-owner",
|
||||
account: "owner@example.com",
|
||||
passwordHash: data.hashPassword("StrongPass123"),
|
||||
displayName: "企业管理员",
|
||||
role: "highest_admin",
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "account-mfa",
|
||||
account: "mfa@example.com",
|
||||
passwordHash: data.hashPassword("StrongPass123"),
|
||||
displayName: "MFA 管理员",
|
||||
role: "admin",
|
||||
status: "active",
|
||||
mfaRequired: true,
|
||||
mfaSecret: "test-mfa-secret",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
function loginRequest(body: Record<string, unknown>, headers: Record<string, string> = {}) {
|
||||
return new NextRequest("http://127.0.0.1:3000/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", ...headers },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function restoreRequest(restoreToken: string) {
|
||||
return new NextRequest("http://127.0.0.1:3000/api/auth/restore", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ restoreToken }),
|
||||
});
|
||||
}
|
||||
|
||||
test("auth mutations reject explicit cross-site browser posts", async () => {
|
||||
const response = await postLogin(loginRequest(
|
||||
{ account: "owner@example.com", password: "StrongPass123", method: "password" },
|
||||
{ origin: "https://evil.example", "sec-fetch-site": "cross-site" },
|
||||
));
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
assert.match((await response.json()).message, /CSRF/);
|
||||
});
|
||||
|
||||
test("native app login is not blocked by browser CSRF headers", async () => {
|
||||
const response = await postLogin(loginRequest(
|
||||
{ account: "owner@example.com", password: "StrongPass123", method: "password" },
|
||||
{ "x-boss-native-app": "1" },
|
||||
));
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
});
|
||||
|
||||
test("restore token rotates on every session restore", async () => {
|
||||
const session = await data.createAuthSession({
|
||||
account: "owner@example.com",
|
||||
role: "highest_admin",
|
||||
displayName: "企业管理员",
|
||||
loginMethod: "password",
|
||||
});
|
||||
|
||||
const first = await postRestore(restoreRequest(session.restoreToken));
|
||||
assert.equal(first.status, 200);
|
||||
const firstPayload = await first.json();
|
||||
const rotatedToken = firstPayload.session.restoreToken;
|
||||
assert.notEqual(rotatedToken, session.restoreToken);
|
||||
assert.match(first.headers.get("set-cookie") ?? "", new RegExp(`${authCookie}=`));
|
||||
|
||||
const oldToken = await postRestore(restoreRequest(session.restoreToken));
|
||||
assert.equal(oldToken.status, 401);
|
||||
|
||||
const second = await postRestore(restoreRequest(rotatedToken));
|
||||
assert.equal(second.status, 200);
|
||||
});
|
||||
|
||||
test("MFA-protected accounts require a valid one-time code after password verification", async () => {
|
||||
const missing = await postLogin(loginRequest({
|
||||
account: "mfa@example.com",
|
||||
password: "StrongPass123",
|
||||
method: "password",
|
||||
}));
|
||||
assert.equal(missing.status, 400);
|
||||
assert.match((await missing.json()).message, /MFA/);
|
||||
|
||||
const validCode = data.generateAuthAccountMfaCode("test-mfa-secret", new Date());
|
||||
const passed = await postLogin(loginRequest({
|
||||
account: "mfa@example.com",
|
||||
password: "StrongPass123",
|
||||
method: "password",
|
||||
mfaCode: validCode,
|
||||
}));
|
||||
assert.equal(passed.status, 200);
|
||||
});
|
||||
152
tests/auth-session-governance.test.ts
Normal file
152
tests/auth-session-governance.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
let runtimeRoot = "";
|
||||
let data: typeof import("../src/lib/boss-data");
|
||||
let authCookie = "";
|
||||
let getSessions: (typeof import("../src/app/api/v1/auth/sessions/route"))["GET"];
|
||||
let postSessions: (typeof import("../src/app/api/v1/auth/sessions/route"))["POST"];
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) return;
|
||||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-auth-sessions-"));
|
||||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||||
const [dataModule, authModule, routeModule] = await Promise.all([
|
||||
import("../src/lib/boss-data.ts"),
|
||||
import("../src/lib/boss-auth.ts"),
|
||||
import("../src/app/api/v1/auth/sessions/route.ts"),
|
||||
]);
|
||||
data = dataModule;
|
||||
authCookie = authModule.AUTH_SESSION_COOKIE;
|
||||
getSessions = routeModule.GET;
|
||||
postSessions = routeModule.POST;
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
if (runtimeRoot) await rm(runtimeRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await setup();
|
||||
const state = await data.readState();
|
||||
await data.writeState({
|
||||
...state,
|
||||
authSessions: [],
|
||||
authAccounts: [
|
||||
...state.authAccounts.filter((account) => account.account !== "worker@example.com"),
|
||||
{
|
||||
id: "account-worker",
|
||||
account: "worker@example.com",
|
||||
passwordHash: "scrypt$test",
|
||||
displayName: "Worker",
|
||||
role: "member",
|
||||
createdAt: "2026-04-26T12:00:00+08:00",
|
||||
updatedAt: "2026-04-26T12:00:00+08:00",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
function requestWithSession(sessionToken: string, init: RequestInit = {}) {
|
||||
return new NextRequest("http://127.0.0.1:3000/api/v1/auth/sessions", {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.headers ?? {}),
|
||||
cookie: `${authCookie}=${sessionToken}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test("member can see and revoke only their own active sessions", async () => {
|
||||
const first = await data.createAuthSession({
|
||||
account: "worker@example.com",
|
||||
role: "member",
|
||||
displayName: "Worker",
|
||||
loginMethod: "password",
|
||||
});
|
||||
const second = await data.createAuthSession({
|
||||
account: "worker@example.com",
|
||||
role: "member",
|
||||
displayName: "Worker",
|
||||
loginMethod: "code",
|
||||
});
|
||||
const admin = await data.createAuthSession({
|
||||
account: "krisolo",
|
||||
role: "highest_admin",
|
||||
displayName: "Boss",
|
||||
loginMethod: "password",
|
||||
});
|
||||
|
||||
const getResponse = await getSessions(requestWithSession(second.sessionToken));
|
||||
assert.equal(getResponse.status, 200);
|
||||
const getPayload = await getResponse.json();
|
||||
assert.deepEqual(
|
||||
getPayload.sessions.map((session: { account: string }) => session.account),
|
||||
["worker@example.com", "worker@example.com"],
|
||||
);
|
||||
assert.equal(getPayload.sessions.some((session: { sessionToken?: string }) => session.sessionToken), false);
|
||||
assert.equal(getPayload.sessions.some((session: { restoreToken?: string }) => session.restoreToken), false);
|
||||
|
||||
const forbidden = await postSessions(requestWithSession(second.sessionToken, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ action: "revoke_session", sessionId: admin.sessionId }),
|
||||
}));
|
||||
assert.equal(forbidden.status, 403);
|
||||
|
||||
const revokeSelf = await postSessions(requestWithSession(second.sessionToken, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ action: "revoke_session", sessionId: first.sessionId }),
|
||||
}));
|
||||
assert.equal(revokeSelf.status, 200);
|
||||
|
||||
const after = await getSessions(requestWithSession(second.sessionToken));
|
||||
const afterPayload = await after.json();
|
||||
assert.deepEqual(
|
||||
afterPayload.sessions.map((session: { sessionId: string }) => session.sessionId),
|
||||
[second.sessionId],
|
||||
);
|
||||
});
|
||||
|
||||
test("highest admin can inspect and revoke all active sessions", async () => {
|
||||
const worker = await data.createAuthSession({
|
||||
account: "worker@example.com",
|
||||
role: "member",
|
||||
displayName: "Worker",
|
||||
loginMethod: "password",
|
||||
});
|
||||
const admin = await data.createAuthSession({
|
||||
account: "krisolo",
|
||||
role: "highest_admin",
|
||||
displayName: "Boss",
|
||||
loginMethod: "password",
|
||||
});
|
||||
|
||||
const getResponse = await getSessions(requestWithSession(admin.sessionToken));
|
||||
assert.equal(getResponse.status, 200);
|
||||
const getPayload = await getResponse.json();
|
||||
assert.deepEqual(
|
||||
getPayload.sessions.map((session: { account: string }) => session.account).sort(),
|
||||
["krisolo", "worker@example.com"],
|
||||
);
|
||||
|
||||
const revokeResponse = await postSessions(requestWithSession(admin.sessionToken, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ action: "revoke_session", sessionId: worker.sessionId }),
|
||||
}));
|
||||
assert.equal(revokeResponse.status, 200);
|
||||
assert.equal(await data.getAuthSession(worker.sessionToken), null);
|
||||
});
|
||||
|
||||
test("primary admin session uses the current production admin account", async () => {
|
||||
const session = await data.createPrimaryAdminSession();
|
||||
assert.equal(session.account, "krisolo");
|
||||
|
||||
const state = await data.readState();
|
||||
assert.equal(state.user.account, "krisolo");
|
||||
assert.equal(state.authAccounts.find((account) => account.account === "krisolo")?.isPrimary, true);
|
||||
});
|
||||
113
tests/boss-admin-web-source.test.ts
Normal file
113
tests/boss-admin-web-source.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
async function readSource(path: string) {
|
||||
return readFile(new URL(path, import.meta.url), "utf8");
|
||||
}
|
||||
|
||||
test("independent Boss admin web app declares Vue Ant Design Vue runtime", async () => {
|
||||
const source = await readSource("../apps/boss-admin-web/package.json");
|
||||
const pkg = JSON.parse(source);
|
||||
|
||||
assert.equal(pkg.name, "@boss/admin-web");
|
||||
assert.equal(pkg.private, true);
|
||||
assert.match(pkg.scripts.dev, /vite/);
|
||||
assert.match(pkg.scripts.build, /vite build/);
|
||||
assert.ok(pkg.dependencies.vue);
|
||||
assert.ok(pkg.dependencies["ant-design-vue"]);
|
||||
assert.ok(pkg.devDependencies.vite);
|
||||
assert.ok(pkg.devDependencies["@vitejs/plugin-vue"]);
|
||||
});
|
||||
|
||||
test("independent Boss admin web app uses the backoffice BFF with cookie session", async () => {
|
||||
const apiSource = await readSource("../apps/boss-admin-web/src/api/bossAdmin.ts");
|
||||
|
||||
assert.match(apiSource, /\/api\/v1\/admin\/backoffice/);
|
||||
assert.match(apiSource, /\/api\/v1\/admin\/access/);
|
||||
assert.match(apiSource, /\/api\/v1\/admin\/risks\/actions/);
|
||||
assert.match(apiSource, /\/api\/v1\/admin\/skills\/requests/);
|
||||
assert.match(apiSource, /credentials:\s*["']include["']/);
|
||||
assert.match(apiSource, /menuTree/);
|
||||
assert.match(apiSource, /tenants/);
|
||||
assert.match(apiSource, /resourceGroups/);
|
||||
for (const fn of ["postAdminAccess", "postRiskAction", "postSkillLifecycleRequest"]) {
|
||||
assert.match(apiSource, new RegExp(`function\\s+${fn}|const\\s+${fn}`));
|
||||
}
|
||||
});
|
||||
|
||||
test("independent Boss admin web app includes enterprise management sections", async () => {
|
||||
const appSource = await readSource("../apps/boss-admin-web/src/App.vue");
|
||||
|
||||
for (const label of [
|
||||
"Boss 企业后台",
|
||||
"工作台",
|
||||
"租户管理",
|
||||
"账号管理",
|
||||
"角色权限",
|
||||
"资源授权",
|
||||
"Skill 中心",
|
||||
"风险告警",
|
||||
"审计日志",
|
||||
]) {
|
||||
assert.match(appSource, new RegExp(label));
|
||||
}
|
||||
assert.match(appSource, /menuTree/);
|
||||
assert.match(appSource, /workbench/);
|
||||
assert.match(appSource, /tenants/);
|
||||
});
|
||||
|
||||
test("independent Boss admin web app exposes management actions instead of read only tables", async () => {
|
||||
const appSource = await readSource("../apps/boss-admin-web/src/App.vue");
|
||||
|
||||
for (const label of [
|
||||
"新建租户",
|
||||
"启用租户",
|
||||
"停用租户",
|
||||
"新建账号",
|
||||
"重置密码",
|
||||
"离职回收",
|
||||
"分配资源",
|
||||
"套用权限模板",
|
||||
"撤销授权",
|
||||
"指派负责人",
|
||||
"设置 SLA",
|
||||
"确认风险",
|
||||
"关闭风险",
|
||||
"创建工单",
|
||||
"创建 Skill 请求",
|
||||
]) {
|
||||
assert.match(appSource, new RegExp(label));
|
||||
}
|
||||
|
||||
for (const action of [
|
||||
"upsert_company",
|
||||
"set_company_status",
|
||||
"upsert_account",
|
||||
"reset_account_password",
|
||||
"reclaim_account",
|
||||
"apply_template",
|
||||
"grant_device",
|
||||
"grant_project",
|
||||
"grant_skill",
|
||||
"revoke_grant",
|
||||
]) {
|
||||
assert.match(appSource, new RegExp(action));
|
||||
}
|
||||
});
|
||||
|
||||
test("root Next project isolates the independent Vue admin workspace", async () => {
|
||||
const [tsconfigSource, eslintSource, rootPkgSource] = await Promise.all([
|
||||
readSource("../tsconfig.json"),
|
||||
readSource("../eslint.config.mjs"),
|
||||
readSource("../package.json"),
|
||||
]);
|
||||
const tsconfig = JSON.parse(tsconfigSource);
|
||||
const rootPkg = JSON.parse(rootPkgSource);
|
||||
|
||||
assert.ok(tsconfig.exclude.includes("apps/boss-admin-web/**"));
|
||||
assert.match(eslintSource, /apps\/boss-admin-web\/\*\*/);
|
||||
assert.match(eslintSource, /public\/admin-web\/\*\*/);
|
||||
assert.match(rootPkg.scripts["admin:web:dev"], /apps\/boss-admin-web/);
|
||||
assert.match(rootPkg.scripts["admin:web:build"], /apps\/boss-admin-web/);
|
||||
});
|
||||
40
tests/boss-mail.test.ts
Normal file
40
tests/boss-mail.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { resolveSendmailSpawnCommand } from "../src/lib/boss-mail.ts";
|
||||
|
||||
test("resolveSendmailSpawnCommand keeps the executable static for build tracing", () => {
|
||||
const originalPath = process.env.BOSS_SENDMAIL_PATH;
|
||||
delete process.env.BOSS_SENDMAIL_PATH;
|
||||
|
||||
try {
|
||||
const command = resolveSendmailSpawnCommand();
|
||||
|
||||
assert.equal(command.executable, "/usr/bin/env");
|
||||
assert.deepEqual(command.args, ["--", "/usr/sbin/sendmail", "-t", "-i"]);
|
||||
} finally {
|
||||
if (originalPath === undefined) {
|
||||
delete process.env.BOSS_SENDMAIL_PATH;
|
||||
} else {
|
||||
process.env.BOSS_SENDMAIL_PATH = originalPath;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveSendmailSpawnCommand preserves a configured sendmail path behind env", () => {
|
||||
const originalPath = process.env.BOSS_SENDMAIL_PATH;
|
||||
process.env.BOSS_SENDMAIL_PATH = "/opt/sendmail/bin/sendmail";
|
||||
|
||||
try {
|
||||
const command = resolveSendmailSpawnCommand();
|
||||
|
||||
assert.equal(command.executable, "/usr/bin/env");
|
||||
assert.deepEqual(command.args, ["--", "/opt/sendmail/bin/sendmail", "-t", "-i"]);
|
||||
} finally {
|
||||
if (originalPath === undefined) {
|
||||
delete process.env.BOSS_SENDMAIL_PATH;
|
||||
} else {
|
||||
process.env.BOSS_SENDMAIL_PATH = originalPath;
|
||||
}
|
||||
}
|
||||
});
|
||||
189
tests/boss-permissions.test.ts
Normal file
189
tests/boss-permissions.test.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
|
||||
let runtimeRoot = "";
|
||||
let data: typeof import("../src/lib/boss-data");
|
||||
let permissions: typeof import("../src/lib/boss-permissions");
|
||||
let baseState: Awaited<ReturnType<typeof import("../src/lib/boss-data")["readState"]>>;
|
||||
|
||||
async function setup() {
|
||||
if (!runtimeRoot) {
|
||||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-rbac-permissions-"));
|
||||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||||
}
|
||||
if (!data) {
|
||||
data = await import("../src/lib/boss-data.ts");
|
||||
baseState = structuredClone(await data.readState());
|
||||
}
|
||||
if (!permissions) {
|
||||
permissions = await import("../src/lib/boss-permissions.ts");
|
||||
}
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
if (runtimeRoot) {
|
||||
await rm(runtimeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await setup();
|
||||
await data.writeState({
|
||||
...structuredClone(baseState),
|
||||
accountDeviceGrants: [],
|
||||
accountProjectGrants: [],
|
||||
accountSkillGrants: [],
|
||||
skillCatalog: [],
|
||||
permissionAuditLogs: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("highest admin can access every device and project without explicit grants", async () => {
|
||||
const state = await data.readState();
|
||||
const session = {
|
||||
account: "krisolo",
|
||||
role: "highest_admin" as const,
|
||||
displayName: "Boss 超级管理员",
|
||||
};
|
||||
|
||||
assert.equal(permissions.canAccessDevice(state, session, "mac-studio", "device.view"), true);
|
||||
assert.equal(permissions.canAccessProject(state, session, "audit-collab", "project.view"), true);
|
||||
});
|
||||
|
||||
test("device.view grant gives project read visibility but not thread chat", async () => {
|
||||
const state = await data.readState();
|
||||
state.accountDeviceGrants = [
|
||||
{
|
||||
grantId: "grant-device-view",
|
||||
account: "worker@example.com",
|
||||
deviceId: "mac-studio",
|
||||
permissions: ["device.view"],
|
||||
grantedBy: "krisolo",
|
||||
grantedAt: "2026-04-26T12:00:00+08:00",
|
||||
},
|
||||
];
|
||||
await data.writeState(state);
|
||||
|
||||
const next = await data.readState();
|
||||
const session = {
|
||||
account: "worker@example.com",
|
||||
role: "member" as const,
|
||||
displayName: "Worker",
|
||||
};
|
||||
|
||||
assert.equal(permissions.canAccessDevice(next, session, "mac-studio", "device.view"), true);
|
||||
assert.equal(permissions.canAccessProject(next, session, "master-agent", "project.view"), true);
|
||||
assert.equal(permissions.canAccessProject(next, session, "master-agent", "thread.chat"), false);
|
||||
});
|
||||
|
||||
test("explicit project thread.chat grant allows posting to that project", async () => {
|
||||
const state = await data.readState();
|
||||
state.accountProjectGrants = [
|
||||
{
|
||||
grantId: "grant-thread-chat",
|
||||
account: "worker@example.com",
|
||||
projectId: "master-agent",
|
||||
permissions: ["project.view", "thread.chat", "master_agent.ask"],
|
||||
grantedBy: "krisolo",
|
||||
grantedAt: "2026-04-26T12:00:00+08:00",
|
||||
},
|
||||
];
|
||||
await data.writeState(state);
|
||||
|
||||
const next = await data.readState();
|
||||
const session = {
|
||||
account: "worker@example.com",
|
||||
role: "member" as const,
|
||||
displayName: "Worker",
|
||||
};
|
||||
|
||||
assert.equal(permissions.canAccessProject(next, session, "master-agent", "project.view"), true);
|
||||
assert.equal(permissions.canAccessProject(next, session, "master-agent", "thread.chat"), true);
|
||||
assert.equal(permissions.canAccessProject(next, session, "master-agent", "computer.control"), false);
|
||||
});
|
||||
|
||||
test("expired grants are ignored", async () => {
|
||||
const state = await data.readState();
|
||||
state.accountDeviceGrants = [
|
||||
{
|
||||
grantId: "expired-device-grant",
|
||||
account: "worker@example.com",
|
||||
deviceId: "mac-studio",
|
||||
permissions: ["device.view"],
|
||||
grantedBy: "krisolo",
|
||||
grantedAt: "2026-04-25T12:00:00+08:00",
|
||||
expiresAt: "2000-01-01T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
await data.writeState(state);
|
||||
|
||||
const next = await data.readState();
|
||||
const session = {
|
||||
account: "worker@example.com",
|
||||
role: "member" as const,
|
||||
displayName: "Worker",
|
||||
};
|
||||
|
||||
assert.equal(permissions.canAccessDevice(next, session, "mac-studio", "device.view"), false);
|
||||
});
|
||||
|
||||
test("legacy device account ownership remains a compatibility fallback", async () => {
|
||||
const state = await data.readState();
|
||||
state.devices.push({
|
||||
id: "worker-mac",
|
||||
name: "Worker Mac",
|
||||
avatar: "W",
|
||||
account: "worker@example.com",
|
||||
source: "production",
|
||||
status: "online",
|
||||
projects: ["worker-project"],
|
||||
quota5h: 0,
|
||||
quota7d: 0,
|
||||
lastSeenAt: "2026-04-26T12:00:00+08:00",
|
||||
preferredExecutionMode: "cli",
|
||||
});
|
||||
state.projects.push({
|
||||
id: "worker-project",
|
||||
name: "Worker Project",
|
||||
pinned: false,
|
||||
systemPinned: false,
|
||||
deviceIds: ["worker-mac"],
|
||||
preview: "Owned by worker.",
|
||||
updatedAt: "2026-04-26T12:00:00+08:00",
|
||||
lastMessageAt: "2026-04-26T12:00:00+08:00",
|
||||
isGroup: false,
|
||||
threadMeta: {
|
||||
projectId: "worker-project",
|
||||
threadId: "thread-worker-project",
|
||||
threadDisplayName: "Worker Project",
|
||||
folderName: "Worker",
|
||||
activityIconCount: 0,
|
||||
updatedAt: "2026-04-26T12:00:00+08:00",
|
||||
codexThreadRef: "thread-worker-project",
|
||||
codexFolderRef: "worker",
|
||||
},
|
||||
groupMembers: [],
|
||||
createdByAgent: true,
|
||||
collaborationMode: "development",
|
||||
approvalState: "not_required",
|
||||
unreadCount: 0,
|
||||
riskLevel: "low",
|
||||
messages: [],
|
||||
goals: [],
|
||||
versions: [],
|
||||
});
|
||||
await data.writeState(state);
|
||||
const next = await data.readState();
|
||||
const session = {
|
||||
account: "worker@example.com",
|
||||
role: "member" as const,
|
||||
displayName: "Worker",
|
||||
};
|
||||
|
||||
assert.equal(permissions.canAccessDevice(next, session, "worker-mac", "device.view"), true);
|
||||
assert.equal(permissions.canAccessProject(next, session, "worker-project", "project.view"), true);
|
||||
});
|
||||
109
tests/boss-state-migrations.test.ts
Normal file
109
tests/boss-state-migrations.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, rm, readFile } from "node:fs/promises";
|
||||
|
||||
let runtimeRoot = "";
|
||||
let data: typeof import("../src/lib/boss-data");
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) return;
|
||||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-state-migrations-"));
|
||||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||||
data = await import("../src/lib/boss-data.ts");
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
if (runtimeRoot) await rm(runtimeRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("migrates legacy unversioned state into the current schema metadata and RBAC/Skill arrays", async () => {
|
||||
await setup();
|
||||
|
||||
const migrated = data.migrateBossState({
|
||||
accountDeviceGrants: [
|
||||
{
|
||||
account: "worker@example.com",
|
||||
deviceId: "mac-studio",
|
||||
permissions: ["device.view", "device.view", "invalid.permission"],
|
||||
},
|
||||
],
|
||||
accountProjectGrants: [
|
||||
{
|
||||
account: "worker@example.com",
|
||||
projectId: "master-agent",
|
||||
permissions: ["project.view", "thread.chat"],
|
||||
},
|
||||
{
|
||||
account: "broken@example.com",
|
||||
projectId: "master-agent",
|
||||
permissions: ["not-real"],
|
||||
},
|
||||
],
|
||||
accountSkillGrants: undefined,
|
||||
skillLifecycleRequests: [
|
||||
{
|
||||
deviceId: "mac-studio",
|
||||
sourceUrl: "https://example.com/skills/demo.git",
|
||||
action: "not-a-real-action",
|
||||
status: "not-a-real-status",
|
||||
},
|
||||
],
|
||||
permissionAuditLogs: [
|
||||
{
|
||||
actorAccount: "krisolo",
|
||||
action: "unexpected-action",
|
||||
targetAccount: "worker@example.com",
|
||||
permissions: ["device.view", "bad.permission"],
|
||||
},
|
||||
],
|
||||
} as unknown as Partial<data.BossState>);
|
||||
|
||||
assert.equal(migrated.schemaVersion, data.CURRENT_BOSS_STATE_SCHEMA_VERSION);
|
||||
assert.match(migrated.migratedAt, /^\d{4}-\d{2}-\d{2}T/);
|
||||
assert.deepEqual(migrated.accountDeviceGrants[0]?.permissions, ["device.view"]);
|
||||
assert.deepEqual(migrated.accountProjectGrants.map((grant) => grant.account), ["worker@example.com"]);
|
||||
assert.deepEqual(migrated.accountSkillGrants, []);
|
||||
assert.equal(migrated.skillLifecycleRequests[0]?.action, "install");
|
||||
assert.equal(migrated.skillLifecycleRequests[0]?.status, "pending");
|
||||
assert.equal(migrated.permissionAuditLogs[0]?.action, "grant.updated");
|
||||
assert.deepEqual(migrated.permissionAuditLogs[0]?.permissions, ["device.view"]);
|
||||
});
|
||||
|
||||
test("preserves current schema migration metadata instead of rewriting it on every normalize", async () => {
|
||||
await setup();
|
||||
|
||||
const migrated = data.migrateBossState({
|
||||
schemaVersion: data.CURRENT_BOSS_STATE_SCHEMA_VERSION,
|
||||
migratedAt: "2026-04-20T08:00:00.000Z",
|
||||
accountDeviceGrants: [],
|
||||
accountProjectGrants: [],
|
||||
accountSkillGrants: [],
|
||||
skillLifecycleRequests: [],
|
||||
permissionAuditLogs: [],
|
||||
} as Partial<data.BossState>);
|
||||
|
||||
assert.equal(migrated.schemaVersion, data.CURRENT_BOSS_STATE_SCHEMA_VERSION);
|
||||
assert.equal(migrated.migratedAt, "2026-04-20T08:00:00.000Z");
|
||||
});
|
||||
|
||||
test("writeState persists schema metadata while keeping the state file JSON-compatible", async () => {
|
||||
await setup();
|
||||
|
||||
const state = await data.readState();
|
||||
assert.equal(state.schemaVersion, data.CURRENT_BOSS_STATE_SCHEMA_VERSION);
|
||||
assert.match(state.migratedAt, /^\d{4}-\d{2}-\d{2}T/);
|
||||
|
||||
await data.writeState(state);
|
||||
|
||||
const persisted = JSON.parse(await readFile(process.env.BOSS_STATE_FILE as string, "utf8"));
|
||||
assert.equal(persisted.schemaVersion, data.CURRENT_BOSS_STATE_SCHEMA_VERSION);
|
||||
assert.equal(persisted.migratedAt, state.migratedAt);
|
||||
assert.ok(Array.isArray(persisted.accountDeviceGrants));
|
||||
assert.ok(Array.isArray(persisted.accountProjectGrants));
|
||||
assert.ok(Array.isArray(persisted.accountSkillGrants));
|
||||
assert.ok(Array.isArray(persisted.skillLifecycleRequests));
|
||||
assert.ok(Array.isArray(persisted.permissionAuditLogs));
|
||||
});
|
||||
46
tests/boss-state-store.test.ts
Normal file
46
tests/boss-state-store.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
const schemaPath = new URL("../scripts/postgres-state-schema.sql", import.meta.url);
|
||||
|
||||
test("state store defaults to file mode", async () => {
|
||||
delete process.env.BOSS_STATE_STORE;
|
||||
const { describeBossStateStore } = await import("../src/lib/boss-state-store.ts");
|
||||
|
||||
const summary = describeBossStateStore();
|
||||
assert.equal(summary.mode, "file");
|
||||
assert.equal(summary.ready, true);
|
||||
});
|
||||
|
||||
test("postgres state store fails closed without a database url", async () => {
|
||||
process.env.BOSS_STATE_STORE = "postgres";
|
||||
delete process.env.BOSS_DATABASE_URL;
|
||||
const { describeBossStateStore, createBossStateStore } = await import("../src/lib/boss-state-store.ts");
|
||||
|
||||
const summary = describeBossStateStore();
|
||||
assert.equal(summary.mode, "postgres");
|
||||
assert.equal(summary.ready, false);
|
||||
assert.match(summary.reason ?? "", /BOSS_DATABASE_URL/);
|
||||
assert.throws(
|
||||
() => createBossStateStore({ dataFile: "/tmp/boss-state.json", backupFile: "/tmp/boss-state.json.bak" }),
|
||||
/BOSS_DATABASE_URL_REQUIRED/,
|
||||
);
|
||||
|
||||
delete process.env.BOSS_STATE_STORE;
|
||||
});
|
||||
|
||||
test("postgres state schema stores a single jsonb state snapshot", async () => {
|
||||
const schema = await readFile(schemaPath, "utf8");
|
||||
|
||||
assert.match(schema, /CREATE TABLE IF NOT EXISTS boss_state_snapshots/);
|
||||
assert.match(schema, /state JSONB NOT NULL/);
|
||||
assert.match(schema, /PRIMARY KEY/);
|
||||
});
|
||||
|
||||
test("state store loads postgres lazily so file mode works in standalone builds", async () => {
|
||||
const source = await readFile(new URL("../src/lib/boss-state-store.ts", import.meta.url), "utf8");
|
||||
|
||||
assert.doesNotMatch(source, /import\s+\{\s*Client\s*\}\s+from\s+["']pg["']/);
|
||||
assert.match(source, /await import\(["']pg["']\)/);
|
||||
});
|
||||
139
tests/browser-desktop-control-summary-message.test.ts
Normal file
139
tests/browser-desktop-control-summary-message.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
|
||||
let runtimeRoot = "";
|
||||
let appendProjectMessages: (typeof import("../src/lib/boss-data"))["appendProjectMessages"];
|
||||
let completeMasterAgentTask: (typeof import("../src/lib/boss-data"))["completeMasterAgentTask"];
|
||||
let queueMasterAgentTask: (typeof import("../src/lib/boss-data"))["queueMasterAgentTask"];
|
||||
let readState: (typeof import("../src/lib/boss-data"))["readState"];
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) return;
|
||||
|
||||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-control-summary-task-"));
|
||||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||||
|
||||
const data = await import("../src/lib/boss-data.ts");
|
||||
appendProjectMessages = data.appendProjectMessages;
|
||||
completeMasterAgentTask = data.completeMasterAgentTask;
|
||||
queueMasterAgentTask = data.queueMasterAgentTask;
|
||||
readState = data.readState;
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
if (runtimeRoot) {
|
||||
await rm(runtimeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("completed browser control task mirrors a control summary message into master-agent conversation", async () => {
|
||||
await setup();
|
||||
|
||||
const [requestMessage] = await appendProjectMessages({
|
||||
projectId: "master-agent",
|
||||
messages: [
|
||||
{
|
||||
senderLabel: "Boss 超级管理员",
|
||||
body: "打开 https://example.com 看一下首页",
|
||||
kind: "text",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const task = await queueMasterAgentTask({
|
||||
projectId: "master-agent",
|
||||
taskType: "browser_control",
|
||||
requestMessageId: requestMessage.id,
|
||||
requestText: "打开 https://example.com 看一下首页",
|
||||
requestedBy: "Boss 超级管理员",
|
||||
requestedByAccount: "krisolo",
|
||||
deviceId: "mac-studio",
|
||||
accountId: "openai-master",
|
||||
accountLabel: "gpt-5.4-mini",
|
||||
intentCategory: "browser_control",
|
||||
runtimeKind: "browser-automation-runtime",
|
||||
riskLevel: "medium",
|
||||
confirmationPolicy: "light_confirm",
|
||||
requiresUserConfirmation: true,
|
||||
confirmationScopeKey: "mac-studio:master-agent",
|
||||
});
|
||||
|
||||
await completeMasterAgentTask({
|
||||
taskId: task.taskId,
|
||||
deviceId: "mac-studio",
|
||||
status: "completed",
|
||||
replyBody: "浏览器控制已完成:打开 https://example.com 看一下首页",
|
||||
targetUrl: "https://example.com",
|
||||
});
|
||||
|
||||
const state = await readState();
|
||||
const project = state.projects.find((item) => item.id === "master-agent");
|
||||
const controlSummary = project?.messages.find((message) =>
|
||||
message.kind === "control_summary" &&
|
||||
message.body === "浏览器控制已完成:打开 https://example.com 看一下首页"
|
||||
);
|
||||
|
||||
assert.ok(controlSummary);
|
||||
assert.equal(controlSummary?.sender, "master");
|
||||
assert.equal(controlSummary?.senderLabel, "主 Agent · gpt-5.4-mini");
|
||||
assert.equal(controlSummary?.body, "浏览器控制已完成:打开 https://example.com 看一下首页");
|
||||
assert.equal((controlSummary as { controlTarget?: string }).controlTarget, "https://example.com");
|
||||
});
|
||||
|
||||
test("completed desktop control task mirrors a control summary message into master-agent conversation", async () => {
|
||||
await setup();
|
||||
|
||||
const [requestMessage] = await appendProjectMessages({
|
||||
projectId: "master-agent",
|
||||
messages: [
|
||||
{
|
||||
senderLabel: "Boss 超级管理员",
|
||||
body: "打开微信并准备切到聊天窗口",
|
||||
kind: "text",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const task = await queueMasterAgentTask({
|
||||
projectId: "master-agent",
|
||||
taskType: "desktop_control",
|
||||
requestMessageId: requestMessage.id,
|
||||
requestText: "打开微信并准备切到聊天窗口",
|
||||
requestedBy: "Boss 超级管理员",
|
||||
requestedByAccount: "krisolo",
|
||||
deviceId: "mac-studio",
|
||||
accountId: "openai-master",
|
||||
accountLabel: "gpt-5.4-mini",
|
||||
intentCategory: "desktop_control",
|
||||
runtimeKind: "computer-use-runtime",
|
||||
riskLevel: "medium",
|
||||
confirmationPolicy: "light_confirm",
|
||||
requiresUserConfirmation: true,
|
||||
confirmationScopeKey: "mac-studio:master-agent",
|
||||
});
|
||||
|
||||
await completeMasterAgentTask({
|
||||
taskId: task.taskId,
|
||||
deviceId: "mac-studio",
|
||||
status: "completed",
|
||||
replyBody: "桌面控制已完成:打开微信并准备切到聊天窗口",
|
||||
targetApp: "微信",
|
||||
});
|
||||
|
||||
const state = await readState();
|
||||
const project = state.projects.find((item) => item.id === "master-agent");
|
||||
const controlSummary = project?.messages.find((message) =>
|
||||
message.kind === "control_summary" &&
|
||||
message.body === "桌面控制已完成:打开微信并准备切到聊天窗口"
|
||||
);
|
||||
|
||||
assert.ok(controlSummary);
|
||||
assert.equal(controlSummary?.sender, "master");
|
||||
assert.equal(controlSummary?.senderLabel, "主 Agent · gpt-5.4-mini");
|
||||
assert.equal(controlSummary?.body, "桌面控制已完成:打开微信并准备切到聊天窗口");
|
||||
assert.equal((controlSummary as { controlTarget?: string }).controlTarget, "微信");
|
||||
});
|
||||
118
tests/browser-desktop-runtime-config-defaults.test.mjs
Normal file
118
tests/browser-desktop-runtime-config-defaults.test.mjs
Normal file
@@ -0,0 +1,118 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
test("shipped local-agent configs include browser and desktop runtime smoke defaults", async () => {
|
||||
const exampleConfig = JSON.parse(
|
||||
await readFile(path.join(repoRoot, "local-agent", "config.example.json"), "utf8"),
|
||||
);
|
||||
const cloudConfig = JSON.parse(
|
||||
await readFile(path.join(repoRoot, "local-agent", "config.cloud.json"), "utf8"),
|
||||
);
|
||||
|
||||
assert.equal(exampleConfig.browserControlEnabled, true);
|
||||
assert.equal(cloudConfig.browserControlEnabled, true);
|
||||
assert.equal(exampleConfig.browserControlCommand, "node");
|
||||
assert.equal(cloudConfig.browserControlCommand, "node");
|
||||
assert.deepEqual(exampleConfig.browserControlArgs, ["scripts/browser-control-smoke.mjs"]);
|
||||
assert.deepEqual(cloudConfig.browserControlArgs, ["scripts/browser-control-smoke.mjs"]);
|
||||
assert.equal(exampleConfig.browserAutomationConnected, true);
|
||||
assert.equal(cloudConfig.browserAutomationConnected, true);
|
||||
|
||||
assert.equal(exampleConfig.computerUseEnabled, true);
|
||||
assert.equal(cloudConfig.computerUseEnabled, true);
|
||||
assert.equal(exampleConfig.computerUseCommand, "node");
|
||||
assert.equal(cloudConfig.computerUseCommand, "node");
|
||||
assert.deepEqual(exampleConfig.computerUseArgs, ["scripts/computer-use-smoke.mjs"]);
|
||||
assert.deepEqual(cloudConfig.computerUseArgs, ["scripts/computer-use-smoke.mjs"]);
|
||||
assert.equal(exampleConfig.computerUseConnected, true);
|
||||
assert.equal(cloudConfig.computerUseConnected, true);
|
||||
assert.equal(exampleConfig.dialogGuardEnabled, true);
|
||||
assert.equal(cloudConfig.dialogGuardEnabled, true);
|
||||
assert.equal(exampleConfig.dialogGuardConsentRequired, true);
|
||||
assert.equal(cloudConfig.dialogGuardConsentRequired, true);
|
||||
assert.deepEqual(exampleConfig.dialogGuardPlatformAdapters, ["darwin", "win32"]);
|
||||
assert.deepEqual(cloudConfig.dialogGuardPlatformAdapters, ["darwin", "win32"]);
|
||||
assert.equal(exampleConfig.dialogGuardMacActionCommand, "");
|
||||
assert.equal(cloudConfig.dialogGuardMacActionCommand, "");
|
||||
assert.deepEqual(exampleConfig.dialogGuardMacActionArgs, []);
|
||||
assert.deepEqual(cloudConfig.dialogGuardMacActionArgs, []);
|
||||
assert.equal(exampleConfig.dialogGuardWindowsActionCommand, "");
|
||||
assert.equal(cloudConfig.dialogGuardWindowsActionCommand, "");
|
||||
assert.deepEqual(exampleConfig.dialogGuardWindowsActionArgs, []);
|
||||
assert.deepEqual(cloudConfig.dialogGuardWindowsActionArgs, []);
|
||||
|
||||
assert.equal(exampleConfig.codexDesktopRefreshEnabled, true);
|
||||
assert.equal(cloudConfig.codexDesktopRefreshEnabled, true);
|
||||
assert.equal(exampleConfig.codexDesktopRefreshCommand, "node");
|
||||
assert.equal(cloudConfig.codexDesktopRefreshCommand, "node");
|
||||
assert.deepEqual(exampleConfig.codexDesktopRefreshArgs, ["scripts/codex-desktop-refresh-hint.mjs"]);
|
||||
assert.deepEqual(cloudConfig.codexDesktopRefreshArgs, ["scripts/codex-desktop-refresh-hint.mjs"]);
|
||||
assert.equal(exampleConfig.codexDesktopRefreshEndpoint, "http://127.0.0.1:4318/api/v1/codex-desktop/refresh");
|
||||
assert.equal(cloudConfig.codexDesktopRefreshEndpoint, "http://127.0.0.1:4318/api/v1/codex-desktop/refresh");
|
||||
assert.equal(exampleConfig.codexDesktopRefreshAppName, "Codex");
|
||||
assert.equal(cloudConfig.codexDesktopRefreshAppName, "Codex");
|
||||
assert.equal(exampleConfig.codexDesktopRefreshMode, "deeplink-reload");
|
||||
assert.equal(cloudConfig.codexDesktopRefreshMode, "deeplink-reload");
|
||||
assert.equal(exampleConfig.codexDesktopRefreshRetryCount, 2);
|
||||
assert.equal(cloudConfig.codexDesktopRefreshRetryCount, 2);
|
||||
assert.equal(exampleConfig.codexDesktopRefreshRetryDelayMs, 120);
|
||||
assert.equal(cloudConfig.codexDesktopRefreshRetryDelayMs, 120);
|
||||
});
|
||||
|
||||
test("repo ships browser and desktop smoke runtime scripts", async () => {
|
||||
const browserSmoke = await readFile(path.join(repoRoot, "scripts", "browser-control-smoke.mjs"), "utf8");
|
||||
const computerSmoke = await readFile(path.join(repoRoot, "scripts", "computer-use-smoke.mjs"), "utf8");
|
||||
const codexDesktopRefreshHint = await readFile(
|
||||
path.join(repoRoot, "scripts", "codex-desktop-refresh-hint.mjs"),
|
||||
"utf8",
|
||||
);
|
||||
const codexDesktopRefreshBridgeDaemon = await readFile(
|
||||
path.join(repoRoot, "scripts", "codex-desktop-refresh-bridge-daemon.mjs"),
|
||||
"utf8",
|
||||
);
|
||||
const codexDesktopEventConsumer = await readFile(
|
||||
path.join(repoRoot, "scripts", "codex-desktop-event-consumer.mjs"),
|
||||
"utf8",
|
||||
);
|
||||
const codexDesktopIntegrationProbe = await readFile(
|
||||
path.join(repoRoot, "scripts", "codex-desktop-integration-probe.mjs"),
|
||||
"utf8",
|
||||
);
|
||||
const codexDesktopBridgeLaunchAgent = await readFile(
|
||||
path.join(repoRoot, "deployment", "launchd", "com.hyzq.boss.codex-desktop-bridge.plist"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(browserSmoke, /status/);
|
||||
assert.match(browserSmoke, /replyBody/);
|
||||
assert.match(browserSmoke, /BOSS_BROWSER_AUTOMATION_MODE/);
|
||||
assert.match(computerSmoke, /status/);
|
||||
assert.match(computerSmoke, /replyBody/);
|
||||
assert.match(computerSmoke, /resolveOpenAppPrefixArgs/);
|
||||
assert.match(computerSmoke, /BOSS_COMPUTER_USE_MODE/);
|
||||
assert.match(computerSmoke, /osascript/);
|
||||
assert.match(codexDesktopRefreshHint, /codex_desktop_refresh_hint/);
|
||||
assert.match(codexDesktopRefreshHint, /osascript/);
|
||||
assert.match(codexDesktopRefreshHint, /activate/);
|
||||
assert.match(codexDesktopRefreshHint, /refreshMode/);
|
||||
assert.match(codexDesktopRefreshHint, /codex:\/\/threads\//);
|
||||
assert.match(codexDesktopRefreshHint, /BOSS_CODEX_DESKTOP_REFRESH_DRY_RUN/);
|
||||
assert.match(codexDesktopRefreshHint, /key code 15/);
|
||||
assert.match(codexDesktopRefreshBridgeDaemon, /api\/v1\/codex-desktop\/refresh/);
|
||||
assert.match(codexDesktopRefreshBridgeDaemon, /api\/v1\/codex-desktop\/events/);
|
||||
assert.match(codexDesktopRefreshBridgeDaemon, /text\/event-stream/);
|
||||
assert.match(codexDesktopRefreshBridgeDaemon, /127\.0\.0\.1/);
|
||||
assert.match(codexDesktopEventConsumer, /BOSS_CODEX_DESKTOP_EVENTS_URL/);
|
||||
assert.match(codexDesktopEventConsumer, /BOSS_CODEX_DESKTOP_EVENTS_ONCE/);
|
||||
assert.match(codexDesktopEventConsumer, /codex_desktop_refresh/);
|
||||
assert.match(codexDesktopIntegrationProbe, /BOSS_CODEX_DESKTOP_APP_PATH/);
|
||||
assert.match(codexDesktopIntegrationProbe, /codex:\/\/threads\/\{threadId\}/);
|
||||
assert.match(codexDesktopIntegrationProbe, /packagePatch/);
|
||||
assert.match(codexDesktopBridgeLaunchAgent, /codex-desktop-refresh-bridge-daemon\.mjs/);
|
||||
assert.match(codexDesktopBridgeLaunchAgent, /BOSS_CODEX_DESKTOP_BRIDGE_PORT/);
|
||||
});
|
||||
938
tests/browser-desktop-smoke-runtime-scripts.test.mjs
Normal file
938
tests/browser-desktop-smoke-runtime-scripts.test.mjs
Normal file
@@ -0,0 +1,938 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import http from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
async function writeOpenMarkerScript(markerFile) {
|
||||
const scriptDir = await fs.mkdtemp(path.join(os.tmpdir(), "boss-open-marker-script-"));
|
||||
const scriptPath = path.join(scriptDir, "open-marker.mjs");
|
||||
await fs.writeFile(
|
||||
scriptPath,
|
||||
`import fs from "node:fs";\nfs.writeFileSync(${JSON.stringify(markerFile)}, process.argv[2] || "", "utf8");\n`,
|
||||
"utf8",
|
||||
);
|
||||
await fs.chmod(scriptPath, 0o755);
|
||||
return { scriptDir, scriptPath };
|
||||
}
|
||||
|
||||
async function writeArgumentMarkerCommand(markerFile, commandName = "open") {
|
||||
const scriptDir = await fs.mkdtemp(path.join(os.tmpdir(), `boss-${commandName}-marker-script-`));
|
||||
const scriptPath = path.join(scriptDir, commandName);
|
||||
await fs.writeFile(
|
||||
scriptPath,
|
||||
[
|
||||
"#!/usr/bin/env node",
|
||||
'import fs from "node:fs";',
|
||||
`fs.writeFileSync(${JSON.stringify(markerFile)}, JSON.stringify(process.argv.slice(2)), "utf8");`,
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await fs.chmod(scriptPath, 0o755);
|
||||
return { scriptDir, scriptPath };
|
||||
}
|
||||
|
||||
async function writeBrowserAutomationScript(logFile) {
|
||||
const scriptDir = await fs.mkdtemp(path.join(os.tmpdir(), "boss-browser-automation-script-"));
|
||||
const scriptPath = path.join(scriptDir, "browser-automation.mjs");
|
||||
await fs.writeFile(
|
||||
scriptPath,
|
||||
[
|
||||
'#!/usr/bin/env node',
|
||||
'import fs from "node:fs";',
|
||||
`fs.appendFileSync(${JSON.stringify(logFile)}, JSON.stringify(process.argv.slice(2)) + "\\n", "utf8");`,
|
||||
'if (process.argv.includes("eval")) {',
|
||||
' process.stdout.write("Boss Automated Title\\n");',
|
||||
'}',
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await fs.chmod(scriptPath, 0o755);
|
||||
return { scriptDir, scriptPath };
|
||||
}
|
||||
|
||||
async function writeCodexHomePlaywrightWrapper(codexHome, logFile) {
|
||||
const wrapperDir = path.join(codexHome, "skills", "playwright", "scripts");
|
||||
await fs.mkdir(wrapperDir, { recursive: true });
|
||||
const wrapperPath = path.join(wrapperDir, "playwright_cli.sh");
|
||||
await fs.writeFile(
|
||||
wrapperPath,
|
||||
[
|
||||
"#!/bin/zsh",
|
||||
`printf '%s\\n' \"$(python3 -c 'import json,sys; args=sys.argv[1:]; print(json.dumps(args[1:] if args[:1] == [\"--\"] else args))' -- \"$@\")\" >> ${JSON.stringify(logFile)}`,
|
||||
'if [[ " $* " == *" eval "* ]]; then',
|
||||
' printf "Boss Auto Wrapper Title\\n"',
|
||||
"fi",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await fs.chmod(wrapperPath, 0o755);
|
||||
return wrapperPath;
|
||||
}
|
||||
|
||||
async function runRuntimeWithServer(scriptPath, payload, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [scriptPath], {
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
...(options.env || {}),
|
||||
},
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(stderr.trim() || `exit code ${code}`));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(stdout.trim().split(/\r?\n/).at(-1) || ""));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
child.stdin.write(JSON.stringify(payload));
|
||||
child.stdin.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function runRuntime(scriptPath, payload, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [scriptPath], {
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
BOSS_BROWSER_AUTOMATION_MODE: "off",
|
||||
...(options.env || {}),
|
||||
},
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(stderr.trim() || `exit code ${code}`));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(stdout.trim().split(/\r?\n/).at(-1) || ""));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
child.stdin.write(JSON.stringify(payload));
|
||||
child.stdin.end();
|
||||
});
|
||||
}
|
||||
|
||||
test("browser smoke runtime returns normalized completed payload", async () => {
|
||||
const result = await runRuntime(path.join(repoRoot, "scripts", "browser-control-smoke.mjs"), {
|
||||
requestKind: "browser_control",
|
||||
requestId: "browser-smoke-1",
|
||||
objective: "打开 boss 控制台首页",
|
||||
context: {
|
||||
riskLevel: "medium",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.status, "completed");
|
||||
assert.equal(result.requestId, "browser-smoke-1");
|
||||
assert.match(result.replyBody, /浏览器控制已完成/);
|
||||
assert.match(result.replyBody, /打开 boss 控制台首页/);
|
||||
});
|
||||
|
||||
test("browser smoke runtime emits target url when objective contains a website", async () => {
|
||||
const result = await runRuntime(path.join(repoRoot, "scripts", "browser-control-smoke.mjs"), {
|
||||
requestKind: "browser_control",
|
||||
requestId: "browser-smoke-url",
|
||||
objective: "打开 https://example.com 看一下首页",
|
||||
context: {
|
||||
riskLevel: "medium",
|
||||
dryRun: true,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.status, "completed");
|
||||
assert.equal(result.targetUrl, "https://example.com");
|
||||
assert.match(result.executionSummary, /open_url/);
|
||||
});
|
||||
|
||||
test("browser smoke runtime can invoke configured browser automation command", async () => {
|
||||
const markerDir = await fs.mkdtemp(path.join(os.tmpdir(), "boss-browser-automation-marker-"));
|
||||
const markerFile = path.join(markerDir, "automation.log");
|
||||
let automationScript;
|
||||
try {
|
||||
automationScript = await writeBrowserAutomationScript(markerFile);
|
||||
const result = await runRuntime(
|
||||
path.join(repoRoot, "scripts", "browser-control-smoke.mjs"),
|
||||
{
|
||||
requestKind: "browser_control",
|
||||
requestId: "browser-automation-1",
|
||||
objective: "打开 https://example.com 看一下首页",
|
||||
context: {
|
||||
riskLevel: "medium",
|
||||
dryRun: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
env: {
|
||||
BOSS_BROWSER_AUTOMATION_MODE: "playwright",
|
||||
BOSS_BROWSER_AUTOMATION_COMMAND: process.execPath,
|
||||
BOSS_BROWSER_AUTOMATION_ARGS_JSON: JSON.stringify([automationScript.scriptPath]),
|
||||
BOSS_BROWSER_AUTOMATION_SESSION: "boss-browser-test",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.status, "completed");
|
||||
assert.match(result.replyBody, /Boss Automated Title/);
|
||||
const lines = (await fs.readFile(markerFile, "utf8")).trim().split(/\r?\n/).map((line) => JSON.parse(line));
|
||||
assert.deepEqual(lines[0], ["--session", "boss-browser-test", "open", "https://example.com"]);
|
||||
assert.deepEqual(lines[1], ["--session", "boss-browser-test", "eval", "document.title"]);
|
||||
} finally {
|
||||
if (automationScript?.scriptDir) {
|
||||
await fs.rm(automationScript.scriptDir, { recursive: true, force: true });
|
||||
}
|
||||
await fs.rm(markerDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("browser smoke runtime auto-detects bundled playwright wrapper and uses request id as session", async () => {
|
||||
const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "boss-browser-autodetect-"));
|
||||
const logFile = path.join(tmpRoot, "wrapper.log");
|
||||
try {
|
||||
const codexHome = path.join(tmpRoot, ".codex");
|
||||
await writeCodexHomePlaywrightWrapper(codexHome, logFile);
|
||||
const result = await runRuntimeWithServer(
|
||||
path.join(repoRoot, "scripts", "browser-control-smoke.mjs"),
|
||||
{
|
||||
requestKind: "browser_control",
|
||||
requestId: "browser-auto-session",
|
||||
objective: "打开 https://example.com 看一下首页",
|
||||
context: {
|
||||
riskLevel: "medium",
|
||||
dryRun: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
env: {
|
||||
CODEX_HOME: codexHome,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.status, "completed");
|
||||
assert.match(result.replyBody, /Boss Auto Wrapper Title/);
|
||||
const lines = (await fs.readFile(logFile, "utf8")).trim().split(/\r?\n/).map((line) => JSON.parse(line));
|
||||
assert.deepEqual(lines[0], ["--session", "browser-auto-session", "open", "https://example.com"]);
|
||||
assert.deepEqual(lines[1], ["--session", "browser-auto-session", "eval", "document.title"]);
|
||||
} finally {
|
||||
await fs.rm(tmpRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("browser smoke runtime fetches page title for a reachable target url", async () => {
|
||||
const server = http.createServer((_request, response) => {
|
||||
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
||||
response.end("<html><head><title>Boss Browser Runtime Test</title></head><body>ok</body></html>");
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
const port = typeof address === "object" && address ? address.port : 0;
|
||||
try {
|
||||
const result = await runRuntimeWithServer(path.join(repoRoot, "scripts", "browser-control-smoke.mjs"), {
|
||||
requestKind: "browser_control",
|
||||
requestId: "browser-smoke-title",
|
||||
objective: `打开 http://127.0.0.1:${port}/ 看一下首页`,
|
||||
context: {
|
||||
riskLevel: "medium",
|
||||
dryRun: false,
|
||||
},
|
||||
}, {
|
||||
env: {
|
||||
BOSS_BROWSER_AUTOMATION_MODE: "fetch",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.status, "completed");
|
||||
assert.match(result.replyBody, /Boss Browser Runtime Test/);
|
||||
assert.match(result.executionSummary, /title=Boss Browser Runtime Test/);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
||||
}
|
||||
});
|
||||
|
||||
test("computer use smoke runtime returns normalized completed payload", async () => {
|
||||
const result = await runRuntime(path.join(repoRoot, "scripts", "computer-use-smoke.mjs"), {
|
||||
requestKind: "desktop_control",
|
||||
requestId: "computer-smoke-1",
|
||||
objective: "打开系统设置",
|
||||
context: {
|
||||
riskLevel: "high",
|
||||
dryRun: true,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.status, "completed");
|
||||
assert.equal(result.requestId, "computer-smoke-1");
|
||||
assert.match(result.replyBody, /桌面控制已完成/);
|
||||
assert.match(result.replyBody, /打开系统设置/);
|
||||
});
|
||||
|
||||
test("computer use smoke runtime emits target app when objective contains an app name", async () => {
|
||||
const result = await runRuntime(path.join(repoRoot, "scripts", "computer-use-smoke.mjs"), {
|
||||
requestKind: "desktop_control",
|
||||
requestId: "computer-smoke-app",
|
||||
objective: "打开微信并准备切到聊天窗口",
|
||||
context: {
|
||||
riskLevel: "medium",
|
||||
dryRun: true,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.status, "completed");
|
||||
assert.equal(result.targetApp, "微信");
|
||||
assert.match(result.executionSummary, /open_wechat|open_app/);
|
||||
});
|
||||
|
||||
test("computer use smoke runtime auto-handles safe cross-platform dialog snapshots", async () => {
|
||||
const result = await runRuntime(
|
||||
path.join(repoRoot, "scripts", "computer-use-smoke.mjs"),
|
||||
{
|
||||
requestKind: "desktop_control",
|
||||
requestId: "computer-dialog-safe",
|
||||
objective: "打开 QQ",
|
||||
context: {
|
||||
riskLevel: "medium",
|
||||
dryRun: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
env: {
|
||||
BOSS_DIALOG_GUARD_ENABLED: "true",
|
||||
BOSS_DIALOG_GUARD_SNAPSHOT_JSON: JSON.stringify({
|
||||
platform: "win32",
|
||||
deviceId: "win-node",
|
||||
appName: "QQ",
|
||||
title: "Welcome",
|
||||
text: "Welcome. Not now",
|
||||
buttons: ["Get started", "Not now"],
|
||||
}),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.status, "completed");
|
||||
assert.equal(result.dialogGuard?.disposition, "auto_action");
|
||||
assert.equal(result.dialogGuard?.button, "Not now");
|
||||
assert.match(result.executionSummary, /dialogGuard=auto_action/);
|
||||
});
|
||||
|
||||
test("computer use smoke runtime invokes configured platform dialog action command for safe dialogs", async () => {
|
||||
const markerDir = await fs.mkdtemp(path.join(os.tmpdir(), "boss-dialog-action-marker-"));
|
||||
const markerFile = path.join(markerDir, "action.json");
|
||||
let actionCommand;
|
||||
try {
|
||||
actionCommand = await writeArgumentMarkerCommand(markerFile, "dialog-action");
|
||||
const result = await runRuntime(
|
||||
path.join(repoRoot, "scripts", "computer-use-smoke.mjs"),
|
||||
{
|
||||
requestKind: "desktop_control",
|
||||
requestId: "computer-dialog-action",
|
||||
objective: "打开 QQ",
|
||||
context: {
|
||||
riskLevel: "medium",
|
||||
dryRun: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
env: {
|
||||
BOSS_DIALOG_GUARD_ENABLED: "true",
|
||||
BOSS_WINDOWS_DIALOG_GUARD_ACTION_COMMAND: actionCommand.scriptPath,
|
||||
BOSS_DIALOG_GUARD_SNAPSHOT_JSON: JSON.stringify({
|
||||
platform: "win32",
|
||||
deviceId: "win-node",
|
||||
appName: "QQ",
|
||||
title: "Welcome",
|
||||
text: "Welcome. Not now",
|
||||
buttons: ["Get started", "Not now"],
|
||||
}),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.status, "completed");
|
||||
assert.equal(result.dialogGuard?.actionApplied, true);
|
||||
const args = JSON.parse(await fs.readFile(markerFile, "utf8"));
|
||||
assert.ok(args.includes("--platform"));
|
||||
assert.ok(args.includes("win32"));
|
||||
assert.ok(args.includes("--button"));
|
||||
assert.ok(args.includes("Not now"));
|
||||
} finally {
|
||||
if (actionCommand?.scriptDir) {
|
||||
await fs.rm(actionCommand.scriptDir, { recursive: true, force: true });
|
||||
}
|
||||
await fs.rm(markerDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("computer use smoke runtime pauses before action for blocked system permission dialogs", async () => {
|
||||
const result = await runRuntime(
|
||||
path.join(repoRoot, "scripts", "computer-use-smoke.mjs"),
|
||||
{
|
||||
requestKind: "desktop_control",
|
||||
requestId: "computer-dialog-blocked",
|
||||
objective: "打开系统设置",
|
||||
context: {
|
||||
riskLevel: "high",
|
||||
dryRun: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
env: {
|
||||
BOSS_DIALOG_GUARD_ENABLED: "true",
|
||||
BOSS_DIALOG_GUARD_SNAPSHOT_JSON: JSON.stringify({
|
||||
platform: "darwin",
|
||||
deviceId: "mac-node",
|
||||
appName: "System Settings",
|
||||
title: "Screen Recording",
|
||||
text: "BossComputerUseHelper would like to record this computer's screen",
|
||||
buttons: ["Allow", "Don't Allow"],
|
||||
}),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.status, "needs_user_action");
|
||||
assert.equal(result.kind, "dialog_intervention_required");
|
||||
assert.equal(result.risk, "high");
|
||||
assert.deepEqual(result.availableActions, ["handled_on_device", "cancel_task"]);
|
||||
});
|
||||
|
||||
test("browser smoke runtime writes action artifact when BOSS_CONTROL_ARTIFACT_DIR is set", async () => {
|
||||
const artifactDir = await fs.mkdtemp(path.join(os.tmpdir(), "boss-browser-artifacts-"));
|
||||
try {
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [path.join(repoRoot, "scripts", "browser-control-smoke.mjs")], {
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
BOSS_CONTROL_ARTIFACT_DIR: artifactDir,
|
||||
},
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(stderr.trim() || `exit code ${code}`));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(stdout.trim().split(/\r?\n/).at(-1) || ""));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
child.stdin.write(
|
||||
JSON.stringify({
|
||||
requestKind: "browser_control",
|
||||
requestId: "browser-artifact-1",
|
||||
objective: "打开 https://example.com 看一下首页",
|
||||
context: { dryRun: true },
|
||||
}),
|
||||
);
|
||||
child.stdin.end();
|
||||
});
|
||||
|
||||
assert.equal(result.status, "completed");
|
||||
assert.ok(Array.isArray(result.artifacts));
|
||||
assert.ok(result.artifacts[0]?.path);
|
||||
const artifactText = await fs.readFile(result.artifacts[0].path, "utf8");
|
||||
assert.match(artifactText, /https:\/\/example\.com/);
|
||||
} finally {
|
||||
await fs.rm(artifactDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("browser smoke runtime can execute an injected url opener when not dry-run", async () => {
|
||||
const marker = await fs.mkdtemp(path.join(os.tmpdir(), "boss-browser-open-"));
|
||||
let openerScript;
|
||||
const markerFile = path.join(marker, "opened.txt");
|
||||
try {
|
||||
openerScript = await writeOpenMarkerScript(markerFile);
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [path.join(repoRoot, "scripts", "browser-control-smoke.mjs")], {
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
BOSS_BROWSER_AUTOMATION_MODE: "off",
|
||||
BOSS_BROWSER_OPEN_COMMAND: process.execPath,
|
||||
BOSS_BROWSER_OPEN_ARGS_JSON: JSON.stringify([openerScript.scriptPath]),
|
||||
},
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(stderr.trim() || `exit code ${code}`));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(stdout.trim().split(/\r?\n/).at(-1) || ""));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
child.stdin.write(
|
||||
JSON.stringify({
|
||||
requestKind: "browser_control",
|
||||
requestId: "browser-open-1",
|
||||
objective: "打开 https://example.com 看一下首页",
|
||||
context: { dryRun: false },
|
||||
}),
|
||||
);
|
||||
child.stdin.end();
|
||||
});
|
||||
|
||||
assert.equal(result.status, "completed");
|
||||
assert.equal(result.targetUrl, "https://example.com");
|
||||
assert.match(result.executionSummary, /executed/);
|
||||
const openedUrl = await fs.readFile(markerFile, "utf8");
|
||||
assert.equal(openedUrl, "https://example.com");
|
||||
} finally {
|
||||
if (openerScript?.scriptDir) {
|
||||
await fs.rm(openerScript.scriptDir, { recursive: true, force: true });
|
||||
}
|
||||
await fs.rm(marker, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("computer use smoke runtime can execute an injected app opener when not dry-run", async () => {
|
||||
const marker = await fs.mkdtemp(path.join(os.tmpdir(), "boss-computer-open-"));
|
||||
let openerScript;
|
||||
const markerFile = path.join(marker, "opened.txt");
|
||||
try {
|
||||
openerScript = await writeOpenMarkerScript(markerFile);
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [path.join(repoRoot, "scripts", "computer-use-smoke.mjs")], {
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
BOSS_COMPUTER_USE_MODE: "open",
|
||||
BOSS_COMPUTER_USE_OPEN_APP_COMMAND: process.execPath,
|
||||
BOSS_COMPUTER_USE_OPEN_APP_ARGS_JSON: JSON.stringify([openerScript.scriptPath]),
|
||||
},
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(stderr.trim() || `exit code ${code}`));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(stdout.trim().split(/\r?\n/).at(-1) || ""));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
child.stdin.write(
|
||||
JSON.stringify({
|
||||
requestKind: "desktop_control",
|
||||
requestId: "computer-open-1",
|
||||
objective: "打开微信并准备切到聊天窗口",
|
||||
context: { dryRun: false },
|
||||
}),
|
||||
);
|
||||
child.stdin.end();
|
||||
});
|
||||
|
||||
assert.equal(result.status, "completed");
|
||||
assert.equal(result.targetApp, "微信");
|
||||
assert.match(result.executionSummary, /executed/);
|
||||
const openedApp = await fs.readFile(markerFile, "utf8");
|
||||
assert.equal(openedApp, "微信");
|
||||
} finally {
|
||||
if (openerScript?.scriptDir) {
|
||||
await fs.rm(openerScript.scriptDir, { recursive: true, force: true });
|
||||
}
|
||||
await fs.rm(marker, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("computer use smoke runtime defaults to open -a style args for macOS opener", async () => {
|
||||
const marker = await fs.mkdtemp(path.join(os.tmpdir(), "boss-computer-open-default-"));
|
||||
let openerCommand;
|
||||
const markerFile = path.join(marker, "argv.json");
|
||||
try {
|
||||
openerCommand = await writeArgumentMarkerCommand(markerFile, "open");
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [path.join(repoRoot, "scripts", "computer-use-smoke.mjs")], {
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
BOSS_COMPUTER_USE_MODE: "open",
|
||||
BOSS_COMPUTER_USE_OPEN_APP_COMMAND: openerCommand.scriptPath,
|
||||
},
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(stderr.trim() || `exit code ${code}`));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(stdout.trim().split(/\r?\n/).at(-1) || ""));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
child.stdin.write(
|
||||
JSON.stringify({
|
||||
requestKind: "desktop_control",
|
||||
requestId: "computer-open-default",
|
||||
objective: "打开微信并准备切到聊天窗口",
|
||||
context: { dryRun: false },
|
||||
}),
|
||||
);
|
||||
child.stdin.end();
|
||||
});
|
||||
|
||||
assert.equal(result.status, "completed");
|
||||
const argv = JSON.parse(await fs.readFile(markerFile, "utf8"));
|
||||
assert.deepEqual(argv, ["-a", "微信"]);
|
||||
} finally {
|
||||
if (openerCommand?.scriptDir) {
|
||||
await fs.rm(openerCommand.scriptDir, { recursive: true, force: true });
|
||||
}
|
||||
await fs.rm(marker, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("computer use smoke runtime can prepend configured open -a style args", async () => {
|
||||
const marker = await fs.mkdtemp(path.join(os.tmpdir(), "boss-computer-open-default-"));
|
||||
let openerCommand;
|
||||
const markerFile = path.join(marker, "argv.json");
|
||||
try {
|
||||
openerCommand = await writeArgumentMarkerCommand(markerFile, "open");
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [path.join(repoRoot, "scripts", "computer-use-smoke.mjs")], {
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
BOSS_COMPUTER_USE_MODE: "open",
|
||||
BOSS_COMPUTER_USE_OPEN_APP_COMMAND: openerCommand.scriptPath,
|
||||
BOSS_COMPUTER_USE_OPEN_APP_ARGS_JSON: JSON.stringify(["-a"]),
|
||||
},
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(stderr.trim() || `exit code ${code}`));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(stdout.trim().split(/\r?\n/).at(-1) || ""));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
child.stdin.write(
|
||||
JSON.stringify({
|
||||
requestKind: "desktop_control",
|
||||
requestId: "computer-open-default",
|
||||
objective: "打开微信并准备切到聊天窗口",
|
||||
context: { dryRun: false },
|
||||
}),
|
||||
);
|
||||
child.stdin.end();
|
||||
});
|
||||
|
||||
assert.equal(result.status, "completed");
|
||||
const argv = JSON.parse(await fs.readFile(markerFile, "utf8"));
|
||||
assert.deepEqual(argv, ["-a", "微信"]);
|
||||
} finally {
|
||||
if (openerCommand?.scriptDir) {
|
||||
await fs.rm(openerCommand.scriptDir, { recursive: true, force: true });
|
||||
}
|
||||
await fs.rm(marker, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("computer use smoke runtime supports osascript mode via injected command", async () => {
|
||||
const marker = await fs.mkdtemp(path.join(os.tmpdir(), "boss-computer-osascript-"));
|
||||
let openerScript;
|
||||
const markerFile = path.join(marker, "argv.json");
|
||||
try {
|
||||
openerScript = await writeArgumentMarkerCommand(markerFile, "osascript");
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [path.join(repoRoot, "scripts", "computer-use-smoke.mjs")], {
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
BOSS_COMPUTER_USE_MODE: "osascript",
|
||||
PATH: `${openerScript.scriptDir}:${process.env.PATH || ""}`,
|
||||
},
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(stderr.trim() || `exit code ${code}`));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(stdout.trim().split(/\r?\n/).at(-1) || ""));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
child.stdin.write(
|
||||
JSON.stringify({
|
||||
requestKind: "desktop_control",
|
||||
requestId: "computer-osascript",
|
||||
objective: "打开微信并准备切到聊天窗口",
|
||||
context: { dryRun: false },
|
||||
}),
|
||||
);
|
||||
child.stdin.end();
|
||||
});
|
||||
|
||||
assert.equal(result.status, "completed");
|
||||
assert.match(result.executionSummary, /mode=osascript/);
|
||||
const argv = JSON.parse(await fs.readFile(markerFile, "utf8"));
|
||||
assert.equal(argv[0], "-e");
|
||||
assert.match(argv[1], /tell application "微信"/);
|
||||
} finally {
|
||||
if (openerScript?.scriptDir) {
|
||||
await fs.rm(openerScript.scriptDir, { recursive: true, force: true });
|
||||
}
|
||||
await fs.rm(marker, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("computer use smoke runtime types quoted text in osascript mode and can submit", async () => {
|
||||
const marker = await fs.mkdtemp(path.join(os.tmpdir(), "boss-computer-osascript-type-"));
|
||||
let openerScript;
|
||||
const markerFile = path.join(marker, "argv.json");
|
||||
try {
|
||||
openerScript = await writeArgumentMarkerCommand(markerFile, "osascript");
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [path.join(repoRoot, "scripts", "computer-use-smoke.mjs")], {
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
BOSS_COMPUTER_USE_MODE: "osascript",
|
||||
PATH: `${openerScript.scriptDir}:${process.env.PATH || ""}`,
|
||||
},
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(stderr.trim() || `exit code ${code}`));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(stdout.trim().split(/\r?\n/).at(-1) || ""));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
child.stdin.write(
|
||||
JSON.stringify({
|
||||
requestKind: "desktop_control",
|
||||
requestId: "computer-osascript-type",
|
||||
objective: "打开微信并输入“Boss 已接管当前线程”后发送",
|
||||
context: { dryRun: false },
|
||||
}),
|
||||
);
|
||||
child.stdin.end();
|
||||
});
|
||||
|
||||
assert.equal(result.status, "completed");
|
||||
assert.equal(result.typedText, "Boss 已接管当前线程");
|
||||
const argv = JSON.parse(await fs.readFile(markerFile, "utf8"));
|
||||
assert.equal(argv[0], "-e");
|
||||
assert.match(argv[1], /keystroke "Boss 已接管当前线程"/);
|
||||
assert.match(argv[1], /key code 36/);
|
||||
} finally {
|
||||
if (openerScript?.scriptDir) {
|
||||
await fs.rm(openerScript.scriptDir, { recursive: true, force: true });
|
||||
}
|
||||
await fs.rm(marker, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("computer use smoke runtime writes action artifact when BOSS_CONTROL_ARTIFACT_DIR is set", async () => {
|
||||
const artifactDir = await fs.mkdtemp(path.join(os.tmpdir(), "boss-desktop-artifacts-"));
|
||||
try {
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [path.join(repoRoot, "scripts", "computer-use-smoke.mjs")], {
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
BOSS_CONTROL_ARTIFACT_DIR: artifactDir,
|
||||
},
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(stderr.trim() || `exit code ${code}`));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(stdout.trim().split(/\r?\n/).at(-1) || ""));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
child.stdin.write(
|
||||
JSON.stringify({
|
||||
requestKind: "desktop_control",
|
||||
requestId: "desktop-artifact-1",
|
||||
objective: "打开系统设置",
|
||||
context: { dryRun: true },
|
||||
}),
|
||||
);
|
||||
child.stdin.end();
|
||||
});
|
||||
|
||||
assert.equal(result.status, "completed");
|
||||
assert.ok(Array.isArray(result.artifacts));
|
||||
assert.ok(result.artifacts[0]?.path);
|
||||
const artifactText = await fs.readFile(result.artifacts[0].path, "utf8");
|
||||
assert.match(artifactText, /系统设置/);
|
||||
assert.match(artifactText, /mode/);
|
||||
} finally {
|
||||
await fs.rm(artifactDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
123
tests/codex-desktop-integration-probe.test.mjs
Normal file
123
tests/codex-desktop-integration-probe.test.mjs
Normal file
@@ -0,0 +1,123 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
import { detectCodexDesktopIntegration } from "../scripts/codex-desktop-integration-probe.mjs";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "..");
|
||||
|
||||
async function makeFakeCodexApp() {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "boss-codex-app-"));
|
||||
const appPath = path.join(tempDir, "Codex.app");
|
||||
const contentsDir = path.join(appPath, "Contents");
|
||||
const resourcesDir = path.join(contentsDir, "Resources");
|
||||
await fs.mkdir(resourcesDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(contentsDir, "Info.plist"),
|
||||
`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.openai.codex</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>26.429.30905</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>2345</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>Codex</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>codex</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(resourcesDir, "app.asar"),
|
||||
"function copyThreadLink(id){return `codex://threads/${id}`} // no plugin host export",
|
||||
"utf8",
|
||||
);
|
||||
return { tempDir, appPath };
|
||||
}
|
||||
|
||||
test("detectCodexDesktopIntegration reports stable deeplink and bridge capabilities without patching app", async () => {
|
||||
const { tempDir, appPath } = await makeFakeCodexApp();
|
||||
try {
|
||||
const result = await detectCodexDesktopIntegration({
|
||||
appPath,
|
||||
bridgeEventsUrl: "http://127.0.0.1:4318/api/v1/codex-desktop/events",
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.app.bundleIdentifier, "com.openai.codex");
|
||||
assert.equal(result.app.shortVersion, "26.429.30905");
|
||||
assert.deepEqual(result.app.urlSchemes, ["codex"]);
|
||||
assert.deepEqual(result.capabilities.threadDeepLink, {
|
||||
supported: true,
|
||||
template: "codex://threads/{threadId}",
|
||||
evidence: "CFBundleURLSchemes contains codex and app resources contain codex://threads/",
|
||||
});
|
||||
assert.equal(result.capabilities.desktopBridgeSse.supported, true);
|
||||
assert.equal(result.capabilities.desktopBridgeSse.url, "http://127.0.0.1:4318/api/v1/codex-desktop/events");
|
||||
assert.equal(result.capabilities.inAppSubscription.supported, false);
|
||||
assert.equal(result.capabilities.packagePatch.supported, false);
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("codex desktop refresh bridge daemon exposes capabilities from the integration probe", async () => {
|
||||
const { tempDir, appPath } = await makeFakeCodexApp();
|
||||
const child = spawn(process.execPath, [path.join(repoRoot, "scripts/codex-desktop-refresh-bridge-daemon.mjs")], {
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
BOSS_CODEX_DESKTOP_BRIDGE_HOST: "127.0.0.1",
|
||||
BOSS_CODEX_DESKTOP_BRIDGE_PORT: "0",
|
||||
BOSS_CODEX_DESKTOP_APP_PATH: appPath,
|
||||
BOSS_CODEX_DESKTOP_REFRESH_DRY_RUN: "true",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
try {
|
||||
const ready = await new Promise((resolve, reject) => {
|
||||
let buffer = "";
|
||||
const timer = setTimeout(() => reject(new Error("daemon not ready")), 4000);
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => {
|
||||
buffer += chunk;
|
||||
const line = buffer.trim().split(/\r?\n/).at(-1);
|
||||
if (line) {
|
||||
try {
|
||||
clearTimeout(timer);
|
||||
resolve(JSON.parse(line));
|
||||
} catch {
|
||||
// wait
|
||||
}
|
||||
}
|
||||
});
|
||||
child.on("error", reject);
|
||||
});
|
||||
const response = await fetch(`http://${ready.host}:${ready.port}/api/v1/codex-desktop/capabilities`);
|
||||
const result = await response.json();
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.capabilities.threadDeepLink.supported, true);
|
||||
assert.equal(result.capabilities.packagePatch.supported, false);
|
||||
} finally {
|
||||
child.kill("SIGTERM");
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
38
tests/computer-control-permission-policy.test.ts
Normal file
38
tests/computer-control-permission-policy.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { evaluatePermissionPolicyForTesting } from "@/lib/execution/permission-policy";
|
||||
|
||||
test("browser control medium risk requires confirmation but stays allowed", () => {
|
||||
const result = evaluatePermissionPolicyForTesting({
|
||||
project: {
|
||||
id: "thread-browser",
|
||||
isGroup: false,
|
||||
collaborationMode: "development",
|
||||
approvalState: "not_required",
|
||||
},
|
||||
requestedTool: "browser_control",
|
||||
requestedRiskLevel: "medium",
|
||||
});
|
||||
|
||||
assert.equal(result.allowed, true);
|
||||
assert.equal(result.requiresApproval, true);
|
||||
assert.deepEqual(result.toolPolicy.allowedTools.includes("browser_control"), true);
|
||||
});
|
||||
|
||||
test("desktop control high risk is blocked until explicit confirmation", () => {
|
||||
const result = evaluatePermissionPolicyForTesting({
|
||||
project: {
|
||||
id: "thread-desktop",
|
||||
isGroup: false,
|
||||
collaborationMode: "development",
|
||||
approvalState: "not_required",
|
||||
},
|
||||
requestedTool: "desktop_control",
|
||||
requestedRiskLevel: "high",
|
||||
});
|
||||
|
||||
assert.equal(result.allowed, false);
|
||||
assert.equal(result.requiresApproval, true);
|
||||
assert.match(result.reason ?? "", /确认|高风险/);
|
||||
assert.deepEqual(result.toolPolicy.deniedTools.includes("desktop_control"), true);
|
||||
});
|
||||
51
tests/computer-control-task-model.test.ts
Normal file
51
tests/computer-control-task-model.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { ExecutionRequestKind } from "@/lib/execution/types";
|
||||
import type { ExecutionToolName } from "@/lib/execution/tool-registry";
|
||||
import type { MasterAgentTask } from "@/lib/boss-data";
|
||||
|
||||
test("execution request kinds support browser and desktop control", () => {
|
||||
const browserKind: ExecutionRequestKind = "browser_control";
|
||||
const desktopKind: ExecutionRequestKind = "desktop_control";
|
||||
|
||||
assert.equal(browserKind, "browser_control");
|
||||
assert.equal(desktopKind, "desktop_control");
|
||||
});
|
||||
|
||||
test("execution tool names support browser and desktop control", () => {
|
||||
const browserTool: ExecutionToolName = "browser_control";
|
||||
const desktopTool: ExecutionToolName = "desktop_control";
|
||||
|
||||
assert.equal(browserTool, "browser_control");
|
||||
assert.equal(desktopTool, "desktop_control");
|
||||
});
|
||||
|
||||
test("master agent task supports computer control metadata", () => {
|
||||
const task: MasterAgentTask = {
|
||||
taskId: "task-browser-control",
|
||||
projectId: "master-agent",
|
||||
taskType: "browser_control",
|
||||
requestMessageId: "msg-1",
|
||||
requestText: "打开后台首页",
|
||||
executionPrompt: "请打开后台首页并检查顶部导航。",
|
||||
requestedBy: "Boss 超级管理员",
|
||||
requestedByAccount: "krisolo",
|
||||
deviceId: "mac-studio",
|
||||
status: "queued",
|
||||
requestedAt: "2026-04-22T10:00:00.000Z",
|
||||
intentCategory: "browser_control",
|
||||
runtimeKind: "browser-automation-runtime",
|
||||
riskLevel: "medium",
|
||||
confirmationPolicy: "light_confirm",
|
||||
requiresUserConfirmation: true,
|
||||
confirmationScopeKey: "mac-studio:boss",
|
||||
};
|
||||
|
||||
assert.equal(task.taskType, "browser_control");
|
||||
assert.equal(task.intentCategory, "browser_control");
|
||||
assert.equal(task.runtimeKind, "browser-automation-runtime");
|
||||
assert.equal(task.riskLevel, "medium");
|
||||
assert.equal(task.confirmationPolicy, "light_confirm");
|
||||
assert.equal(task.requiresUserConfirmation, true);
|
||||
assert.equal(task.confirmationScopeKey, "mac-studio:boss");
|
||||
});
|
||||
@@ -88,7 +88,7 @@ test("upsertAttachmentStorageConfig publishes storage refresh event", async () =
|
||||
});
|
||||
|
||||
await upsertAttachmentStorageConfig({
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
mode: "server_file",
|
||||
updatedAt: "2026-04-07T10:20:00.000Z",
|
||||
});
|
||||
@@ -105,7 +105,7 @@ test("master agent prompt policy publishes master agent settings refresh event",
|
||||
|
||||
await updateMasterAgentPromptPolicy({
|
||||
globalPrompt: "保持简洁并只输出有效内容。",
|
||||
updatedBy: "17600003315",
|
||||
updatedBy: "krisolo",
|
||||
});
|
||||
unsubscribe();
|
||||
|
||||
@@ -120,7 +120,7 @@ test("master agent memory writes publish master agent settings refresh event", a
|
||||
});
|
||||
|
||||
await createUserMasterMemory({
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
scope: "global",
|
||||
title: "用户偏好",
|
||||
content: "群聊里默认一键通过。",
|
||||
@@ -143,7 +143,7 @@ test("master agent takeover changes publish master agent settings refresh event"
|
||||
{
|
||||
globalTakeoverEnabled: true,
|
||||
},
|
||||
"17600003315",
|
||||
"krisolo",
|
||||
);
|
||||
unsubscribe();
|
||||
|
||||
|
||||
@@ -6,17 +6,21 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
|
||||
let runtimeRoot = "";
|
||||
let readState: (typeof import("../src/lib/boss-data"))["readState"];
|
||||
let writeState: (typeof import("../src/lib/boss-data"))["writeState"];
|
||||
let updateConversationAction: (typeof import("../src/lib/boss-data"))["updateConversationAction"];
|
||||
let getConversationHomeItems: (typeof import("../src/lib/boss-projections"))["getConversationHomeItems"];
|
||||
let getConversationWebItems: (typeof import("../src/lib/boss-projections"))["getConversationWebItems"];
|
||||
let getConversationHomeItemForProject: (typeof import("../src/lib/boss-projections"))["getConversationHomeItemForProject"];
|
||||
let getConversationThreadItemForProject: (typeof import("../src/lib/boss-projections"))["getConversationThreadItemForProject"];
|
||||
let getConversationFolderView: (typeof import("../src/lib/boss-projections"))["getConversationFolderView"];
|
||||
let getProjectDetailView: (typeof import("../src/lib/boss-projections"))["getProjectDetailView"];
|
||||
let buildProjectMessagesRealtimePayload: (typeof import("../src/lib/boss-projections"))["buildProjectMessagesRealtimePayload"];
|
||||
let formatTimestampLabel: (typeof import("../src/lib/boss-projections"))["formatTimestampLabel"];
|
||||
let getConversationListItemPresentation: (typeof import("../src/components/app-ui"))["getConversationListItemPresentation"];
|
||||
let getConversationActionAvailability: (typeof import("../src/components/app-ui"))["getConversationActionAvailability"];
|
||||
let getConversationActionsPath: (typeof import("../src/components/app-ui"))["getConversationActionsPath"];
|
||||
let getConversationPinnedBadgeLabel: (typeof import("../src/components/app-ui"))["getConversationPinnedBadgeLabel"];
|
||||
let seededStateSnapshot: Awaited<ReturnType<typeof import("../src/lib/boss-data").readState>> | null = null;
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) return;
|
||||
@@ -30,17 +34,28 @@ async function setup() {
|
||||
import("../src/components/app-ui.tsx"),
|
||||
]);
|
||||
readState = data.readState;
|
||||
writeState = data.writeState;
|
||||
updateConversationAction = data.updateConversationAction;
|
||||
getConversationHomeItems = projections.getConversationHomeItems;
|
||||
getConversationWebItems = projections.getConversationWebItems;
|
||||
getConversationHomeItemForProject = projections.getConversationHomeItemForProject;
|
||||
getConversationThreadItemForProject = projections.getConversationThreadItemForProject;
|
||||
getConversationFolderView = projections.getConversationFolderView;
|
||||
getProjectDetailView = projections.getProjectDetailView;
|
||||
buildProjectMessagesRealtimePayload = projections.buildProjectMessagesRealtimePayload;
|
||||
formatTimestampLabel = projections.formatTimestampLabel;
|
||||
getConversationListItemPresentation = ui.getConversationListItemPresentation;
|
||||
getConversationActionAvailability = ui.getConversationActionAvailability;
|
||||
getConversationActionsPath = ui.getConversationActionsPath;
|
||||
getConversationPinnedBadgeLabel = ui.getConversationPinnedBadgeLabel;
|
||||
seededStateSnapshot = structuredClone(await readState());
|
||||
}
|
||||
|
||||
async function resetSeedState() {
|
||||
if (!seededStateSnapshot) {
|
||||
throw new Error("seeded state snapshot missing");
|
||||
}
|
||||
await writeState(structuredClone(seededStateSnapshot));
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
@@ -165,20 +180,26 @@ test("folder archives use the latest thread preview/time while subtitle and cont
|
||||
),
|
||||
];
|
||||
|
||||
const folder = getConversationHomeItems(state).find((item) => item.conversationType === "folder_archive");
|
||||
const originalNow = Date.now;
|
||||
Date.now = () => new Date("2026-04-04T12:30:00+08:00").getTime();
|
||||
try {
|
||||
const folder = getConversationHomeItems(state).find((item) => item.conversationType === "folder_archive");
|
||||
|
||||
assert.ok(folder, "expected grouped folder archive item");
|
||||
assert.equal(folder?.threadTitle, "Boss");
|
||||
assert.equal(folder?.folderLabel, "2 个线程 · 最近:最新线程");
|
||||
assert.equal(folder?.preview, "最近消息:最新线程");
|
||||
assert.equal(folder?.lastMessagePreview, "最近消息:最新线程");
|
||||
assert.equal(folder?.latestReplyAt, "2026-04-04T12:00:00+08:00");
|
||||
assert.equal(folder?.latestReplyLabel, formatTimestampLabel("2026-04-04T12:00:00+08:00"));
|
||||
assert.equal(folder?.contextBudgetIndicator.level, "critical");
|
||||
assert.equal(folder?.contextBudgetIndicator.percent, 87);
|
||||
assert.equal(folder?.mustFinishBeforeCompaction, true);
|
||||
assert.equal(folder?.contextBudgetSourceNodeId, "node-urgent");
|
||||
assert.equal(folder?.contextBudgetUpdatedAt, "2026-04-04T11:05:00+08:00");
|
||||
assert.ok(folder, "expected grouped folder archive item");
|
||||
assert.equal(folder?.threadTitle, "Boss");
|
||||
assert.equal(folder?.folderLabel, "2 个线程 · 最近:最新线程");
|
||||
assert.equal(folder?.preview, "最近消息:最新线程");
|
||||
assert.equal(folder?.lastMessagePreview, "最近消息:最新线程");
|
||||
assert.equal(folder?.latestReplyAt, "2026-04-04T12:00:00+08:00");
|
||||
assert.equal(folder?.latestReplyLabel, formatTimestampLabel("2026-04-04T12:00:00+08:00"));
|
||||
assert.equal(folder?.contextBudgetIndicator.level, "critical");
|
||||
assert.equal(folder?.contextBudgetIndicator.percent, 87);
|
||||
assert.equal(folder?.mustFinishBeforeCompaction, true);
|
||||
assert.equal(folder?.contextBudgetSourceNodeId, "node-urgent");
|
||||
assert.equal(folder?.contextBudgetUpdatedAt, "2026-04-04T11:05:00+08:00");
|
||||
} finally {
|
||||
Date.now = originalNow;
|
||||
}
|
||||
});
|
||||
|
||||
test("conversation home patch lookup returns the visible folder archive item for grouped threads", async () => {
|
||||
@@ -388,61 +409,238 @@ test("folder archive context ring prefers newer latestReplyAt when mustFinishBef
|
||||
assert.equal(folder?.contextBudgetUpdatedAt, "2026-04-04T12:05:00+08:00");
|
||||
});
|
||||
|
||||
test("conversation home upgrades and downgrades a folder archive as thread count crosses 1 and 2", async () => {
|
||||
test("conversation home stays visually empty after history is cleared", async () => {
|
||||
await setup();
|
||||
const state = await readState();
|
||||
|
||||
state.conversationHistoryClearedAt = "2026-04-24T10:00:00.000Z";
|
||||
state.projects = state.projects.filter((project) => project.id === "master-agent");
|
||||
const masterProject = state.projects.find((project) => project.id === "master-agent");
|
||||
assert.ok(masterProject, "expected master-agent project");
|
||||
if (masterProject) {
|
||||
masterProject.preview = "";
|
||||
masterProject.messages = [];
|
||||
masterProject.unreadCount = 0;
|
||||
}
|
||||
state.threadContextSnapshots = [];
|
||||
state.opsFaults = [];
|
||||
state.auditRequests = [];
|
||||
state.auditResults = [];
|
||||
state.projects.push(
|
||||
buildImportedThreadProject(
|
||||
"mac-studio",
|
||||
"boss-thread-clear-a",
|
||||
"Boss",
|
||||
"boss",
|
||||
"线程 A",
|
||||
"thread-clear-a",
|
||||
"2026-04-24T09:00:00.000Z",
|
||||
),
|
||||
buildImportedThreadProject(
|
||||
"mac-studio",
|
||||
"boss-thread-clear-b",
|
||||
"Boss",
|
||||
"boss",
|
||||
"线程 B",
|
||||
"thread-clear-b",
|
||||
"2026-04-24T08:00:00.000Z",
|
||||
),
|
||||
);
|
||||
for (const project of state.projects) {
|
||||
if (project.id !== "master-agent") {
|
||||
project.preview = "";
|
||||
project.messages = [];
|
||||
project.unreadCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const items = getConversationHomeItems(state);
|
||||
const masterItem = items.find((item) => item.projectId === "master-agent");
|
||||
const folderItem = items.find((item) => item.conversationType === "folder_archive");
|
||||
|
||||
assert.ok(masterItem, "expected master-agent home item");
|
||||
assert.equal(masterItem?.preview, "");
|
||||
assert.equal(masterItem?.lastMessagePreview, "");
|
||||
|
||||
assert.ok(folderItem, "expected folder archive item");
|
||||
assert.equal(folderItem?.preview, "");
|
||||
assert.equal(folderItem?.lastMessagePreview, "");
|
||||
});
|
||||
|
||||
test("conversation home hides legacy process-like previews on direct thread rows", async () => {
|
||||
await setup();
|
||||
const state = await readState();
|
||||
|
||||
state.projects = state.projects.filter((project) => project.id === "master-agent");
|
||||
state.projects.push(
|
||||
buildImportedThreadProject(
|
||||
state.projects.push({
|
||||
...buildImportedThreadProject(
|
||||
"mac-studio",
|
||||
"boss-thread-1",
|
||||
"legacy-process-preview-thread",
|
||||
"Boss",
|
||||
"boss",
|
||||
"归档确认",
|
||||
"Boss开发主线程",
|
||||
"thread-legacy-process-preview",
|
||||
"2026-04-24T18:30:00+08:00",
|
||||
),
|
||||
preview: "我继续把这条链路又往下收了一层,补的是“历史脏消息”的兼容,不只是新消息规则。",
|
||||
messages: [
|
||||
{
|
||||
id: "legacy-process-preview-message",
|
||||
sender: "device",
|
||||
senderLabel: "Boss开发主线程",
|
||||
body: "我继续把这条链路又往下收了一层,补的是“历史脏消息”的兼容,不只是新消息规则。",
|
||||
sentAt: "2026-04-24T18:30:00+08:00",
|
||||
kind: "thread_process",
|
||||
},
|
||||
],
|
||||
unreadCount: 0,
|
||||
});
|
||||
|
||||
const item = getConversationHomeItems(state).find((entry) => entry.projectId === "legacy-process-preview-thread");
|
||||
|
||||
assert.ok(item, "expected direct thread home item");
|
||||
assert.equal(item?.preview, "");
|
||||
assert.equal(item?.lastMessagePreview, "");
|
||||
});
|
||||
|
||||
test("conversation home collapses multiline markdown-heavy previews into a short digest", async () => {
|
||||
await setup();
|
||||
const state = await readState();
|
||||
|
||||
state.projects = state.projects.filter((project) => project.id === "master-agent");
|
||||
state.projects.push({
|
||||
...buildImportedThreadProject(
|
||||
"mac-studio",
|
||||
"markdown-heavy-preview-thread",
|
||||
"Boss",
|
||||
"boss",
|
||||
"Boss开发主线程",
|
||||
"thread-markdown-heavy-preview",
|
||||
"2026-04-24T18:31:00+08:00",
|
||||
),
|
||||
preview:
|
||||
"这轮我继续把真机阻塞往下压了一层,而且拿到了比 xcodebuild.log 更强的设备侧证据。\n\n" +
|
||||
"我先把设备 syslog 伴随采集正式接进了 run_ipad_harness.sh,并先用失败断言锁住在 run-ipad-harness-source.test.ts。\n" +
|
||||
"现在每次 harness 真机跑都会自动落盘 device-syslog.log 和 device-syslog-signals.log。",
|
||||
messages: [
|
||||
{
|
||||
id: "markdown-heavy-preview-message",
|
||||
sender: "device",
|
||||
senderLabel: "Boss开发主线程",
|
||||
body:
|
||||
"这轮我继续把真机阻塞往下压了一层,而且拿到了比 xcodebuild.log 更强的设备侧证据。\n\n" +
|
||||
"我先把设备 syslog 伴随采集正式接进了 run_ipad_harness.sh,并先用失败断言锁住在 run-ipad-harness-source.test.ts。\n" +
|
||||
"现在每次 harness 真机跑都会自动落盘 device-syslog.log 和 device-syslog-signals.log。",
|
||||
sentAt: "2026-04-24T18:31:00+08:00",
|
||||
kind: "text",
|
||||
},
|
||||
],
|
||||
unreadCount: 1,
|
||||
});
|
||||
|
||||
const item = getConversationHomeItems(state).find((entry) => entry.projectId === "markdown-heavy-preview-thread");
|
||||
|
||||
assert.ok(item, "expected direct thread home item");
|
||||
assert.equal(item?.preview.includes("\n"), false);
|
||||
assert.equal(item?.lastMessagePreview.includes("\n"), false);
|
||||
assert.equal((item?.preview ?? "").length <= 72, true);
|
||||
assert.match(item?.preview ?? "", /这轮我继续把真机阻塞往下压了一层/);
|
||||
});
|
||||
|
||||
test("conversation home compacts structured project summary json previews into readable text", async () => {
|
||||
await setup();
|
||||
const state = await readState();
|
||||
|
||||
state.projects = state.projects.filter((project) => project.id === "master-agent");
|
||||
state.projects.push({
|
||||
...buildImportedThreadProject(
|
||||
"mac-studio",
|
||||
"summary-thread-1",
|
||||
"luyinka",
|
||||
"/Users/kris/code/luyinka",
|
||||
"Android 对齐",
|
||||
"thread-1",
|
||||
"2026-04-04T10:00:00+08:00",
|
||||
"2026-04-24T12:00:00+08:00",
|
||||
),
|
||||
);
|
||||
await writeFile(process.env.BOSS_STATE_FILE as string, `${JSON.stringify(state, null, 2)}\n`);
|
||||
preview: JSON.stringify({
|
||||
projectGoal: "以安卓成熟版和正式截图为基准完成鸿蒙原生 SHMCI 的页面、录音卡链路与主要功能对齐。",
|
||||
currentProgress: "主链路页面已对齐,正在收口录音卡和设备联调。",
|
||||
technicalArchitecture: "鸿蒙原生 ArkUI + 音频链路适配。",
|
||||
currentBlockers: "",
|
||||
recommendedNextStep: "继续真机回归。",
|
||||
}),
|
||||
lastMessageAt: "2026-04-24T12:00:00+08:00",
|
||||
});
|
||||
|
||||
let items = getConversationHomeItems(state);
|
||||
let direct = items.find((item) => item.projectId === "boss-thread-1");
|
||||
const item = getConversationHomeItems(state).find((entry) => entry.projectId === "summary-thread-1");
|
||||
|
||||
assert.ok(direct, "expected a single thread to remain direct");
|
||||
assert.equal(direct?.conversationType, "single_device");
|
||||
assert.ok(item, "expected json preview item");
|
||||
assert.equal(item?.preview.startsWith("{"), false);
|
||||
assert.match(item?.preview ?? "", /目标:以安卓成熟版和正式截图为基准完成鸿蒙原生 SHMCI/);
|
||||
assert.equal((item?.preview ?? "").length <= 73, true);
|
||||
});
|
||||
|
||||
state.projects.push(
|
||||
buildImportedThreadProject(
|
||||
"mac-studio",
|
||||
"boss-thread-2",
|
||||
"Boss",
|
||||
"boss",
|
||||
"发布回滚",
|
||||
"thread-2",
|
||||
"2026-04-04T11:00:00+08:00",
|
||||
),
|
||||
);
|
||||
test("conversation home upgrades and downgrades a folder archive as thread count crosses 1 and 2", async () => {
|
||||
await setup();
|
||||
const state = await readState();
|
||||
|
||||
items = getConversationHomeItems(state);
|
||||
const folder = items.find((item) => item.conversationType === "folder_archive" && item.folderKey === "mac-studio:boss");
|
||||
try {
|
||||
state.projects = state.projects.filter((project) => project.id === "master-agent");
|
||||
state.projects.push(
|
||||
buildImportedThreadProject(
|
||||
"mac-studio",
|
||||
"boss-thread-1",
|
||||
"Boss",
|
||||
"boss",
|
||||
"归档确认",
|
||||
"thread-1",
|
||||
"2026-04-04T10:00:00+08:00",
|
||||
),
|
||||
);
|
||||
await writeFile(process.env.BOSS_STATE_FILE as string, `${JSON.stringify(state, null, 2)}\n`);
|
||||
|
||||
assert.ok(folder, "expected folder archive once the folder has 2 threads");
|
||||
assert.equal(folder?.threadCount, 2);
|
||||
assert.equal(items.some((item) => item.projectId === "boss-thread-1"), false);
|
||||
assert.equal(items.some((item) => item.projectId === "boss-thread-2"), false);
|
||||
let items = getConversationHomeItems(state);
|
||||
let direct = items.find((item) => item.projectId === "boss-thread-1");
|
||||
|
||||
state.projects = state.projects.filter((project) => project.id !== "boss-thread-2");
|
||||
assert.ok(direct, "expected a single thread to remain direct");
|
||||
assert.equal(direct?.conversationType, "single_device");
|
||||
|
||||
items = getConversationHomeItems(state);
|
||||
direct = items.find((item) => item.projectId === "boss-thread-1");
|
||||
state.projects.push(
|
||||
buildImportedThreadProject(
|
||||
"mac-studio",
|
||||
"boss-thread-2",
|
||||
"Boss",
|
||||
"boss",
|
||||
"发布回滚",
|
||||
"thread-2",
|
||||
"2026-04-04T11:00:00+08:00",
|
||||
),
|
||||
);
|
||||
|
||||
assert.ok(direct, "expected a single remaining thread to downgrade back to direct");
|
||||
assert.equal(direct?.conversationType, "single_device");
|
||||
assert.equal(
|
||||
items.some((item) => item.conversationType === "folder_archive" && item.folderKey === "mac-studio:boss"),
|
||||
false,
|
||||
);
|
||||
items = getConversationHomeItems(state);
|
||||
const folder = items.find(
|
||||
(item) => item.conversationType === "folder_archive" && item.folderKey === "mac-studio:boss",
|
||||
);
|
||||
|
||||
assert.ok(folder, "expected folder archive once the folder has 2 threads");
|
||||
assert.equal(folder?.threadCount, 2);
|
||||
assert.equal(items.some((item) => item.projectId === "boss-thread-1"), false);
|
||||
assert.equal(items.some((item) => item.projectId === "boss-thread-2"), false);
|
||||
|
||||
state.projects = state.projects.filter((project) => project.id !== "boss-thread-2");
|
||||
|
||||
items = getConversationHomeItems(state);
|
||||
direct = items.find((item) => item.projectId === "boss-thread-1");
|
||||
|
||||
assert.ok(direct, "expected a single remaining thread to downgrade back to direct");
|
||||
assert.equal(direct?.conversationType, "single_device");
|
||||
assert.equal(
|
||||
items.some((item) => item.conversationType === "folder_archive" && item.folderKey === "mac-studio:boss"),
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
await resetSeedState();
|
||||
}
|
||||
});
|
||||
|
||||
test("folder archive pin state follows child threads and folder toggle syncs all threads", async () => {
|
||||
@@ -502,46 +700,50 @@ test("folder archive toggle_pin updates all threads that share the folder key",
|
||||
await setup();
|
||||
const state = await readState();
|
||||
|
||||
state.projects = state.projects.filter((project) => project.id === "master-agent");
|
||||
state.projects.push(
|
||||
buildImportedThreadProject(
|
||||
"mac-studio",
|
||||
"yuandi-thread-1",
|
||||
"园地",
|
||||
"/Users/kris/code/yuandi",
|
||||
"线程一",
|
||||
"thread-1",
|
||||
"2026-04-05T10:00:00+08:00",
|
||||
),
|
||||
buildImportedThreadProject(
|
||||
"mac-studio",
|
||||
"yuandi-thread-2",
|
||||
"园地",
|
||||
"/Users/kris/code/yuandi",
|
||||
"线程二",
|
||||
"thread-2",
|
||||
"2026-04-05T11:00:00+08:00",
|
||||
),
|
||||
);
|
||||
await writeFile(process.env.BOSS_STATE_FILE as string, `${JSON.stringify(state, null, 2)}\n`);
|
||||
try {
|
||||
state.projects = state.projects.filter((project) => project.id === "master-agent");
|
||||
state.projects.push(
|
||||
buildImportedThreadProject(
|
||||
"mac-studio",
|
||||
"yuandi-thread-1",
|
||||
"园地",
|
||||
"/Users/kris/code/yuandi",
|
||||
"线程一",
|
||||
"thread-1",
|
||||
"2026-04-05T10:00:00+08:00",
|
||||
),
|
||||
buildImportedThreadProject(
|
||||
"mac-studio",
|
||||
"yuandi-thread-2",
|
||||
"园地",
|
||||
"/Users/kris/code/yuandi",
|
||||
"线程二",
|
||||
"thread-2",
|
||||
"2026-04-05T11:00:00+08:00",
|
||||
),
|
||||
);
|
||||
await writeFile(process.env.BOSS_STATE_FILE as string, `${JSON.stringify(state, null, 2)}\n`);
|
||||
|
||||
await updateConversationAction("mac-studio:/users/kris/code/yuandi", "toggle_pin");
|
||||
let nextState = await readState();
|
||||
assert.deepEqual(
|
||||
nextState.projects
|
||||
.filter((project) => project.id.startsWith("yuandi-thread-"))
|
||||
.map((project) => project.pinned),
|
||||
[true, true],
|
||||
);
|
||||
await updateConversationAction("mac-studio:/users/kris/code/yuandi", "toggle_pin");
|
||||
let nextState = await readState();
|
||||
assert.deepEqual(
|
||||
nextState.projects
|
||||
.filter((project) => project.id.startsWith("yuandi-thread-"))
|
||||
.map((project) => project.pinned),
|
||||
[true, true],
|
||||
);
|
||||
|
||||
await updateConversationAction("mac-studio:/users/kris/code/yuandi", "toggle_pin");
|
||||
nextState = await readState();
|
||||
assert.deepEqual(
|
||||
nextState.projects
|
||||
.filter((project) => project.id.startsWith("yuandi-thread-"))
|
||||
.map((project) => project.pinned),
|
||||
[false, false],
|
||||
);
|
||||
await updateConversationAction("mac-studio:/users/kris/code/yuandi", "toggle_pin");
|
||||
nextState = await readState();
|
||||
assert.deepEqual(
|
||||
nextState.projects
|
||||
.filter((project) => project.id.startsWith("yuandi-thread-"))
|
||||
.map((project) => project.pinned),
|
||||
[false, false],
|
||||
);
|
||||
} finally {
|
||||
await resetSeedState();
|
||||
}
|
||||
});
|
||||
|
||||
test("conversation home groups multiple imported threads by folder while keeping single-thread projects direct", async () => {
|
||||
@@ -709,6 +911,100 @@ test("conversation home compacts imported previews and trims local workspace pre
|
||||
assert.equal(item?.lastMessagePreview, "已导入线程");
|
||||
});
|
||||
|
||||
test("conversation home sanitizes leaked prompt titles to folder fallbacks", async () => {
|
||||
await setup();
|
||||
const state = await readState();
|
||||
|
||||
state.projects = state.projects.filter((project) => project.id === "master-agent");
|
||||
state.projects.push(
|
||||
buildImportedThreadProject(
|
||||
"mac-studio",
|
||||
"boss-thread-prompt",
|
||||
"boss",
|
||||
"/Users/kris/code/boss",
|
||||
"你当前接手的项目根目录是:",
|
||||
"thread-prompt",
|
||||
"2026-04-24T10:00:00+08:00",
|
||||
),
|
||||
buildImportedThreadProject(
|
||||
"mac-studio",
|
||||
"yuandi-thread-prompt",
|
||||
"yuandi",
|
||||
"/Users/kris/code/yuandi",
|
||||
"你现在接手的项目根目录是 /Users/kris/code/yuandi。",
|
||||
"thread-prompt-2",
|
||||
"2026-04-24T10:05:00+08:00",
|
||||
),
|
||||
);
|
||||
|
||||
const bossItem = getConversationHomeItems(state).find((item) => item.projectId === "boss-thread-prompt");
|
||||
const yuandiItem = getConversationHomeItems(state).find((item) => item.projectId === "yuandi-thread-prompt");
|
||||
|
||||
assert.ok(bossItem, "expected prompt-leak boss item");
|
||||
assert.equal(bossItem?.conversationType, "single_device");
|
||||
assert.equal(bossItem?.projectTitle, "boss");
|
||||
assert.equal(bossItem?.threadTitle, "boss");
|
||||
|
||||
assert.ok(yuandiItem, "expected prompt-leak yuandi item");
|
||||
assert.equal(yuandiItem?.conversationType, "single_device");
|
||||
assert.equal(yuandiItem?.projectTitle, "yuandi");
|
||||
assert.equal(yuandiItem?.threadTitle, "yuandi");
|
||||
});
|
||||
|
||||
test("project detail view sanitizes leaked prompt title for single-thread projects", async () => {
|
||||
await setup();
|
||||
await resetSeedState();
|
||||
const state = await readState();
|
||||
const project = buildImportedThreadProject(
|
||||
"mac-studio",
|
||||
"detail-sanitize-thread",
|
||||
"boss",
|
||||
"boss",
|
||||
"你当前接手的项目根目录是:",
|
||||
"detail-sanitize-thread-id",
|
||||
"2026-04-24T12:00:00+08:00",
|
||||
);
|
||||
state.projects.push(project);
|
||||
|
||||
project.name = "你当前接手的项目根目录是:";
|
||||
project.threadMeta.threadDisplayName = "你当前接手的项目根目录是:";
|
||||
project.threadMeta.folderName = "boss";
|
||||
project.threadMeta.codexFolderRef = "boss";
|
||||
|
||||
const detail = getProjectDetailView(state, project.id);
|
||||
|
||||
assert.ok(detail, "expected project detail");
|
||||
assert.equal(detail?.project.name, "boss");
|
||||
assert.equal(detail?.project.threadMeta.threadDisplayName, "boss");
|
||||
});
|
||||
|
||||
test("project messages realtime payload sanitizes leaked prompt title for thread chat header", async () => {
|
||||
await setup();
|
||||
await resetSeedState();
|
||||
const state = await readState();
|
||||
const project = buildImportedThreadProject(
|
||||
"mac-studio",
|
||||
"messages-sanitize-thread",
|
||||
"yuandi",
|
||||
"yuandi",
|
||||
"你现在接手的项目根目录是 /Users/kris/code/yuandi。",
|
||||
"messages-sanitize-thread-id",
|
||||
"2026-04-24T12:01:00+08:00",
|
||||
);
|
||||
state.projects.push(project);
|
||||
|
||||
project.name = "你现在接手的项目根目录是 /Users/kris/code/yuandi。";
|
||||
project.threadMeta.threadDisplayName = "你现在接手的项目根目录是 /Users/kris/code/yuandi。";
|
||||
project.threadMeta.folderName = "";
|
||||
project.threadMeta.codexFolderRef = "yuandi";
|
||||
|
||||
const payload = buildProjectMessagesRealtimePayload(state, project.id);
|
||||
|
||||
assert.ok(payload, "expected realtime payload");
|
||||
assert.equal(payload?.project.name, "yuandi");
|
||||
assert.equal(payload?.project.threadMeta.threadDisplayName, "yuandi");
|
||||
});
|
||||
|
||||
test("folder archive homepage rows do not expose pin toggles in the Web surface", async () => {
|
||||
await setup();
|
||||
const state = await readState();
|
||||
@@ -971,10 +1267,10 @@ test("conversation items hide context ring when no thread snapshot exists", asyn
|
||||
assert.equal(directThread?.contextBudgetIndicator.level, undefined);
|
||||
});
|
||||
|
||||
test("conversation items prefer latest observed codex activity over stale last message time", async () => {
|
||||
test("conversation items keep latest reply ordering anchored to actual message time", async () => {
|
||||
await setup();
|
||||
const state = await readState();
|
||||
const baseProject = buildImportedThreadProject(
|
||||
const staleProject = buildImportedThreadProject(
|
||||
"mac-studio",
|
||||
"stale-thread",
|
||||
"Talking",
|
||||
@@ -983,24 +1279,47 @@ test("conversation items prefer latest observed codex activity over stale last m
|
||||
"thread-stale",
|
||||
"2026-04-04T06:12:00+08:00",
|
||||
);
|
||||
const freshProject = buildImportedThreadProject(
|
||||
"mac-studio",
|
||||
"fresh-thread",
|
||||
"Fresh",
|
||||
"fresh",
|
||||
"最近真回复",
|
||||
"thread-fresh",
|
||||
"2026-04-04T12:10:00+08:00",
|
||||
);
|
||||
|
||||
state.projects = state.projects.filter((project) => project.id === "master-agent");
|
||||
state.projects.push(
|
||||
freshProject,
|
||||
{
|
||||
...baseProject,
|
||||
...staleProject,
|
||||
threadMeta: {
|
||||
...baseProject.threadMeta,
|
||||
...staleProject.threadMeta,
|
||||
lastObservedCodexActivityAt: "2026-04-04T11:48:00+08:00",
|
||||
},
|
||||
projectUnderstanding: {
|
||||
projectGoal: "保持会话列表稳定",
|
||||
currentProgress: "后台只更新状态文档",
|
||||
technicalArchitecture: "Boss 会话页",
|
||||
currentBlockers: "",
|
||||
recommendedNextStep: "不要让非消息事件抬升排序",
|
||||
updatedAt: "2026-04-04T12:08:00+08:00",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const items = getConversationHomeItems(state);
|
||||
const thread = items.find((item) => item.projectId === "stale-thread");
|
||||
const freshThreadIndex = items.findIndex((item) => item.projectId === "fresh-thread");
|
||||
const staleThreadIndex = items.findIndex((item) => item.projectId === "stale-thread");
|
||||
|
||||
assert.ok(thread);
|
||||
assert.equal(thread?.latestReplyAt, "2026-04-04T11:48:00+08:00");
|
||||
assert.equal(thread?.latestReplyLabel, formatTimestampLabel("2026-04-04T11:48:00+08:00"));
|
||||
assert.ok(freshThreadIndex >= 0);
|
||||
assert.ok(staleThreadIndex >= 0);
|
||||
assert.ok(freshThreadIndex < staleThreadIndex, "后台活动不应把旧会话抬到真实新消息前面");
|
||||
assert.equal(thread?.latestReplyAt, "2026-04-04T06:12:00+08:00");
|
||||
assert.equal(thread?.latestReplyLabel, formatTimestampLabel("2026-04-04T06:12:00+08:00"));
|
||||
});
|
||||
|
||||
test("conversation items mark stale context-backed timestamps as waiting for sync", async () => {
|
||||
@@ -1047,6 +1366,7 @@ test("default seeded conversations no longer expose Boss 移动控制台", async
|
||||
const items = getConversationHomeItems(state);
|
||||
|
||||
assert.ok(items.some((item) => item.projectId === "master-agent"), "expected master-agent to remain available");
|
||||
assert.ok(items.some((item) => item.projectId === "audit-collab"), "expected audit collaboration to remain available");
|
||||
assert.equal(
|
||||
items.some((item) => item.projectId === "boss-console" || item.threadTitle === "Boss 移动控制台"),
|
||||
false,
|
||||
|
||||
@@ -21,7 +21,7 @@ test("deployment Caddyfile keeps boss and gptpluscontrol routes in a single site
|
||||
"expected deployment Caddyfile to continue proxying boss-web to port 3000",
|
||||
);
|
||||
assert.equal(
|
||||
(source.match(/boss\.hyzq\.net\s*\{/g) ?? []).length,
|
||||
(source.match(/^boss\.hyzq\.net\s*\{/gm) ?? []).length,
|
||||
1,
|
||||
"expected deployment Caddyfile to avoid duplicate boss.hyzq.net site definitions",
|
||||
);
|
||||
|
||||
283
tests/desktop-dialog-guard-backend.test.ts
Normal file
283
tests/desktop-dialog-guard-backend.test.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
let runtimeRoot = "";
|
||||
let data: typeof import("../src/lib/boss-data");
|
||||
let completeTaskRoute: typeof import("../src/app/api/v1/master-agent/tasks/[taskId]/complete/route");
|
||||
let decisionRoute: typeof import("../src/app/api/v1/dialog-guard/interventions/[interventionId]/decision/route");
|
||||
let events: typeof import("../src/lib/boss-events");
|
||||
let authCookie = "";
|
||||
let baseState: Awaited<ReturnType<typeof import("../src/lib/boss-data")["readState"]>>;
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) return;
|
||||
|
||||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-dialog-guard-backend-"));
|
||||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||||
|
||||
const [dataModule, completeRouteModule, decisionRouteModule, eventsModule, authModule] =
|
||||
await Promise.all([
|
||||
import("../src/lib/boss-data.ts"),
|
||||
import("../src/app/api/v1/master-agent/tasks/[taskId]/complete/route.ts"),
|
||||
import("../src/app/api/v1/dialog-guard/interventions/[interventionId]/decision/route.ts"),
|
||||
import("../src/lib/boss-events.ts"),
|
||||
import("../src/lib/boss-auth.ts"),
|
||||
]);
|
||||
|
||||
data = dataModule;
|
||||
completeTaskRoute = completeRouteModule;
|
||||
decisionRoute = decisionRouteModule;
|
||||
events = eventsModule;
|
||||
authCookie = authModule.AUTH_SESSION_COOKIE;
|
||||
baseState = structuredClone(await data.readState());
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
if (runtimeRoot) {
|
||||
await rm(runtimeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await setup();
|
||||
const state = structuredClone(baseState);
|
||||
state.masterAgentTasks = [];
|
||||
state.permissionAuditLogs = [];
|
||||
state.dialogGuardInterventions = [];
|
||||
await data.writeState(state);
|
||||
});
|
||||
|
||||
function deviceRequest(taskId: string, body: Record<string, unknown>) {
|
||||
return new NextRequest(
|
||||
`http://127.0.0.1:3000/api/v1/master-agent/tasks/${taskId}/complete`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-boss-device-token": "boss-mac-studio-token",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function authedDecisionRequest(interventionId: string, body: Record<string, unknown>) {
|
||||
const session = await data.createAuthSession({
|
||||
account: "krisolo",
|
||||
role: "highest_admin",
|
||||
displayName: "Boss 超级管理员",
|
||||
loginMethod: "password",
|
||||
});
|
||||
return new NextRequest(
|
||||
`http://127.0.0.1:3000/api/v1/dialog-guard/interventions/${interventionId}/decision`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
cookie: `${authCookie}=${session.sessionToken}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function queueDesktopTask() {
|
||||
const [requestMessage] = await data.appendProjectMessages({
|
||||
projectId: "master-agent",
|
||||
messages: [
|
||||
{
|
||||
senderLabel: "Boss 超级管理员",
|
||||
body: "打开微信发送一句测试消息",
|
||||
kind: "text",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return data.queueMasterAgentTask({
|
||||
projectId: "master-agent",
|
||||
taskType: "desktop_control",
|
||||
requestMessageId: requestMessage.id,
|
||||
requestText: "打开微信发送一句测试消息",
|
||||
executionPrompt: "打开微信发送一句测试消息",
|
||||
requestedBy: "Boss 超级管理员",
|
||||
requestedByAccount: "krisolo",
|
||||
deviceId: "mac-studio",
|
||||
accountId: "openai-master",
|
||||
accountLabel: "gpt-5.4-mini",
|
||||
intentCategory: "desktop_control",
|
||||
runtimeKind: "computer-use-runtime",
|
||||
riskLevel: "medium",
|
||||
confirmationPolicy: "light_confirm",
|
||||
});
|
||||
}
|
||||
|
||||
test("needs_user_action task complete creates pending dialog intervention audit log and realtime event", async () => {
|
||||
await setup();
|
||||
const task = await queueDesktopTask();
|
||||
const seenEvents: Array<{
|
||||
event: string;
|
||||
payload: {
|
||||
interventionId?: string;
|
||||
projectId?: string;
|
||||
appName?: string;
|
||||
taskId?: string;
|
||||
status?: string;
|
||||
};
|
||||
}> = [];
|
||||
const unsubscribe = events.subscribeBossEvents((event, payload) => {
|
||||
seenEvents.push({ event, payload });
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await completeTaskRoute.POST(
|
||||
deviceRequest(task.taskId, {
|
||||
deviceId: "mac-studio",
|
||||
status: "needs_user_action",
|
||||
kind: "dialog_intervention_required",
|
||||
requestId: "runtime-request-001",
|
||||
dialogId: "dialog-wechat-send-confirm",
|
||||
appName: "微信",
|
||||
platform: "darwin",
|
||||
risk: "high",
|
||||
summary: "微信即将发送外部可见消息,需要用户确认。",
|
||||
recommendedAction: "allow_once",
|
||||
availableActions: ["allow_once", "deny", "cancel_task"],
|
||||
}),
|
||||
{ params: Promise.resolve({ taskId: task.taskId }) },
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.ok, true);
|
||||
assert.equal(payload.task.status, "needs_user_action");
|
||||
|
||||
const state = await data.readState();
|
||||
const intervention = state.dialogGuardInterventions.at(0);
|
||||
assert.ok(intervention, "expected dialog intervention to be persisted");
|
||||
assert.equal(intervention.taskId, task.taskId);
|
||||
assert.equal(intervention.dialogId, "dialog-wechat-send-confirm");
|
||||
assert.equal(intervention.requestId, "runtime-request-001");
|
||||
assert.equal(intervention.deviceId, "mac-studio");
|
||||
assert.equal(intervention.projectId, "master-agent");
|
||||
assert.equal(intervention.appName, "微信");
|
||||
assert.equal(intervention.platform, "darwin");
|
||||
assert.equal(intervention.risk, "high");
|
||||
assert.equal(intervention.status, "pending");
|
||||
assert.deepEqual(intervention.availableActions, ["allow_once", "deny", "cancel_task"]);
|
||||
|
||||
assert.equal(
|
||||
state.permissionAuditLogs.some(
|
||||
(log) =>
|
||||
log.action === "dialog_guard.intervention_required" &&
|
||||
log.deviceId === "mac-studio" &&
|
||||
log.projectId === "master-agent" &&
|
||||
log.requestId === "runtime-request-001",
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
seenEvents.some(
|
||||
(item) =>
|
||||
item.event === "desktop.dialog_guard.intervention_required" &&
|
||||
item.payload.interventionId === intervention.interventionId &&
|
||||
item.payload.projectId === "master-agent" &&
|
||||
item.payload.appName === "微信" &&
|
||||
item.payload.taskId === task.taskId &&
|
||||
item.payload.status === "pending",
|
||||
),
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
test("decision route resolves pending intervention writes audit log and emits resolved event", async () => {
|
||||
await setup();
|
||||
const task = await queueDesktopTask();
|
||||
await completeTaskRoute.POST(
|
||||
deviceRequest(task.taskId, {
|
||||
deviceId: "mac-studio",
|
||||
status: "needs_user_action",
|
||||
kind: "dialog_intervention_required",
|
||||
requestId: "runtime-request-002",
|
||||
dialogId: "dialog-open-file",
|
||||
appName: "Finder",
|
||||
platform: "darwin",
|
||||
risk: "medium",
|
||||
summary: "Finder 要打开一个本地文件。",
|
||||
recommendedAction: "allow_once",
|
||||
availableActions: ["allow_once", "deny", "handled_on_device"],
|
||||
}),
|
||||
{ params: Promise.resolve({ taskId: task.taskId }) },
|
||||
);
|
||||
|
||||
const pending = (await data.readState()).dialogGuardInterventions.at(0);
|
||||
assert.ok(pending, "expected setup to create a pending intervention");
|
||||
|
||||
const seenEvents: Array<{
|
||||
event: string;
|
||||
payload: {
|
||||
interventionId?: string;
|
||||
projectId?: string;
|
||||
decision?: string;
|
||||
status?: string;
|
||||
};
|
||||
}> = [];
|
||||
const unsubscribe = events.subscribeBossEvents((event, payload) => {
|
||||
seenEvents.push({ event, payload });
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await decisionRoute.POST(
|
||||
await authedDecisionRequest(pending.interventionId, {
|
||||
decision: "allow_once",
|
||||
note: "本次允许。",
|
||||
}),
|
||||
{ params: Promise.resolve({ interventionId: pending.interventionId }) },
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.ok, true);
|
||||
assert.equal(payload.intervention.status, "resolved");
|
||||
assert.equal(payload.intervention.decision, "allow_once");
|
||||
|
||||
const state = await data.readState();
|
||||
const resolved = state.dialogGuardInterventions.find(
|
||||
(item) => item.interventionId === pending.interventionId,
|
||||
);
|
||||
assert.equal(resolved?.status, "resolved");
|
||||
assert.equal(resolved?.decision, "allow_once");
|
||||
assert.ok(resolved?.resolvedAt);
|
||||
assert.equal(
|
||||
state.permissionAuditLogs.some(
|
||||
(log) =>
|
||||
log.action === "dialog_guard.intervention_resolved" &&
|
||||
log.actorAccount === "krisolo" &&
|
||||
log.deviceId === "mac-studio" &&
|
||||
log.projectId === "master-agent" &&
|
||||
log.requestId === "runtime-request-002",
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
seenEvents.some(
|
||||
(item) =>
|
||||
item.event === "desktop.dialog_guard.intervention_resolved" &&
|
||||
item.payload.interventionId === pending.interventionId &&
|
||||
item.payload.projectId === "master-agent" &&
|
||||
item.payload.decision === "allow_once" &&
|
||||
item.payload.status === "resolved",
|
||||
),
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
61
tests/device-computer-control-capabilities.test.ts
Normal file
61
tests/device-computer-control-capabilities.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
|
||||
let runtimeRoot = "";
|
||||
let readState: (typeof import("../src/lib/boss-data"))["readState"];
|
||||
let writeState: (typeof import("../src/lib/boss-data"))["writeState"];
|
||||
let getDeviceWorkspaceView: (typeof import("../src/lib/boss-projections"))["getDeviceWorkspaceView"];
|
||||
let buildDeviceWorkspaceDetailCards: (typeof import("../src/components/app-ui"))["buildDeviceWorkspaceDetailCards"];
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) return;
|
||||
|
||||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-device-computer-control-capabilities-"));
|
||||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||||
|
||||
const [data, projections, ui] = await Promise.all([
|
||||
import("../src/lib/boss-data.ts"),
|
||||
import("../src/lib/boss-projections.ts"),
|
||||
import("../src/components/app-ui.tsx"),
|
||||
]);
|
||||
|
||||
readState = data.readState;
|
||||
writeState = data.writeState;
|
||||
getDeviceWorkspaceView = projections.getDeviceWorkspaceView;
|
||||
buildDeviceWorkspaceDetailCards = ui.buildDeviceWorkspaceDetailCards;
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
if (runtimeRoot) {
|
||||
await rm(runtimeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("device detail exposes browser automation and computer use capability state", async () => {
|
||||
await setup();
|
||||
|
||||
const state = await readState();
|
||||
const macStudio = state.devices.find((device) => device.id === "mac-studio");
|
||||
assert.ok(macStudio, "expected seeded mac-studio");
|
||||
macStudio.capabilities.browserAutomation = {
|
||||
connected: true,
|
||||
lastSeenAt: "2026-04-22T10:00:00.000Z",
|
||||
lastActiveProjectId: "master-agent",
|
||||
};
|
||||
macStudio.capabilities.computerUse = {
|
||||
connected: false,
|
||||
lastSeenAt: "2026-04-22T10:00:00.000Z",
|
||||
lastActiveProjectId: "",
|
||||
};
|
||||
await writeState(state);
|
||||
|
||||
const workspace = getDeviceWorkspaceView(await readState(), "mac-studio");
|
||||
const cards = buildDeviceWorkspaceDetailCards(workspace);
|
||||
|
||||
assert.equal(cards.capabilities.items.browserAutomation, "浏览器自动化:已连接");
|
||||
assert.equal(cards.capabilities.items.computerUse, "桌面控制:未连接");
|
||||
});
|
||||
@@ -193,7 +193,7 @@ test("claimNextMasterAgentTask keeps conversation replies queued when the device
|
||||
requestText: "继续推进当前线程任务",
|
||||
executionPrompt: "请继续推进当前线程任务",
|
||||
requestedBy: "Boss 超级管理员",
|
||||
requestedByAccount: "17600003315",
|
||||
requestedByAccount: "krisolo",
|
||||
deviceId: "mac-studio",
|
||||
taskType: "conversation_reply",
|
||||
targetProjectId: project.id,
|
||||
@@ -224,7 +224,7 @@ test("heartbeat external activity on an active cli folder blocks the next claim
|
||||
requestText: "先推进一轮",
|
||||
executionPrompt: "请先推进一轮",
|
||||
requestedBy: "Boss 超级管理员",
|
||||
requestedByAccount: "17600003315",
|
||||
requestedByAccount: "krisolo",
|
||||
deviceId: "mac-studio",
|
||||
taskType: "conversation_reply",
|
||||
targetProjectId: project.id,
|
||||
@@ -240,7 +240,7 @@ test("heartbeat external activity on an active cli folder blocks the next claim
|
||||
deviceId: "mac-studio",
|
||||
name: "Mac Studio",
|
||||
avatar: "M",
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
status: "online",
|
||||
quota5h: 72,
|
||||
quota7d: 86,
|
||||
@@ -272,7 +272,7 @@ test("heartbeat external activity on an active cli folder blocks the next claim
|
||||
requestText: "继续推进第二轮",
|
||||
executionPrompt: "请继续推进第二轮",
|
||||
requestedBy: "Boss 超级管理员",
|
||||
requestedByAccount: "17600003315",
|
||||
requestedByAccount: "krisolo",
|
||||
deviceId: "mac-studio",
|
||||
taskType: "conversation_reply",
|
||||
targetProjectId: project.id,
|
||||
@@ -341,7 +341,7 @@ test("stale blocked policy does not keep queued conversation replies stuck forev
|
||||
requestText: "继续推进这个线程",
|
||||
executionPrompt: "请继续推进这个线程",
|
||||
requestedBy: "Boss 超级管理员",
|
||||
requestedByAccount: "17600003315",
|
||||
requestedByAccount: "krisolo",
|
||||
deviceId: "mac-studio",
|
||||
taskType: "conversation_reply",
|
||||
targetProjectId: project.id,
|
||||
@@ -361,3 +361,80 @@ test("stale blocked policy does not keep queued conversation replies stuck forev
|
||||
assert.equal(policy?.activeCliExecution, true);
|
||||
assert.equal(policy?.recentExternalActivityAt, undefined);
|
||||
});
|
||||
|
||||
test("claimNextMasterAgentTask reclaims stale running conversation replies for the same device", async () => {
|
||||
await setup();
|
||||
|
||||
const project = await getCliProject();
|
||||
const task = await queueMasterAgentTask({
|
||||
projectId: project.id,
|
||||
requestMessageId: "msg-stale-running-reply",
|
||||
requestText: "请继续推进当前线程",
|
||||
executionPrompt: "请继续推进当前线程",
|
||||
requestedBy: "Boss 超级管理员",
|
||||
requestedByAccount: "krisolo",
|
||||
deviceId: "mac-studio",
|
||||
taskType: "conversation_reply",
|
||||
targetProjectId: project.id,
|
||||
targetThreadId: project.threadMeta.threadId,
|
||||
targetThreadDisplayName: project.threadMeta.threadDisplayName,
|
||||
targetCodexThreadRef: project.threadMeta.codexThreadRef,
|
||||
targetCodexFolderRef: project.threadMeta.codexFolderRef,
|
||||
});
|
||||
|
||||
const initialClaim = await claimNextMasterAgentTask("mac-studio");
|
||||
assert.equal(initialClaim?.taskId, task.taskId);
|
||||
|
||||
const state = await readState();
|
||||
const runningTask = state.masterAgentTasks.find((item) => item.taskId === task.taskId);
|
||||
assert.equal(runningTask?.status, "running");
|
||||
runningTask!.claimedAt = "2026-04-01T00:00:00.000Z";
|
||||
await writeState(state);
|
||||
|
||||
const reclaimed = await claimNextMasterAgentTask("mac-studio");
|
||||
assert.equal(reclaimed?.taskId, task.taskId);
|
||||
|
||||
const nextState = await readState();
|
||||
const reclaimedTask = nextState.masterAgentTasks.find((item) => item.taskId === task.taskId);
|
||||
assert.equal(reclaimedTask?.status, "running");
|
||||
assert.notEqual(reclaimedTask?.claimedAt, "2026-04-01T00:00:00.000Z");
|
||||
});
|
||||
|
||||
test("claimNextMasterAgentTask does not automatically reclaim stale running dispatch_execution tasks", async () => {
|
||||
await setup();
|
||||
|
||||
const project = await getCliProject();
|
||||
const task = await queueMasterAgentTask({
|
||||
projectId: project.id,
|
||||
requestMessageId: "msg-stale-running-dispatch",
|
||||
requestText: "请执行修复任务",
|
||||
executionPrompt: "请执行修复任务",
|
||||
requestedBy: "Boss 超级管理员",
|
||||
requestedByAccount: "krisolo",
|
||||
deviceId: "mac-studio",
|
||||
taskType: "dispatch_execution",
|
||||
dispatchExecutionId: "dispatch-exec-stale-1",
|
||||
targetProjectId: project.id,
|
||||
targetThreadId: project.threadMeta.threadId,
|
||||
targetThreadDisplayName: project.threadMeta.threadDisplayName,
|
||||
targetCodexThreadRef: project.threadMeta.codexThreadRef,
|
||||
targetCodexFolderRef: project.threadMeta.codexFolderRef,
|
||||
});
|
||||
|
||||
const initialClaim = await claimNextMasterAgentTask("mac-studio");
|
||||
assert.equal(initialClaim?.taskId, task.taskId);
|
||||
|
||||
const state = await readState();
|
||||
const runningTask = state.masterAgentTasks.find((item) => item.taskId === task.taskId);
|
||||
assert.equal(runningTask?.status, "running");
|
||||
runningTask!.claimedAt = "2026-04-01T00:00:00.000Z";
|
||||
await writeState(state);
|
||||
|
||||
const reclaimed = await claimNextMasterAgentTask("mac-studio");
|
||||
assert.equal(reclaimed, null);
|
||||
|
||||
const nextState = await readState();
|
||||
const unchangedTask = nextState.masterAgentTasks.find((item) => item.taskId === task.taskId);
|
||||
assert.equal(unchangedTask?.status, "running");
|
||||
assert.equal(unchangedTask?.claimedAt, "2026-04-01T00:00:00.000Z");
|
||||
});
|
||||
|
||||
@@ -171,7 +171,7 @@ test("device heartbeat persists gui cli capability state on the same physical de
|
||||
deviceId: "mac-studio",
|
||||
name: "Mac Studio",
|
||||
avatar: "M",
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
status: "online",
|
||||
quota5h: 72,
|
||||
quota7d: 86,
|
||||
@@ -214,7 +214,7 @@ test("device heartbeat does not overwrite the preferred execution mode chosen in
|
||||
deviceId: "mac-studio",
|
||||
name: "Mac Studio",
|
||||
avatar: "M",
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
status: "online",
|
||||
quota5h: 72,
|
||||
quota7d: 86,
|
||||
@@ -276,7 +276,7 @@ test("device heartbeat without capability payload refreshes stale gui cli lastSe
|
||||
deviceId: "mac-studio",
|
||||
name: "Mac Studio",
|
||||
avatar: "M",
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
status: "online",
|
||||
quota5h: 72,
|
||||
quota7d: 86,
|
||||
|
||||
522
tests/device-heartbeat-codex-message-sync.test.ts
Normal file
522
tests/device-heartbeat-codex-message-sync.test.ts
Normal file
@@ -0,0 +1,522 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
|
||||
let runtimeRoot = "";
|
||||
let readState: (typeof import("../src/lib/boss-data"))["readState"];
|
||||
let upsertDeviceHeartbeat: (typeof import("../src/lib/boss-data"))["upsertDeviceHeartbeat"];
|
||||
let writeState: (typeof import("../src/lib/boss-data"))["writeState"];
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) return;
|
||||
|
||||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-device-heartbeat-message-sync-"));
|
||||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||||
|
||||
const data = await import("../src/lib/boss-data.ts");
|
||||
readState = data.readState;
|
||||
upsertDeviceHeartbeat = data.upsertDeviceHeartbeat;
|
||||
writeState = data.writeState;
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
if (runtimeRoot) {
|
||||
await rm(runtimeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("device heartbeat mirrors recent codex desktop replies into the matching thread conversation once", async () => {
|
||||
await setup();
|
||||
|
||||
const seedHeartbeat = {
|
||||
deviceId: "device-message-sync",
|
||||
token: "device-message-sync-token",
|
||||
name: "Mac Studio",
|
||||
avatar: "M",
|
||||
account: "krisolo",
|
||||
status: "online" as const,
|
||||
quota5h: 76,
|
||||
quota7d: 85,
|
||||
projects: [],
|
||||
endpoint: "mac://kris.local",
|
||||
projectCandidates: [
|
||||
{
|
||||
folderName: "boss",
|
||||
folderRef: "/Users/kris/code/boss",
|
||||
threadId: "thread-boss-main",
|
||||
threadDisplayName: "Boss开发主线程",
|
||||
codexFolderRef: "/Users/kris/code/boss",
|
||||
codexThreadRef: "thread-boss-main",
|
||||
lastActiveAt: "2026-04-20T10:00:00.000Z",
|
||||
suggestedImport: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await upsertDeviceHeartbeat(seedHeartbeat);
|
||||
await upsertDeviceHeartbeat(seedHeartbeat);
|
||||
|
||||
const initialState = await readState();
|
||||
const importedProject = initialState.projects.find(
|
||||
(project) => project.threadMeta.codexThreadRef === "thread-boss-main",
|
||||
);
|
||||
assert.ok(importedProject, "expected heartbeat auto-import to create the thread conversation");
|
||||
|
||||
await upsertDeviceHeartbeat({
|
||||
...seedHeartbeat,
|
||||
projectCandidates: [
|
||||
{
|
||||
...seedHeartbeat.projectCandidates[0],
|
||||
recentAssistantMessages: [
|
||||
{
|
||||
messageId: "codex-thread:thread-boss-main:2026-04-20T10:02:10.000Z:reply-1",
|
||||
body: "桌面 Codex 已经把会话实时同步链路修好了。",
|
||||
sentAt: "2026-04-20T10:02:10.000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let nextState = await readState();
|
||||
let nextProject = nextState.projects.find((project) => project.id === importedProject?.id);
|
||||
const mirroredMessage = nextProject?.messages.find(
|
||||
(message) => message.externalMessageId === "codex-thread:thread-boss-main:2026-04-20T10:02:10.000Z:reply-1",
|
||||
);
|
||||
|
||||
assert.ok(mirroredMessage);
|
||||
assert.equal(mirroredMessage?.sender, "device");
|
||||
assert.equal(mirroredMessage?.senderLabel, "Boss开发主线程");
|
||||
assert.equal(mirroredMessage?.body, "桌面 Codex 已经把会话实时同步链路修好了。");
|
||||
assert.equal(nextProject?.lastMessageAt, "2026-04-20T10:02:10.000Z");
|
||||
assert.equal(nextProject?.preview, "桌面 Codex 已经把会话实时同步链路修好了。");
|
||||
assert.equal(nextProject?.unreadCount, 1);
|
||||
|
||||
await upsertDeviceHeartbeat({
|
||||
...seedHeartbeat,
|
||||
projectCandidates: [
|
||||
{
|
||||
...seedHeartbeat.projectCandidates[0],
|
||||
recentAssistantMessages: [
|
||||
{
|
||||
messageId: "codex-thread:thread-boss-main:2026-04-20T10:02:10.000Z:reply-1",
|
||||
body: "桌面 Codex 已经把会话实时同步链路修好了。",
|
||||
sentAt: "2026-04-20T10:02:10.000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
nextState = await readState();
|
||||
nextProject = nextState.projects.find((project) => project.id === importedProject?.id);
|
||||
const mirroredCopies = nextProject?.messages.filter(
|
||||
(message) => message.externalMessageId === "codex-thread:thread-boss-main:2026-04-20T10:02:10.000Z:reply-1",
|
||||
);
|
||||
assert.equal(mirroredCopies?.length, 1);
|
||||
assert.equal(nextProject?.unreadCount, 1);
|
||||
});
|
||||
|
||||
test("device heartbeat does not duplicate a reply already written by task completion", async () => {
|
||||
await setup();
|
||||
|
||||
const seedHeartbeat = {
|
||||
deviceId: "device-message-completion-dedupe",
|
||||
token: "device-message-completion-dedupe-token",
|
||||
name: "Mac Studio",
|
||||
avatar: "M",
|
||||
account: "krisolo",
|
||||
status: "online" as const,
|
||||
quota5h: 76,
|
||||
quota7d: 85,
|
||||
projects: [],
|
||||
endpoint: "mac://kris.local",
|
||||
projectCandidates: [
|
||||
{
|
||||
folderName: "boss",
|
||||
folderRef: "/Users/kris/code/boss",
|
||||
threadId: "thread-boss-completion-dedupe",
|
||||
threadDisplayName: "Boss开发主线程",
|
||||
codexFolderRef: "/Users/kris/code/boss",
|
||||
codexThreadRef: "thread-boss-completion-dedupe",
|
||||
lastActiveAt: "2026-04-20T10:00:00.000Z",
|
||||
suggestedImport: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await upsertDeviceHeartbeat(seedHeartbeat);
|
||||
await upsertDeviceHeartbeat(seedHeartbeat);
|
||||
const initialState = await readState();
|
||||
const importedProject = initialState.projects.find(
|
||||
(project) => project.threadMeta.codexThreadRef === "thread-boss-completion-dedupe",
|
||||
);
|
||||
assert.ok(importedProject);
|
||||
|
||||
const directReplyBody = "BOSS回归APP消息已收到。";
|
||||
const targetProject = initialState.projects.find((project) => project.id === importedProject?.id);
|
||||
assert.ok(targetProject);
|
||||
targetProject!.messages = [
|
||||
{
|
||||
id: "msg-direct-completion",
|
||||
sender: "device",
|
||||
senderLabel: "Boss开发主线程",
|
||||
body: directReplyBody,
|
||||
sentAt: "2026-04-20T10:02:10.000Z",
|
||||
kind: "text",
|
||||
},
|
||||
];
|
||||
targetProject!.preview = directReplyBody;
|
||||
targetProject!.lastMessageAt = "2026-04-20T10:02:10.000Z";
|
||||
targetProject!.unreadCount = 1;
|
||||
await writeState(initialState);
|
||||
|
||||
await upsertDeviceHeartbeat({
|
||||
...seedHeartbeat,
|
||||
projectCandidates: [
|
||||
{
|
||||
...seedHeartbeat.projectCandidates[0],
|
||||
recentAssistantMessages: [
|
||||
{
|
||||
messageId: "codex-thread:thread-boss-completion-dedupe:2026-04-20T10:02:10.001Z:reply-1",
|
||||
body: directReplyBody,
|
||||
sentAt: "2026-04-20T10:02:10.001Z",
|
||||
phase: "final_answer",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const nextState = await readState();
|
||||
const nextProject = nextState.projects.find((project) => project.id === importedProject?.id);
|
||||
const matchingReplies = nextProject?.messages.filter((message) => message.body === directReplyBody);
|
||||
|
||||
assert.equal(matchingReplies?.length, 1);
|
||||
assert.equal(matchingReplies?.[0]?.externalMessageId, undefined);
|
||||
assert.equal(nextProject?.unreadCount, 1);
|
||||
assert.equal(nextProject?.preview, directReplyBody);
|
||||
});
|
||||
|
||||
test("device heartbeat does not duplicate a takeover reply already written by master agent", async () => {
|
||||
await setup();
|
||||
|
||||
const seedHeartbeat = {
|
||||
deviceId: "device-message-master-dedupe",
|
||||
token: "device-message-master-dedupe-token",
|
||||
name: "Mac Studio",
|
||||
avatar: "M",
|
||||
account: "krisolo",
|
||||
status: "online" as const,
|
||||
quota5h: 76,
|
||||
quota7d: 85,
|
||||
projects: [],
|
||||
endpoint: "mac://kris.local",
|
||||
projectCandidates: [
|
||||
{
|
||||
folderName: "boss",
|
||||
folderRef: "/Users/kris/code/boss",
|
||||
threadId: "thread-boss-master-dedupe",
|
||||
threadDisplayName: "Boss开发主线程",
|
||||
codexFolderRef: "/Users/kris/code/boss",
|
||||
codexThreadRef: "thread-boss-master-dedupe",
|
||||
lastActiveAt: "2026-04-20T10:00:00.000Z",
|
||||
suggestedImport: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await upsertDeviceHeartbeat(seedHeartbeat);
|
||||
await upsertDeviceHeartbeat(seedHeartbeat);
|
||||
const initialState = await readState();
|
||||
const importedProject = initialState.projects.find(
|
||||
(project) => project.threadMeta.codexThreadRef === "thread-boss-master-dedupe",
|
||||
);
|
||||
assert.ok(importedProject);
|
||||
|
||||
const replyBody = "主Agent托管链路已收到。";
|
||||
const targetProject = initialState.projects.find((project) => project.id === importedProject?.id);
|
||||
assert.ok(targetProject);
|
||||
targetProject!.messages = [
|
||||
{
|
||||
id: "msg-master-completion",
|
||||
sender: "master",
|
||||
senderLabel: "主 Agent",
|
||||
body: replyBody,
|
||||
sentAt: "2026-04-20T10:02:10.000Z",
|
||||
kind: "text",
|
||||
},
|
||||
];
|
||||
targetProject!.preview = replyBody;
|
||||
targetProject!.lastMessageAt = "2026-04-20T10:02:10.000Z";
|
||||
targetProject!.unreadCount = 1;
|
||||
await writeState(initialState);
|
||||
|
||||
await upsertDeviceHeartbeat({
|
||||
...seedHeartbeat,
|
||||
projectCandidates: [
|
||||
{
|
||||
...seedHeartbeat.projectCandidates[0],
|
||||
recentAssistantMessages: [
|
||||
{
|
||||
messageId: "codex-thread:thread-boss-master-dedupe:2026-04-20T10:02:10.001Z:reply-1",
|
||||
body: replyBody,
|
||||
sentAt: "2026-04-20T10:02:10.001Z",
|
||||
phase: "final_answer",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const nextState = await readState();
|
||||
const nextProject = nextState.projects.find((project) => project.id === importedProject?.id);
|
||||
const matchingReplies = nextProject?.messages.filter((message) => message.body === replyBody);
|
||||
|
||||
assert.equal(matchingReplies?.length, 1);
|
||||
assert.equal(matchingReplies?.[0]?.sender, "master");
|
||||
assert.equal(matchingReplies?.[0]?.externalMessageId, undefined);
|
||||
assert.equal(nextProject?.unreadCount, 1);
|
||||
assert.equal(nextProject?.preview, replyBody);
|
||||
});
|
||||
|
||||
test("device heartbeat does not count commentary replies as unread and keeps only the final result unread", async () => {
|
||||
await setup();
|
||||
|
||||
const seedHeartbeat = {
|
||||
deviceId: "device-message-phase",
|
||||
token: "device-message-phase-token",
|
||||
name: "Mac Studio",
|
||||
avatar: "M",
|
||||
account: "krisolo",
|
||||
status: "online" as const,
|
||||
quota5h: 76,
|
||||
quota7d: 85,
|
||||
projects: [],
|
||||
endpoint: "mac://kris.local",
|
||||
projectCandidates: [
|
||||
{
|
||||
folderName: "boss",
|
||||
folderRef: "/Users/kris/code/boss",
|
||||
threadId: "thread-boss-phase",
|
||||
threadDisplayName: "Boss开发主线程",
|
||||
codexFolderRef: "/Users/kris/code/boss",
|
||||
codexThreadRef: "thread-boss-phase",
|
||||
lastActiveAt: "2026-04-20T10:00:00.000Z",
|
||||
suggestedImport: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await upsertDeviceHeartbeat(seedHeartbeat);
|
||||
await upsertDeviceHeartbeat(seedHeartbeat);
|
||||
|
||||
await upsertDeviceHeartbeat({
|
||||
...seedHeartbeat,
|
||||
projectCandidates: [
|
||||
{
|
||||
...seedHeartbeat.projectCandidates[0],
|
||||
recentAssistantMessages: [
|
||||
{
|
||||
messageId: "codex-thread:thread-boss-phase:2026-04-20T10:03:00.000Z:commentary-1",
|
||||
body: "我先检查聊天折叠链路,确认过程消息不会直接展开。",
|
||||
sentAt: "2026-04-20T10:03:00.000Z",
|
||||
phase: "commentary",
|
||||
},
|
||||
{
|
||||
messageId: "codex-thread:thread-boss-phase:2026-04-20T10:05:00.000Z:final-1",
|
||||
body: "这轮已经完成折叠修复,未读现在只会算最终结果。",
|
||||
sentAt: "2026-04-20T10:05:00.000Z",
|
||||
phase: "final_answer",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const nextState = await readState();
|
||||
const nextProject = nextState.projects.find(
|
||||
(project) => project.threadMeta.codexThreadRef === "thread-boss-phase",
|
||||
);
|
||||
const processMessage = nextProject?.messages.find(
|
||||
(message) =>
|
||||
message.externalMessageId === "codex-thread:thread-boss-phase:2026-04-20T10:03:00.000Z:commentary-1",
|
||||
);
|
||||
const finalMessage = nextProject?.messages.find(
|
||||
(message) =>
|
||||
message.externalMessageId === "codex-thread:thread-boss-phase:2026-04-20T10:05:00.000Z:final-1",
|
||||
);
|
||||
|
||||
assert.ok(nextProject);
|
||||
assert.equal(processMessage?.kind, "thread_process");
|
||||
assert.equal(finalMessage?.kind, "text");
|
||||
assert.equal(nextProject?.preview, "这轮已经完成折叠修复,未读现在只会算最终结果。");
|
||||
assert.equal(nextProject?.unreadCount, 1);
|
||||
});
|
||||
|
||||
test("device heartbeat does not replay old desktop replies after conversation history is cleared", async () => {
|
||||
await setup();
|
||||
|
||||
const seedHeartbeat = {
|
||||
deviceId: "device-message-reset",
|
||||
token: "device-message-reset-token",
|
||||
name: "Mac Studio",
|
||||
avatar: "M",
|
||||
account: "krisolo",
|
||||
status: "online" as const,
|
||||
quota5h: 76,
|
||||
quota7d: 85,
|
||||
projects: [],
|
||||
endpoint: "mac://kris.local",
|
||||
projectCandidates: [
|
||||
{
|
||||
folderName: "boss",
|
||||
folderRef: "/Users/kris/code/boss",
|
||||
threadId: "thread-boss-reset",
|
||||
threadDisplayName: "Boss开发主线程",
|
||||
codexFolderRef: "/Users/kris/code/boss",
|
||||
codexThreadRef: "thread-boss-reset",
|
||||
lastActiveAt: "2026-04-20T10:00:00.000Z",
|
||||
suggestedImport: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await upsertDeviceHeartbeat(seedHeartbeat);
|
||||
await upsertDeviceHeartbeat(seedHeartbeat);
|
||||
|
||||
const initialState = await readState();
|
||||
const importedProject = initialState.projects.find(
|
||||
(project) => project.threadMeta.codexThreadRef === "thread-boss-reset",
|
||||
);
|
||||
assert.ok(importedProject);
|
||||
|
||||
initialState.conversationHistoryClearedAt = "2026-04-20T10:10:00.000Z";
|
||||
const targetProject = initialState.projects.find((project) => project.id === importedProject?.id);
|
||||
assert.ok(targetProject);
|
||||
targetProject!.messages = [];
|
||||
targetProject!.preview = "";
|
||||
targetProject!.unreadCount = 0;
|
||||
await writeState(initialState);
|
||||
|
||||
await upsertDeviceHeartbeat({
|
||||
...seedHeartbeat,
|
||||
projectCandidates: [
|
||||
{
|
||||
...seedHeartbeat.projectCandidates[0],
|
||||
lastActiveAt: "2026-04-20T10:12:00.000Z",
|
||||
recentAssistantMessages: [
|
||||
{
|
||||
messageId: "codex-thread:thread-boss-reset:2026-04-20T10:05:00.000Z:old-final",
|
||||
body: "这条旧回复不应该在清空历史后被重新导回。",
|
||||
sentAt: "2026-04-20T10:05:00.000Z",
|
||||
phase: "final_answer",
|
||||
},
|
||||
{
|
||||
messageId: "codex-thread:thread-boss-reset:2026-04-20T10:11:00.000Z:new-final",
|
||||
body: "这条新回复应该继续同步回来。",
|
||||
sentAt: "2026-04-20T10:11:00.000Z",
|
||||
phase: "final_answer",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const nextState = await readState();
|
||||
const nextProject = nextState.projects.find((project) => project.id === importedProject?.id);
|
||||
assert.ok(nextProject);
|
||||
assert.equal(
|
||||
nextProject?.messages.some(
|
||||
(message) =>
|
||||
message.externalMessageId === "codex-thread:thread-boss-reset:2026-04-20T10:05:00.000Z:old-final",
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
nextProject?.messages.some(
|
||||
(message) =>
|
||||
message.externalMessageId === "codex-thread:thread-boss-reset:2026-04-20T10:11:00.000Z:new-final",
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(nextProject?.preview, "这条新回复应该继续同步回来。");
|
||||
assert.equal(nextProject?.unreadCount, 1);
|
||||
});
|
||||
|
||||
test("device heartbeat legacy process text is normalized to thread_process and does not become preview", async () => {
|
||||
await setup();
|
||||
|
||||
const seedHeartbeat = {
|
||||
deviceId: "device-message-legacy-process",
|
||||
token: "device-message-legacy-process-token",
|
||||
name: "Mac Studio",
|
||||
avatar: "M",
|
||||
account: "krisolo",
|
||||
status: "online" as const,
|
||||
quota5h: 76,
|
||||
quota7d: 85,
|
||||
projects: [],
|
||||
endpoint: "mac://kris.local",
|
||||
projectCandidates: [
|
||||
{
|
||||
folderName: "boss",
|
||||
folderRef: "/Users/kris/code/boss",
|
||||
threadId: "thread-boss-legacy-process",
|
||||
threadDisplayName: "Boss开发主线程",
|
||||
codexFolderRef: "/Users/kris/code/boss",
|
||||
codexThreadRef: "thread-boss-legacy-process",
|
||||
lastActiveAt: "2026-04-20T10:00:00.000Z",
|
||||
suggestedImport: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await upsertDeviceHeartbeat(seedHeartbeat);
|
||||
await upsertDeviceHeartbeat(seedHeartbeat);
|
||||
|
||||
const resetState = await readState();
|
||||
resetState.conversationHistoryClearedAt = undefined;
|
||||
await writeState(resetState);
|
||||
|
||||
await upsertDeviceHeartbeat({
|
||||
...seedHeartbeat,
|
||||
projectCandidates: [
|
||||
{
|
||||
...seedHeartbeat.projectCandidates[0],
|
||||
recentAssistantMessages: [
|
||||
{
|
||||
messageId: "codex-thread:thread-boss-legacy-process:2026-04-20T10:03:00.000Z:commentary-legacy",
|
||||
body: "我继续把这条链路又往下收了一层,补的是“历史脏消息”的兼容,不只是新消息规则。",
|
||||
sentAt: "2026-04-20T10:03:00.000Z",
|
||||
phase: "commentary",
|
||||
},
|
||||
{
|
||||
messageId: "codex-thread:thread-boss-legacy-process:2026-04-20T10:05:00.000Z:final-1",
|
||||
body: "这轮已经完成折叠修复,未读现在只会算最终结果。",
|
||||
sentAt: "2026-04-20T10:05:00.000Z",
|
||||
phase: "final_answer",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const nextState = await readState();
|
||||
const nextProject = nextState.projects.find(
|
||||
(project) => project.threadMeta.codexThreadRef === "thread-boss-legacy-process",
|
||||
);
|
||||
const legacyProcessMessage = nextProject?.messages.find(
|
||||
(message) =>
|
||||
message.externalMessageId ===
|
||||
"codex-thread:thread-boss-legacy-process:2026-04-20T10:03:00.000Z:commentary-legacy",
|
||||
);
|
||||
|
||||
assert.ok(nextProject);
|
||||
assert.equal(legacyProcessMessage?.kind, "thread_process");
|
||||
assert.equal(nextProject?.preview, "这轮已经完成折叠修复,未读现在只会算最终结果。");
|
||||
assert.equal(nextProject?.unreadCount, 1);
|
||||
});
|
||||
@@ -44,7 +44,7 @@ function buildHeartbeatPayload(deviceId: string, projectCandidates: Array<{
|
||||
token: `${deviceId}-token`,
|
||||
name: "Mac Studio",
|
||||
avatar: "M",
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
status: "online" as const,
|
||||
quota5h: 68,
|
||||
quota7d: 81,
|
||||
|
||||
@@ -37,7 +37,7 @@ test.after(async () => {
|
||||
|
||||
async function createAuthedSessionCookie() {
|
||||
const session = await createAuthSession({
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
role: "highest_admin",
|
||||
displayName: "Boss 超级管理员",
|
||||
loginMethod: "password",
|
||||
@@ -54,7 +54,7 @@ test("auto-sync import keeps long-prefix project candidates distinct", async ()
|
||||
id: "mac-studio",
|
||||
name: "Mac Studio",
|
||||
avatar: "M",
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
source: "production",
|
||||
status: "online",
|
||||
projects: [],
|
||||
@@ -79,7 +79,7 @@ test("auto-sync import keeps long-prefix project candidates distinct", async ()
|
||||
token: "boss-mac-studio-token",
|
||||
name: "Mac Studio",
|
||||
avatar: "M",
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
status: "online",
|
||||
quota5h: 68,
|
||||
quota7d: 81,
|
||||
|
||||
@@ -84,8 +84,8 @@ test("device import draft copy shows pending agent review when task is queued",
|
||||
requestMessageId: "draft-1",
|
||||
requestText: "请审核设备导入",
|
||||
executionPrompt: "prompt",
|
||||
requestedBy: "17600003315",
|
||||
requestedByAccount: "17600003315",
|
||||
requestedBy: "krisolo",
|
||||
requestedByAccount: "krisolo",
|
||||
deviceId: "mac-studio",
|
||||
deviceImportDraftId: "draft-1",
|
||||
status: "queued",
|
||||
|
||||
@@ -56,7 +56,7 @@ test.after(async () => {
|
||||
});
|
||||
|
||||
async function createAuthedRequest(url: string, method: "GET" | "POST", body?: unknown) {
|
||||
return createAuthedRequestFor("17600003315", "highest_admin", url, method, body);
|
||||
return createAuthedRequestFor("krisolo", "highest_admin", url, method, body);
|
||||
}
|
||||
|
||||
async function createAuthedRequestFor(
|
||||
@@ -90,7 +90,7 @@ test("device import draft review queues only the resolution task, then completio
|
||||
await createAuthedRequest("http://127.0.0.1:3000/api/v1/devices/enrollments", "POST", {
|
||||
name: "MacBook Pro",
|
||||
avatar: "P",
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
endpoint: "mac://mbp.local",
|
||||
note: "待导入新设备",
|
||||
}),
|
||||
@@ -110,7 +110,7 @@ test("device import draft review queues only the resolution task, then completio
|
||||
pairingCode: enrollmentPayload.enrollment.pairingCode,
|
||||
name: "MacBook Pro",
|
||||
avatar: "P",
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
status: "online",
|
||||
quota5h: 72,
|
||||
quota7d: 88,
|
||||
@@ -317,7 +317,7 @@ test("device import draft review queues only the resolution task, then completio
|
||||
pairingCode: enrollmentPayload.enrollment.pairingCode,
|
||||
name: "Mac mini",
|
||||
avatar: "M",
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
status: "online",
|
||||
quota5h: 73,
|
||||
quota7d: 84,
|
||||
@@ -364,7 +364,7 @@ test("imported thread projects append progress events on newer activity without
|
||||
await createAuthedRequest("http://127.0.0.1:3000/api/v1/devices/enrollments", "POST", {
|
||||
name: "Mac mini",
|
||||
avatar: "M",
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
endpoint: "mac://mini.local",
|
||||
note: "project sync follow-up",
|
||||
}),
|
||||
@@ -380,7 +380,7 @@ test("imported thread projects append progress events on newer activity without
|
||||
pairingCode: enrollmentPayload.enrollment.pairingCode,
|
||||
name: "Mac mini",
|
||||
avatar: "M",
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
status: "online" as const,
|
||||
quota5h: 73,
|
||||
quota7d: 84,
|
||||
@@ -580,7 +580,7 @@ test("heartbeat candidates no longer auto-create chat windows from legacy projec
|
||||
await createAuthedRequest("http://127.0.0.1:3000/api/v1/devices/enrollments", "POST", {
|
||||
name: "ThinkPad",
|
||||
avatar: "T",
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
endpoint: "pc://thinkpad.local",
|
||||
note: "legacy projects should not auto import",
|
||||
}),
|
||||
@@ -603,7 +603,7 @@ test("heartbeat candidates no longer auto-create chat windows from legacy projec
|
||||
pairingCode: enrollmentPayload.enrollment.pairingCode,
|
||||
name: "ThinkPad",
|
||||
avatar: "T",
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
status: "online",
|
||||
quota5h: 60,
|
||||
quota7d: 75,
|
||||
@@ -641,7 +641,7 @@ test("device import apply is idempotent and heartbeat preserves applied status",
|
||||
await createAuthedRequest("http://127.0.0.1:3000/api/v1/devices/enrollments", "POST", {
|
||||
name: "Studio Mac",
|
||||
avatar: "S",
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
endpoint: "mac://studio.local",
|
||||
note: "idempotent import apply",
|
||||
}),
|
||||
@@ -657,7 +657,7 @@ test("device import apply is idempotent and heartbeat preserves applied status",
|
||||
pairingCode: enrollmentPayload.enrollment.pairingCode,
|
||||
name: "Studio Mac",
|
||||
avatar: "S",
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
status: "online" as const,
|
||||
quota5h: 68,
|
||||
quota7d: 82,
|
||||
@@ -801,7 +801,7 @@ test("clearing device import selection resets draft back to pending_selection an
|
||||
await createAuthedRequest("http://127.0.0.1:3000/api/v1/devices/enrollments", "POST", {
|
||||
name: "Review Mac",
|
||||
avatar: "R",
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
endpoint: "mac://review.local",
|
||||
note: "selection reset",
|
||||
}),
|
||||
@@ -823,7 +823,7 @@ test("clearing device import selection resets draft back to pending_selection an
|
||||
pairingCode: enrollmentPayload.enrollment.pairingCode,
|
||||
name: "Review Mac",
|
||||
avatar: "R",
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
status: "online",
|
||||
quota5h: 66,
|
||||
quota7d: 79,
|
||||
@@ -913,7 +913,7 @@ test("device import routes reject unrelated logged-in members", async () => {
|
||||
await createAuthedRequest("http://127.0.0.1:3000/api/v1/devices/enrollments", "POST", {
|
||||
name: "Build Mac",
|
||||
avatar: "B",
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
endpoint: "mac://build.local",
|
||||
note: "route auth test",
|
||||
}),
|
||||
@@ -935,7 +935,7 @@ test("device import routes reject unrelated logged-in members", async () => {
|
||||
pairingCode: enrollmentPayload.enrollment.pairingCode,
|
||||
name: "Build Mac",
|
||||
avatar: "B",
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
status: "online",
|
||||
quota5h: 71,
|
||||
quota7d: 80,
|
||||
@@ -984,7 +984,7 @@ test("existing bound production devices auto-sync suggested candidates into conv
|
||||
token: "boss-mac-studio-token",
|
||||
name: "Mac Studio",
|
||||
avatar: "M",
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
status: "online",
|
||||
quota5h: 68,
|
||||
quota7d: 81,
|
||||
@@ -1047,7 +1047,7 @@ test("existing bound production devices auto-sync suggested candidates into conv
|
||||
token: "boss-mac-studio-token",
|
||||
name: "Mac Studio",
|
||||
avatar: "M",
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
status: "online",
|
||||
quota5h: 68,
|
||||
quota7d: 81,
|
||||
|
||||
@@ -52,7 +52,7 @@ test.after(async () => {
|
||||
|
||||
async function createAuthedRequest(url: string, method: "POST", body: unknown) {
|
||||
const session = await createAuthSession({
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
role: "highest_admin",
|
||||
displayName: "Boss 超级管理员",
|
||||
loginMethod: "password",
|
||||
@@ -135,7 +135,7 @@ async function createConfirmedDispatchExecution() {
|
||||
const groupProject = await createProjectGroupChat({
|
||||
sourceProjectId: memberProjects[0].id,
|
||||
memberProjectIds: [memberProjects[1].id],
|
||||
createdBy: "17600003315",
|
||||
createdBy: "krisolo",
|
||||
});
|
||||
|
||||
const messageResponse = await postMessageRoute(
|
||||
|
||||
@@ -120,7 +120,7 @@ function buildDispatchableThreadProject({
|
||||
|
||||
async function createAuthedRequest(url: string, method: "GET" | "POST" | "PATCH", body?: unknown) {
|
||||
const session = await createAuthSession({
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
role: "highest_admin",
|
||||
displayName: "Boss 超级管理员",
|
||||
loginMethod: "password",
|
||||
@@ -173,7 +173,7 @@ async function createDispatchPlanForTest() {
|
||||
const groupProject = await createProjectGroupChat({
|
||||
sourceProjectId: memberProjects[0].id,
|
||||
memberProjectIds: [memberProjects[1].id],
|
||||
createdBy: "17600003315",
|
||||
createdBy: "krisolo",
|
||||
});
|
||||
|
||||
const response = await postMessageRoute(
|
||||
|
||||
18
tests/fixtures/browser-control-runtime.mjs
vendored
Normal file
18
tests/fixtures/browser-control-runtime.mjs
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
let input = "";
|
||||
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (chunk) => {
|
||||
input += chunk;
|
||||
});
|
||||
|
||||
process.stdin.on("end", () => {
|
||||
const payload = JSON.parse(input || "{}");
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
status: "completed",
|
||||
replyBody: `浏览器运行时已执行:${payload.objective || "未提供目标"}`,
|
||||
executionSummary: "browser-runtime-ok",
|
||||
requestId: payload.requestId,
|
||||
})}\n`,
|
||||
);
|
||||
});
|
||||
47
tests/fixtures/codex-desktop-refresh-flaky-runtime.mjs
vendored
Normal file
47
tests/fixtures/codex-desktop-refresh-flaky-runtime.mjs
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(typeof chunk === "string" ? chunk : chunk.toString("utf8"));
|
||||
}
|
||||
|
||||
const payload = JSON.parse(chunks.join("") || "{}");
|
||||
const stateFile = process.env.BOSS_CODEX_REFRESH_FLAKY_STATE;
|
||||
let count = 0;
|
||||
if (stateFile) {
|
||||
try {
|
||||
const state = JSON.parse(await fs.readFile(stateFile, "utf8"));
|
||||
count = Number.isFinite(state.count) ? state.count : 0;
|
||||
} catch {
|
||||
count = 0;
|
||||
}
|
||||
}
|
||||
count += 1;
|
||||
if (stateFile) {
|
||||
await fs.writeFile(stateFile, `${JSON.stringify({ count })}\n`, "utf8");
|
||||
}
|
||||
|
||||
if (count === 1) {
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
status: "failed",
|
||||
targetThreadRef: payload.targetThreadRef,
|
||||
appName: payload.appName,
|
||||
error: "transient desktop refresh failure",
|
||||
})}\n`,
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
status: "completed",
|
||||
targetThreadRef: payload.targetThreadRef,
|
||||
appName: payload.appName,
|
||||
deepLink: `codex://threads/${payload.targetThreadRef}`,
|
||||
detail: `flaky refresh accepted after ${count} attempts`,
|
||||
attemptCount: count,
|
||||
})}\n`,
|
||||
);
|
||||
16
tests/fixtures/codex-desktop-refresh-runtime.mjs
vendored
Normal file
16
tests/fixtures/codex-desktop-refresh-runtime.mjs
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const chunks = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(typeof chunk === "string" ? chunk : chunk.toString("utf8"));
|
||||
}
|
||||
|
||||
const payload = JSON.parse(chunks.join("") || "{}");
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
status: "completed",
|
||||
targetThreadRef: payload.targetThreadRef,
|
||||
appName: payload.appName,
|
||||
detail: `refresh hint accepted: ${payload.refreshMode}`,
|
||||
})}\n`,
|
||||
);
|
||||
18
tests/fixtures/computer-use-runtime.mjs
vendored
Normal file
18
tests/fixtures/computer-use-runtime.mjs
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
let input = "";
|
||||
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (chunk) => {
|
||||
input += chunk;
|
||||
});
|
||||
|
||||
process.stdin.on("end", () => {
|
||||
const payload = JSON.parse(input || "{}");
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
status: "completed",
|
||||
replyBody: `桌面运行时已执行:${payload.objective || "未提供目标"}`,
|
||||
executionSummary: "computer-use-runtime-ok",
|
||||
requestId: payload.requestId,
|
||||
})}\n`,
|
||||
);
|
||||
});
|
||||
@@ -103,7 +103,7 @@ function buildDispatchableThreadProject({
|
||||
|
||||
async function createAuthedRequest(projectId: string, body: { body: string; kind?: string }) {
|
||||
const session = await createAuthSession({
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
role: "highest_admin",
|
||||
displayName: "Boss 超级管理员",
|
||||
loginMethod: "password",
|
||||
@@ -156,7 +156,7 @@ test("POST /api/v1/projects/[projectId]/messages returns a dispatch plan for gro
|
||||
|
||||
const groupProject = await createIndependentGroupChat({
|
||||
memberProjectIds: [memberProjects[0].id, memberProjects[1].id],
|
||||
createdBy: "17600003315",
|
||||
createdBy: "krisolo",
|
||||
});
|
||||
|
||||
const response = await POST(await createAuthedRequest(groupProject.id, { body: "请大家汇总今天的阻塞点" }), {
|
||||
@@ -275,6 +275,57 @@ test("POST /api/v1/projects/master-agent/messages returns a dispatch plan for th
|
||||
assert.ok(queuedDispatchTask, "expected master-agent thread-op request to enqueue a dispatch recommendation task");
|
||||
});
|
||||
|
||||
test("POST /api/v1/projects/master-agent/messages routes named project summary sync to the target thread understanding task", async () => {
|
||||
await setup();
|
||||
const [primaryProject] = await ensureTwoSingleThreadProjects();
|
||||
assert.ok(primaryProject, "expected seeded single-thread project");
|
||||
|
||||
const response = await POST(
|
||||
await createAuthedRequest("master-agent", {
|
||||
body: "请同步一下北区试产线回归当前项目目标和版本记录",
|
||||
}),
|
||||
{ params: Promise.resolve({ projectId: "master-agent" }) },
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
ok: boolean;
|
||||
message: { id: string; body: string };
|
||||
replyMessage?: { body: string };
|
||||
task?: { taskId: string; taskType: string; status: string } | null;
|
||||
dispatchPlan: null | { planId: string };
|
||||
masterReply?: { ok: boolean; masterReplyState?: string };
|
||||
};
|
||||
|
||||
assert.equal(payload.ok, true);
|
||||
assert.equal(payload.dispatchPlan, null);
|
||||
assert.equal(payload.masterReply?.masterReplyState, "queued");
|
||||
assert.ok(payload.replyMessage?.body.includes("北区试产线回归"));
|
||||
assert.ok(payload.replyMessage?.body.includes("项目目标"));
|
||||
assert.ok(payload.replyMessage?.body.includes("版本记录"));
|
||||
assert.ok(!/OTA|MVP|设备在线|运行时/.test(payload.replyMessage?.body ?? ""));
|
||||
|
||||
const nextState = await readState();
|
||||
const syncTask = nextState.masterAgentTasks.find(
|
||||
(task) =>
|
||||
task.projectId === "master-agent" &&
|
||||
task.projectUnderstandingTargetProjectId === primaryProject.id &&
|
||||
task.requestText.includes(primaryProject.name),
|
||||
);
|
||||
assert.ok(syncTask, "expected target project understanding sync task");
|
||||
assert.equal(payload.task?.taskId, syncTask?.taskId);
|
||||
assert.match(syncTask!.executionPrompt, /只输出 JSON/);
|
||||
assert.match(syncTask!.executionPrompt, /不要把全局 OTA 可用状态/);
|
||||
|
||||
const genericDispatchTask = nextState.masterAgentTasks.find(
|
||||
(task) =>
|
||||
task.projectId === "master-agent" &&
|
||||
task.requestMessageId === payload.message.id &&
|
||||
task.taskType === "group_dispatch_plan",
|
||||
);
|
||||
assert.equal(genericDispatchTask, undefined, "summary sync should not create a generic dispatch plan");
|
||||
});
|
||||
|
||||
test("POST /api/v1/projects/[projectId]/messages marks approval_required groups as pending user approval", async () => {
|
||||
await setup();
|
||||
const memberProjects = await ensureTwoSingleThreadProjects();
|
||||
@@ -282,7 +333,7 @@ test("POST /api/v1/projects/[projectId]/messages marks approval_required groups
|
||||
|
||||
const groupProject = await createIndependentGroupChat({
|
||||
memberProjectIds: [memberProjects[0].id, memberProjects[1].id],
|
||||
createdBy: "17600003315",
|
||||
createdBy: "krisolo",
|
||||
});
|
||||
|
||||
const state = await readState();
|
||||
@@ -343,7 +394,7 @@ test("POST /api/v1/projects/[projectId]/messages blocks new approval_required re
|
||||
|
||||
const groupProject = await createIndependentGroupChat({
|
||||
memberProjectIds: [memberProjects[0].id, memberProjects[1].id],
|
||||
createdBy: "17600003315",
|
||||
createdBy: "krisolo",
|
||||
});
|
||||
|
||||
const state = await readState();
|
||||
@@ -413,7 +464,7 @@ test("POST /api/v1/projects/[projectId]/messages keeps message success when grou
|
||||
|
||||
const groupProject = await createIndependentGroupChat({
|
||||
memberProjectIds: [memberProjects[0].id, memberProjects[1].id],
|
||||
createdBy: "17600003315",
|
||||
createdBy: "krisolo",
|
||||
});
|
||||
|
||||
const state = await readState();
|
||||
@@ -495,7 +546,7 @@ test("POST /api/v1/projects/[projectId]/messages excludes master-agent from grou
|
||||
|
||||
const groupProject = await createIndependentGroupChat({
|
||||
memberProjectIds: [memberProjects[0].id, memberProjects[1].id],
|
||||
createdBy: "17600003315",
|
||||
createdBy: "krisolo",
|
||||
});
|
||||
|
||||
const state = await readState();
|
||||
@@ -552,7 +603,7 @@ test("createIndependentGroupChat rejects non-thread members like master-agent",
|
||||
() =>
|
||||
createIndependentGroupChat({
|
||||
memberProjectIds: ["master-agent", realThread.id],
|
||||
createdBy: "17600003315",
|
||||
createdBy: "krisolo",
|
||||
}),
|
||||
/GROUP_CHAT_MEMBER_NOT_THREAD/,
|
||||
);
|
||||
|
||||
@@ -26,6 +26,51 @@ const originalEnv = {
|
||||
BOSS_OMX_TIMEOUT_MS: process.env.BOSS_OMX_TIMEOUT_MS,
|
||||
};
|
||||
|
||||
function buildDispatchableProject(id: string, name: string, threadId: string) {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
pinned: false,
|
||||
systemPinned: false,
|
||||
deviceIds: ["mac-studio"],
|
||||
preview: "等待群聊下发。",
|
||||
updatedAt: "2026-04-03T10:00:00+08:00",
|
||||
lastMessageAt: "2026-04-03T10:00:00+08:00",
|
||||
isGroup: false,
|
||||
unreadCount: 0,
|
||||
riskLevel: "low" as const,
|
||||
contextBudgetPct: 80,
|
||||
contextBudgetLabel: "80%",
|
||||
threadMeta: {
|
||||
projectId: id,
|
||||
threadId,
|
||||
threadDisplayName: name,
|
||||
folderName: "boss",
|
||||
activityIconCount: 0,
|
||||
updatedAt: "2026-04-03T10:00:00+08:00",
|
||||
codexFolderRef: "/Users/kris/code/boss",
|
||||
codexThreadRef: threadId,
|
||||
},
|
||||
groupMembers: [],
|
||||
messages: [
|
||||
{
|
||||
id: `msg-${id}`,
|
||||
sender: "device" as const,
|
||||
senderLabel: "Mac Studio / Codex",
|
||||
body: "等待群聊下发。",
|
||||
sentAt: "2026-04-03T10:00:00+08:00",
|
||||
kind: "text" as const,
|
||||
},
|
||||
],
|
||||
goals: [],
|
||||
versions: [],
|
||||
createdByAgent: true,
|
||||
collaborationMode: "development" as const,
|
||||
approvalState: "not_required" as const,
|
||||
lightDispatchReminderEnabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) return;
|
||||
|
||||
@@ -56,7 +101,7 @@ async function setup() {
|
||||
|
||||
async function authedRequest(url: string, method: "GET" | "PATCH" | "POST", body?: unknown) {
|
||||
const session = await createAuthSession({
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
role: "highest_admin",
|
||||
displayName: "Boss 超级管理员",
|
||||
loginMethod: "password",
|
||||
@@ -78,34 +123,13 @@ async function ensureTwoSingleThreadProjects() {
|
||||
if (singles.length >= 2) {
|
||||
return singles;
|
||||
}
|
||||
assert.ok(singles[0], "expected at least one dispatchable project");
|
||||
const seed = singles[0];
|
||||
const clone = {
|
||||
...seed,
|
||||
id: "omx-thread-b",
|
||||
name: "Boss OMX 副线程",
|
||||
threadMeta: {
|
||||
...seed.threadMeta,
|
||||
projectId: "omx-thread-b",
|
||||
threadId: "thread-omx-b",
|
||||
threadDisplayName: "OMX 副线程",
|
||||
codexThreadRef: "thread-omx-b",
|
||||
codexFolderRef: "/Users/kris/code/boss",
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
id: "msg-omx-seed",
|
||||
sender: "device" as const,
|
||||
senderLabel: "Mac Studio / Codex",
|
||||
body: "等待群聊下发。",
|
||||
sentAt: "2026-04-03T10:00:00+08:00",
|
||||
kind: "text" as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
const seeded = [
|
||||
buildDispatchableProject("omx-thread-a", "Boss OMX 主线程", "thread-omx-a"),
|
||||
buildDispatchableProject("omx-thread-b", "Boss OMX 副线程", "thread-omx-b"),
|
||||
].slice(singles.length);
|
||||
await writeState({
|
||||
...state,
|
||||
projects: [...state.projects, clone],
|
||||
projects: [...state.projects, ...seeded],
|
||||
});
|
||||
const nextState = await readState();
|
||||
return nextState.projects.filter((project) => isDispatchableThreadProject(project));
|
||||
@@ -145,7 +169,7 @@ test("GET orchestration backend returns null requested backend for default group
|
||||
const group = await createProjectGroupChat({
|
||||
sourceProjectId: singles[0].id,
|
||||
memberProjectIds: [singles[1].id],
|
||||
createdBy: "17600003315",
|
||||
createdBy: "krisolo",
|
||||
});
|
||||
|
||||
const response = await getRoute(
|
||||
@@ -172,7 +196,7 @@ test("PATCH orchestration backend rejects omx when runtime is unavailable", asyn
|
||||
const group = await createProjectGroupChat({
|
||||
sourceProjectId: singles[0].id,
|
||||
memberProjectIds: [singles[1].id],
|
||||
createdBy: "17600003315",
|
||||
createdBy: "krisolo",
|
||||
});
|
||||
|
||||
const response = await patchRoute(
|
||||
@@ -204,7 +228,7 @@ test("group dispatch plans and executions carry omx backend when selected and av
|
||||
const group = await createProjectGroupChat({
|
||||
sourceProjectId: singles[0].id,
|
||||
memberProjectIds: [singles[1].id],
|
||||
createdBy: "17600003315",
|
||||
createdBy: "krisolo",
|
||||
});
|
||||
|
||||
const saveResponse = await patchRoute(
|
||||
|
||||
@@ -14,6 +14,51 @@ let readState: (typeof import("../src/lib/boss-data"))["readState"];
|
||||
let writeState: (typeof import("../src/lib/boss-data"))["writeState"];
|
||||
let AUTH_SESSION_COOKIE = "";
|
||||
|
||||
function buildSingleThreadProject(id: string, threadDisplayName: string) {
|
||||
return {
|
||||
id,
|
||||
name: threadDisplayName,
|
||||
pinned: false,
|
||||
systemPinned: false,
|
||||
deviceIds: ["mac-studio"],
|
||||
preview: "等待线程参与群聊。",
|
||||
updatedAt: "2026-04-03T10:00:00+08:00",
|
||||
lastMessageAt: "2026-04-03T10:00:00+08:00",
|
||||
isGroup: false,
|
||||
unreadCount: 0,
|
||||
riskLevel: "low" as const,
|
||||
contextBudgetPct: 80,
|
||||
contextBudgetLabel: "80%",
|
||||
threadMeta: {
|
||||
projectId: id,
|
||||
threadId: id,
|
||||
threadDisplayName,
|
||||
folderName: "repair-folder",
|
||||
activityIconCount: 0,
|
||||
updatedAt: "2026-04-03T10:00:00+08:00",
|
||||
codexFolderRef: "/Users/kris/code/repair-folder",
|
||||
codexThreadRef: id,
|
||||
},
|
||||
groupMembers: [],
|
||||
messages: [
|
||||
{
|
||||
id: `msg-${id}`,
|
||||
sender: "device" as const,
|
||||
senderLabel: "Mac Studio / Codex",
|
||||
body: "等待线程参与群聊。",
|
||||
sentAt: "2026-04-03T10:00:00+08:00",
|
||||
kind: "text" as const,
|
||||
},
|
||||
],
|
||||
goals: [],
|
||||
versions: [],
|
||||
createdByAgent: true,
|
||||
collaborationMode: "development" as const,
|
||||
approvalState: "not_required" as const,
|
||||
lightDispatchReminderEnabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) return;
|
||||
|
||||
@@ -44,7 +89,7 @@ test.after(async () => {
|
||||
|
||||
async function createAuthedRequest(url: string, method: "GET" | "POST", body?: unknown) {
|
||||
const session = await createAuthSession({
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
role: "highest_admin",
|
||||
displayName: "Boss 超级管理员",
|
||||
loginMethod: "password",
|
||||
@@ -66,28 +111,14 @@ async function ensureTwoSingleThreadProjects() {
|
||||
if (singles.length >= 2) {
|
||||
return singles;
|
||||
}
|
||||
|
||||
assert.ok(singles[0], "expected seeded single-thread project");
|
||||
const seed = singles[0];
|
||||
const clone = {
|
||||
...seed,
|
||||
id: "repair-thread-clone",
|
||||
name: "Repair Thread Clone",
|
||||
deviceIds: ["mac-studio"],
|
||||
threadMeta: {
|
||||
...seed.threadMeta,
|
||||
projectId: "repair-thread-clone",
|
||||
threadId: "repair-thread-clone",
|
||||
threadDisplayName: "维修回归线程",
|
||||
folderName: "repair-folder",
|
||||
codexThreadRef: "repair-thread-clone",
|
||||
codexFolderRef: "repair-folder",
|
||||
},
|
||||
};
|
||||
const seeded = [
|
||||
buildSingleThreadProject("repair-thread-a", "维修主线程"),
|
||||
buildSingleThreadProject("repair-thread-b", "维修回归线程"),
|
||||
].slice(singles.length);
|
||||
|
||||
await writeState({
|
||||
...state,
|
||||
projects: [...state.projects, clone],
|
||||
projects: [...state.projects, ...seeded],
|
||||
});
|
||||
const nextState = await readState();
|
||||
return nextState.projects.filter((project) => project.id !== "master-agent" && !project.isGroup);
|
||||
@@ -99,7 +130,7 @@ test("GET /api/v1/projects/[projectId]/participants marks dirty groups as repair
|
||||
const groupProject = await createProjectGroupChat({
|
||||
sourceProjectId: singles[0].id,
|
||||
memberProjectIds: [singles[1].id],
|
||||
createdBy: "17600003315",
|
||||
createdBy: "krisolo",
|
||||
});
|
||||
|
||||
const state = await readState();
|
||||
@@ -153,7 +184,7 @@ test("POST /api/v1/projects/[projectId]/participants replaces dirty members with
|
||||
const groupProject = await createProjectGroupChat({
|
||||
sourceProjectId: singles[0].id,
|
||||
memberProjectIds: [singles[1].id],
|
||||
createdBy: "17600003315",
|
||||
createdBy: "krisolo",
|
||||
});
|
||||
|
||||
const state = await readState();
|
||||
|
||||
@@ -157,7 +157,7 @@ test.after(async () => {
|
||||
|
||||
async function createAuthedRequest(url: string) {
|
||||
const session = await createAuthSession({
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
role: "highest_admin",
|
||||
displayName: "Boss 超级管理员",
|
||||
loginMethod: "password",
|
||||
|
||||
123
tests/local-agent-browser-control-runner.test.mjs
Normal file
123
tests/local-agent-browser-control-runner.test.mjs
Normal file
@@ -0,0 +1,123 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
buildBrowserControlTaskExecution,
|
||||
canHandleBrowserControlTask,
|
||||
executeBrowserControlTask,
|
||||
getBrowserControlTaskRunnerConfig,
|
||||
parseBrowserControlTaskResult,
|
||||
} from "../local-agent/browser-control-task-runner.mjs";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
test("browser control runner handles browser_control tasks", async () => {
|
||||
assert.equal(
|
||||
canHandleBrowserControlTask({
|
||||
taskType: "browser_control",
|
||||
requestText: "打开后台首页",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("browser control runner derives config from explicit values", () => {
|
||||
const config = getBrowserControlTaskRunnerConfig({}, {
|
||||
browserControlEnabled: true,
|
||||
browserControlCommand: "node",
|
||||
browserControlArgs: ["tests/fixtures/browser-control-runtime.mjs"],
|
||||
browserControlWorkdir: repoRoot,
|
||||
browserControlTimeoutMs: 12000,
|
||||
});
|
||||
|
||||
assert.equal(config.enabled, true);
|
||||
assert.equal(config.command, "node");
|
||||
assert.deepEqual(config.args, ["tests/fixtures/browser-control-runtime.mjs"]);
|
||||
assert.equal(config.cwd, repoRoot);
|
||||
assert.equal(config.timeoutMs, 12000);
|
||||
});
|
||||
|
||||
test("browser control runner builds normalized stdin payload", () => {
|
||||
const execution = buildBrowserControlTaskExecution(
|
||||
{
|
||||
enabled: true,
|
||||
command: "node",
|
||||
args: ["tests/fixtures/browser-control-runtime.mjs"],
|
||||
cwd: repoRoot,
|
||||
timeoutMs: 3000,
|
||||
},
|
||||
{
|
||||
taskId: "browser-task-1",
|
||||
taskType: "browser_control",
|
||||
requestText: "打开后台首页",
|
||||
projectId: "boss-console",
|
||||
threadId: "thread-1",
|
||||
requestedByAccount: "17600001111",
|
||||
confirmationScopeKey: "thread:1",
|
||||
riskLevel: "medium",
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(execution.command, "node");
|
||||
assert.equal(execution.cwd, repoRoot);
|
||||
assert.equal(execution.timeoutMs, 3000);
|
||||
assert.equal(execution.stdinPayload.requestKind, "browser_control");
|
||||
assert.equal(execution.stdinPayload.requestId, "browser-task-1");
|
||||
assert.equal(execution.stdinPayload.objective, "打开后台首页");
|
||||
assert.equal(execution.stdinPayload.context.projectId, "boss-console");
|
||||
assert.equal(execution.stdinPayload.context.threadId, "thread-1");
|
||||
assert.equal(execution.stdinPayload.context.confirmationScopeKey, "thread:1");
|
||||
assert.equal(execution.stdinPayload.context.riskLevel, "medium");
|
||||
});
|
||||
|
||||
test("browser control runner parses completed runtime payload", () => {
|
||||
const result = parseBrowserControlTaskResult(
|
||||
'{"status":"completed","replyBody":"已打开后台首页","executionSummary":"browser ok"}',
|
||||
);
|
||||
|
||||
assert.equal(result.status, "completed");
|
||||
assert.equal(result.replyBody, "已打开后台首页");
|
||||
assert.equal(result.executionSummary, "browser ok");
|
||||
});
|
||||
|
||||
test("browser control runner parses failed runtime payload", () => {
|
||||
const result = parseBrowserControlTaskResult('{"status":"failed","error":"BROWSER_DENIED"}');
|
||||
|
||||
assert.equal(result.status, "failed");
|
||||
assert.equal(result.errorMessage, "BROWSER_DENIED");
|
||||
});
|
||||
|
||||
test("browser control runner executes configured runtime command", async () => {
|
||||
const result = await executeBrowserControlTask(
|
||||
{
|
||||
taskId: "browser-task-exec",
|
||||
taskType: "browser_control",
|
||||
requestText: "打开用户后台",
|
||||
projectId: "boss-console",
|
||||
threadId: "thread-browser",
|
||||
requestedByAccount: "17600002222",
|
||||
},
|
||||
{
|
||||
browserControlEnabled: true,
|
||||
browserControlCommand: process.execPath,
|
||||
browserControlArgs: ["tests/fixtures/browser-control-runtime.mjs"],
|
||||
browserControlWorkdir: repoRoot,
|
||||
browserControlTimeoutMs: 4000,
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.status, "completed");
|
||||
assert.match(result.replyBody ?? "", /浏览器运行时已执行/);
|
||||
assert.match(result.replyBody ?? "", /打开用户后台/);
|
||||
});
|
||||
|
||||
test("browser control runner reports disabled runtime instead of pretending browser work completed", async () => {
|
||||
const result = await executeBrowserControlTask({
|
||||
taskId: "task-browser-control",
|
||||
requestText: "打开后台首页",
|
||||
}, {});
|
||||
|
||||
assert.equal(result.status, "failed");
|
||||
assert.equal(result.errorMessage, "BROWSER_CONTROL_RUNTIME_DISABLED");
|
||||
});
|
||||
620
tests/local-agent-codex-desktop-refresh-bridge.test.mjs
Normal file
620
tests/local-agent-codex-desktop-refresh-bridge.test.mjs
Normal file
@@ -0,0 +1,620 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import http from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
import {
|
||||
buildCodexDesktopRefreshExecution,
|
||||
executeCodexDesktopRefreshBridge,
|
||||
getCodexDesktopRefreshBridgeConfig,
|
||||
} from "../local-agent/codex-desktop-refresh-bridge.mjs";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "..");
|
||||
|
||||
function runRefreshHintDryRun(payload) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [path.join(repoRoot, "scripts/codex-desktop-refresh-hint.mjs")], {
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
BOSS_CODEX_DESKTOP_REFRESH_DRY_RUN: "true",
|
||||
},
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(stderr.trim() || `refresh hint dry run exited ${code}`));
|
||||
return;
|
||||
}
|
||||
resolve(JSON.parse(stdout.trim().split(/\r?\n/).at(-1)));
|
||||
});
|
||||
child.stdin.end(JSON.stringify(payload));
|
||||
});
|
||||
}
|
||||
|
||||
function closeServer(server) {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function readRequestJson(request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let raw = "";
|
||||
request.setEncoding("utf8");
|
||||
request.on("data", (chunk) => {
|
||||
raw += chunk;
|
||||
});
|
||||
request.on("end", () => {
|
||||
try {
|
||||
resolve(JSON.parse(raw || "{}"));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
request.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function listen(server) {
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
resolve(server.address());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function waitForJsonLine(stream, timeoutMs = 4000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let buffer = "";
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error("TIMED_OUT_WAITING_FOR_JSON_LINE"));
|
||||
}, timeoutMs);
|
||||
function cleanup() {
|
||||
clearTimeout(timer);
|
||||
stream.off("data", handleData);
|
||||
stream.off("error", handleError);
|
||||
}
|
||||
function handleError(error) {
|
||||
cleanup();
|
||||
reject(error);
|
||||
}
|
||||
function handleData(chunk) {
|
||||
buffer += chunk;
|
||||
const lines = buffer.split(/\r?\n/);
|
||||
buffer = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
cleanup();
|
||||
resolve(JSON.parse(trimmed));
|
||||
return;
|
||||
} catch {
|
||||
// Ignore non-JSON startup noise.
|
||||
}
|
||||
}
|
||||
}
|
||||
stream.setEncoding("utf8");
|
||||
stream.on("data", handleData);
|
||||
stream.on("error", handleError);
|
||||
});
|
||||
}
|
||||
|
||||
function subscribeToRefreshEvents(baseUrl, timeoutMs = 4000) {
|
||||
const events = [];
|
||||
let request;
|
||||
let response;
|
||||
let buffer = "";
|
||||
let currentEvent = {};
|
||||
let resolveNext;
|
||||
let nextTimer;
|
||||
|
||||
function cleanup() {
|
||||
clearTimeout(nextTimer);
|
||||
request?.destroy();
|
||||
response?.destroy();
|
||||
}
|
||||
|
||||
function finishEvent() {
|
||||
if (!currentEvent.event || currentEvent.event === "ready") {
|
||||
currentEvent = {};
|
||||
return;
|
||||
}
|
||||
const event = {
|
||||
event: currentEvent.event,
|
||||
id: currentEvent.id,
|
||||
data: currentEvent.data ? JSON.parse(currentEvent.data) : {},
|
||||
};
|
||||
if (resolveNext) {
|
||||
const resolve = resolveNext;
|
||||
resolveNext = undefined;
|
||||
clearTimeout(nextTimer);
|
||||
resolve(event);
|
||||
} else {
|
||||
events.push(event);
|
||||
}
|
||||
currentEvent = {};
|
||||
}
|
||||
|
||||
function handleLine(line) {
|
||||
if (line === "") {
|
||||
finishEvent();
|
||||
return;
|
||||
}
|
||||
if (line.startsWith("event:")) {
|
||||
currentEvent.event = line.slice("event:".length).trim();
|
||||
} else if (line.startsWith("id:")) {
|
||||
currentEvent.id = line.slice("id:".length).trim();
|
||||
} else if (line.startsWith("data:")) {
|
||||
currentEvent.data = `${currentEvent.data || ""}${line.slice("data:".length).trim()}`;
|
||||
}
|
||||
}
|
||||
|
||||
const ready = new Promise((resolve, reject) => {
|
||||
const readyTimer = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error("TIMED_OUT_WAITING_FOR_SSE_READY"));
|
||||
}, timeoutMs);
|
||||
const url = new URL("/api/v1/codex-desktop/events", baseUrl);
|
||||
request = http.get(url, (incoming) => {
|
||||
response = incoming;
|
||||
incoming.setEncoding("utf8");
|
||||
incoming.on("data", (chunk) => {
|
||||
buffer += chunk;
|
||||
const lines = buffer.split(/\r?\n/);
|
||||
buffer = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("event: ready")) {
|
||||
clearTimeout(readyTimer);
|
||||
resolve();
|
||||
}
|
||||
handleLine(line);
|
||||
}
|
||||
});
|
||||
incoming.on("error", (error) => {
|
||||
clearTimeout(readyTimer);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
request.on("error", (error) => {
|
||||
clearTimeout(readyTimer);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
ready,
|
||||
events,
|
||||
nextEvent() {
|
||||
if (events.length > 0) {
|
||||
return Promise.resolve(events.shift());
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
resolveNext = resolve;
|
||||
nextTimer = setTimeout(() => {
|
||||
reject(new Error("TIMED_OUT_WAITING_FOR_SSE_EVENT"));
|
||||
}, timeoutMs);
|
||||
});
|
||||
},
|
||||
close: cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
test("Codex desktop refresh bridge is skipped when disabled", async () => {
|
||||
const result = await executeCodexDesktopRefreshBridge(
|
||||
{
|
||||
targetThreadRef: "019d-thread-refresh",
|
||||
sourceMessageId: "msg-refresh",
|
||||
rolloutPath: "/tmp/rollout.jsonl",
|
||||
},
|
||||
{
|
||||
codexDesktopRefreshEnabled: false,
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
status: "skipped",
|
||||
reason: "disabled",
|
||||
});
|
||||
});
|
||||
|
||||
test("Codex desktop refresh bridge daemon exposes a local persistent refresh endpoint", async () => {
|
||||
const child = spawn(process.execPath, [path.join(repoRoot, "scripts/codex-desktop-refresh-bridge-daemon.mjs")], {
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
BOSS_CODEX_DESKTOP_BRIDGE_HOST: "127.0.0.1",
|
||||
BOSS_CODEX_DESKTOP_BRIDGE_PORT: "0",
|
||||
BOSS_CODEX_DESKTOP_REFRESH_DRY_RUN: "true",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
try {
|
||||
const ready = await waitForJsonLine(child.stdout);
|
||||
assert.equal(ready.status, "ready");
|
||||
assert.equal(ready.host, "127.0.0.1");
|
||||
assert.equal(typeof ready.port, "number");
|
||||
|
||||
const response = await fetch(`http://${ready.host}:${ready.port}/api/v1/codex-desktop/refresh`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
kind: "codex_desktop_refresh_hint",
|
||||
targetThreadRef: "019d-thread-refresh",
|
||||
sourceMessageId: "msg-refresh-daemon",
|
||||
appName: "Codex",
|
||||
refreshMode: "deeplink-reload",
|
||||
message: "must not be reflected",
|
||||
}),
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(result.status, "completed");
|
||||
assert.equal(result.targetThreadRef, "019d-thread-refresh");
|
||||
assert.equal(result.deepLink, "codex://threads/019d-thread-refresh");
|
||||
assert.doesNotMatch(result.detail, /must not be reflected/);
|
||||
} finally {
|
||||
child.kill("SIGTERM");
|
||||
}
|
||||
});
|
||||
|
||||
test("Codex desktop refresh bridge daemon broadcasts safe realtime events over SSE", async () => {
|
||||
const child = spawn(process.execPath, [path.join(repoRoot, "scripts/codex-desktop-refresh-bridge-daemon.mjs")], {
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
BOSS_CODEX_DESKTOP_BRIDGE_HOST: "127.0.0.1",
|
||||
BOSS_CODEX_DESKTOP_BRIDGE_PORT: "0",
|
||||
BOSS_CODEX_DESKTOP_REFRESH_DRY_RUN: "true",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let subscription;
|
||||
|
||||
try {
|
||||
const ready = await waitForJsonLine(child.stdout);
|
||||
const baseUrl = `http://${ready.host}:${ready.port}`;
|
||||
subscription = subscribeToRefreshEvents(baseUrl);
|
||||
await subscription.ready;
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/v1/codex-desktop/refresh`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
kind: "codex_desktop_refresh_hint",
|
||||
targetThreadRef: "019d-thread-refresh",
|
||||
sourceMessageId: "msg-refresh-sse",
|
||||
appName: "Codex",
|
||||
refreshMode: "deeplink-reload",
|
||||
message: "must not be broadcast",
|
||||
executionPrompt: "must not be broadcast",
|
||||
}),
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
|
||||
const event = await subscription.nextEvent();
|
||||
assert.equal(event.event, "codex_desktop_refresh");
|
||||
assert.equal(event.data.targetThreadRef, "019d-thread-refresh");
|
||||
assert.equal(event.data.sourceMessageId, "msg-refresh-sse");
|
||||
assert.equal(event.data.status, "completed");
|
||||
assert.equal(event.data.deepLink, "codex://threads/019d-thread-refresh");
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(event.data, "message"), false);
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(event.data, "executionPrompt"), false);
|
||||
|
||||
const recentResponse = await fetch(`${baseUrl}/api/v1/codex-desktop/events/recent`);
|
||||
const recent = await recentResponse.json();
|
||||
assert.equal(recent.ok, true);
|
||||
assert.equal(recent.events.at(-1).sourceMessageId, "msg-refresh-sse");
|
||||
} finally {
|
||||
subscription?.close();
|
||||
child.kill("SIGTERM");
|
||||
}
|
||||
});
|
||||
|
||||
test("Codex desktop event consumer receives one safe refresh event from the local bridge", async () => {
|
||||
const daemon = spawn(process.execPath, [path.join(repoRoot, "scripts/codex-desktop-refresh-bridge-daemon.mjs")], {
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
BOSS_CODEX_DESKTOP_BRIDGE_HOST: "127.0.0.1",
|
||||
BOSS_CODEX_DESKTOP_BRIDGE_PORT: "0",
|
||||
BOSS_CODEX_DESKTOP_REFRESH_DRY_RUN: "true",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let consumer;
|
||||
|
||||
try {
|
||||
const ready = await waitForJsonLine(daemon.stdout);
|
||||
const baseUrl = `http://${ready.host}:${ready.port}`;
|
||||
consumer = spawn(process.execPath, [path.join(repoRoot, "scripts/codex-desktop-event-consumer.mjs")], {
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
BOSS_CODEX_DESKTOP_EVENTS_URL: `${baseUrl}/api/v1/codex-desktop/events`,
|
||||
BOSS_CODEX_DESKTOP_EVENTS_ONCE: "true",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
const response = await fetch(`${baseUrl}/api/v1/codex-desktop/refresh`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
kind: "codex_desktop_refresh_hint",
|
||||
targetThreadRef: "019d-thread-consumer",
|
||||
sourceMessageId: "msg-refresh-consumer",
|
||||
appName: "Codex",
|
||||
refreshMode: "deeplink-reload",
|
||||
message: "must not reach consumer",
|
||||
executionPrompt: "must not reach consumer",
|
||||
}),
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
|
||||
const consumed = await waitForJsonLine(consumer.stdout);
|
||||
assert.equal(consumed.eventType, "codex_desktop_refresh");
|
||||
assert.equal(consumed.targetThreadRef, "019d-thread-consumer");
|
||||
assert.equal(consumed.sourceMessageId, "msg-refresh-consumer");
|
||||
assert.equal(consumed.deepLink, "codex://threads/019d-thread-consumer");
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(consumed, "message"), false);
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(consumed, "executionPrompt"), false);
|
||||
} finally {
|
||||
consumer?.kill("SIGTERM");
|
||||
daemon.kill("SIGTERM");
|
||||
}
|
||||
});
|
||||
|
||||
test("Codex desktop refresh bridge can use a persistent local endpoint without a command", async () => {
|
||||
const receivedPayloads = [];
|
||||
const server = createServer(async (request, response) => {
|
||||
assert.equal(request.method, "POST");
|
||||
assert.equal(request.url, "/api/v1/codex-desktop/refresh");
|
||||
const payload = await readRequestJson(request);
|
||||
receivedPayloads.push(payload);
|
||||
response.writeHead(200, { "Content-Type": "application/json" });
|
||||
response.end(
|
||||
`${JSON.stringify({
|
||||
status: "completed",
|
||||
targetThreadRef: payload.targetThreadRef,
|
||||
appName: payload.appName,
|
||||
deepLink: `codex://threads/${payload.targetThreadRef}`,
|
||||
detail: "persistent bridge accepted refresh hint",
|
||||
})}\n`,
|
||||
);
|
||||
});
|
||||
const address = await listen(server);
|
||||
|
||||
try {
|
||||
const config = getCodexDesktopRefreshBridgeConfig(
|
||||
{},
|
||||
{
|
||||
codexDesktopRefreshEnabled: true,
|
||||
codexDesktopRefreshEndpoint: `http://${address.address}:${address.port}/api/v1/codex-desktop/refresh`,
|
||||
codexDesktopRefreshTimeoutMs: 4000,
|
||||
codexDesktopRefreshAppName: "Codex",
|
||||
codexDesktopRefreshMode: "deeplink-reload",
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(config.endpoint, `http://${address.address}:${address.port}/api/v1/codex-desktop/refresh`);
|
||||
|
||||
const result = await executeCodexDesktopRefreshBridge(
|
||||
{
|
||||
targetThreadRef: "019d-thread-refresh",
|
||||
sourceMessageId: "msg-refresh-endpoint",
|
||||
rolloutPath: "/tmp/rollout.jsonl",
|
||||
threadTouchStatus: "updated",
|
||||
},
|
||||
config,
|
||||
);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
status: "completed",
|
||||
targetThreadRef: "019d-thread-refresh",
|
||||
appName: "Codex",
|
||||
deepLink: "codex://threads/019d-thread-refresh",
|
||||
detail: "persistent bridge accepted refresh hint",
|
||||
});
|
||||
assert.equal(receivedPayloads.length, 1);
|
||||
assert.equal(receivedPayloads[0].kind, "codex_desktop_refresh_hint");
|
||||
assert.equal(receivedPayloads[0].targetThreadRef, "019d-thread-refresh");
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(receivedPayloads[0], "message"), false);
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(receivedPayloads[0], "executionPrompt"), false);
|
||||
} finally {
|
||||
await closeServer(server);
|
||||
}
|
||||
});
|
||||
|
||||
test("Codex desktop refresh bridge falls back to command when the local endpoint is unavailable", async () => {
|
||||
const config = getCodexDesktopRefreshBridgeConfig(
|
||||
{},
|
||||
{
|
||||
codexDesktopRefreshEnabled: true,
|
||||
codexDesktopRefreshEndpoint: "http://127.0.0.1:9/api/v1/codex-desktop/refresh",
|
||||
codexDesktopRefreshCommand: process.execPath,
|
||||
codexDesktopRefreshArgs: ["tests/fixtures/codex-desktop-refresh-runtime.mjs"],
|
||||
codexDesktopRefreshWorkdir: repoRoot,
|
||||
codexDesktopRefreshTimeoutMs: 4000,
|
||||
codexDesktopRefreshRetryCount: 0,
|
||||
codexDesktopRefreshRetryDelayMs: 1,
|
||||
codexDesktopRefreshAppName: "Codex",
|
||||
codexDesktopRefreshMode: "deeplink-reload",
|
||||
},
|
||||
);
|
||||
|
||||
const result = await executeCodexDesktopRefreshBridge(
|
||||
{
|
||||
targetThreadRef: "019d-thread-refresh",
|
||||
sourceMessageId: "msg-refresh-fallback",
|
||||
rolloutPath: "/tmp/rollout.jsonl",
|
||||
threadTouchStatus: "updated",
|
||||
},
|
||||
config,
|
||||
);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
status: "completed",
|
||||
targetThreadRef: "019d-thread-refresh",
|
||||
appName: "Codex",
|
||||
detail: "refresh hint accepted: deeplink-reload",
|
||||
});
|
||||
});
|
||||
|
||||
test("Codex desktop refresh bridge sends a safe refresh hint to the configured runtime", async () => {
|
||||
const config = getCodexDesktopRefreshBridgeConfig(
|
||||
{},
|
||||
{
|
||||
codexDesktopRefreshEnabled: true,
|
||||
codexDesktopRefreshCommand: process.execPath,
|
||||
codexDesktopRefreshArgs: ["tests/fixtures/codex-desktop-refresh-runtime.mjs"],
|
||||
codexDesktopRefreshWorkdir: repoRoot,
|
||||
codexDesktopRefreshTimeoutMs: 4000,
|
||||
codexDesktopRefreshAppName: "Codex",
|
||||
codexDesktopRefreshMode: "deeplink-reload",
|
||||
},
|
||||
);
|
||||
|
||||
const execution = buildCodexDesktopRefreshExecution(config, {
|
||||
targetThreadRef: "019d-thread-refresh",
|
||||
sourceMessageId: "msg-refresh",
|
||||
rolloutPath: "/tmp/rollout.jsonl",
|
||||
threadTouchStatus: "updated",
|
||||
});
|
||||
|
||||
assert.equal(execution.command, process.execPath);
|
||||
assert.deepEqual(execution.args, [path.join(repoRoot, "tests/fixtures/codex-desktop-refresh-runtime.mjs")]);
|
||||
assert.equal(execution.stdinPayload.kind, "codex_desktop_refresh_hint");
|
||||
assert.equal(execution.stdinPayload.targetThreadRef, "019d-thread-refresh");
|
||||
assert.equal(execution.stdinPayload.sourceMessageId, "msg-refresh");
|
||||
assert.equal(execution.stdinPayload.rolloutPath, "/tmp/rollout.jsonl");
|
||||
assert.equal(execution.stdinPayload.appName, "Codex");
|
||||
assert.equal(execution.stdinPayload.refreshMode, "deeplink-reload");
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(execution.stdinPayload, "message"), false);
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(execution.stdinPayload, "executionPrompt"), false);
|
||||
|
||||
const result = await executeCodexDesktopRefreshBridge(
|
||||
{
|
||||
targetThreadRef: "019d-thread-refresh",
|
||||
sourceMessageId: "msg-refresh",
|
||||
rolloutPath: "/tmp/rollout.jsonl",
|
||||
threadTouchStatus: "updated",
|
||||
},
|
||||
config,
|
||||
);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
status: "completed",
|
||||
targetThreadRef: "019d-thread-refresh",
|
||||
appName: "Codex",
|
||||
detail: "refresh hint accepted: deeplink-reload",
|
||||
});
|
||||
});
|
||||
|
||||
test("Codex desktop refresh bridge retries a failed runtime and reports attempt count", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "boss-codex-refresh-"));
|
||||
const stateFile = path.join(tempDir, "flaky-state.json");
|
||||
const previousStateFile = process.env.BOSS_CODEX_REFRESH_FLAKY_STATE;
|
||||
process.env.BOSS_CODEX_REFRESH_FLAKY_STATE = stateFile;
|
||||
|
||||
try {
|
||||
const config = getCodexDesktopRefreshBridgeConfig(
|
||||
{
|
||||
BOSS_CODEX_DESKTOP_REFRESH_RETRY_COUNT: "2",
|
||||
BOSS_CODEX_DESKTOP_REFRESH_RETRY_DELAY_MS: "1",
|
||||
},
|
||||
{
|
||||
codexDesktopRefreshEnabled: true,
|
||||
codexDesktopRefreshCommand: process.execPath,
|
||||
codexDesktopRefreshArgs: ["tests/fixtures/codex-desktop-refresh-flaky-runtime.mjs"],
|
||||
codexDesktopRefreshWorkdir: repoRoot,
|
||||
codexDesktopRefreshTimeoutMs: 4000,
|
||||
codexDesktopRefreshAppName: "Codex",
|
||||
codexDesktopRefreshMode: "deeplink-reload",
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(config.retryCount, 2);
|
||||
assert.equal(config.retryDelayMs, 1);
|
||||
|
||||
const result = await executeCodexDesktopRefreshBridge(
|
||||
{
|
||||
targetThreadRef: "019d-thread-refresh",
|
||||
sourceMessageId: "msg-refresh-flaky",
|
||||
rolloutPath: "/tmp/rollout.jsonl",
|
||||
threadTouchStatus: "updated",
|
||||
},
|
||||
config,
|
||||
);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
status: "completed",
|
||||
targetThreadRef: "019d-thread-refresh",
|
||||
appName: "Codex",
|
||||
deepLink: "codex://threads/019d-thread-refresh",
|
||||
detail: "flaky refresh accepted after 2 attempts",
|
||||
attemptCount: 2,
|
||||
});
|
||||
|
||||
const state = JSON.parse(await fs.readFile(stateFile, "utf8"));
|
||||
assert.equal(state.count, 2);
|
||||
} finally {
|
||||
if (previousStateFile === undefined) {
|
||||
delete process.env.BOSS_CODEX_REFRESH_FLAKY_STATE;
|
||||
} else {
|
||||
process.env.BOSS_CODEX_REFRESH_FLAKY_STATE = previousStateFile;
|
||||
}
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Codex desktop refresh hint can target a concrete desktop thread deeplink without sending message text", async () => {
|
||||
const result = await runRefreshHintDryRun({
|
||||
kind: "codex_desktop_refresh_hint",
|
||||
targetThreadRef: "019d-thread-refresh",
|
||||
sourceMessageId: "msg-refresh",
|
||||
appName: "Codex",
|
||||
refreshMode: "deeplink-reload",
|
||||
message: "this must not be interpreted by the desktop bridge",
|
||||
});
|
||||
|
||||
assert.equal(result.status, "completed");
|
||||
assert.equal(result.targetThreadRef, "019d-thread-refresh");
|
||||
assert.equal(result.deepLink, "codex://threads/019d-thread-refresh");
|
||||
assert.match(result.detail, /would open codex:\/\/threads\/019d-thread-refresh/);
|
||||
assert.doesNotMatch(result.detail, /this must not be interpreted/);
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
|
||||
@@ -196,6 +197,7 @@ test("discoverCodexProjectCandidates prefers Codex sqlite indexes and session na
|
||||
});
|
||||
|
||||
assert.deepEqual(discovered.projects, ["boss", "yuandi"]);
|
||||
assert.equal(discovered.guiConnected, true);
|
||||
assert.equal(discovered.projectCandidates.length, 2);
|
||||
|
||||
const bossSession = discovered.projectCandidates.find((item) => item.threadId === "019d3bossmain");
|
||||
@@ -213,6 +215,190 @@ test("discoverCodexProjectCandidates prefers Codex sqlite indexes and session na
|
||||
assert.equal(yuandiSession?.codexFolderRef, "/Users/kris/code/yuandi");
|
||||
});
|
||||
|
||||
test("discoverCodexProjectCandidates merges session-only Codex threads when state db is partially stale", async () => {
|
||||
await setup();
|
||||
|
||||
const codexRoot = path.join(runtimeRoot, ".codex-session-merge");
|
||||
const now = new Date("2026-05-02T10:00:00+08:00");
|
||||
await mkdir(codexRoot, { recursive: true });
|
||||
const stateDbPath = path.join(codexRoot, "state_5.sqlite");
|
||||
const logsDbPath = path.join(codexRoot, "logs_1.sqlite");
|
||||
const sessionIndexPath = path.join(codexRoot, "session_index.jsonl");
|
||||
const globalStatePath = path.join(codexRoot, ".codex-global-state.json");
|
||||
const sessionsDir = path.join(codexRoot, "sessions");
|
||||
const sessionOnlyDir = path.join(sessionsDir, "2026", "05", "02");
|
||||
await mkdir(sessionOnlyDir, { recursive: true });
|
||||
|
||||
const stateDb = new DatabaseSync(stateDbPath);
|
||||
stateDb.exec(`
|
||||
CREATE TABLE threads (
|
||||
id TEXT PRIMARY KEY,
|
||||
rollout_path TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
model_provider TEXT NOT NULL,
|
||||
cwd TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
sandbox_policy TEXT NOT NULL,
|
||||
approval_mode TEXT NOT NULL,
|
||||
tokens_used INTEGER NOT NULL DEFAULT 0,
|
||||
has_user_event INTEGER NOT NULL DEFAULT 0,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
archived_at INTEGER,
|
||||
git_sha TEXT,
|
||||
git_branch TEXT,
|
||||
git_origin_url TEXT,
|
||||
cli_version TEXT NOT NULL DEFAULT '',
|
||||
first_user_message TEXT NOT NULL DEFAULT '',
|
||||
agent_nickname TEXT,
|
||||
agent_role TEXT,
|
||||
memory_mode TEXT NOT NULL DEFAULT 'enabled',
|
||||
model TEXT,
|
||||
reasoning_effort TEXT,
|
||||
agent_path TEXT
|
||||
);
|
||||
`);
|
||||
stateDb.prepare(`
|
||||
INSERT INTO threads (
|
||||
id, rollout_path, created_at, updated_at, source, model_provider, cwd, title,
|
||||
sandbox_policy, approval_mode, tokens_used, has_user_event, archived,
|
||||
cli_version, first_user_message, agent_nickname, agent_role, memory_mode, model, reasoning_effort
|
||||
) VALUES (?, ?, 1777686800, 1777686800, 'vscode', 'openai', ?, 'Boss 主线程', 'workspace-write', 'never', 0, 1, 0, '0.118.0', '', '', '', 'enabled', 'gpt-5.4', 'medium')
|
||||
`).run(
|
||||
"019d-state-thread",
|
||||
path.join(sessionsDir, "2026/05/02/rollout-state-thread.jsonl"),
|
||||
"/Users/kris/code/boss",
|
||||
);
|
||||
stateDb.close();
|
||||
|
||||
const logsDb = new DatabaseSync(logsDbPath);
|
||||
logsDb.exec(`
|
||||
CREATE TABLE logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
ts_nanos INTEGER NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
feedback_log_body TEXT,
|
||||
module_path TEXT,
|
||||
file TEXT,
|
||||
line INTEGER,
|
||||
thread_id TEXT,
|
||||
process_uuid TEXT,
|
||||
estimated_bytes INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`);
|
||||
logsDb.prepare(`
|
||||
INSERT INTO logs (ts, ts_nanos, level, target, thread_id, estimated_bytes)
|
||||
VALUES (1777686800, 0, 'info', 'codex', '019d-state-thread', 0)
|
||||
`).run();
|
||||
logsDb.close();
|
||||
|
||||
await writeFile(sessionIndexPath, "", "utf8");
|
||||
await writeFile(
|
||||
globalStatePath,
|
||||
JSON.stringify({ "thread-workspace-root-hints": {} }, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
path.join(sessionOnlyDir, "rollout-2026-05-02T09-53-45-019d-session-only.jsonl"),
|
||||
`${JSON.stringify({
|
||||
timestamp: "2026-05-02T01:53:45.000Z",
|
||||
type: "session_meta",
|
||||
payload: {
|
||||
id: "019d-session-only",
|
||||
cwd: "/Users/kris/code/boss-regression-smoke",
|
||||
timestamp: "2026-05-02T01:53:45.000Z",
|
||||
},
|
||||
})}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const discovered = await discoverCodexProjectCandidates({
|
||||
stateDbPath,
|
||||
logsDbPath,
|
||||
sessionIndexPath,
|
||||
globalStatePath,
|
||||
sessionsDir,
|
||||
lookbackHours: 24,
|
||||
now,
|
||||
});
|
||||
|
||||
assert.deepEqual(discovered.projects, ["boss", "boss-regression-smoke"]);
|
||||
assert.equal(discovered.guiConnected, true);
|
||||
assert.ok(
|
||||
discovered.projectCandidates.some((item) => item.threadId === "019d-session-only"),
|
||||
"expected session-only Codex threads to be imported even when state db has other rows",
|
||||
);
|
||||
});
|
||||
|
||||
test("discoverCodexProjectCandidates collapses duplicate final assistant records from the same rollout turn", async () => {
|
||||
await setup();
|
||||
|
||||
const codexRoot = path.join(runtimeRoot, ".codex-rollout-dedupe");
|
||||
const sessionsDir = path.join(codexRoot, "sessions");
|
||||
const rolloutDir = path.join(sessionsDir, "2026", "05", "02");
|
||||
const rolloutPath = path.join(
|
||||
rolloutDir,
|
||||
"rollout-2026-05-02T10-10-00-019drolloutdedupe.jsonl",
|
||||
);
|
||||
await mkdir(rolloutDir, { recursive: true });
|
||||
|
||||
const replyBody = "BOSS回归APP消息已收到。";
|
||||
await writeFile(
|
||||
rolloutPath,
|
||||
[
|
||||
JSON.stringify({
|
||||
timestamp: "2026-05-02T02:10:00.000Z",
|
||||
type: "session_meta",
|
||||
payload: {
|
||||
id: "019drolloutdedupe",
|
||||
cwd: "/Users/kris/code/boss-regression-smoke",
|
||||
timestamp: "2026-05-02T02:10:00.000Z",
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: "2026-05-02T02:10:36.311Z",
|
||||
type: "event_msg",
|
||||
payload: {
|
||||
type: "agent_message",
|
||||
message: replyBody,
|
||||
phase: "final_answer",
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: "2026-05-02T02:10:36.312Z",
|
||||
type: "response_item",
|
||||
payload: {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: replyBody }],
|
||||
},
|
||||
}),
|
||||
].join("\n") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const discovered = await discoverCodexProjectCandidates({
|
||||
sessionsDir,
|
||||
stateDbPath: path.join(codexRoot, "missing-state.sqlite"),
|
||||
logsDbPath: path.join(codexRoot, "missing-logs.sqlite"),
|
||||
sessionIndexPath: path.join(codexRoot, "missing-session-index.jsonl"),
|
||||
globalStatePath: path.join(codexRoot, "missing-global-state.json"),
|
||||
lookbackHours: 24,
|
||||
now: new Date("2026-05-02T10:30:00+08:00"),
|
||||
});
|
||||
|
||||
const smokeThread = discovered.projectCandidates.find(
|
||||
(candidate) => candidate.threadId === "019drolloutdedupe",
|
||||
);
|
||||
assert.ok(smokeThread, "expected rollout-only smoke thread to be discovered");
|
||||
assert.equal(smokeThread?.recentAssistantMessages?.length, 1);
|
||||
assert.equal(smokeThread?.recentAssistantMessages?.[0]?.body, replyBody);
|
||||
assert.equal(smokeThread?.recentAssistantMessages?.[0]?.phase, "final_answer");
|
||||
});
|
||||
|
||||
test("discoverCodexProjectCandidates excludes read-only threads even when they are the newest primary thread", async () => {
|
||||
await setup();
|
||||
|
||||
@@ -342,3 +528,393 @@ test("discoverCodexProjectCandidates excludes read-only threads even when they a
|
||||
assert.equal(discovered.projectCandidates[0]?.threadId, "019d-boss-writable");
|
||||
assert.equal(discovered.projectCandidates[0]?.threadDisplayName, "Boss 可写线程");
|
||||
});
|
||||
|
||||
test("discoverCodexProjectCandidates falls back to workspace folder when thread title leaks internal prompt text", async () => {
|
||||
await setup();
|
||||
|
||||
const codexRoot = path.join(runtimeRoot, ".codex-prompt-title");
|
||||
const now = new Date("2026-04-24T11:00:00+08:00");
|
||||
await mkdir(codexRoot, { recursive: true });
|
||||
const stateDbPath = path.join(codexRoot, "state_5.sqlite");
|
||||
const logsDbPath = path.join(codexRoot, "logs_1.sqlite");
|
||||
const sessionIndexPath = path.join(codexRoot, "session_index.jsonl");
|
||||
const globalStatePath = path.join(codexRoot, ".codex-global-state.json");
|
||||
|
||||
const stateDb = new DatabaseSync(stateDbPath);
|
||||
stateDb.exec(`
|
||||
CREATE TABLE threads (
|
||||
id TEXT PRIMARY KEY,
|
||||
rollout_path TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
model_provider TEXT NOT NULL,
|
||||
cwd TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
sandbox_policy TEXT NOT NULL,
|
||||
approval_mode TEXT NOT NULL,
|
||||
tokens_used INTEGER NOT NULL DEFAULT 0,
|
||||
has_user_event INTEGER NOT NULL DEFAULT 0,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
archived_at INTEGER,
|
||||
git_sha TEXT,
|
||||
git_branch TEXT,
|
||||
git_origin_url TEXT,
|
||||
cli_version TEXT NOT NULL DEFAULT '',
|
||||
first_user_message TEXT NOT NULL DEFAULT '',
|
||||
agent_nickname TEXT,
|
||||
agent_role TEXT,
|
||||
memory_mode TEXT NOT NULL DEFAULT 'enabled',
|
||||
model TEXT,
|
||||
reasoning_effort TEXT,
|
||||
agent_path TEXT
|
||||
);
|
||||
`);
|
||||
stateDb.prepare(`
|
||||
INSERT INTO threads (
|
||||
id, rollout_path, created_at, updated_at, source, model_provider, cwd, title,
|
||||
sandbox_policy, approval_mode, tokens_used, has_user_event, archived,
|
||||
cli_version, first_user_message, memory_mode, model, reasoning_effort
|
||||
) VALUES (?, ?, ?, ?, 'desktop', 'openai', ?, ?, 'workspace-write', 'never', 0, 1, 0, '0.118.0', '', 'enabled', 'gpt-5.4', 'medium')
|
||||
`).run(
|
||||
"019d-prompt-main",
|
||||
path.join(codexRoot, "sessions/2026/04/24/rollout-prompt-main.jsonl"),
|
||||
1776998400,
|
||||
1776998460,
|
||||
"/Users/kris/code/boss",
|
||||
"你当前接手的项目根目录是:",
|
||||
);
|
||||
stateDb.close();
|
||||
|
||||
const logsDb = new DatabaseSync(logsDbPath);
|
||||
logsDb.exec(`
|
||||
CREATE TABLE logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
ts_nanos INTEGER NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
feedback_log_body TEXT,
|
||||
module_path TEXT,
|
||||
file TEXT,
|
||||
line INTEGER,
|
||||
thread_id TEXT,
|
||||
process_uuid TEXT,
|
||||
estimated_bytes INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`);
|
||||
logsDb.prepare(`
|
||||
INSERT INTO logs (ts, ts_nanos, level, target, thread_id, estimated_bytes)
|
||||
VALUES (?, 0, 'info', 'codex', ?, 0)
|
||||
`).run(1776998460, "019d-prompt-main");
|
||||
logsDb.close();
|
||||
|
||||
await writeFile(
|
||||
sessionIndexPath,
|
||||
JSON.stringify({
|
||||
id: "019d-prompt-main",
|
||||
thread_name: "你现在接手的项目根目录是 /Users/kris/code/boss。",
|
||||
updated_at: "2026-04-24T03:21:00.000000Z",
|
||||
}) + "\n",
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
globalStatePath,
|
||||
JSON.stringify({ "thread-workspace-root-hints": {} }, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const discovered = await discoverCodexProjectCandidates({
|
||||
stateDbPath,
|
||||
logsDbPath,
|
||||
sessionIndexPath,
|
||||
globalStatePath,
|
||||
lookbackHours: 24,
|
||||
now,
|
||||
});
|
||||
|
||||
assert.deepEqual(discovered.projects, ["boss"]);
|
||||
assert.equal(discovered.projectCandidates.length, 1);
|
||||
assert.equal(discovered.projectCandidates[0]?.threadDisplayName, "boss");
|
||||
});
|
||||
|
||||
test("discoverCodexProjectCandidates mirrors recent desktop assistant replies from rollout files", async () => {
|
||||
await setup();
|
||||
|
||||
const codexRoot = path.join(runtimeRoot, ".codex-message-sync");
|
||||
const now = new Date("2026-04-20T18:00:00+08:00");
|
||||
await mkdir(path.join(codexRoot, "sessions/2026/04/20"), { recursive: true });
|
||||
const stateDbPath = path.join(codexRoot, "state_5.sqlite");
|
||||
const logsDbPath = path.join(codexRoot, "logs_1.sqlite");
|
||||
const rolloutPath = path.join(codexRoot, "sessions/2026/04/20/rollout-boss-main.jsonl");
|
||||
|
||||
const stateDb = new DatabaseSync(stateDbPath);
|
||||
stateDb.exec(`
|
||||
CREATE TABLE threads (
|
||||
id TEXT PRIMARY KEY,
|
||||
rollout_path TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
model_provider TEXT NOT NULL,
|
||||
cwd TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
sandbox_policy TEXT NOT NULL,
|
||||
approval_mode TEXT NOT NULL,
|
||||
tokens_used INTEGER NOT NULL DEFAULT 0,
|
||||
has_user_event INTEGER NOT NULL DEFAULT 0,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
archived_at INTEGER,
|
||||
git_sha TEXT,
|
||||
git_branch TEXT,
|
||||
git_origin_url TEXT,
|
||||
cli_version TEXT NOT NULL DEFAULT '',
|
||||
first_user_message TEXT NOT NULL DEFAULT '',
|
||||
agent_nickname TEXT,
|
||||
agent_role TEXT,
|
||||
memory_mode TEXT NOT NULL DEFAULT 'enabled',
|
||||
model TEXT,
|
||||
reasoning_effort TEXT,
|
||||
agent_path TEXT
|
||||
);
|
||||
`);
|
||||
stateDb.prepare(`
|
||||
INSERT INTO threads (
|
||||
id, rollout_path, created_at, updated_at, source, model_provider, cwd, title,
|
||||
sandbox_policy, approval_mode, tokens_used, has_user_event, archived,
|
||||
cli_version, first_user_message, memory_mode, model, reasoning_effort
|
||||
) VALUES (?, ?, ?, ?, 'desktop', 'openai', ?, ?, 'workspace-write', 'never', 0, 1, 0, '0.118.0', '', 'enabled', 'gpt-5.4', 'medium')
|
||||
`).run(
|
||||
"019d-message-main",
|
||||
rolloutPath,
|
||||
1776680000,
|
||||
1776680100,
|
||||
"/Users/kris/code/boss",
|
||||
"Boss 主线程",
|
||||
);
|
||||
stateDb.close();
|
||||
|
||||
const logsDb = new DatabaseSync(logsDbPath);
|
||||
logsDb.exec(`
|
||||
CREATE TABLE logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
ts_nanos INTEGER NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
feedback_log_body TEXT,
|
||||
module_path TEXT,
|
||||
file TEXT,
|
||||
line INTEGER,
|
||||
thread_id TEXT,
|
||||
process_uuid TEXT,
|
||||
estimated_bytes INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`);
|
||||
logsDb
|
||||
.prepare("INSERT INTO logs (ts, ts_nanos, level, target, thread_id, estimated_bytes) VALUES (?, 0, 'info', 'codex', ?, 0)")
|
||||
.run(1776680100, "019d-message-main");
|
||||
logsDb.close();
|
||||
|
||||
const assistantText = "桌面线程已经完成实时同步修复。";
|
||||
const assistantSentAt = "2026-04-20T09:34:56.000Z";
|
||||
await writeFile(
|
||||
rolloutPath,
|
||||
[
|
||||
JSON.stringify({
|
||||
type: "session_meta",
|
||||
payload: {
|
||||
id: "019d-message-main",
|
||||
cwd: "/Users/kris/code/boss",
|
||||
timestamp: "2026-04-20T09:30:00.000Z",
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: assistantSentAt,
|
||||
type: "event_msg",
|
||||
payload: {
|
||||
type: "agent_message",
|
||||
message: assistantText,
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: assistantSentAt,
|
||||
type: "response_item",
|
||||
payload: {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: assistantText }],
|
||||
},
|
||||
}),
|
||||
].join("\n") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const discovered = await discoverCodexProjectCandidates({
|
||||
stateDbPath,
|
||||
logsDbPath,
|
||||
lookbackHours: 24,
|
||||
now,
|
||||
});
|
||||
|
||||
assert.equal(discovered.projectCandidates.length, 1);
|
||||
const candidate = discovered.projectCandidates[0];
|
||||
assert.ok(candidate);
|
||||
assert.deepEqual(candidate?.recentAssistantMessages, [
|
||||
{
|
||||
messageId: `codex-thread:019d-message-main:${assistantSentAt}:${crypto.createHash("sha1").update(assistantText).digest("hex").slice(0, 12)}`,
|
||||
body: assistantText,
|
||||
sentAt: assistantSentAt,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("discoverCodexProjectCandidates preserves assistant reply phase for process folding", async () => {
|
||||
await setup();
|
||||
|
||||
const codexRoot = path.join(runtimeRoot, ".codex-message-phase");
|
||||
const now = new Date("2026-04-20T18:30:00+08:00");
|
||||
await mkdir(path.join(codexRoot, "sessions/2026/04/20"), { recursive: true });
|
||||
const stateDbPath = path.join(codexRoot, "state_5.sqlite");
|
||||
const logsDbPath = path.join(codexRoot, "logs_1.sqlite");
|
||||
const rolloutPath = path.join(codexRoot, "sessions/2026/04/20/rollout-boss-main.jsonl");
|
||||
|
||||
const stateDb = new DatabaseSync(stateDbPath);
|
||||
stateDb.exec(`
|
||||
CREATE TABLE threads (
|
||||
id TEXT PRIMARY KEY,
|
||||
rollout_path TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
model_provider TEXT NOT NULL,
|
||||
cwd TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
sandbox_policy TEXT NOT NULL,
|
||||
approval_mode TEXT NOT NULL,
|
||||
tokens_used INTEGER NOT NULL DEFAULT 0,
|
||||
has_user_event INTEGER NOT NULL DEFAULT 0,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
archived_at INTEGER,
|
||||
git_sha TEXT,
|
||||
git_branch TEXT,
|
||||
git_origin_url TEXT,
|
||||
cli_version TEXT NOT NULL DEFAULT '',
|
||||
first_user_message TEXT NOT NULL DEFAULT '',
|
||||
agent_nickname TEXT,
|
||||
agent_role TEXT,
|
||||
memory_mode TEXT NOT NULL DEFAULT 'enabled',
|
||||
model TEXT,
|
||||
reasoning_effort TEXT,
|
||||
agent_path TEXT
|
||||
);
|
||||
`);
|
||||
stateDb.prepare(`
|
||||
INSERT INTO threads (
|
||||
id, rollout_path, created_at, updated_at, source, model_provider, cwd, title,
|
||||
sandbox_policy, approval_mode, tokens_used, has_user_event, archived,
|
||||
cli_version, first_user_message, memory_mode, model, reasoning_effort
|
||||
) VALUES (?, ?, ?, ?, 'desktop', 'openai', ?, ?, 'workspace-write', 'never', 0, 1, 0, '0.118.0', '', 'enabled', 'gpt-5.4', 'medium')
|
||||
`).run(
|
||||
"019d-message-phase",
|
||||
rolloutPath,
|
||||
1776680000,
|
||||
1776681000,
|
||||
"/Users/kris/code/boss",
|
||||
"Boss 主线程",
|
||||
);
|
||||
stateDb.close();
|
||||
|
||||
const logsDb = new DatabaseSync(logsDbPath);
|
||||
logsDb.exec(`
|
||||
CREATE TABLE logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
ts_nanos INTEGER NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
feedback_log_body TEXT,
|
||||
module_path TEXT,
|
||||
file TEXT,
|
||||
line INTEGER,
|
||||
thread_id TEXT,
|
||||
process_uuid TEXT,
|
||||
estimated_bytes INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`);
|
||||
logsDb
|
||||
.prepare("INSERT INTO logs (ts, ts_nanos, level, target, thread_id, estimated_bytes) VALUES (?, 0, 'info', 'codex', ?, 0)")
|
||||
.run(1776681000, "019d-message-phase");
|
||||
logsDb.close();
|
||||
|
||||
const processText = "我先检查聊天折叠链路,确认过程消息不会直接展开。";
|
||||
const finalText = "已完成折叠修复,过程消息会收进按钮里,未读只增加一次。";
|
||||
const processSentAt = "2026-04-20T10:28:10.000Z";
|
||||
const finalSentAt = "2026-04-20T10:29:30.000Z";
|
||||
await writeFile(
|
||||
rolloutPath,
|
||||
[
|
||||
JSON.stringify({
|
||||
type: "session_meta",
|
||||
payload: {
|
||||
id: "019d-message-phase",
|
||||
cwd: "/Users/kris/code/boss",
|
||||
timestamp: "2026-04-20T10:20:00.000Z",
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: processSentAt,
|
||||
type: "event_msg",
|
||||
payload: {
|
||||
type: "agent_message",
|
||||
message: processText,
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: processSentAt,
|
||||
type: "response_item",
|
||||
payload: {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: processText }],
|
||||
phase: "commentary",
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: finalSentAt,
|
||||
type: "response_item",
|
||||
payload: {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: finalText }],
|
||||
phase: "final_answer",
|
||||
},
|
||||
}),
|
||||
].join("\n") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const discovered = await discoverCodexProjectCandidates({
|
||||
stateDbPath,
|
||||
logsDbPath,
|
||||
lookbackHours: 24,
|
||||
now,
|
||||
});
|
||||
|
||||
const candidate = discovered.projectCandidates[0];
|
||||
assert.ok(candidate);
|
||||
assert.deepEqual(candidate.recentAssistantMessages, [
|
||||
{
|
||||
messageId: `codex-thread:019d-message-phase:${processSentAt}:${crypto.createHash("sha1").update(processText).digest("hex").slice(0, 12)}`,
|
||||
body: processText,
|
||||
sentAt: processSentAt,
|
||||
phase: "commentary",
|
||||
},
|
||||
{
|
||||
messageId: `codex-thread:019d-message-phase:${finalSentAt}:${crypto.createHash("sha1").update(finalText).digest("hex").slice(0, 12)}`,
|
||||
body: finalText,
|
||||
sentAt: finalSentAt,
|
||||
phase: "final_answer",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
275
tests/local-agent-codex-rollout-writer.test.mjs
Normal file
275
tests/local-agent-codex-rollout-writer.test.mjs
Normal file
@@ -0,0 +1,275 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
|
||||
import { appendBossUserMessageToCodexThreadRollout } from "../local-agent/codex-thread-rollout-writer.mjs";
|
||||
|
||||
let runtimeRoot = "";
|
||||
|
||||
async function ensureRuntimeRoot() {
|
||||
if (!runtimeRoot) {
|
||||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-codex-rollout-writer-"));
|
||||
}
|
||||
return runtimeRoot;
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
if (runtimeRoot) {
|
||||
await rm(runtimeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function createThreadBinding({ threadId, rolloutPath }) {
|
||||
const root = await ensureRuntimeRoot();
|
||||
const dbPath = path.join(root, `state-${Math.random().toString(16).slice(2)}.sqlite`);
|
||||
const db = new DatabaseSync(dbPath);
|
||||
db.exec(`
|
||||
CREATE TABLE threads (
|
||||
id TEXT PRIMARY KEY,
|
||||
rollout_path TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
updated_at_ms INTEGER,
|
||||
source TEXT NOT NULL,
|
||||
model_provider TEXT NOT NULL,
|
||||
cwd TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
sandbox_policy TEXT NOT NULL,
|
||||
approval_mode TEXT NOT NULL,
|
||||
tokens_used INTEGER NOT NULL DEFAULT 0,
|
||||
has_user_event INTEGER NOT NULL DEFAULT 0,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
archived_at INTEGER,
|
||||
git_sha TEXT,
|
||||
git_branch TEXT,
|
||||
git_origin_url TEXT,
|
||||
cli_version TEXT NOT NULL DEFAULT '',
|
||||
first_user_message TEXT NOT NULL DEFAULT '',
|
||||
agent_nickname TEXT,
|
||||
agent_role TEXT,
|
||||
memory_mode TEXT NOT NULL DEFAULT 'enabled',
|
||||
model TEXT,
|
||||
reasoning_effort TEXT,
|
||||
agent_path TEXT
|
||||
);
|
||||
`);
|
||||
db.prepare(`
|
||||
INSERT INTO threads (
|
||||
id, rollout_path, created_at, updated_at, updated_at_ms, source, model_provider, cwd, title,
|
||||
sandbox_policy, approval_mode, tokens_used, has_user_event, archived,
|
||||
cli_version, first_user_message, agent_nickname, agent_role, memory_mode, model, reasoning_effort
|
||||
) VALUES (?, ?, 1774845600, 1774845618, 1774845618000, 'desktop', 'openai', ?, ?, '{"type":"workspace-write"}', 'never', 0, 0, 0, '0.118.0', '', '', '', 'enabled', 'gpt-5.4', 'medium')
|
||||
`).run(threadId, rolloutPath, root, threadId);
|
||||
db.close();
|
||||
return dbPath;
|
||||
}
|
||||
|
||||
async function createThreadsDbWithoutBinding() {
|
||||
const root = await ensureRuntimeRoot();
|
||||
const dbPath = path.join(root, `state-empty-${Math.random().toString(16).slice(2)}.sqlite`);
|
||||
const db = new DatabaseSync(dbPath);
|
||||
db.exec(`
|
||||
CREATE TABLE threads (
|
||||
id TEXT PRIMARY KEY,
|
||||
rollout_path TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
updated_at_ms INTEGER,
|
||||
source TEXT NOT NULL,
|
||||
model_provider TEXT NOT NULL,
|
||||
cwd TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
sandbox_policy TEXT NOT NULL,
|
||||
approval_mode TEXT NOT NULL,
|
||||
tokens_used INTEGER NOT NULL DEFAULT 0,
|
||||
has_user_event INTEGER NOT NULL DEFAULT 0,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
archived_at INTEGER,
|
||||
git_sha TEXT,
|
||||
git_branch TEXT,
|
||||
git_origin_url TEXT,
|
||||
cli_version TEXT NOT NULL DEFAULT '',
|
||||
first_user_message TEXT NOT NULL DEFAULT '',
|
||||
agent_nickname TEXT,
|
||||
agent_role TEXT,
|
||||
memory_mode TEXT NOT NULL DEFAULT 'enabled',
|
||||
model TEXT,
|
||||
reasoning_effort TEXT,
|
||||
agent_path TEXT
|
||||
);
|
||||
`);
|
||||
db.close();
|
||||
return dbPath;
|
||||
}
|
||||
|
||||
test("appendBossUserMessageToCodexThreadRollout writes one user_message event and dedupes by source message id", async () => {
|
||||
const root = await ensureRuntimeRoot();
|
||||
const rolloutPath = path.join(root, "rollout-thread-real.jsonl");
|
||||
await writeFile(
|
||||
rolloutPath,
|
||||
`${JSON.stringify({
|
||||
timestamp: "2026-04-21T08:59:00.000Z",
|
||||
type: "session_meta",
|
||||
payload: {
|
||||
id: "019d-thread-real",
|
||||
cwd: "/Users/kris/code/boss",
|
||||
},
|
||||
})}\n`,
|
||||
"utf8",
|
||||
);
|
||||
const stateDbPath = await createThreadBinding({
|
||||
threadId: "019d-thread-real",
|
||||
rolloutPath,
|
||||
});
|
||||
const beforeTouchDb = new DatabaseSync(stateDbPath, { readonly: true });
|
||||
const beforeTouchRow = beforeTouchDb
|
||||
.prepare("SELECT updated_at, updated_at_ms, has_user_event FROM threads WHERE id = ?")
|
||||
.get("019d-thread-real");
|
||||
beforeTouchDb.close();
|
||||
|
||||
const first = await appendBossUserMessageToCodexThreadRollout({
|
||||
stateDbPath,
|
||||
targetThreadRef: "019d-thread-real",
|
||||
sourceMessageId: "msg-1",
|
||||
message: "请继续推进",
|
||||
sentAt: "2026-04-21T09:00:00.000Z",
|
||||
});
|
||||
const second = await appendBossUserMessageToCodexThreadRollout({
|
||||
stateDbPath,
|
||||
targetThreadRef: "019d-thread-real",
|
||||
sourceMessageId: "msg-1",
|
||||
message: "请继续推进",
|
||||
sentAt: "2026-04-21T09:00:00.000Z",
|
||||
});
|
||||
|
||||
assert.equal(first.status, "written");
|
||||
assert.equal(second.status, "duplicate");
|
||||
|
||||
const raw = await readFile(rolloutPath, "utf8");
|
||||
const lines = raw.trim().split("\n").map((line) => JSON.parse(line));
|
||||
const mirrored = lines.filter(
|
||||
(entry) =>
|
||||
entry?.type === "event_msg" &&
|
||||
entry?.payload?.type === "user_message" &&
|
||||
entry?.payload?.metadata?.bossSourceMessageId === "msg-1",
|
||||
);
|
||||
const mirroredResponseItems = lines.filter(
|
||||
(entry) =>
|
||||
entry?.type === "response_item" &&
|
||||
entry?.payload?.type === "message" &&
|
||||
entry?.payload?.role === "user" &&
|
||||
entry?.payload?.content?.[0]?.type === "input_text" &&
|
||||
entry?.payload?.content?.[0]?.text === "请继续推进",
|
||||
);
|
||||
|
||||
assert.equal(mirrored.length, 1);
|
||||
assert.equal(mirroredResponseItems.length, 1);
|
||||
assert.equal(mirrored[0]?.payload?.message, "请继续推进");
|
||||
assert.equal(mirrored[0]?.timestamp, "2026-04-21T09:00:00.000Z");
|
||||
|
||||
const afterTouchDb = new DatabaseSync(stateDbPath, { readonly: true });
|
||||
const afterTouchRow = afterTouchDb
|
||||
.prepare("SELECT updated_at, updated_at_ms, has_user_event FROM threads WHERE id = ?")
|
||||
.get("019d-thread-real");
|
||||
afterTouchDb.close();
|
||||
|
||||
assert.equal(afterTouchRow?.has_user_event, 1);
|
||||
assert.ok(
|
||||
Number(afterTouchRow?.updated_at) > Number(beforeTouchRow?.updated_at),
|
||||
"expected mirrored write to refresh updated_at",
|
||||
);
|
||||
assert.ok(
|
||||
Number(afterTouchRow?.updated_at_ms) > Number(beforeTouchRow?.updated_at_ms),
|
||||
"expected mirrored write to refresh updated_at_ms",
|
||||
);
|
||||
});
|
||||
|
||||
test("appendBossUserMessageToCodexThreadRollout falls back to sessions dir when the state db is unavailable", async () => {
|
||||
const root = await ensureRuntimeRoot();
|
||||
const sessionsDir = path.join(root, "sessions");
|
||||
const nestedSessionDir = path.join(sessionsDir, "2026", "04", "21");
|
||||
await mkdir(nestedSessionDir, { recursive: true });
|
||||
const threadId = "019d-session-only";
|
||||
const rolloutPath = path.join(
|
||||
nestedSessionDir,
|
||||
`rollout-2026-04-21T20-33-36-${threadId}.jsonl`,
|
||||
);
|
||||
await writeFile(
|
||||
rolloutPath,
|
||||
`${JSON.stringify({
|
||||
timestamp: "2026-04-21T12:33:36.000Z",
|
||||
type: "session_meta",
|
||||
payload: {
|
||||
id: threadId,
|
||||
cwd: "/tmp/boss-codex-desktop-sync-smoke",
|
||||
},
|
||||
})}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = await appendBossUserMessageToCodexThreadRollout({
|
||||
stateDbPath: path.join(root, "missing-state.sqlite"),
|
||||
sessionsDir,
|
||||
targetThreadRef: threadId,
|
||||
sourceMessageId: "msg-session-only",
|
||||
message: "从 APP 发起的一条消息",
|
||||
sentAt: "2026-04-21T12:34:00.000Z",
|
||||
});
|
||||
|
||||
assert.equal(result.status, "written");
|
||||
assert.equal(result.threadTouch.status, "skipped");
|
||||
|
||||
const raw = await readFile(rolloutPath, "utf8");
|
||||
const lines = raw.trim().split("\n").map((line) => JSON.parse(line));
|
||||
assert.ok(
|
||||
lines.some(
|
||||
(entry) =>
|
||||
entry?.type === "event_msg" &&
|
||||
entry?.payload?.type === "user_message" &&
|
||||
entry?.payload?.metadata?.bossSourceMessageId === "msg-session-only",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("appendBossUserMessageToCodexThreadRollout skips thread touch when rollout is found in sessions but the db has no matching thread row", async () => {
|
||||
const root = await ensureRuntimeRoot();
|
||||
const sessionsDir = path.join(root, "sessions-touch-skip");
|
||||
const nestedSessionDir = path.join(sessionsDir, "2026", "04", "21");
|
||||
await mkdir(nestedSessionDir, { recursive: true });
|
||||
const threadId = "019d-session-without-thread-row";
|
||||
const rolloutPath = path.join(
|
||||
nestedSessionDir,
|
||||
`rollout-2026-04-21T20-35-36-${threadId}.jsonl`,
|
||||
);
|
||||
await writeFile(
|
||||
rolloutPath,
|
||||
`${JSON.stringify({
|
||||
timestamp: "2026-04-21T12:35:36.000Z",
|
||||
type: "session_meta",
|
||||
payload: {
|
||||
id: threadId,
|
||||
cwd: "/tmp/boss-codex-desktop-sync-smoke",
|
||||
},
|
||||
})}\n`,
|
||||
"utf8",
|
||||
);
|
||||
const stateDbPath = await createThreadsDbWithoutBinding();
|
||||
|
||||
const result = await appendBossUserMessageToCodexThreadRollout({
|
||||
stateDbPath,
|
||||
sessionsDir,
|
||||
targetThreadRef: threadId,
|
||||
sourceMessageId: "msg-touch-skipped",
|
||||
message: "这条消息应该只写 rollout,不误报 thread touch",
|
||||
sentAt: "2026-04-21T12:36:00.000Z",
|
||||
});
|
||||
|
||||
assert.equal(result.status, "written");
|
||||
assert.deepEqual(result.threadTouch, {
|
||||
status: "skipped",
|
||||
reason: "thread-not-found",
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, mkdir, rm } from "node:fs/promises";
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
|
||||
import {
|
||||
@@ -90,6 +90,10 @@ test("conversation reply resumes the real Codex thread when thread ref is availa
|
||||
{
|
||||
taskType: "conversation_reply",
|
||||
executionPrompt: "请回复用户",
|
||||
sourceMessageId: "msg-1",
|
||||
sourceMessageBody: "请回复用户",
|
||||
sourceMessageSentAt: "2026-04-21T09:00:00.000Z",
|
||||
mirrorBossUserMessageToCodexDesktop: true,
|
||||
targetCodexThreadRef: "019d-thread-real",
|
||||
targetCodexFolderRef: "/Users/kris/code/meiyesaas",
|
||||
},
|
||||
@@ -109,6 +113,13 @@ test("conversation reply resumes the real Codex thread when thread ref is availa
|
||||
"019d-thread-real",
|
||||
"请回复用户",
|
||||
]);
|
||||
assert.deepEqual(execution.desktopMirror, {
|
||||
enabled: true,
|
||||
targetThreadRef: "019d-thread-real",
|
||||
sourceMessageId: "msg-1",
|
||||
sourceMessageBody: "请回复用户",
|
||||
sourceMessageSentAt: "2026-04-21T09:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
test("dispatch execution falls back to targetThreadId when codex thread ref is missing", () => {
|
||||
@@ -167,6 +178,38 @@ test("master agent reply without target thread stays on ephemeral exec", () => {
|
||||
"gpt-5.4",
|
||||
"你是主 Agent",
|
||||
]);
|
||||
assert.deepEqual(execution.desktopMirror, { enabled: false });
|
||||
});
|
||||
|
||||
test("relay conversation reply mirrors the clean Boss user message into the desktop child thread", () => {
|
||||
const execution = buildCodexTaskExecution(
|
||||
{
|
||||
masterAgentWorkdir: "/Users/kris/code/boss",
|
||||
masterAgentSandbox: "workspace-write",
|
||||
masterAgentModel: "gpt-5.4",
|
||||
},
|
||||
{
|
||||
taskType: "conversation_reply",
|
||||
executionPrompt: "你是主 Agent",
|
||||
relayViaMasterAgent: true,
|
||||
sourceMessageId: "msg-relay",
|
||||
sourceMessageBody: "帮我推进当前线程",
|
||||
sourceMessageSentAt: "2026-04-21T09:10:00.000Z",
|
||||
mirrorBossUserMessageToCodexDesktop: true,
|
||||
targetCodexThreadRef: "019d-thread-real",
|
||||
targetCodexFolderRef: "/Users/kris/code/meiyesaas",
|
||||
},
|
||||
"/tmp/master.txt",
|
||||
);
|
||||
|
||||
assert.deepEqual(execution.desktopMirror, {
|
||||
enabled: true,
|
||||
targetThreadRef: "019d-thread-real",
|
||||
sourceMessageId: "msg-relay",
|
||||
sourceMessageBody: "帮我推进当前线程",
|
||||
sourceMessageSentAt: "2026-04-21T09:10:00.000Z",
|
||||
});
|
||||
assert.notEqual(execution.desktopMirror.sourceMessageBody, execution.args.at(-1));
|
||||
});
|
||||
|
||||
test("conversation reply preflight fails closed when target cwd is missing", async () => {
|
||||
@@ -199,6 +242,55 @@ test("conversation reply preflight fails closed when target cwd is missing", asy
|
||||
assert.match(result.error.message, /missing-workdir/);
|
||||
});
|
||||
|
||||
test("conversation reply preflight accepts session-only Codex threads when state db is stale", async () => {
|
||||
const root = await ensureRuntimeRoot();
|
||||
const validCwd = path.join(root, "session-only-project");
|
||||
const sessionsDir = path.join(root, "sessions-only", "2026", "05", "02");
|
||||
const threadId = "019d-session-only-task";
|
||||
await mkdir(validCwd, { recursive: true });
|
||||
await mkdir(sessionsDir, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(sessionsDir, `rollout-2026-05-02T10-10-00-${threadId}.jsonl`),
|
||||
`${JSON.stringify({
|
||||
timestamp: "2026-05-02T02:10:00.000Z",
|
||||
type: "session_meta",
|
||||
payload: {
|
||||
id: threadId,
|
||||
cwd: validCwd,
|
||||
},
|
||||
})}\n`,
|
||||
"utf8",
|
||||
);
|
||||
const stateDbPath = await createCodexStateDb([
|
||||
{
|
||||
id: "019d-thread-other",
|
||||
cwd: validCwd,
|
||||
title: "Other thread",
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await prepareCodexTaskExecution(
|
||||
{
|
||||
masterAgentWorkdir: "/Users/kris/code/boss",
|
||||
masterAgentSandbox: "workspace-write",
|
||||
codexStateDbPath: stateDbPath,
|
||||
codexSessionsDir: path.join(root, "sessions-only"),
|
||||
},
|
||||
{
|
||||
taskType: "conversation_reply",
|
||||
executionPrompt: "请回复用户",
|
||||
targetCodexThreadRef: threadId,
|
||||
targetCodexFolderRef: validCwd,
|
||||
},
|
||||
"/tmp/reply.txt",
|
||||
);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.execution.mode, "resume");
|
||||
assert.equal(result.execution.cwd, validCwd);
|
||||
assert.deepEqual(result.execution.args.slice(-2), [threadId, "请回复用户"]);
|
||||
});
|
||||
|
||||
test("dispatch execution preflight fails closed when target thread ref is missing", async () => {
|
||||
const result = await prepareCodexTaskExecution(
|
||||
{
|
||||
|
||||
197
tests/local-agent-computer-use-runner.test.mjs
Normal file
197
tests/local-agent-computer-use-runner.test.mjs
Normal file
@@ -0,0 +1,197 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
buildComputerUseTaskExecution,
|
||||
canHandleComputerUseTask,
|
||||
executeComputerUseTask,
|
||||
getComputerUseTaskRunnerConfig,
|
||||
parseComputerUseTaskResult,
|
||||
} from "../local-agent/computer-use-task-runner.mjs";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
test("computer use runner handles desktop_control tasks", async () => {
|
||||
assert.equal(
|
||||
canHandleComputerUseTask({
|
||||
taskType: "desktop_control",
|
||||
requestText: "打开系统设置",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("computer use runner derives config from explicit values", () => {
|
||||
const config = getComputerUseTaskRunnerConfig({}, {
|
||||
computerUseEnabled: true,
|
||||
computerUseCommand: "node",
|
||||
computerUseArgs: ["tests/fixtures/computer-use-runtime.mjs"],
|
||||
computerUseWorkdir: repoRoot,
|
||||
computerUseTimeoutMs: 12000,
|
||||
dialogGuardEnabled: true,
|
||||
dialogGuardConsentRequired: true,
|
||||
dialogGuardPlatformAdapters: ["darwin", "win32"],
|
||||
dialogGuardMacActionCommand: "/usr/local/bin/boss-mac-dialog-helper",
|
||||
dialogGuardMacActionArgs: ["click-dialog"],
|
||||
dialogGuardWindowsActionCommand: "powershell.exe",
|
||||
dialogGuardWindowsActionArgs: ["-File", "C:/Boss/dialog-helper.ps1"],
|
||||
});
|
||||
|
||||
assert.equal(config.enabled, true);
|
||||
assert.equal(config.command, "node");
|
||||
assert.deepEqual(config.args, ["tests/fixtures/computer-use-runtime.mjs"]);
|
||||
assert.equal(config.cwd, repoRoot);
|
||||
assert.equal(config.timeoutMs, 12000);
|
||||
assert.equal(config.dialogGuardEnabled, true);
|
||||
assert.equal(config.dialogGuardConsentRequired, true);
|
||||
assert.deepEqual(config.dialogGuardPlatformAdapters, ["darwin", "win32"]);
|
||||
assert.equal(config.dialogGuardMacActionCommand, "/usr/local/bin/boss-mac-dialog-helper");
|
||||
assert.deepEqual(config.dialogGuardMacActionArgs, ["click-dialog"]);
|
||||
assert.equal(config.dialogGuardWindowsActionCommand, "powershell.exe");
|
||||
assert.deepEqual(config.dialogGuardWindowsActionArgs, ["-File", "C:/Boss/dialog-helper.ps1"]);
|
||||
});
|
||||
|
||||
test("computer use runner builds normalized stdin payload", () => {
|
||||
const execution = buildComputerUseTaskExecution(
|
||||
{
|
||||
enabled: true,
|
||||
command: "node",
|
||||
args: ["tests/fixtures/computer-use-runtime.mjs"],
|
||||
cwd: repoRoot,
|
||||
timeoutMs: 3000,
|
||||
},
|
||||
{
|
||||
taskId: "desktop-task-1",
|
||||
taskType: "desktop_control",
|
||||
requestText: "打开系统设置",
|
||||
projectId: "boss-console",
|
||||
threadId: "thread-desktop",
|
||||
requestedByAccount: "17600001111",
|
||||
confirmationScopeKey: "thread:desktop",
|
||||
riskLevel: "high",
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(execution.command, "node");
|
||||
assert.equal(execution.cwd, repoRoot);
|
||||
assert.equal(execution.timeoutMs, 3000);
|
||||
assert.equal(execution.stdinPayload.requestKind, "desktop_control");
|
||||
assert.equal(execution.stdinPayload.requestId, "desktop-task-1");
|
||||
assert.equal(execution.stdinPayload.objective, "打开系统设置");
|
||||
assert.equal(execution.stdinPayload.context.projectId, "boss-console");
|
||||
assert.equal(execution.stdinPayload.context.threadId, "thread-desktop");
|
||||
assert.equal(execution.stdinPayload.context.confirmationScopeKey, "thread:desktop");
|
||||
assert.equal(execution.stdinPayload.context.riskLevel, "high");
|
||||
});
|
||||
|
||||
test("computer use runner passes dialog guard config to runtime env", () => {
|
||||
const execution = buildComputerUseTaskExecution(
|
||||
{
|
||||
enabled: true,
|
||||
command: "node",
|
||||
args: ["tests/fixtures/computer-use-runtime.mjs"],
|
||||
cwd: repoRoot,
|
||||
timeoutMs: 3000,
|
||||
dialogGuardEnabled: true,
|
||||
dialogGuardConsentRequired: true,
|
||||
dialogGuardPlatformAdapters: ["darwin", "win32"],
|
||||
dialogGuardMacActionCommand: "/usr/local/bin/boss-mac-dialog-helper",
|
||||
dialogGuardMacActionArgs: ["click-dialog"],
|
||||
dialogGuardWindowsActionCommand: "powershell.exe",
|
||||
dialogGuardWindowsActionArgs: ["-File", "C:/Boss/dialog-helper.ps1"],
|
||||
},
|
||||
{
|
||||
taskId: "desktop-dialog-env",
|
||||
taskType: "desktop_control",
|
||||
requestText: "打开 QQ",
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(execution.env.BOSS_DIALOG_GUARD_ENABLED, "true");
|
||||
assert.equal(execution.env.BOSS_DIALOG_GUARD_CONSENT_REQUIRED, "true");
|
||||
assert.equal(execution.env.BOSS_DIALOG_GUARD_PLATFORM_ADAPTERS, "darwin,win32");
|
||||
assert.equal(execution.env.BOSS_MAC_DIALOG_GUARD_ACTION_COMMAND, "/usr/local/bin/boss-mac-dialog-helper");
|
||||
assert.equal(execution.env.BOSS_MAC_DIALOG_GUARD_ACTION_ARGS_JSON, JSON.stringify(["click-dialog"]));
|
||||
assert.equal(execution.env.BOSS_WINDOWS_DIALOG_GUARD_ACTION_COMMAND, "powershell.exe");
|
||||
assert.equal(
|
||||
execution.env.BOSS_WINDOWS_DIALOG_GUARD_ACTION_ARGS_JSON,
|
||||
JSON.stringify(["-File", "C:/Boss/dialog-helper.ps1"]),
|
||||
);
|
||||
});
|
||||
|
||||
test("computer use runner parses completed runtime payload", () => {
|
||||
const result = parseComputerUseTaskResult(
|
||||
'{"status":"completed","replyBody":"已打开系统设置","executionSummary":"desktop ok"}',
|
||||
);
|
||||
|
||||
assert.equal(result.status, "completed");
|
||||
assert.equal(result.replyBody, "已打开系统设置");
|
||||
assert.equal(result.executionSummary, "desktop ok");
|
||||
});
|
||||
|
||||
test("computer use runner parses failed runtime payload", () => {
|
||||
const result = parseComputerUseTaskResult('{"status":"failed","error":"COMPUTER_USE_DENIED"}');
|
||||
|
||||
assert.equal(result.status, "failed");
|
||||
assert.equal(result.errorMessage, "COMPUTER_USE_DENIED");
|
||||
});
|
||||
|
||||
test("computer use runner parses dialog intervention runtime payload", () => {
|
||||
const result = parseComputerUseTaskResult(
|
||||
JSON.stringify({
|
||||
status: "needs_user_action",
|
||||
requestId: "desktop-task-dialog",
|
||||
kind: "dialog_intervention_required",
|
||||
dialogId: "dialog-1",
|
||||
risk: "medium",
|
||||
summary: "QQ 弹窗需要确认",
|
||||
recommendedAction: "review",
|
||||
availableActions: ["allow_once", "deny"],
|
||||
platform: "darwin",
|
||||
appName: "QQ",
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(result.status, "needs_user_action");
|
||||
assert.equal(result.requestId, "desktop-task-dialog");
|
||||
assert.equal(result.kind, "dialog_intervention_required");
|
||||
assert.equal(result.dialogId, "dialog-1");
|
||||
assert.equal(result.risk, "medium");
|
||||
assert.equal(result.summary, "QQ 弹窗需要确认");
|
||||
assert.deepEqual(result.availableActions, ["allow_once", "deny"]);
|
||||
});
|
||||
|
||||
test("computer use runner executes configured runtime command", async () => {
|
||||
const result = await executeComputerUseTask(
|
||||
{
|
||||
taskId: "desktop-task-exec",
|
||||
taskType: "desktop_control",
|
||||
requestText: "打开飞书",
|
||||
projectId: "boss-console",
|
||||
threadId: "thread-desktop",
|
||||
requestedByAccount: "17600002222",
|
||||
},
|
||||
{
|
||||
computerUseEnabled: true,
|
||||
computerUseCommand: process.execPath,
|
||||
computerUseArgs: ["tests/fixtures/computer-use-runtime.mjs"],
|
||||
computerUseWorkdir: repoRoot,
|
||||
computerUseTimeoutMs: 4000,
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.status, "completed");
|
||||
assert.match(result.replyBody ?? "", /桌面运行时已执行/);
|
||||
assert.match(result.replyBody ?? "", /打开飞书/);
|
||||
});
|
||||
|
||||
test("computer use runner reports disabled runtime instead of pretending desktop work completed", async () => {
|
||||
const result = await executeComputerUseTask({
|
||||
taskId: "task-desktop-control",
|
||||
requestText: "打开系统设置",
|
||||
}, {});
|
||||
|
||||
assert.equal(result.status, "failed");
|
||||
assert.equal(result.errorMessage, "COMPUTER_USE_RUNTIME_DISABLED");
|
||||
});
|
||||
116
tests/local-agent-desktop-dialog-guard.test.mjs
Normal file
116
tests/local-agent-desktop-dialog-guard.test.mjs
Normal file
@@ -0,0 +1,116 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
buildDialogInterventionResult,
|
||||
createDialogSignature,
|
||||
evaluateDialogSnapshot,
|
||||
normalizeDialogSnapshot,
|
||||
readDialogSnapshotFromEnv,
|
||||
} from "../local-agent/desktop-dialog-guard.mjs";
|
||||
|
||||
test("dialog guard auto-handles safe welcome prompts on macOS and Windows", () => {
|
||||
for (const platform of ["darwin", "win32"]) {
|
||||
const decision = evaluateDialogSnapshot({
|
||||
platform,
|
||||
appName: platform === "darwin" ? "Google Chrome" : "Microsoft Edge",
|
||||
title: "Welcome",
|
||||
text: "Welcome. Not now",
|
||||
buttons: ["Get started", "Not now"],
|
||||
});
|
||||
|
||||
assert.equal(decision.disposition, "auto_action");
|
||||
assert.equal(decision.action, "click_button");
|
||||
assert.equal(decision.button, "Not now");
|
||||
assert.equal(decision.risk, "low");
|
||||
}
|
||||
});
|
||||
|
||||
test("dialog guard pauses for sensitive permission prompts", () => {
|
||||
const decision = evaluateDialogSnapshot({
|
||||
platform: "darwin",
|
||||
appName: "System Settings",
|
||||
title: "Screen Recording",
|
||||
text: "BossComputerUseHelper would like to record this computer's screen",
|
||||
buttons: ["Allow", "Don't Allow"],
|
||||
});
|
||||
|
||||
assert.equal(decision.disposition, "needs_user_action");
|
||||
assert.equal(decision.risk, "high");
|
||||
assert.equal(decision.kind, "permission_required");
|
||||
});
|
||||
|
||||
test("dialog guard generates stable signatures from normalized content", () => {
|
||||
const a = createDialogSignature({
|
||||
platform: "darwin",
|
||||
deviceId: "macbook-air",
|
||||
appBundleId: "com.google.Chrome",
|
||||
title: " Welcome ",
|
||||
text: "Not now",
|
||||
buttons: ["Not now", "OK"],
|
||||
});
|
||||
const b = createDialogSignature({
|
||||
platform: "darwin",
|
||||
deviceId: "macbook-air",
|
||||
appBundleId: "com.google.Chrome",
|
||||
title: "Welcome",
|
||||
text: " Not now ",
|
||||
buttons: ["Not now", "OK"],
|
||||
});
|
||||
|
||||
assert.equal(a.id, b.id);
|
||||
assert.equal(a.scopeKey, "darwin:macbook-air:com.google.Chrome");
|
||||
});
|
||||
|
||||
test("dialog guard emits app-safe intervention payload", () => {
|
||||
const snapshot = normalizeDialogSnapshot({
|
||||
platform: "win32",
|
||||
deviceId: "win-node",
|
||||
appName: "Installer",
|
||||
title: "User Account Control",
|
||||
text: "Do you want to allow this app to make changes to your device?",
|
||||
buttons: ["Yes", "No"],
|
||||
});
|
||||
const decision = evaluateDialogSnapshot(snapshot);
|
||||
const result = buildDialogInterventionResult({
|
||||
requestId: "desktop-task-1",
|
||||
snapshot,
|
||||
decision,
|
||||
});
|
||||
|
||||
assert.equal(result.status, "needs_user_action");
|
||||
assert.equal(result.kind, "dialog_intervention_required");
|
||||
assert.equal(result.risk, "high");
|
||||
assert.equal(result.recommendedAction, "handled_on_device");
|
||||
assert.deepEqual(result.availableActions, ["handled_on_device", "cancel_task"]);
|
||||
assert.match(result.summary, /Installer/);
|
||||
});
|
||||
|
||||
test("dialog guard reads platform-specific macOS and Windows snapshots from env", () => {
|
||||
const mac = readDialogSnapshotFromEnv(
|
||||
{
|
||||
BOSS_MAC_DIALOG_GUARD_SNAPSHOT_JSON: JSON.stringify({
|
||||
appName: "System Settings",
|
||||
title: "Accessibility",
|
||||
text: "Accessibility permission",
|
||||
buttons: ["Open Settings"],
|
||||
}),
|
||||
},
|
||||
"darwin",
|
||||
);
|
||||
const windows = readDialogSnapshotFromEnv(
|
||||
{
|
||||
BOSS_WINDOWS_DIALOG_GUARD_SNAPSHOT_JSON: JSON.stringify({
|
||||
appName: "Windows Security",
|
||||
title: "User Account Control",
|
||||
text: "Do you want to allow this app to make changes to your device?",
|
||||
buttons: ["Yes", "No"],
|
||||
}),
|
||||
},
|
||||
"win32",
|
||||
);
|
||||
|
||||
assert.equal(mac.platform, "darwin");
|
||||
assert.equal(mac.appName, "System Settings");
|
||||
assert.equal(windows.platform, "win32");
|
||||
assert.equal(windows.appName, "Windows Security");
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer } from "node:http";
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
async function startMockControlPlane() {
|
||||
let resolveHeartbeat;
|
||||
const heartbeatReceived = new Promise((resolve) => {
|
||||
resolveHeartbeat = resolve;
|
||||
});
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
const chunks = [];
|
||||
for await (const chunk of request) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
if (request.method === "POST" && request.url === "/api/device-heartbeat") {
|
||||
resolveHeartbeat(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
||||
}
|
||||
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({ ok: true }));
|
||||
});
|
||||
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("failed to bind mock control plane");
|
||||
}
|
||||
return { server, port: address.port, heartbeatReceived };
|
||||
}
|
||||
|
||||
test("local-agent heartbeat reports browser automation and computer use capabilities", async () => {
|
||||
const runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-local-agent-computer-capabilities-"));
|
||||
const skillsDir = path.join(runtimeRoot, "skills");
|
||||
await mkdir(skillsDir, { recursive: true });
|
||||
|
||||
const mockControlPlane = await startMockControlPlane();
|
||||
const exampleConfig = JSON.parse(
|
||||
await readFile(path.join(repoRoot, "local-agent", "config.example.json"), "utf8"),
|
||||
);
|
||||
const configPath = path.join(runtimeRoot, "config.json");
|
||||
await writeFile(
|
||||
configPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
...exampleConfig,
|
||||
bindHost: "127.0.0.1",
|
||||
port: 0,
|
||||
controlPlaneUrl: `http://127.0.0.1:${mockControlPlane.port}`,
|
||||
heartbeatIntervalMs: 60_000,
|
||||
masterAgentPollIntervalMs: 60_000,
|
||||
masterAgentEnabled: false,
|
||||
codexSessionDiscoveryEnabled: false,
|
||||
projects: [],
|
||||
projectCandidates: [],
|
||||
skillsDir,
|
||||
browserAutomationConnected: true,
|
||||
computerUseConnected: false,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const child = spawn(process.execPath, ["local-agent/server.mjs", configPath], {
|
||||
cwd: repoRoot,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
try {
|
||||
const payload = await Promise.race([
|
||||
mockControlPlane.heartbeatReceived,
|
||||
new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error("timed out waiting for heartbeat")), 8000);
|
||||
}),
|
||||
]);
|
||||
|
||||
assert.equal(payload.capabilities.browserAutomation.connected, true);
|
||||
assert.equal(payload.capabilities.computerUse.connected, true);
|
||||
} finally {
|
||||
child.kill("SIGTERM");
|
||||
await new Promise((resolve) => child.once("close", resolve)).catch(() => null);
|
||||
await new Promise((resolve) => mockControlPlane.server.close(resolve));
|
||||
await rm(runtimeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("local-agent heartbeat derives browser and computer control capabilities from runtime config", async () => {
|
||||
const runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-local-agent-runtime-capabilities-"));
|
||||
const skillsDir = path.join(runtimeRoot, "skills");
|
||||
await mkdir(skillsDir, { recursive: true });
|
||||
|
||||
const mockControlPlane = await startMockControlPlane();
|
||||
const exampleConfig = JSON.parse(
|
||||
await readFile(path.join(repoRoot, "local-agent", "config.example.json"), "utf8"),
|
||||
);
|
||||
const configPath = path.join(runtimeRoot, "config.json");
|
||||
await writeFile(
|
||||
configPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
...exampleConfig,
|
||||
bindHost: "127.0.0.1",
|
||||
port: 0,
|
||||
controlPlaneUrl: `http://127.0.0.1:${mockControlPlane.port}`,
|
||||
heartbeatIntervalMs: 60_000,
|
||||
masterAgentPollIntervalMs: 60_000,
|
||||
masterAgentEnabled: false,
|
||||
codexSessionDiscoveryEnabled: false,
|
||||
projects: [],
|
||||
projectCandidates: [],
|
||||
skillsDir,
|
||||
browserAutomationConnected: false,
|
||||
computerUseConnected: false,
|
||||
browserControlEnabled: true,
|
||||
browserControlCommand: process.execPath,
|
||||
browserControlArgs: ["tests/fixtures/browser-control-runtime.mjs"],
|
||||
computerUseEnabled: true,
|
||||
computerUseCommand: process.execPath,
|
||||
computerUseArgs: ["tests/fixtures/computer-use-runtime.mjs"],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const child = spawn(process.execPath, ["local-agent/server.mjs", configPath], {
|
||||
cwd: repoRoot,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
try {
|
||||
const payload = await Promise.race([
|
||||
mockControlPlane.heartbeatReceived,
|
||||
new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error("timed out waiting for heartbeat")), 8000);
|
||||
}),
|
||||
]);
|
||||
|
||||
assert.equal(payload.capabilities.browserAutomation.connected, true);
|
||||
assert.equal(payload.capabilities.computerUse.connected, true);
|
||||
} finally {
|
||||
child.kill("SIGTERM");
|
||||
await new Promise((resolve) => child.once("close", resolve)).catch(() => null);
|
||||
await new Promise((resolve) => mockControlPlane.server.close(resolve));
|
||||
await rm(runtimeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -16,6 +16,12 @@ test("shipped local-agent configs use the faster heartbeat default", async () =>
|
||||
|
||||
assert.equal(exampleConfig.heartbeatIntervalMs, 15_000);
|
||||
assert.equal(cloudConfig.heartbeatIntervalMs, 15_000);
|
||||
assert.equal(exampleConfig.masterAgentPollIntervalMs, 1_000);
|
||||
assert.equal(cloudConfig.masterAgentPollIntervalMs, 1_000);
|
||||
assert.equal(exampleConfig.skillLifecyclePollIntervalMs, 5_000);
|
||||
assert.equal(cloudConfig.skillLifecyclePollIntervalMs, 5_000);
|
||||
assert.equal(exampleConfig.skillLifecycleEnabled, true);
|
||||
assert.equal(cloudConfig.skillLifecycleEnabled, true);
|
||||
});
|
||||
|
||||
test("device enrollment snippet advertises the faster heartbeat default", async () => {
|
||||
@@ -26,4 +32,25 @@ test("device enrollment snippet advertises the faster heartbeat default", async
|
||||
test("local-agent runtime falls back to the faster heartbeat default", async () => {
|
||||
const source = await readFile(path.join(repoRoot, "local-agent", "server.mjs"), "utf8");
|
||||
assert.match(source, /heartbeatIntervalMs\s*\?\?\s*15000/);
|
||||
assert.match(source, /masterAgentPollIntervalMs\s*\?\?\s*1000/);
|
||||
assert.match(source, /skillLifecyclePollIntervalMs\s*\?\?\s*5000/);
|
||||
});
|
||||
|
||||
test("android reply wait loop polls with sub-second cadence", async () => {
|
||||
const source = await readFile(
|
||||
path.join(
|
||||
repoRoot,
|
||||
"android",
|
||||
"app",
|
||||
"src",
|
||||
"main",
|
||||
"java",
|
||||
"com",
|
||||
"hyzq",
|
||||
"boss",
|
||||
"ProjectDetailActivity.java",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(source, /REPLY_WAIT_POLL_INTERVAL_MS\s*=\s*800L/);
|
||||
});
|
||||
|
||||
42
tests/local-agent-master-task-output-sanitizer.test.mjs
Normal file
42
tests/local-agent-master-task-output-sanitizer.test.mjs
Normal file
@@ -0,0 +1,42 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
MASTER_CODEX_NODE_OUTPUT_LEAKED,
|
||||
sanitizeSensitiveTaskFailureDetailForLog,
|
||||
sanitizeSensitiveTaskFailureDetailForTransport,
|
||||
shouldBlockSensitiveMasterAgentOutput,
|
||||
} from "../local-agent/master-task-output-sanitizer.mjs";
|
||||
|
||||
const leakedPrompt = [
|
||||
"管理员全局主提示词:",
|
||||
"你是 Boss 控制台的主 Agent。",
|
||||
"默认只说和当前问题直接相关的判断、动作和风险。",
|
||||
"",
|
||||
"用户私有主提示词:",
|
||||
"默认中文回复。",
|
||||
"",
|
||||
"当前对话附加提示词:",
|
||||
"同步项目目标和版本记录后记得告诉我。",
|
||||
"",
|
||||
"当前消息:",
|
||||
"同步完成记得要和我说,以后也是这样。",
|
||||
].join("\n");
|
||||
|
||||
test("local-agent 会阻断包含执行提示词片段的主 Agent 输出", () => {
|
||||
assert.equal(shouldBlockSensitiveMasterAgentOutput(leakedPrompt), true);
|
||||
assert.equal(
|
||||
sanitizeSensitiveTaskFailureDetailForTransport(leakedPrompt),
|
||||
MASTER_CODEX_NODE_OUTPUT_LEAKED,
|
||||
);
|
||||
assert.match(
|
||||
sanitizeSensitiveTaskFailureDetailForLog(leakedPrompt) ?? "",
|
||||
/已拦截内部执行日志|原始内容不再展示/,
|
||||
);
|
||||
});
|
||||
|
||||
test("local-agent 会保留普通失败信息", () => {
|
||||
const error = "THREAD_BINDING_REQUIRED";
|
||||
assert.equal(shouldBlockSensitiveMasterAgentOutput(error), false);
|
||||
assert.equal(sanitizeSensitiveTaskFailureDetailForTransport(error), error);
|
||||
assert.equal(sanitizeSensitiveTaskFailureDetailForLog(error), error);
|
||||
});
|
||||
289
tests/local-agent-skill-lifecycle-runner.test.mjs
Normal file
289
tests/local-agent-skill-lifecycle-runner.test.mjs
Normal file
@@ -0,0 +1,289 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import {
|
||||
executeSkillLifecycleRequest,
|
||||
getSkillLifecycleRunnerConfig,
|
||||
slugifySkillName,
|
||||
} from "../local-agent/skill-lifecycle-runner.mjs";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
async function git(args, cwd) {
|
||||
await execFileAsync("git", args, { cwd });
|
||||
}
|
||||
|
||||
async function createGitSkillRepo(tmp, name = "remote-skill") {
|
||||
const repo = path.join(tmp, `${name}-repo`);
|
||||
await mkdir(repo, { recursive: true });
|
||||
await git(["init"], repo);
|
||||
await git(["config", "user.email", "boss-tests@example.com"], repo);
|
||||
await git(["config", "user.name", "Boss Tests"], repo);
|
||||
await writeFile(path.join(repo, "SKILL.md"), "---\ndescription: old\n---\nold\n", "utf8");
|
||||
await git(["add", "SKILL.md"], repo);
|
||||
await git(["commit", "-m", "old"], repo);
|
||||
const oldCommit = (await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: repo })).stdout.trim();
|
||||
await writeFile(path.join(repo, "SKILL.md"), "---\ndescription: new\n---\nnew\n", "utf8");
|
||||
await git(["add", "SKILL.md"], repo);
|
||||
await git(["commit", "-m", "new"], repo);
|
||||
const newCommit = (await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: repo })).stdout.trim();
|
||||
return { repo, oldCommit, newCommit };
|
||||
}
|
||||
|
||||
function sha256(value) {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
test("skill lifecycle runner derives enabled config from local-agent config", () => {
|
||||
const config = getSkillLifecycleRunnerConfig({}, {
|
||||
skillLifecycleEnabled: true,
|
||||
skillsDir: "/tmp/boss-skills",
|
||||
skillLifecycleTimeoutMs: 1234,
|
||||
skillLifecycleAllowedSources: ["https://example.com/boss-skills/"],
|
||||
});
|
||||
|
||||
assert.equal(config.enabled, true);
|
||||
assert.equal(config.skillsDir, "/tmp/boss-skills");
|
||||
assert.equal(config.timeoutMs, 1234);
|
||||
assert.deepEqual(config.allowedSources, ["https://example.com/boss-skills/"]);
|
||||
});
|
||||
|
||||
test("skill lifecycle runner writes version lock file", async () => {
|
||||
const tmp = await mkdtemp(path.join(os.tmpdir(), "boss-skill-lock-"));
|
||||
try {
|
||||
const result = await executeSkillLifecycleRequest(
|
||||
{
|
||||
requestId: "request-lock",
|
||||
action: "version_lock",
|
||||
status: "running",
|
||||
deviceId: "mac-studio",
|
||||
skillId: "mac-studio:demo-skill",
|
||||
lockedVersion: "1.2.3",
|
||||
},
|
||||
{
|
||||
skillsDir: tmp,
|
||||
skillLifecycleEnabled: true,
|
||||
},
|
||||
{ lastSkills: [] },
|
||||
);
|
||||
|
||||
assert.equal(result.status, "completed");
|
||||
const locks = JSON.parse(await readFile(path.join(tmp, ".boss-skill-locks.json"), "utf8"));
|
||||
assert.equal(locks["mac-studio:demo-skill"].lockedVersion, "1.2.3");
|
||||
} finally {
|
||||
await rm(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("skill lifecycle runner uninstalls only skills inside the configured skills directory", async () => {
|
||||
const tmp = await mkdtemp(path.join(os.tmpdir(), "boss-skill-uninstall-"));
|
||||
const skillDir = path.join(tmp, "demo-skill");
|
||||
await mkdir(skillDir, { recursive: true });
|
||||
await writeFile(path.join(skillDir, "SKILL.md"), "---\ndescription: demo\n---\n", "utf8");
|
||||
|
||||
try {
|
||||
const result = await executeSkillLifecycleRequest(
|
||||
{
|
||||
requestId: "request-uninstall",
|
||||
action: "uninstall",
|
||||
status: "running",
|
||||
deviceId: "mac-studio",
|
||||
skillId: "mac-studio:demo-skill",
|
||||
},
|
||||
{
|
||||
skillsDir: tmp,
|
||||
skillLifecycleEnabled: true,
|
||||
},
|
||||
{
|
||||
lastSkills: [
|
||||
{
|
||||
name: "demo-skill",
|
||||
path: path.join(skillDir, "SKILL.md"),
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.status, "completed");
|
||||
await assert.rejects(() => readFile(path.join(skillDir, "SKILL.md"), "utf8"));
|
||||
} finally {
|
||||
await rm(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("skill lifecycle runner rejects deleting a skill path outside skillsDir", async () => {
|
||||
const tmp = await mkdtemp(path.join(os.tmpdir(), "boss-skill-safe-"));
|
||||
const outside = await mkdtemp(path.join(os.tmpdir(), "boss-skill-outside-"));
|
||||
await writeFile(path.join(outside, "SKILL.md"), "---\ndescription: outside\n---\n", "utf8");
|
||||
|
||||
try {
|
||||
const result = await executeSkillLifecycleRequest(
|
||||
{
|
||||
requestId: "request-uninstall-outside",
|
||||
action: "uninstall",
|
||||
status: "running",
|
||||
deviceId: "mac-studio",
|
||||
skillId: "mac-studio:outside-skill",
|
||||
},
|
||||
{
|
||||
skillsDir: tmp,
|
||||
skillLifecycleEnabled: true,
|
||||
},
|
||||
{
|
||||
lastSkills: [
|
||||
{
|
||||
name: "outside-skill",
|
||||
path: path.join(outside, "SKILL.md"),
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.status, "failed");
|
||||
assert.equal(result.error, "SKILL_PATH_OUTSIDE_SKILLS_DIR");
|
||||
assert.equal(await readFile(path.join(outside, "SKILL.md"), "utf8"), "---\ndescription: outside\n---\n");
|
||||
} finally {
|
||||
await rm(tmp, { recursive: true, force: true });
|
||||
await rm(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("skill lifecycle runner rejects install sourceUrl when no source allowlist or trusted source is configured", async () => {
|
||||
const tmp = await mkdtemp(path.join(os.tmpdir(), "boss-skill-install-source-"));
|
||||
const { repo } = await createGitSkillRepo(tmp, "blocked-skill");
|
||||
const skillsDir = path.join(tmp, "skills");
|
||||
|
||||
try {
|
||||
const result = await executeSkillLifecycleRequest(
|
||||
{
|
||||
requestId: "request-install-blocked-source",
|
||||
action: "install",
|
||||
status: "running",
|
||||
deviceId: "mac-studio",
|
||||
sourceUrl: repo,
|
||||
},
|
||||
{
|
||||
skillsDir,
|
||||
skillLifecycleEnabled: true,
|
||||
},
|
||||
{ lastSkills: [] },
|
||||
);
|
||||
|
||||
assert.equal(result.status, "failed");
|
||||
assert.equal(result.error, "SKILL_SOURCE_NOT_ALLOWED");
|
||||
} finally {
|
||||
await rm(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("skill lifecycle runner rejects source urls that only share an allowlist prefix", async () => {
|
||||
const tmp = await mkdtemp(path.join(os.tmpdir(), "boss-skill-install-prefix-"));
|
||||
const { repo } = await createGitSkillRepo(tmp, "trusted-evil");
|
||||
const skillsDir = path.join(tmp, "skills");
|
||||
|
||||
try {
|
||||
const result = await executeSkillLifecycleRequest(
|
||||
{
|
||||
requestId: "request-install-prefix-bypass",
|
||||
action: "install",
|
||||
status: "running",
|
||||
deviceId: "mac-studio",
|
||||
sourceUrl: repo,
|
||||
},
|
||||
{
|
||||
skillsDir,
|
||||
skillLifecycleEnabled: true,
|
||||
skillLifecycleAllowedSources: [path.join(tmp, "trusted")],
|
||||
},
|
||||
{ lastSkills: [] },
|
||||
);
|
||||
|
||||
assert.equal(result.status, "failed");
|
||||
assert.equal(result.error, "SKILL_SOURCE_NOT_ALLOWED");
|
||||
} finally {
|
||||
await rm(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("skill lifecycle runner removes a newly cloned skill when checksum verification fails", async () => {
|
||||
const tmp = await mkdtemp(path.join(os.tmpdir(), "boss-skill-checksum-install-"));
|
||||
const { repo } = await createGitSkillRepo(tmp, "checksum-skill");
|
||||
const skillsDir = path.join(tmp, "skills");
|
||||
|
||||
try {
|
||||
const result = await executeSkillLifecycleRequest(
|
||||
{
|
||||
requestId: "request-install-bad-checksum",
|
||||
action: "install",
|
||||
status: "running",
|
||||
deviceId: "mac-studio",
|
||||
sourceUrl: repo,
|
||||
expectedChecksum: sha256("not the installed skill"),
|
||||
},
|
||||
{
|
||||
skillsDir,
|
||||
skillLifecycleEnabled: true,
|
||||
skillLifecycleAllowedSources: [repo],
|
||||
},
|
||||
{ lastSkills: [] },
|
||||
);
|
||||
|
||||
assert.equal(result.status, "failed");
|
||||
assert.equal(result.error, "SKILL_CHECKSUM_MISMATCH");
|
||||
await assert.rejects(() => readFile(path.join(skillsDir, "remote", "checksum-skill-repo", "SKILL.md"), "utf8"));
|
||||
} finally {
|
||||
await rm(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("skill lifecycle runner backs up and restores an existing skill when update checksum verification fails", async () => {
|
||||
const tmp = await mkdtemp(path.join(os.tmpdir(), "boss-skill-checksum-update-"));
|
||||
const { repo, oldCommit } = await createGitSkillRepo(tmp, "update-skill");
|
||||
const skillsDir = path.join(tmp, "skills");
|
||||
const skillDir = path.join(skillsDir, "update-skill");
|
||||
await mkdir(skillsDir, { recursive: true });
|
||||
await git(["clone", repo, skillDir], tmp);
|
||||
await git(["reset", "--hard", oldCommit], skillDir);
|
||||
|
||||
try {
|
||||
const result = await executeSkillLifecycleRequest(
|
||||
{
|
||||
requestId: "request-update-bad-checksum",
|
||||
action: "update",
|
||||
status: "running",
|
||||
deviceId: "mac-studio",
|
||||
skillId: "mac-studio:update-skill",
|
||||
expectedChecksum: sha256("wrong expected skill"),
|
||||
},
|
||||
{
|
||||
skillsDir,
|
||||
skillLifecycleEnabled: true,
|
||||
},
|
||||
{
|
||||
lastSkills: [
|
||||
{
|
||||
name: "update-skill",
|
||||
path: path.join(skillDir, "SKILL.md"),
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.status, "failed");
|
||||
assert.equal(result.error, "SKILL_CHECKSUM_MISMATCH");
|
||||
assert.equal(await readFile(path.join(skillDir, "SKILL.md"), "utf8"), "---\ndescription: old\n---\nold\n");
|
||||
const backups = await readdir(path.join(skillsDir, ".boss-skill-backups"));
|
||||
assert.equal(backups.length, 1);
|
||||
} finally {
|
||||
await rm(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("skill lifecycle slug matches server skill id convention", () => {
|
||||
assert.equal(slugifySkillName("Boss Server Debug"), "boss-server-debug");
|
||||
});
|
||||
@@ -171,7 +171,7 @@ test("master-agent 对话控制路由可读写并回显到项目详情", async (
|
||||
|
||||
try {
|
||||
const session = await createAuthSession({
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
role: "highest_admin",
|
||||
displayName: "Boss 超级管理员",
|
||||
loginMethod: "password",
|
||||
@@ -286,7 +286,7 @@ test("master-agent 对话控制按当前账号隔离,不会串到其他用户"
|
||||
await setup();
|
||||
|
||||
const adminSession = await createAuthSession({
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
role: "highest_admin",
|
||||
displayName: "Boss 超级管理员",
|
||||
loginMethod: "password",
|
||||
@@ -346,7 +346,7 @@ test("master-agent 对话控制路由单字段更新不会清掉另一字段", a
|
||||
await setup();
|
||||
|
||||
const session = await createAuthSession({
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
role: "highest_admin",
|
||||
displayName: "Boss 超级管理员",
|
||||
loginMethod: "password",
|
||||
@@ -397,7 +397,7 @@ test("全局接管默认会透传到普通线程会话详情", async () => {
|
||||
await setup();
|
||||
const projectId = await ensureOrdinaryProject("ordinary-takeover-project");
|
||||
const session = await createAuthSession({
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
role: "highest_admin",
|
||||
displayName: "Boss 超级管理员",
|
||||
loginMethod: "password",
|
||||
@@ -444,7 +444,7 @@ test("普通线程会话可以单独关闭主 Agent 协同接管并覆盖全局
|
||||
await setup();
|
||||
const projectId = await ensureOrdinaryProject("ordinary-project-override");
|
||||
const session = await createAuthSession({
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
role: "highest_admin",
|
||||
displayName: "Boss 超级管理员",
|
||||
loginMethod: "password",
|
||||
@@ -477,7 +477,7 @@ test("普通线程会话可以单独关闭主 Agent 协同接管并覆盖全局
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
|
||||
const detail = getProjectDetailView(await readState(), projectId, "17600003315");
|
||||
const detail = getProjectDetailView(await readState(), projectId, "krisolo");
|
||||
assert.equal(detail?.agentControls?.effectiveTakeoverEnabled, false);
|
||||
assert.equal(detail?.agentControls?.takeoverInheritedFromGlobal, false);
|
||||
});
|
||||
@@ -486,7 +486,7 @@ test("master-agent 对话控制 POST 清空后仍稳定回传 controls null", as
|
||||
await setup();
|
||||
|
||||
const session = await createAuthSession({
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
role: "highest_admin",
|
||||
displayName: "Boss 超级管理员",
|
||||
loginMethod: "password",
|
||||
@@ -536,7 +536,7 @@ test("普通线程项目详情会回传接管控制占位", async () => {
|
||||
const ordinaryProjectId = await ensureOrdinaryProject();
|
||||
|
||||
const session = await createAuthSession({
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
role: "highest_admin",
|
||||
displayName: "Boss 超级管理员",
|
||||
loginMethod: "password",
|
||||
@@ -603,7 +603,7 @@ test("master-agent 对话控制 POST 会稳定拒绝非法 modelOverride", async
|
||||
await setup();
|
||||
|
||||
const session = await createAuthSession({
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
role: "highest_admin",
|
||||
displayName: "Boss 超级管理员",
|
||||
loginMethod: "password",
|
||||
@@ -637,7 +637,7 @@ test("master-agent 对话控制 POST 会稳定拒绝 malformed JSON 和空对象
|
||||
await setup();
|
||||
|
||||
const session = await createAuthSession({
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
role: "highest_admin",
|
||||
displayName: "Boss 超级管理员",
|
||||
loginMethod: "password",
|
||||
@@ -843,7 +843,7 @@ test("GET /agent-controls returns 404 for missing project", async () => {
|
||||
await setup();
|
||||
|
||||
const session = await createAuthSession({
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
role: "highest_admin",
|
||||
displayName: "Boss 超级管理员",
|
||||
loginMethod: "password",
|
||||
@@ -888,7 +888,7 @@ test(
|
||||
const route = routeModule.default ?? routeModule;
|
||||
|
||||
const session = await data.createAuthSession({
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
role: "highest_admin",
|
||||
displayName: "Boss 超级管理员",
|
||||
loginMethod: "password",
|
||||
@@ -965,7 +965,7 @@ test("GET /agent-controls 在未显式设置 BOSS_STATE_FILE 时仍可正常读
|
||||
});
|
||||
|
||||
const session = await data.createAuthSession({
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
role: "highest_admin",
|
||||
displayName: "Boss 超级管理员",
|
||||
loginMethod: "password",
|
||||
@@ -1011,7 +1011,7 @@ test("GET /agent-controls supports ordinary projects for takeover settings", asy
|
||||
const ordinaryProjectId = await ensureOrdinaryProject();
|
||||
|
||||
const session = await createAuthSession({
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
role: "highest_admin",
|
||||
displayName: "Boss 超级管理员",
|
||||
loginMethod: "password",
|
||||
@@ -1035,7 +1035,7 @@ test("POST /agent-controls rejects unknown-key payload and preserves controls",
|
||||
await setup();
|
||||
|
||||
const session = await createAuthSession({
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
role: "highest_admin",
|
||||
displayName: "Boss 超级管理员",
|
||||
loginMethod: "password",
|
||||
@@ -1080,7 +1080,7 @@ test("master-agent 对话控制 POST 会稳定拒绝非法 backendOverride", asy
|
||||
await setup();
|
||||
|
||||
const session = await createAuthSession({
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
role: "highest_admin",
|
||||
displayName: "Boss 超级管理员",
|
||||
loginMethod: "password",
|
||||
|
||||
@@ -104,9 +104,9 @@ test("主 Agent 执行配置会合成管理员提示词、用户提示词和当
|
||||
|
||||
await updateMasterAgentPromptPolicy({
|
||||
globalPrompt: "全局主提示词",
|
||||
updatedBy: "17600003315",
|
||||
updatedBy: "krisolo",
|
||||
});
|
||||
await updateUserMasterPrompt("17600003315", "用户私有主提示词");
|
||||
await updateUserMasterPrompt("krisolo", "用户私有主提示词");
|
||||
await updateProjectAgentControls("master-agent", {
|
||||
modelOverride: "gpt-5.4",
|
||||
reasoningEffortOverride: "high",
|
||||
@@ -118,7 +118,7 @@ test("主 Agent 执行配置会合成管理员提示词、用户提示词和当
|
||||
assert.equal(resolved.promptPolicy?.globalPrompt, "全局主提示词");
|
||||
assert.equal(resolved.userPrompt?.content, "用户私有主提示词");
|
||||
assert.equal(resolved.projectPromptOverride, "当前对话提示词");
|
||||
assert.equal(resolved.promptPolicy?.updatedBy, "17600003315");
|
||||
assert.equal(resolved.promptPolicy?.updatedBy, "krisolo");
|
||||
});
|
||||
|
||||
test("主 Agent 执行 prompt 会明确声明管理员全局提示词不可覆盖,并带出项目记忆来源", async () => {
|
||||
@@ -138,14 +138,14 @@ test("主 Agent 执行 prompt 会明确声明管理员全局提示词不可覆
|
||||
|
||||
await updateMasterAgentPromptPolicy({
|
||||
globalPrompt: "系统级主提示词",
|
||||
updatedBy: "17600003315",
|
||||
updatedBy: "krisolo",
|
||||
});
|
||||
await updateUserMasterPrompt("17600003315", "用户私有主提示词");
|
||||
await updateUserMasterPrompt("krisolo", "用户私有主提示词");
|
||||
await updateProjectAgentControls("master-agent", {
|
||||
promptOverride: "当前对话提示词",
|
||||
});
|
||||
await createUserMasterMemory({
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
scope: "project",
|
||||
projectId: "boss-main",
|
||||
title: "boss 项目进度",
|
||||
@@ -154,7 +154,7 @@ test("主 Agent 执行 prompt 会明确声明管理员全局提示词不可覆
|
||||
tags: ["boss", "会话"],
|
||||
});
|
||||
await createUserMasterMemory({
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
scope: "project",
|
||||
projectId: "project-wenshenapp",
|
||||
title: "wenshenapp 项目进度",
|
||||
@@ -165,7 +165,7 @@ test("主 Agent 执行 prompt 会明确声明管理员全局提示词不可覆
|
||||
|
||||
const resolved = await resolveMasterAgentExecutionConfig(
|
||||
"master-agent",
|
||||
"17600003315",
|
||||
"krisolo",
|
||||
"继续推进 boss 项目的会话归档逻辑",
|
||||
);
|
||||
|
||||
|
||||
29
tests/master-agent-control-intent-routing.test.ts
Normal file
29
tests/master-agent-control-intent-routing.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { classifyMasterAgentControlIntentForTesting } from "@/lib/boss-master-agent";
|
||||
|
||||
test("routes ordinary product discussion to discussion_only", () => {
|
||||
const result = classifyMasterAgentControlIntentForTesting("帮我总结一下这个项目当前目标");
|
||||
assert.equal(result.intentCategory, "discussion_only");
|
||||
assert.equal(result.executionMode, "discussion");
|
||||
});
|
||||
|
||||
test("routes development ask to project_development", () => {
|
||||
const result = classifyMasterAgentControlIntentForTesting("继续开发这个项目,修掉登录闪退并跑测试");
|
||||
assert.equal(result.intentCategory, "project_development");
|
||||
assert.equal(result.executionMode, "development");
|
||||
});
|
||||
|
||||
test("routes browser asks to browser_control", () => {
|
||||
const result = classifyMasterAgentControlIntentForTesting("打开 Chrome 去后台看一下订单页");
|
||||
assert.equal(result.intentCategory, "browser_control");
|
||||
assert.equal(result.executionMode, "browser");
|
||||
assert.equal(result.riskLevel, "medium");
|
||||
});
|
||||
|
||||
test("routes desktop gui asks to desktop_control", () => {
|
||||
const result = classifyMasterAgentControlIntentForTesting("打开微信并切到和产品经理的聊天窗口");
|
||||
assert.equal(result.intentCategory, "desktop_control");
|
||||
assert.equal(result.executionMode, "desktop");
|
||||
assert.equal(result.riskLevel, "medium");
|
||||
});
|
||||
@@ -8,6 +8,8 @@ let runtimeRoot = "";
|
||||
let queueMasterAgentTask: (typeof import("../src/lib/boss-data"))["queueMasterAgentTask"];
|
||||
let completeMasterAgentTask: (typeof import("../src/lib/boss-data"))["completeMasterAgentTask"];
|
||||
let listUserMasterMemories: (typeof import("../src/lib/boss-data"))["listUserMasterMemories"];
|
||||
let readState: (typeof import("../src/lib/boss-data"))["readState"];
|
||||
let writeState: (typeof import("../src/lib/boss-data"))["writeState"];
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) return;
|
||||
@@ -20,6 +22,54 @@ async function setup() {
|
||||
queueMasterAgentTask = data.queueMasterAgentTask;
|
||||
completeMasterAgentTask = data.completeMasterAgentTask;
|
||||
listUserMasterMemories = data.listUserMasterMemories;
|
||||
readState = data.readState;
|
||||
writeState = data.writeState;
|
||||
}
|
||||
|
||||
async function ensureBossProject() {
|
||||
const state = await readState();
|
||||
if (state.projects.some((project) => project.id === "boss")) {
|
||||
return;
|
||||
}
|
||||
await writeState({
|
||||
...state,
|
||||
projects: [
|
||||
...state.projects,
|
||||
{
|
||||
id: "boss",
|
||||
name: "boss",
|
||||
pinned: false,
|
||||
systemPinned: false,
|
||||
deviceIds: ["mac-studio"],
|
||||
preview: "等待项目同步。",
|
||||
updatedAt: "2026-04-03T10:00:00+08:00",
|
||||
lastMessageAt: "2026-04-03T10:00:00+08:00",
|
||||
isGroup: false,
|
||||
unreadCount: 0,
|
||||
riskLevel: "low",
|
||||
contextBudgetPct: 80,
|
||||
contextBudgetLabel: "80%",
|
||||
threadMeta: {
|
||||
projectId: "boss",
|
||||
threadId: "boss-thread",
|
||||
threadDisplayName: "Boss开发主线程",
|
||||
folderName: "boss",
|
||||
activityIconCount: 0,
|
||||
updatedAt: "2026-04-03T10:00:00+08:00",
|
||||
codexFolderRef: "/Users/kris/code/boss",
|
||||
codexThreadRef: "boss-thread",
|
||||
},
|
||||
groupMembers: [],
|
||||
messages: [],
|
||||
goals: [],
|
||||
versions: [],
|
||||
createdByAgent: true,
|
||||
collaborationMode: "development",
|
||||
approvalState: "not_required",
|
||||
lightDispatchReminderEnabled: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
@@ -30,14 +80,15 @@ test.after(async () => {
|
||||
|
||||
test("主 Agent 完成对话后会自动沉淀用户偏好和项目记忆", async () => {
|
||||
await setup();
|
||||
await ensureBossProject();
|
||||
|
||||
const task = await queueMasterAgentTask({
|
||||
projectId: "master-agent",
|
||||
requestMessageId: "msg-user-1",
|
||||
requestText: "boss 项目后续都按微信式交互来做,并且默认中文回复。",
|
||||
executionPrompt: "prompt",
|
||||
requestedBy: "17600003315",
|
||||
requestedByAccount: "17600003315",
|
||||
requestedBy: "krisolo",
|
||||
requestedByAccount: "krisolo",
|
||||
deviceId: "master-agent-openai",
|
||||
});
|
||||
|
||||
@@ -48,9 +99,9 @@ test("主 Agent 完成对话后会自动沉淀用户偏好和项目记忆", asyn
|
||||
replyBody: "boss 项目当前进度已更新:会话页会继续按微信式交互推进。",
|
||||
});
|
||||
|
||||
const memories = await listUserMasterMemories("17600003315", { includeArchived: false });
|
||||
const memories = await listUserMasterMemories("krisolo", { includeArchived: false });
|
||||
const globalMemory = memories.find((memory) => memory.scope === "global");
|
||||
const projectMemory = memories.find((memory) => memory.scope === "project" && memory.projectId === "boss-console");
|
||||
const projectMemory = memories.find((memory) => memory.scope === "project" && memory.projectId === "boss");
|
||||
|
||||
assert.ok(globalMemory, "expected a global user memory");
|
||||
assert.ok(projectMemory, "expected a project-scoped memory");
|
||||
@@ -66,8 +117,8 @@ test("主 Agent 不会把低价值短句和瞬时安排自动写入记忆", asyn
|
||||
requestMessageId: "msg-user-2",
|
||||
requestText: "好的,先这样,稍后我再看。",
|
||||
executionPrompt: "prompt",
|
||||
requestedBy: "17600003315",
|
||||
requestedByAccount: "17600003315",
|
||||
requestedBy: "krisolo",
|
||||
requestedByAccount: "krisolo",
|
||||
deviceId: "master-agent-openai",
|
||||
});
|
||||
|
||||
@@ -78,7 +129,7 @@ test("主 Agent 不会把低价值短句和瞬时安排自动写入记忆", asyn
|
||||
replyBody: "好的,稍后继续。",
|
||||
});
|
||||
|
||||
const memories = await listUserMasterMemories("17600003315", { includeArchived: false });
|
||||
const memories = await listUserMasterMemories("krisolo", { includeArchived: false });
|
||||
const noisyMemory = memories.find(
|
||||
(memory) => (memory.content ?? "").includes("先这样") || (memory.content ?? "").includes("稍后继续"),
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ let runtimeRoot = "";
|
||||
let POST: (typeof import("../src/app/api/v1/projects/[projectId]/messages/route"))["POST"];
|
||||
let saveAiAccount: (typeof import("../src/lib/boss-data"))["saveAiAccount"];
|
||||
let updateProjectAgentControls: (typeof import("../src/lib/boss-data"))["updateProjectAgentControls"];
|
||||
let updateAiAccountHealth: (typeof import("../src/lib/boss-data"))["updateAiAccountHealth"];
|
||||
let readState: (typeof import("../src/lib/boss-data"))["readState"];
|
||||
let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"];
|
||||
let appendProjectMessages: (typeof import("../src/lib/boss-data"))["appendProjectMessages"];
|
||||
@@ -32,6 +33,7 @@ async function setup() {
|
||||
POST = messageRoute.POST;
|
||||
saveAiAccount = data.saveAiAccount;
|
||||
updateProjectAgentControls = data.updateProjectAgentControls;
|
||||
updateAiAccountHealth = data.updateAiAccountHealth;
|
||||
readState = data.readState;
|
||||
createAuthSession = data.createAuthSession;
|
||||
appendProjectMessages = data.appendProjectMessages;
|
||||
@@ -40,7 +42,7 @@ async function setup() {
|
||||
|
||||
async function createAuthedRequest(projectId: string, body: unknown) {
|
||||
const session = await createAuthSession({
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
role: "highest_admin",
|
||||
displayName: "Boss 超级管理员",
|
||||
loginMethod: "password",
|
||||
@@ -187,6 +189,7 @@ test("POST /api/v1/projects/master-agent/messages 快速返回队列态并在异
|
||||
const mirroredReply = masterProject?.messages.at(-1);
|
||||
assert.ok(mirroredReply, "expected the async reply to be written back to the master-agent ledger");
|
||||
assert.match(mirroredReply?.body ?? "", /已切到异步队列回复/);
|
||||
assert.equal(masterProject?.unreadCount, 1);
|
||||
|
||||
assert.equal(fetchCalls.length, 1);
|
||||
assert.equal(fetchCalls[0]?.url, "https://api.openai.com/v1/responses");
|
||||
@@ -201,6 +204,54 @@ test("POST /api/v1/projects/master-agent/messages 快速返回队列态并在异
|
||||
}
|
||||
});
|
||||
|
||||
test("POST /api/v1/projects/master-agent/messages returns browser control task metadata for browser asks", async () => {
|
||||
await saveAiAccount({
|
||||
accountId: "openai-master-agent-browser-control",
|
||||
label: "API 容灾",
|
||||
role: "api_fallback",
|
||||
provider: "openai_api",
|
||||
displayName: "OpenAI API Browser Control",
|
||||
model: "gpt-5.4-mini",
|
||||
apiKey: "sk-test-openai-browser-control",
|
||||
enabled: true,
|
||||
setActive: true,
|
||||
loginStatusNote: "用于 browser control 测试。",
|
||||
});
|
||||
|
||||
const response = await POST(
|
||||
await createAuthedRequest("master-agent", {
|
||||
body: "打开 Chrome 去后台看一下订单页",
|
||||
}),
|
||||
{ params: Promise.resolve({ projectId: "master-agent" }) },
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
ok: boolean;
|
||||
task?: { taskId: string; taskType: string; status: string } | null;
|
||||
executionMode?: string;
|
||||
riskLevel?: string;
|
||||
requiresConfirmation?: boolean;
|
||||
};
|
||||
|
||||
assert.equal(payload.ok, true);
|
||||
assert.equal(payload.task?.taskType, "browser_control");
|
||||
assert.equal(payload.task?.status, "queued");
|
||||
assert.equal(payload.executionMode, "browser");
|
||||
assert.equal(payload.riskLevel, "medium");
|
||||
assert.equal(payload.requiresConfirmation, false);
|
||||
|
||||
const state = await readState();
|
||||
const task = state.masterAgentTasks.find((item) => item.taskId === payload.task?.taskId);
|
||||
assert.equal(task?.taskType, "browser_control");
|
||||
assert.equal(task?.intentCategory, "browser_control");
|
||||
assert.equal(task?.runtimeKind, "browser-automation-runtime");
|
||||
assert.equal(task?.riskLevel, "medium");
|
||||
assert.equal(task?.confirmationPolicy, "light_confirm");
|
||||
assert.equal(task?.requiresUserConfirmation, undefined);
|
||||
});
|
||||
|
||||
test("POST /api/v1/projects/master-agent/messages 在快速反应模式下会对简单问题走同步快路径", async () => {
|
||||
await saveAiAccount({
|
||||
accountId: "openai-master-agent-fast-sync",
|
||||
@@ -359,6 +410,125 @@ test("POST /api/v1/projects/master-agent/messages 对模型状态类问题会本
|
||||
}
|
||||
});
|
||||
|
||||
test("POST /api/v1/projects/master-agent/messages 本地快反问候会保持职业经理人口吻且不调用模型", async () => {
|
||||
await saveAiAccount({
|
||||
accountId: "master-codex-primary-local-greeting",
|
||||
label: "主 GPT",
|
||||
role: "primary",
|
||||
provider: "master_codex_node",
|
||||
displayName: "在线 Master Codex Node",
|
||||
nodeId: "mac-studio",
|
||||
nodeLabel: "Mac Studio",
|
||||
model: "gpt-5.4",
|
||||
enabled: true,
|
||||
setActive: true,
|
||||
loginStatusNote: "在线主节点。",
|
||||
});
|
||||
|
||||
await updateProjectAgentControls("master-agent", {
|
||||
modelOverride: "gpt-5.4-mini",
|
||||
reasoningEffortOverride: "low",
|
||||
fastModelOverride: "gpt-5.4-mini",
|
||||
deepModelOverride: "gpt-5.4",
|
||||
});
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
let fetchCalled = false;
|
||||
globalThis.fetch = (async () => {
|
||||
fetchCalled = true;
|
||||
throw new Error("model call should not happen for local greeting replies");
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const response = await POST(
|
||||
await createAuthedRequest("master-agent", {
|
||||
body: "你好",
|
||||
}),
|
||||
{ params: Promise.resolve({ projectId: "master-agent" }) },
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const payload = (await response.json()) as {
|
||||
ok: boolean;
|
||||
task?: { taskId: string } | null;
|
||||
masterReplyState?: "queued" | "running" | "completed" | null;
|
||||
masterReply?: { requestId?: string; effectiveModel?: string } | null;
|
||||
replyMessage?: { body?: string } | null;
|
||||
};
|
||||
|
||||
assert.equal(payload.ok, true);
|
||||
assert.equal(payload.task ?? null, null);
|
||||
assert.equal(payload.masterReplyState, "completed");
|
||||
assert.equal(payload.masterReply?.requestId, "local-fast-path");
|
||||
assert.equal(payload.masterReply?.effectiveModel, "gpt-5.4-mini");
|
||||
assert.match(payload.replyMessage?.body ?? "", /我在/);
|
||||
assert.match(payload.replyMessage?.body ?? "", /先给你结论/);
|
||||
assert.match(payload.replyMessage?.body ?? "", /需要我协调线程|需要我直接推进/);
|
||||
assert.doesNotMatch(payload.replyMessage?.body ?? "", /简单问题我会快速回复/);
|
||||
assert.equal(fetchCalled, false);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("POST /api/v1/projects/master-agent/messages 英文问候也走本地职业经理人快反", async () => {
|
||||
await saveAiAccount({
|
||||
accountId: "master-codex-primary-local-english-greeting",
|
||||
label: "主 GPT",
|
||||
role: "primary",
|
||||
provider: "master_codex_node",
|
||||
displayName: "在线 Master Codex Node",
|
||||
nodeId: "mac-studio",
|
||||
nodeLabel: "Mac Studio",
|
||||
model: "gpt-5.4",
|
||||
enabled: true,
|
||||
setActive: true,
|
||||
loginStatusNote: "在线主节点。",
|
||||
});
|
||||
|
||||
await updateProjectAgentControls("master-agent", {
|
||||
modelOverride: "gpt-5.4-mini",
|
||||
reasoningEffortOverride: "low",
|
||||
fastModelOverride: "gpt-5.4-mini",
|
||||
deepModelOverride: "gpt-5.4",
|
||||
});
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
let fetchCalled = false;
|
||||
globalThis.fetch = (async () => {
|
||||
fetchCalled = true;
|
||||
throw new Error("model call should not happen for local English greeting replies");
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const response = await POST(
|
||||
await createAuthedRequest("master-agent", {
|
||||
body: "hello",
|
||||
}),
|
||||
{ params: Promise.resolve({ projectId: "master-agent" }) },
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const payload = (await response.json()) as {
|
||||
ok: boolean;
|
||||
task?: { taskId: string } | null;
|
||||
masterReplyState?: "queued" | "running" | "completed" | null;
|
||||
masterReply?: { requestId?: string; effectiveModel?: string } | null;
|
||||
replyMessage?: { body?: string } | null;
|
||||
};
|
||||
|
||||
assert.equal(payload.ok, true);
|
||||
assert.equal(payload.task ?? null, null);
|
||||
assert.equal(payload.masterReplyState, "completed");
|
||||
assert.equal(payload.masterReply?.requestId, "local-fast-path");
|
||||
assert.match(payload.replyMessage?.body ?? "", /我在/);
|
||||
assert.match(payload.replyMessage?.body ?? "", /先给你结论/);
|
||||
assert.equal(fetchCalled, false);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("POST /api/v1/projects/master-agent/messages 对可用模型查询会本地秒回并返回模式配置", async () => {
|
||||
await saveAiAccount({
|
||||
accountId: "hyzq-fast-local-list",
|
||||
@@ -475,7 +645,7 @@ test("POST /api/v1/projects/master-agent/messages 对深度思考切换请求会
|
||||
|
||||
const controls = await readState().then((state) =>
|
||||
state.userProjectAgentControls.find(
|
||||
(entry) => entry.projectId === "master-agent" && entry.account === "17600003315",
|
||||
(entry) => entry.projectId === "master-agent" && entry.account === "krisolo",
|
||||
)?.controls,
|
||||
);
|
||||
assert.equal(controls?.modelOverride, "gpt-5.4");
|
||||
@@ -898,6 +1068,57 @@ test("master-agent enqueue 在首选主节点离线时会回退到可用的备
|
||||
assert.equal(task?.deviceId, "mac-studio");
|
||||
});
|
||||
|
||||
test("master-agent enqueue 会继续使用设备在线但账号处于 degraded 的 Master Codex Node", async () => {
|
||||
await saveAiAccount({
|
||||
accountId: "master-codex-primary",
|
||||
label: "主 GPT",
|
||||
role: "primary",
|
||||
provider: "master_codex_node",
|
||||
displayName: "在线但历史失败的 Master Codex Node",
|
||||
nodeId: "mac-studio",
|
||||
nodeLabel: "Mac Studio",
|
||||
model: "gpt-5.4",
|
||||
enabled: true,
|
||||
setActive: true,
|
||||
loginStatusNote: "历史失败后等待自动恢复的主节点",
|
||||
});
|
||||
await updateAiAccountHealth({
|
||||
accountId: "master-codex-primary",
|
||||
status: "degraded",
|
||||
lastError: "MASTER_CODEX_NODE_EXEC_FAILED",
|
||||
lastValidatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const response = await POST(
|
||||
await createAuthedRequest("master-agent", {
|
||||
body: "请把当前托管任务转交给主节点执行,并在完成后回写。",
|
||||
}),
|
||||
{ params: Promise.resolve({ projectId: "master-agent" }) },
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const payload = (await response.json()) as {
|
||||
ok: boolean;
|
||||
task?: { taskId: string; taskType: string; status: string } | null;
|
||||
masterReplyState?: "queued" | "running" | "completed";
|
||||
masterReply?: { accountId?: string } | null;
|
||||
};
|
||||
|
||||
assert.equal(payload.ok, true);
|
||||
assert.equal(payload.masterReplyState, "queued");
|
||||
assert.equal(payload.masterReply?.accountId, "master-codex-primary");
|
||||
assert.equal(payload.task?.taskType, "conversation_reply");
|
||||
|
||||
const state = await readState();
|
||||
const task = state.masterAgentTasks.find((item) => item.taskId === payload.task?.taskId);
|
||||
assert.equal(task?.accountId, "master-codex-primary");
|
||||
assert.equal(task?.deviceId, "mac-studio");
|
||||
const account = state.aiAccounts.find((item) => item.accountId === "master-codex-primary");
|
||||
assert.equal(account?.status, "ready");
|
||||
const masterProject = state.projects.find((project) => project.id === "master-agent");
|
||||
assert.doesNotMatch(masterProject?.messages.at(-1)?.body ?? "", /当前没有可用的 master 节点账号/);
|
||||
});
|
||||
|
||||
test("master-agent enqueue 会在首个 API 候选失败后切到下一条备用链并重写任务账号", async () => {
|
||||
await saveAiAccount({
|
||||
accountId: "openai-primary-queue",
|
||||
|
||||
@@ -101,7 +101,7 @@ test("replyToMasterAgentUserMessage falls back to a runnable OpenAI API account
|
||||
requestMessageId: "msg-master-fallback",
|
||||
requestText: "请只回复:主Agent链路正常。",
|
||||
requestedBy: "Boss 超级管理员",
|
||||
requestedByAccount: "17600003315",
|
||||
requestedByAccount: "krisolo",
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
@@ -179,7 +179,7 @@ test("replyToMasterAgentUserMessage can retry the same degraded API account when
|
||||
requestMessageId: "msg-openai-degraded-retry",
|
||||
requestText: "请只回复:仍然可以重试同一个 API 账号。",
|
||||
requestedBy: "Boss 超级管理员",
|
||||
requestedByAccount: "17600003315",
|
||||
requestedByAccount: "krisolo",
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
@@ -242,7 +242,7 @@ test("replyToMasterAgentUserMessage falls back to a runnable aliyun qwen backup
|
||||
requestMessageId: "msg-master-aliyun-fallback",
|
||||
requestText: "请只回复:阿里备用链路正常。",
|
||||
requestedBy: "Boss 超级管理员",
|
||||
requestedByAccount: "17600003315",
|
||||
requestedByAccount: "krisolo",
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
@@ -314,7 +314,7 @@ test("replyToMasterAgentUserMessage retries the next ready API backup when the f
|
||||
requestMessageId: "msg-master-api-chain",
|
||||
requestText: "请只回复:阿里备用接管成功。",
|
||||
requestedBy: "Boss 超级管理员",
|
||||
requestedByAccount: "17600003315",
|
||||
requestedByAccount: "krisolo",
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
@@ -375,7 +375,7 @@ test("replyToMasterAgentUserMessage 在快速反应模式遇到复杂请求时
|
||||
requestMessageId: "msg-master-smart-upgrade",
|
||||
requestText: "请深入分析当前主 Agent 架构,并给出分阶段实现方案、风险和回归测试建议。",
|
||||
requestedBy: "Boss 超级管理员",
|
||||
requestedByAccount: "17600003315",
|
||||
requestedByAccount: "krisolo",
|
||||
mode: "smart",
|
||||
});
|
||||
|
||||
@@ -436,7 +436,7 @@ test("replyToMasterAgentUserMessage falls back to a ready backup master node acc
|
||||
requestMessageId: "msg-master-node-backup-fallback",
|
||||
requestText: "请切到备用主节点。",
|
||||
requestedBy: "Boss 超级管理员",
|
||||
requestedByAccount: "17600003315",
|
||||
requestedByAccount: "krisolo",
|
||||
mode: "enqueue",
|
||||
});
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ async function setup() {
|
||||
promptProfileRoute = loadedPromptProfileRoute;
|
||||
}
|
||||
|
||||
async function createAuthedRequest(account = "17600003315", role: "member" | "admin" | "highest_admin" = "highest_admin") {
|
||||
async function createAuthedRequest(account = "krisolo", role: "member" | "admin" | "highest_admin" = "highest_admin") {
|
||||
await setup();
|
||||
const session = await createAuthSession({
|
||||
account,
|
||||
@@ -146,7 +146,7 @@ test("master-agent 记忆页会返回当前用户所有项目记忆", async () =
|
||||
headers: adminRequest.headers,
|
||||
body: JSON.stringify({
|
||||
scope: "project",
|
||||
projectId: "boss-console",
|
||||
projectId: "boss",
|
||||
title: "Boss 进度",
|
||||
content: "Boss 项目聊天主链已接通。",
|
||||
memoryType: "project_progress",
|
||||
@@ -183,7 +183,7 @@ test("master-agent 记忆页会返回当前用户所有项目记忆", async () =
|
||||
assert.equal(payload.ok, true);
|
||||
assert.deepEqual(
|
||||
payload.memories.project.map((memory) => memory.projectId).sort(),
|
||||
["boss-console", "master-agent", "wenshenapp"].sort(),
|
||||
["boss", "master-agent", "wenshenapp"].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -45,12 +45,12 @@ test("主 Agent 提示词与用户记忆可读写", async () => {
|
||||
|
||||
await updateMasterAgentPromptPolicy({
|
||||
globalPrompt: "全局主提示词",
|
||||
updatedBy: "17600003315",
|
||||
updatedBy: "krisolo",
|
||||
});
|
||||
await updateUserMasterPrompt("17600003315", "用户私有主提示词");
|
||||
await updateUserMasterPrompt("krisolo", "用户私有主提示词");
|
||||
|
||||
const created = await createUserMasterMemory({
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
scope: "project",
|
||||
projectId: "master-agent",
|
||||
title: "项目进度",
|
||||
@@ -59,14 +59,14 @@ test("主 Agent 提示词与用户记忆可读写", async () => {
|
||||
tags: ["聊天", "主链"],
|
||||
});
|
||||
|
||||
await updateUserMasterMemory(created.memoryId, "17600003315", {
|
||||
await updateUserMasterMemory(created.memoryId, "krisolo", {
|
||||
content: "当前主链优先打通主 Agent 聊天闭环。",
|
||||
tags: ["聊天", "主Agent"],
|
||||
});
|
||||
|
||||
const policy = await getMasterAgentPromptPolicy();
|
||||
const userPrompt = await getUserMasterPrompt("17600003315");
|
||||
const memories = await listUserMasterMemories("17600003315", {
|
||||
const userPrompt = await getUserMasterPrompt("krisolo");
|
||||
const memories = await listUserMasterMemories("krisolo", {
|
||||
includeArchived: false,
|
||||
});
|
||||
|
||||
@@ -76,9 +76,9 @@ test("主 Agent 提示词与用户记忆可读写", async () => {
|
||||
assert.equal(memories[0]?.content, "当前主链优先打通主 Agent 聊天闭环。");
|
||||
assert.deepEqual(memories[0]?.tags, ["聊天", "主Agent"]);
|
||||
|
||||
await archiveUserMasterMemory(created.memoryId, "17600003315");
|
||||
const visible = await listUserMasterMemories("17600003315", { includeArchived: false });
|
||||
const all = await listUserMasterMemories("17600003315", { includeArchived: true });
|
||||
await archiveUserMasterMemory(created.memoryId, "krisolo");
|
||||
const visible = await listUserMasterMemories("krisolo", { includeArchived: false });
|
||||
const all = await listUserMasterMemories("krisolo", { includeArchived: true });
|
||||
const archived = all.find((item) => item.memoryId === created.memoryId);
|
||||
|
||||
assert.equal(visible.length, 0);
|
||||
|
||||
@@ -56,7 +56,7 @@ test("主 Agent 执行 prompt 命中线程时只读取相关状态文档和最
|
||||
label: "主 GPT",
|
||||
role: "primary",
|
||||
provider: "master_codex_node",
|
||||
displayName: "17600003315 · Master Codex Node",
|
||||
displayName: "krisolo · Master Codex Node",
|
||||
nodeId: "mac-studio",
|
||||
nodeLabel: "Mac Studio",
|
||||
enabled: true,
|
||||
@@ -66,14 +66,14 @@ test("主 Agent 执行 prompt 命中线程时只读取相关状态文档和最
|
||||
});
|
||||
await updateMasterAgentPromptPolicy({
|
||||
globalPrompt: "管理员全局主提示词",
|
||||
updatedBy: "17600003315",
|
||||
updatedBy: "krisolo",
|
||||
});
|
||||
await updateUserMasterPrompt("17600003315", "用户私有主提示词");
|
||||
await updateUserMasterPrompt("krisolo", "用户私有主提示词");
|
||||
await updateProjectAgentControls("master-agent", {
|
||||
promptOverride: "当前对话提示词",
|
||||
});
|
||||
await createUserMasterMemory({
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
scope: "project",
|
||||
projectId: "master-agent",
|
||||
title: "项目记忆",
|
||||
@@ -190,7 +190,7 @@ test("主 Agent 执行 prompt 命中线程时只读取相关状态文档和最
|
||||
|
||||
const resolved = await resolveMasterAgentExecutionConfig(
|
||||
"master-agent",
|
||||
"17600003315",
|
||||
"krisolo",
|
||||
"审计对话,请继续推进线程状态同步",
|
||||
);
|
||||
assert.ok(resolved.projectMemories.length > 0);
|
||||
@@ -208,7 +208,7 @@ test("主 Agent 执行 prompt 命中线程时只读取相关状态文档和最
|
||||
const reply = await replyToMasterAgentUserMessage({
|
||||
requestText: "审计对话,请继续推进线程状态同步",
|
||||
requestedBy: "Boss 超级管理员",
|
||||
requestedByAccount: "17600003315",
|
||||
requestedByAccount: "krisolo",
|
||||
mode: "enqueue",
|
||||
});
|
||||
assert.equal(reply.ok, true);
|
||||
@@ -332,7 +332,7 @@ test("主 Agent 执行 prompt 在未命中时退回最近活跃项目,且不
|
||||
const reply = await replyToMasterAgentUserMessage({
|
||||
requestText: "请继续推进线程状态同步(仅深拉兜底)",
|
||||
requestedBy: "Boss 超级管理员",
|
||||
requestedByAccount: "17600003315",
|
||||
requestedByAccount: "krisolo",
|
||||
mode: "enqueue",
|
||||
});
|
||||
assert.equal(reply.ok, true);
|
||||
@@ -369,7 +369,7 @@ test("主 Agent 执行 prompt 在没有线程状态文档和进展事件时才
|
||||
const reply = await replyToMasterAgentUserMessage({
|
||||
requestText: "请继续推进线程状态同步",
|
||||
requestedBy: "Boss 超级管理员",
|
||||
requestedByAccount: "17600003315",
|
||||
requestedByAccount: "krisolo",
|
||||
mode: "enqueue",
|
||||
});
|
||||
assert.equal(reply.ok, true);
|
||||
@@ -382,6 +382,149 @@ test("主 Agent 执行 prompt 在没有线程状态文档和进展事件时才
|
||||
assert.ok(queuedTask?.executionPrompt.includes("深拉兜底目标"));
|
||||
});
|
||||
|
||||
test("非主会话里 @主Agent 时,运行时摘要只允许读取当前项目上下文,不允许串到最近活跃项目", async () => {
|
||||
await setup();
|
||||
|
||||
await saveAiAccount({
|
||||
accountId: "master-codex-primary-thread-scope",
|
||||
label: "主 GPT",
|
||||
role: "primary",
|
||||
provider: "master_codex_node",
|
||||
displayName: "krisolo · Master Codex Node",
|
||||
nodeId: "mac-studio",
|
||||
nodeLabel: "Mac Studio",
|
||||
enabled: true,
|
||||
setActive: true,
|
||||
status: "ready",
|
||||
loginStatusNote: "主节点可用。",
|
||||
});
|
||||
|
||||
const state = await readState();
|
||||
const auditProject = state.projects.find((project) => project.id === "audit-collab");
|
||||
assert.ok(auditProject, "expected seeded audit-collab project");
|
||||
state.projects.push({
|
||||
id: "aitoukui-thread",
|
||||
name: "AItoukui",
|
||||
pinned: false,
|
||||
deviceIds: ["mac-studio"],
|
||||
preview: "等待同步",
|
||||
updatedAt: "2026-04-05T11:59:00+08:00",
|
||||
lastMessageAt: "2026-04-05T11:59:00+08:00",
|
||||
isGroup: false,
|
||||
threadMeta: {
|
||||
projectId: "aitoukui-thread",
|
||||
threadId: "thread-aitoukui-main",
|
||||
threadDisplayName: "AItoukui 主线程",
|
||||
folderName: "AItoukui",
|
||||
activityIconCount: 1,
|
||||
updatedAt: "2026-04-05T11:59:00+08:00",
|
||||
lastObservedCodexActivityAt: "2026-04-05T11:59:00+08:00",
|
||||
codexThreadRef: "thread-aitoukui-main",
|
||||
codexFolderRef: "aitoukui",
|
||||
},
|
||||
groupMembers: [],
|
||||
createdByAgent: true,
|
||||
collaborationMode: "development",
|
||||
approvalState: "not_required",
|
||||
unreadCount: 0,
|
||||
riskLevel: "low",
|
||||
messages: [],
|
||||
goals: [],
|
||||
versions: [],
|
||||
});
|
||||
state.threadStatusDocuments = [
|
||||
{
|
||||
documentId: "thread-status-doc-current-project",
|
||||
projectId: "audit-collab",
|
||||
threadId: "thread-audit-chief",
|
||||
threadDisplayName: "审计对话",
|
||||
folderName: "审计群聊",
|
||||
deviceId: "mac-studio",
|
||||
projectGoal: "当前项目目标",
|
||||
currentPhase: "当前项目阶段",
|
||||
currentProgress: "当前项目进度",
|
||||
technicalArchitecture: "当前项目架构",
|
||||
currentBlockers: "当前项目阻塞",
|
||||
recommendedNextStep: "当前项目下一步",
|
||||
keyFiles: ["src/lib/current-thread.ts"],
|
||||
keyCommands: ["npm run lint"],
|
||||
updatedAt: "2026-04-05T10:00:00+08:00",
|
||||
sourceTaskId: "task-current-project",
|
||||
sourceKind: "incremental_sync",
|
||||
},
|
||||
{
|
||||
documentId: "thread-status-doc-other-project",
|
||||
projectId: "aitoukui-thread",
|
||||
threadId: "thread-aitoukui-main",
|
||||
threadDisplayName: "AItoukui 主线程",
|
||||
folderName: "AItoukui",
|
||||
deviceId: "mac-studio",
|
||||
projectGoal: "别的项目目标",
|
||||
currentPhase: "别的项目阶段",
|
||||
currentProgress: "别的项目进度",
|
||||
technicalArchitecture: "别的项目架构",
|
||||
currentBlockers: "别的项目阻塞",
|
||||
recommendedNextStep: "别的项目下一步",
|
||||
keyFiles: ["src/lib/aitoukui-thread.ts"],
|
||||
keyCommands: ["npm run build"],
|
||||
updatedAt: "2026-04-05T12:00:00+08:00",
|
||||
sourceTaskId: "task-other-project",
|
||||
sourceKind: "incremental_sync",
|
||||
},
|
||||
];
|
||||
state.threadProgressEvents = [
|
||||
{
|
||||
eventId: "thread-progress-current-project",
|
||||
projectId: "audit-collab",
|
||||
threadId: "thread-audit-chief",
|
||||
threadDisplayName: "审计对话",
|
||||
deviceId: "mac-studio",
|
||||
eventType: "progress_updated",
|
||||
summary: "当前项目进展摘要",
|
||||
phase: "当前项目阶段",
|
||||
blockerDelta: "当前项目阻塞变化",
|
||||
nextStepDelta: "当前项目下一步变化",
|
||||
createdAt: "2026-04-05T10:02:00+08:00",
|
||||
sourceTaskId: "task-current-progress",
|
||||
},
|
||||
{
|
||||
eventId: "thread-progress-other-project",
|
||||
projectId: "aitoukui-thread",
|
||||
threadId: "thread-aitoukui-main",
|
||||
threadDisplayName: "AItoukui 主线程",
|
||||
deviceId: "mac-studio",
|
||||
eventType: "progress_updated",
|
||||
summary: "别的项目进展摘要",
|
||||
phase: "别的项目阶段",
|
||||
blockerDelta: "别的项目阻塞变化",
|
||||
nextStepDelta: "别的项目下一步变化",
|
||||
createdAt: "2026-04-05T12:01:00+08:00",
|
||||
sourceTaskId: "task-other-progress",
|
||||
},
|
||||
];
|
||||
await writeState(state);
|
||||
|
||||
const reply = await replyToMasterAgentUserMessage({
|
||||
requestText: "请你看一下当前项目下一步怎么推进",
|
||||
requestedBy: "Boss 超级管理员",
|
||||
requestedByAccount: "krisolo",
|
||||
projectId: "audit-collab",
|
||||
mode: "enqueue",
|
||||
});
|
||||
assert.equal(reply.ok, true);
|
||||
|
||||
const queuedTask = (await readState()).masterAgentTasks.find(
|
||||
(task) =>
|
||||
task.projectId === "audit-collab" &&
|
||||
task.requestText === "请你看一下当前项目下一步怎么推进",
|
||||
);
|
||||
assert.ok(queuedTask, "expected a current-thread master-agent task to be queued");
|
||||
assert.ok(queuedTask?.executionPrompt.includes("当前项目目标"));
|
||||
assert.ok(queuedTask?.executionPrompt.includes("当前项目进展摘要"));
|
||||
assert.ok(!queuedTask?.executionPrompt.includes("别的项目目标"));
|
||||
assert.ok(!queuedTask?.executionPrompt.includes("别的项目进展摘要"));
|
||||
});
|
||||
|
||||
test("项目理解同步 prompt 强制线程先基于本地文档和代码汇总,并允许回写版本记录摘要", async () => {
|
||||
await setup();
|
||||
|
||||
@@ -465,3 +608,183 @@ test("项目理解同步 prompt 强制线程先基于本地文档和代码汇总
|
||||
);
|
||||
assert.equal(refreshedProject!.versions[0]?.summary, "项目理解同步已改成先读本地文档和代码,再回写结构化摘要。");
|
||||
});
|
||||
|
||||
test("主 Agent 总结项目目标和版本记录时不注入 OTA 与设备运行态噪音", async () => {
|
||||
await setup();
|
||||
|
||||
await saveAiAccount({
|
||||
accountId: "master-codex-primary",
|
||||
label: "主 GPT",
|
||||
role: "primary",
|
||||
provider: "master_codex_node",
|
||||
displayName: "krisolo · Master Codex Node",
|
||||
nodeId: "mac-studio",
|
||||
nodeLabel: "Mac Studio",
|
||||
enabled: true,
|
||||
setActive: true,
|
||||
status: "ready",
|
||||
loginStatusNote: "主节点可用。",
|
||||
});
|
||||
|
||||
const state = await readState();
|
||||
const auditProject = state.projects.find((project) => project.id === "audit-collab");
|
||||
assert.ok(auditProject, "expected seeded audit-collab project");
|
||||
auditProject!.projectUnderstanding = {
|
||||
projectGoal: "审计线程目标",
|
||||
currentProgress: "正在补齐版本记录摘要",
|
||||
technicalArchitecture: "Next.js 控制面 + Android 原生客户端 + local-agent",
|
||||
currentBlockers: "",
|
||||
recommendedNextStep: "继续同步版本记录入口",
|
||||
sourceTaskId: "task-audit-summary",
|
||||
updatedAt: "2026-04-05T10:00:00+08:00",
|
||||
sourceKind: "thread_sync",
|
||||
};
|
||||
state.threadStatusDocuments = [
|
||||
{
|
||||
documentId: "thread-status-doc-audit-summary",
|
||||
projectId: "audit-collab",
|
||||
threadId: "thread-audit-chief",
|
||||
threadDisplayName: "审计对话",
|
||||
folderName: "审计群聊",
|
||||
deviceId: "mac-studio",
|
||||
projectGoal: "审计线程目标",
|
||||
currentPhase: "整理项目目标与版本记录",
|
||||
currentProgress: "版本记录入口正在统一",
|
||||
technicalArchitecture: "Next.js 控制面 + Android 原生客户端 + local-agent",
|
||||
currentBlockers: "",
|
||||
recommendedNextStep: "继续回填版本记录摘要",
|
||||
keyFiles: ["src/lib/boss-master-agent.ts"],
|
||||
keyCommands: ["npm run test"],
|
||||
updatedAt: "2026-04-05T10:01:00+08:00",
|
||||
sourceTaskId: "task-audit-summary-doc",
|
||||
sourceKind: "incremental_sync",
|
||||
},
|
||||
];
|
||||
state.threadProgressEvents = [
|
||||
{
|
||||
eventId: "thread-progress-event-audit-summary",
|
||||
projectId: "audit-collab",
|
||||
threadId: "thread-audit-chief",
|
||||
threadDisplayName: "审计对话",
|
||||
deviceId: "mac-studio",
|
||||
eventType: "progress_updated",
|
||||
summary: "项目目标和版本记录已开始重新汇总",
|
||||
phase: "整理项目目标与版本记录",
|
||||
blockerDelta: "",
|
||||
nextStepDelta: "同步到会话顶部入口",
|
||||
createdAt: "2026-04-05T10:02:00+08:00",
|
||||
sourceTaskId: "task-audit-summary-event",
|
||||
},
|
||||
];
|
||||
state.appLogs = [
|
||||
{
|
||||
logId: "app-log-summary-noise",
|
||||
deviceId: "mac-studio",
|
||||
category: "sync",
|
||||
message: "这条日志不应该出现在项目目标总结 prompt 里",
|
||||
createdAt: "2026-04-05T10:03:00+08:00",
|
||||
},
|
||||
];
|
||||
state.otaUpdates = [
|
||||
{
|
||||
releaseId: "ota-summary-noise",
|
||||
version: "v2.1.0",
|
||||
currentVersion: "v2.0.0",
|
||||
channel: "stable",
|
||||
packageType: "android_shell",
|
||||
status: "available",
|
||||
summary: ["这条 OTA 不应该污染项目总结"],
|
||||
targetScope: "Boss Android 原生客户端",
|
||||
requiredRole: "highest_admin",
|
||||
publishedAt: "2026-04-05T10:04:00+08:00",
|
||||
},
|
||||
];
|
||||
await writeState(state);
|
||||
|
||||
const requestText = "请总结审计对话当前的项目目标和版本记录";
|
||||
const reply = await replyToMasterAgentUserMessage({
|
||||
requestText,
|
||||
requestedBy: "Boss 超级管理员",
|
||||
requestedByAccount: "krisolo",
|
||||
mode: "enqueue",
|
||||
});
|
||||
assert.equal(reply.ok, true);
|
||||
|
||||
const queuedTask = (await readState()).masterAgentTasks.find(
|
||||
(task) => task.projectId === "master-agent" && task.requestText === requestText,
|
||||
);
|
||||
assert.ok(queuedTask, "expected summary task to be queued");
|
||||
assert.ok(queuedTask?.executionPrompt.includes("线程状态文档:"));
|
||||
assert.ok(queuedTask?.executionPrompt.includes("审计线程目标"));
|
||||
assert.ok(!queuedTask?.executionPrompt.includes("最新 APP 日志:"));
|
||||
assert.ok(!queuedTask?.executionPrompt.includes("高风险线程:"));
|
||||
assert.ok(!queuedTask?.executionPrompt.includes("在线设备:"));
|
||||
assert.ok(!queuedTask?.executionPrompt.includes("认证状态:"));
|
||||
assert.ok(!queuedTask?.executionPrompt.includes("可用 OTA:"));
|
||||
assert.ok(queuedTask?.executionPrompt.includes("回复风格:像专业职业经理人"));
|
||||
assert.ok(queuedTask?.executionPrompt.includes("先给结论,再给推进动作"));
|
||||
assert.ok(queuedTask?.executionPrompt.includes("不要堆背景,不要重复系统状态"));
|
||||
});
|
||||
|
||||
test("主 Agent 处理 OTA 和设备运行态问题时保留相关运行时摘要", async () => {
|
||||
await setup();
|
||||
|
||||
await saveAiAccount({
|
||||
accountId: "master-codex-primary",
|
||||
label: "主 GPT",
|
||||
role: "primary",
|
||||
provider: "master_codex_node",
|
||||
displayName: "krisolo · Master Codex Node",
|
||||
nodeId: "mac-studio",
|
||||
nodeLabel: "Mac Studio",
|
||||
enabled: true,
|
||||
setActive: true,
|
||||
status: "ready",
|
||||
loginStatusNote: "主节点可用。",
|
||||
});
|
||||
|
||||
const state = await readState();
|
||||
state.appLogs = [
|
||||
{
|
||||
logId: "app-log-runtime-detail",
|
||||
deviceId: "mac-studio",
|
||||
category: "ota",
|
||||
message: "检测到 Android OTA 可更新",
|
||||
createdAt: "2026-04-05T11:00:00+08:00",
|
||||
},
|
||||
];
|
||||
state.otaUpdates = [
|
||||
{
|
||||
releaseId: "ota-runtime-detail",
|
||||
version: "v2.2.0",
|
||||
currentVersion: "v2.1.0",
|
||||
channel: "stable",
|
||||
packageType: "android_shell",
|
||||
status: "available",
|
||||
summary: ["加入新的设备运行态提示"],
|
||||
targetScope: "Boss Android 原生客户端",
|
||||
requiredRole: "highest_admin",
|
||||
publishedAt: "2026-04-05T11:01:00+08:00",
|
||||
},
|
||||
];
|
||||
await writeState(state);
|
||||
|
||||
const requestText = "帮我看一下当前可用 OTA、在线设备状态和最近 APP 日志";
|
||||
const reply = await replyToMasterAgentUserMessage({
|
||||
requestText,
|
||||
requestedBy: "Boss 超级管理员",
|
||||
requestedByAccount: "krisolo",
|
||||
mode: "enqueue",
|
||||
});
|
||||
assert.equal(reply.ok, true);
|
||||
|
||||
const queuedTask = (await readState()).masterAgentTasks.find(
|
||||
(task) => task.projectId === "master-agent" && task.requestText === requestText,
|
||||
);
|
||||
assert.ok(queuedTask, "expected runtime-detail task to be queued");
|
||||
assert.ok(queuedTask?.executionPrompt.includes("最新 APP 日志:"));
|
||||
assert.ok(queuedTask?.executionPrompt.includes("在线设备:"));
|
||||
assert.ok(queuedTask?.executionPrompt.includes("认证状态:"));
|
||||
assert.ok(queuedTask?.executionPrompt.includes("可用 OTA:"));
|
||||
assert.ok(queuedTask?.executionPrompt.includes("v2.2.0 -> Boss Android 原生客户端"));
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ let toggleGoal: (typeof import("../src/lib/boss-data"))["toggleGoal"];
|
||||
let updateGoalText: (typeof import("../src/lib/boss-data"))["updateGoalText"];
|
||||
let createGoal: (typeof import("../src/lib/boss-data"))["createGoal"];
|
||||
let updateProjectAgentControls: (typeof import("../src/lib/boss-data"))["updateProjectAgentControls"];
|
||||
let queueMasterAgentTask: (typeof import("../src/lib/boss-data"))["queueMasterAgentTask"];
|
||||
let completeMasterAgentTask: (typeof import("../src/lib/boss-data"))["completeMasterAgentTask"];
|
||||
let forceProjectUnderstandingSyncTask: (typeof import("../src/lib/boss-data"))["forceProjectUnderstandingSyncTask"];
|
||||
let subscribeBossEvents: (typeof import("../src/lib/boss-events"))["subscribeBossEvents"];
|
||||
@@ -32,6 +33,7 @@ async function setup() {
|
||||
updateGoalText = data.updateGoalText;
|
||||
createGoal = data.createGoal;
|
||||
updateProjectAgentControls = data.updateProjectAgentControls;
|
||||
queueMasterAgentTask = data.queueMasterAgentTask;
|
||||
completeMasterAgentTask = data.completeMasterAgentTask;
|
||||
forceProjectUnderstandingSyncTask = data.forceProjectUnderstandingSyncTask;
|
||||
subscribeBossEvents = events.subscribeBossEvents;
|
||||
@@ -52,6 +54,7 @@ async function resetGoalState() {
|
||||
},
|
||||
];
|
||||
project.versions = [];
|
||||
project.projectUnderstanding = undefined;
|
||||
project.messages = [];
|
||||
project.lastMessageAt = "2026-04-07T10:00:00.000Z";
|
||||
state.projects = state.projects.filter((item) => item.id !== "project-goal-events");
|
||||
@@ -170,3 +173,135 @@ test("project understanding sync completion also publishes project goal refresh
|
||||
);
|
||||
assert.ok(goalRefreshEvent, "expected project understanding sync to publish a goal refresh marker");
|
||||
});
|
||||
|
||||
test("takeover conversation summary reply writes project goal and version record back to the current conversation", async () => {
|
||||
const events: Array<{ event: string; payload: { projectId?: string; note?: string } }> = [];
|
||||
const unsubscribe = subscribeBossEvents((event, payload) => {
|
||||
events.push({ event, payload });
|
||||
});
|
||||
|
||||
const task = await queueMasterAgentTask({
|
||||
projectId: "project-goal-events",
|
||||
requestMessageId: "message-summary-request",
|
||||
requestText: "请汇总当前项目目标和版本记录,并同步到当前对话顶部入口",
|
||||
executionPrompt: "请汇总当前项目目标和版本记录",
|
||||
requestedBy: "Boss 超级管理员",
|
||||
requestedByAccount: "krisolo",
|
||||
deviceId: "mac-studio",
|
||||
accountLabel: "主 GPT",
|
||||
relayViaMasterAgent: true,
|
||||
});
|
||||
|
||||
await completeMasterAgentTask({
|
||||
taskId: task.taskId,
|
||||
deviceId: "mac-studio",
|
||||
status: "completed",
|
||||
replyBody: [
|
||||
"项目目标:完成主 Agent 汇总内容自动回写到当前对话。",
|
||||
"当前进度:已确认普通接管回复需要同步项目摘要。",
|
||||
"技术架构:Boss 文件账本保存项目理解,Android 对话页通过实时事件刷新。",
|
||||
"当前阻塞:无。",
|
||||
"建议下一步:继续做真机回归。",
|
||||
"版本记录:新增主 Agent 接管汇总回复自动写入项目目标和版本记录。",
|
||||
].join("\n"),
|
||||
});
|
||||
unsubscribe();
|
||||
|
||||
const refreshedProject = (await readState()).projects.find((project) => project.id === "project-goal-events");
|
||||
assert.ok(refreshedProject, "expected project to exist");
|
||||
assert.equal(
|
||||
refreshedProject!.projectUnderstanding?.projectGoal,
|
||||
"完成主 Agent 汇总内容自动回写到当前对话。",
|
||||
);
|
||||
assert.equal(
|
||||
refreshedProject!.projectUnderstanding?.currentProgress,
|
||||
"已确认普通接管回复需要同步项目摘要。",
|
||||
);
|
||||
assert.equal(
|
||||
refreshedProject!.versions[0]?.summary,
|
||||
"新增主 Agent 接管汇总回复自动写入项目目标和版本记录。",
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
events.some(
|
||||
(item) =>
|
||||
item.event === "conversation.updated" &&
|
||||
item.payload.projectId === "project-goal-events" &&
|
||||
item.payload.note === "project_goals.updated",
|
||||
),
|
||||
"expected project goal refresh marker",
|
||||
);
|
||||
assert.ok(
|
||||
events.some(
|
||||
(item) =>
|
||||
item.event === "conversation.updated" &&
|
||||
item.payload.projectId === "project-goal-events" &&
|
||||
item.payload.note === "project_versions.updated",
|
||||
),
|
||||
"expected project version refresh marker",
|
||||
);
|
||||
});
|
||||
|
||||
test("takeover conversation summary wording writes project goal and version record back to the current conversation", async () => {
|
||||
const events: Array<{ event: string; payload: { projectId?: string; note?: string } }> = [];
|
||||
const unsubscribe = subscribeBossEvents((event, payload) => {
|
||||
events.push({ event, payload });
|
||||
});
|
||||
|
||||
const state = await readState();
|
||||
const project = state.projects.find((item) => item.id === "project-goal-events");
|
||||
assert.ok(project, "expected project to exist");
|
||||
project!.projectUnderstanding = undefined;
|
||||
project!.versions = [];
|
||||
await writeState(state);
|
||||
|
||||
const task = await queueMasterAgentTask({
|
||||
projectId: "project-goal-events",
|
||||
requestMessageId: "message-summary-wording-request",
|
||||
requestText: "请总结当前项目目标和版本记录",
|
||||
executionPrompt: "请总结当前项目目标和版本记录",
|
||||
requestedBy: "Boss 超级管理员",
|
||||
requestedByAccount: "krisolo",
|
||||
deviceId: "mac-studio",
|
||||
accountLabel: "主 GPT",
|
||||
relayViaMasterAgent: true,
|
||||
});
|
||||
|
||||
await completeMasterAgentTask({
|
||||
taskId: task.taskId,
|
||||
deviceId: "mac-studio",
|
||||
status: "completed",
|
||||
replyBody: [
|
||||
"项目目标:稳定总结类请求的项目目标回写。",
|
||||
"当前进度:已进入总结语义回归。",
|
||||
"技术架构:Boss 文件账本保存项目理解。",
|
||||
"当前阻塞:无。",
|
||||
"建议下一步:继续跑真机回归。",
|
||||
"版本记录:总结类请求也能写入版本记录。",
|
||||
].join("\n"),
|
||||
});
|
||||
unsubscribe();
|
||||
|
||||
const refreshedProject = (await readState()).projects.find((item) => item.id === "project-goal-events");
|
||||
assert.equal(refreshedProject!.projectUnderstanding?.projectGoal, "稳定总结类请求的项目目标回写。");
|
||||
assert.equal(refreshedProject!.versions[0]?.summary, "总结类请求也能写入版本记录。");
|
||||
|
||||
assert.ok(
|
||||
events.some(
|
||||
(item) =>
|
||||
item.event === "conversation.updated" &&
|
||||
item.payload.projectId === "project-goal-events" &&
|
||||
item.payload.note === "project_goals.updated",
|
||||
),
|
||||
"expected project goal refresh marker for summary wording",
|
||||
);
|
||||
assert.ok(
|
||||
events.some(
|
||||
(item) =>
|
||||
item.event === "conversation.updated" &&
|
||||
item.payload.projectId === "project-goal-events" &&
|
||||
item.payload.note === "project_versions.updated",
|
||||
),
|
||||
"expected project version refresh marker for summary wording",
|
||||
);
|
||||
});
|
||||
|
||||
154
tests/project-message-delete.test.ts
Normal file
154
tests/project-message-delete.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
let runtimeRoot = "";
|
||||
let readState: (typeof import("../src/lib/boss-data"))["readState"];
|
||||
let writeState: (typeof import("../src/lib/boss-data"))["writeState"];
|
||||
let deleteProjectMessage: (typeof import("../src/lib/boss-data"))["deleteProjectMessage"];
|
||||
let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"];
|
||||
let deleteMessageRoute: (typeof import("../src/app/api/v1/projects/[projectId]/messages/route"))["DELETE"];
|
||||
let subscribeBossEvents: (typeof import("../src/lib/boss-events"))["subscribeBossEvents"];
|
||||
let AUTH_SESSION_COOKIE = "";
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) return;
|
||||
|
||||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-project-message-delete-"));
|
||||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||||
|
||||
const [data, route, auth, events] = await Promise.all([
|
||||
import("../src/lib/boss-data.ts"),
|
||||
import("../src/app/api/v1/projects/[projectId]/messages/route.ts"),
|
||||
import("../src/lib/boss-auth.ts"),
|
||||
import("../src/lib/boss-events.ts"),
|
||||
]);
|
||||
readState = data.readState;
|
||||
writeState = data.writeState;
|
||||
deleteProjectMessage = data.deleteProjectMessage;
|
||||
createAuthSession = data.createAuthSession;
|
||||
deleteMessageRoute = route.DELETE;
|
||||
subscribeBossEvents = events.subscribeBossEvents;
|
||||
AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE;
|
||||
}
|
||||
|
||||
async function seedProject() {
|
||||
const state = await readState();
|
||||
const baseProject = state.projects.find((project) => project.id !== "master-agent") ?? state.projects[0];
|
||||
const project = structuredClone(baseProject);
|
||||
project.id = "message-delete-project";
|
||||
project.name = "消息删除测试";
|
||||
project.preview = "第二条回复";
|
||||
project.lastMessageAt = "2026-04-26T10:05:00.000Z";
|
||||
project.unreadCount = 2;
|
||||
project.messages = [
|
||||
{
|
||||
id: "msg-1",
|
||||
sender: "user",
|
||||
senderLabel: "你",
|
||||
body: "第一条请求",
|
||||
sentAt: "2026-04-26T10:00:00.000Z",
|
||||
kind: "text",
|
||||
},
|
||||
{
|
||||
id: "msg-process",
|
||||
sender: "device",
|
||||
senderLabel: "线程",
|
||||
body: "我先检查代码。",
|
||||
sentAt: "2026-04-26T10:03:00.000Z",
|
||||
kind: "thread_process",
|
||||
},
|
||||
{
|
||||
id: "msg-2",
|
||||
sender: "master",
|
||||
senderLabel: "主 Agent",
|
||||
body: "第二条回复",
|
||||
sentAt: "2026-04-26T10:05:00.000Z",
|
||||
kind: "text",
|
||||
},
|
||||
];
|
||||
state.projects = state.projects.filter((item) => item.id !== project.id);
|
||||
state.projects.unshift(project);
|
||||
await writeState(state);
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await setup();
|
||||
await seedProject();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
if (runtimeRoot) {
|
||||
await rm(runtimeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("deleteProjectMessage removes a message and refreshes project preview events", async () => {
|
||||
const events: Array<{ event: string; payload: { projectId?: string } }> = [];
|
||||
const unsubscribe = subscribeBossEvents((event, payload) => {
|
||||
events.push({ event, payload });
|
||||
});
|
||||
|
||||
const result = await deleteProjectMessage({
|
||||
projectId: "message-delete-project",
|
||||
messageId: "msg-2",
|
||||
});
|
||||
unsubscribe();
|
||||
|
||||
assert.equal(result.deletedMessage.id, "msg-2");
|
||||
assert.equal(result.projectId, "message-delete-project");
|
||||
|
||||
const project = (await readState()).projects.find((item) => item.id === "message-delete-project");
|
||||
assert.ok(project);
|
||||
assert.deepEqual(project!.messages.map((message) => message.id), ["msg-1", "msg-process"]);
|
||||
assert.equal(project!.preview, "第一条请求");
|
||||
assert.equal(project!.lastMessageAt, "2026-04-26T10:03:00.000Z");
|
||||
assert.ok(
|
||||
events.some(
|
||||
(item) =>
|
||||
item.event === "project.messages.updated" &&
|
||||
item.payload.projectId === "message-delete-project",
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
events.some(
|
||||
(item) =>
|
||||
item.event === "conversation.updated" &&
|
||||
item.payload.projectId === "message-delete-project",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("DELETE /api/v1/projects/[projectId]/messages deletes the requested message", async () => {
|
||||
const session = await createAuthSession({
|
||||
account: "krisolo",
|
||||
role: "highest_admin",
|
||||
displayName: "Boss 超级管理员",
|
||||
loginMethod: "password",
|
||||
});
|
||||
const request = new NextRequest(
|
||||
"http://127.0.0.1:3000/api/v1/projects/message-delete-project/messages?messageId=msg-2",
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const response = await deleteMessageRoute(request, {
|
||||
params: Promise.resolve({ projectId: "message-delete-project" }),
|
||||
});
|
||||
const payload = (await response.json()) as {
|
||||
ok: boolean;
|
||||
deletedMessage?: { id: string };
|
||||
};
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(payload.ok, true);
|
||||
assert.equal(payload.deletedMessage?.id, "msg-2");
|
||||
});
|
||||
20
tests/project-message-execution-mode.test.ts
Normal file
20
tests/project-message-execution-mode.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
test("message response payload shape supports execution mode metadata", () => {
|
||||
const payload: {
|
||||
ok: boolean;
|
||||
executionMode?: "discussion" | "thread" | "development" | "browser" | "desktop";
|
||||
riskLevel?: "low" | "medium" | "high";
|
||||
requiresConfirmation?: boolean;
|
||||
} = {
|
||||
ok: true,
|
||||
executionMode: "browser",
|
||||
riskLevel: "medium",
|
||||
requiresConfirmation: false,
|
||||
};
|
||||
|
||||
assert.equal(payload.executionMode, "browser");
|
||||
assert.equal(payload.riskLevel, "medium");
|
||||
assert.equal(payload.requiresConfirmation, false);
|
||||
});
|
||||
@@ -88,7 +88,7 @@ function buildSingleThreadProject(projectId: string) {
|
||||
|
||||
async function createAuthedRequest(projectId: string) {
|
||||
const session = await createAuthSession({
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
role: "highest_admin",
|
||||
displayName: "Boss 超级管理员",
|
||||
loginMethod: "password",
|
||||
@@ -113,7 +113,7 @@ test("GET /api/v1/projects/[projectId]/messages returns a lightweight chat paylo
|
||||
id: "device-message-lite",
|
||||
name: "Mac Studio",
|
||||
avatar: "M",
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
source: "production",
|
||||
status: "online",
|
||||
projects: [project.id],
|
||||
|
||||
@@ -33,7 +33,7 @@ async function setup() {
|
||||
async function createAuthedHeaders() {
|
||||
await setup();
|
||||
const session = await createAuthSession({
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
role: "highest_admin",
|
||||
displayName: "Boss 超级管理员",
|
||||
loginMethod: "password",
|
||||
|
||||
261
tests/rbac-admin-access-route.test.ts
Normal file
261
tests/rbac-admin-access-route.test.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
let runtimeRoot = "";
|
||||
let data: typeof import("../src/lib/boss-data");
|
||||
let authCookie = "";
|
||||
let getAdminAccess: (typeof import("../src/app/api/v1/admin/access/route"))["GET"];
|
||||
let postAdminAccess: (typeof import("../src/app/api/v1/admin/access/route"))["POST"];
|
||||
let baseState: Awaited<ReturnType<typeof import("../src/lib/boss-data")["readState"]>>;
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) return;
|
||||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-rbac-admin-access-"));
|
||||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||||
const [dataModule, authModule, routeModule] = await Promise.all([
|
||||
import("../src/lib/boss-data.ts"),
|
||||
import("../src/lib/boss-auth.ts"),
|
||||
import("../src/app/api/v1/admin/access/route.ts"),
|
||||
]);
|
||||
data = dataModule;
|
||||
authCookie = authModule.AUTH_SESSION_COOKIE;
|
||||
getAdminAccess = routeModule.GET;
|
||||
postAdminAccess = routeModule.POST;
|
||||
baseState = structuredClone(await data.readState());
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
if (runtimeRoot) await rm(runtimeRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await setup();
|
||||
const state = structuredClone(baseState);
|
||||
state.accountDeviceGrants = [];
|
||||
state.accountProjectGrants = [];
|
||||
state.accountSkillGrants = [];
|
||||
state.permissionAuditLogs = [];
|
||||
if (!state.devices.some((device) => device.id === "win-gpu-01")) {
|
||||
state.devices.push({
|
||||
id: "win-gpu-01",
|
||||
name: "Windows GPU",
|
||||
avatar: "W",
|
||||
account: "gpu@example.com",
|
||||
source: "production",
|
||||
status: "online",
|
||||
projects: [],
|
||||
quota5h: 0,
|
||||
quota7d: 0,
|
||||
lastSeenAt: "2026-04-26T12:00:00+08:00",
|
||||
preferredExecutionMode: "cli",
|
||||
});
|
||||
}
|
||||
state.deviceSkills = [
|
||||
{
|
||||
skillId: "mac-studio:boss-server-debug",
|
||||
deviceId: "mac-studio",
|
||||
name: "boss-server-debug",
|
||||
description: "服务器调试",
|
||||
path: "/Users/kris/.codex/skills/boss-server-debug/SKILL.md",
|
||||
invocation: "$boss-server-debug",
|
||||
category: "Mac Studio",
|
||||
updatedAt: "2026-04-26T12:00:00+08:00",
|
||||
},
|
||||
{
|
||||
skillId: "win-gpu-01:boss-server-debug",
|
||||
deviceId: "win-gpu-01",
|
||||
name: "boss-server-debug",
|
||||
description: "Windows 服务器调试",
|
||||
path: "C:/Users/kris/.codex/skills/boss-server-debug/SKILL.md",
|
||||
invocation: "$boss-server-debug",
|
||||
category: "Windows GPU",
|
||||
updatedAt: "2026-04-26T12:01:00+08:00",
|
||||
},
|
||||
];
|
||||
await data.writeState(state);
|
||||
});
|
||||
|
||||
async function authedRequest(
|
||||
account: string,
|
||||
role: "member" | "admin" | "highest_admin",
|
||||
url: string,
|
||||
init: RequestInit = {},
|
||||
) {
|
||||
const session = await data.createAuthSession({
|
||||
account,
|
||||
role,
|
||||
displayName: account,
|
||||
loginMethod: "password",
|
||||
});
|
||||
return new NextRequest(url, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.headers ?? {}),
|
||||
cookie: `${authCookie}=${session.sessionToken}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function adminPost(body: Record<string, unknown>) {
|
||||
return postAdminAccess(
|
||||
await authedRequest("krisolo", "highest_admin", "http://127.0.0.1:3000/api/v1/admin/access", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
test("member cannot read or mutate access management", async () => {
|
||||
const getResponse = await getAdminAccess(
|
||||
await authedRequest("worker@example.com", "member", "http://127.0.0.1:3000/api/v1/admin/access"),
|
||||
);
|
||||
assert.equal(getResponse.status, 403);
|
||||
|
||||
const postResponse = await postAdminAccess(
|
||||
await authedRequest("worker@example.com", "member", "http://127.0.0.1:3000/api/v1/admin/access", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ action: "grant_device" }),
|
||||
}),
|
||||
);
|
||||
assert.equal(postResponse.status, 403);
|
||||
});
|
||||
|
||||
test("highest admin can create a member and grant scoped device project and skill access", async () => {
|
||||
const accountResponse = await adminPost({
|
||||
action: "upsert_account",
|
||||
account: "worker@example.com",
|
||||
displayName: "Worker",
|
||||
role: "member",
|
||||
password: "worker-pass",
|
||||
});
|
||||
assert.equal(accountResponse.status, 200);
|
||||
const accountPayload = await accountResponse.json();
|
||||
assert.equal(accountPayload.account.account, "worker@example.com");
|
||||
assert.equal(accountPayload.account.passwordHash, undefined);
|
||||
|
||||
const deviceResponse = await adminPost({
|
||||
action: "grant_device",
|
||||
account: "worker@example.com",
|
||||
deviceId: "mac-studio",
|
||||
permissions: ["device.view"],
|
||||
note: "Mac 只读",
|
||||
});
|
||||
assert.equal(deviceResponse.status, 200);
|
||||
const devicePayload = await deviceResponse.json();
|
||||
assert.equal(devicePayload.grant.deviceId, "mac-studio");
|
||||
|
||||
const projectResponse = await adminPost({
|
||||
action: "grant_project",
|
||||
account: "worker@example.com",
|
||||
projectId: "master-agent",
|
||||
permissions: ["project.view", "master_agent.ask"],
|
||||
});
|
||||
assert.equal(projectResponse.status, 200);
|
||||
|
||||
const skillResponse = await adminPost({
|
||||
action: "grant_skill",
|
||||
account: "worker@example.com",
|
||||
skillId: "mac-studio:boss-server-debug",
|
||||
deviceId: "mac-studio",
|
||||
permissions: ["skill.view", "skill.use"],
|
||||
});
|
||||
assert.equal(skillResponse.status, 200);
|
||||
|
||||
const state = await data.readState();
|
||||
assert.equal(state.accountDeviceGrants.length, 1);
|
||||
assert.equal(state.accountProjectGrants.length, 1);
|
||||
assert.equal(state.accountSkillGrants.length, 1);
|
||||
assert.equal(state.permissionAuditLogs.length, 4);
|
||||
|
||||
const getResponse = await getAdminAccess(
|
||||
await authedRequest("krisolo", "highest_admin", "http://127.0.0.1:3000/api/v1/admin/access"),
|
||||
);
|
||||
assert.equal(getResponse.status, 200);
|
||||
const getPayload = await getResponse.json();
|
||||
assert.equal(
|
||||
getPayload.accounts.some((account: { passwordHash?: string }) => Boolean(account.passwordHash)),
|
||||
false,
|
||||
);
|
||||
assert.equal(getPayload.grants.devices.length, 1);
|
||||
assert.equal(getPayload.grants.projects.length, 1);
|
||||
assert.equal(getPayload.grants.skills.length, 1);
|
||||
const bossServerDebugCatalog = getPayload.skillCatalog.find(
|
||||
(item: { name: string }) => item.name === "boss-server-debug",
|
||||
);
|
||||
assert.equal(bossServerDebugCatalog.deviceCount, 2);
|
||||
assert.deepEqual(
|
||||
bossServerDebugCatalog.devices.map((device: { deviceId: string }) => device.deviceId).sort(),
|
||||
["mac-studio", "win-gpu-01"],
|
||||
);
|
||||
});
|
||||
|
||||
test("highest admin can revoke a grant", async () => {
|
||||
const grantResponse = await adminPost({
|
||||
action: "grant_device",
|
||||
account: "worker@example.com",
|
||||
deviceId: "mac-studio",
|
||||
permissions: ["device.view"],
|
||||
});
|
||||
const grantPayload = await grantResponse.json();
|
||||
const revokeResponse = await adminPost({
|
||||
action: "revoke_grant",
|
||||
grantId: grantPayload.grant.grantId,
|
||||
});
|
||||
assert.equal(revokeResponse.status, 200);
|
||||
|
||||
const state = await data.readState();
|
||||
assert.equal(state.accountDeviceGrants.length, 0);
|
||||
assert.equal(state.permissionAuditLogs.at(0)?.action, "grant.revoked");
|
||||
});
|
||||
|
||||
test("highest admin can apply a permission template across device project and skill scopes", async () => {
|
||||
await adminPost({
|
||||
action: "upsert_account",
|
||||
account: "developer@example.com",
|
||||
displayName: "Developer",
|
||||
role: "member",
|
||||
password: "developer-pass",
|
||||
});
|
||||
|
||||
const getResponse = await getAdminAccess(
|
||||
await authedRequest("krisolo", "highest_admin", "http://127.0.0.1:3000/api/v1/admin/access"),
|
||||
);
|
||||
assert.equal(getResponse.status, 200);
|
||||
const getPayload = await getResponse.json();
|
||||
assert.deepEqual(
|
||||
getPayload.permissionTemplates.map((template: { templateId: string }) => template.templateId),
|
||||
["viewer", "developer", "operator"],
|
||||
);
|
||||
|
||||
const applyResponse = await adminPost({
|
||||
action: "apply_template",
|
||||
account: "developer@example.com",
|
||||
templateId: "developer",
|
||||
deviceIds: ["mac-studio"],
|
||||
projectIds: ["master-agent"],
|
||||
skillIds: ["mac-studio:boss-server-debug"],
|
||||
});
|
||||
assert.equal(applyResponse.status, 200);
|
||||
const applyPayload = await applyResponse.json();
|
||||
assert.equal(applyPayload.grants.devices.length, 1);
|
||||
assert.equal(applyPayload.grants.projects.length, 1);
|
||||
assert.equal(applyPayload.grants.skills.length, 1);
|
||||
|
||||
const state = await data.readState();
|
||||
assert.deepEqual(state.accountDeviceGrants.at(0)?.permissions, ["device.view"]);
|
||||
assert.deepEqual(state.accountProjectGrants.at(0)?.permissions, [
|
||||
"project.view",
|
||||
"thread.chat",
|
||||
"master_agent.ask",
|
||||
]);
|
||||
assert.deepEqual(state.accountSkillGrants.at(0)?.permissions, ["skill.view", "skill.use"]);
|
||||
assert.equal(
|
||||
state.permissionAuditLogs.some((log) => log.action === "grant.updated" && log.detail === "template:developer"),
|
||||
true,
|
||||
);
|
||||
});
|
||||
302
tests/rbac-master-agent-scope.test.ts
Normal file
302
tests/rbac-master-agent-scope.test.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
|
||||
let runtimeRoot = "";
|
||||
let data: typeof import("../src/lib/boss-data");
|
||||
let masterAgent: typeof import("../src/lib/boss-master-agent");
|
||||
let baseState: Awaited<ReturnType<typeof import("../src/lib/boss-data")["readState"]>>;
|
||||
|
||||
async function setup() {
|
||||
if (!runtimeRoot) {
|
||||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-rbac-master-scope-"));
|
||||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||||
}
|
||||
if (!data) {
|
||||
data = await import("../src/lib/boss-data.ts");
|
||||
baseState = structuredClone(await data.readState());
|
||||
}
|
||||
if (!masterAgent) {
|
||||
masterAgent = await import("../src/lib/boss-master-agent.ts");
|
||||
}
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
if (runtimeRoot) {
|
||||
await rm(runtimeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await setup();
|
||||
const state = structuredClone(baseState);
|
||||
state.devices.push({
|
||||
id: "cloud-backup",
|
||||
name: "Cloud Backup Secret Mac",
|
||||
avatar: "C",
|
||||
account: "other@example.com",
|
||||
source: "production",
|
||||
status: "online",
|
||||
projects: ["cloud-only-project"],
|
||||
quota5h: 0,
|
||||
quota7d: 0,
|
||||
lastSeenAt: "2026-04-26T12:00:00+08:00",
|
||||
preferredExecutionMode: "cli",
|
||||
});
|
||||
state.projects.push({
|
||||
id: "cloud-only-project",
|
||||
name: "Unauthorized Secret Project",
|
||||
pinned: false,
|
||||
systemPinned: false,
|
||||
deviceIds: ["cloud-backup"],
|
||||
preview: "UNAUTHORIZED_PROJECT_PREVIEW_SHOULD_NOT_LEAK",
|
||||
updatedAt: "2026-04-26T12:00:00+08:00",
|
||||
lastMessageAt: "2026-04-26T12:00:00+08:00",
|
||||
isGroup: false,
|
||||
threadMeta: {
|
||||
projectId: "cloud-only-project",
|
||||
threadId: "thread-cloud-only",
|
||||
threadDisplayName: "Unauthorized Secret Thread",
|
||||
folderName: "Unauthorized Folder",
|
||||
activityIconCount: 0,
|
||||
updatedAt: "2026-04-26T12:00:00+08:00",
|
||||
codexThreadRef: "thread-cloud-only",
|
||||
codexFolderRef: "unauthorized-folder",
|
||||
},
|
||||
groupMembers: [],
|
||||
createdByAgent: true,
|
||||
collaborationMode: "development",
|
||||
approvalState: "not_required",
|
||||
unreadCount: 0,
|
||||
riskLevel: "low",
|
||||
messages: [
|
||||
{
|
||||
id: "secret-message",
|
||||
sender: "assistant",
|
||||
senderLabel: "Codex",
|
||||
body: "UNAUTHORIZED_SECRET_MESSAGE_SHOULD_NOT_LEAK",
|
||||
sentAt: "2026-04-26T12:00:00+08:00",
|
||||
kind: "text",
|
||||
},
|
||||
],
|
||||
goals: [],
|
||||
versions: [],
|
||||
});
|
||||
state.threadStatusDocuments = [
|
||||
{
|
||||
documentId: "visible-status",
|
||||
projectId: "master-agent",
|
||||
threadId: "visible-thread",
|
||||
threadDisplayName: "Visible Status",
|
||||
folderName: "Visible Folder",
|
||||
deviceId: "mac-studio",
|
||||
projectGoal: "VISIBLE_STATUS_DOCUMENT",
|
||||
currentPhase: "联调",
|
||||
currentProgress: "",
|
||||
technicalArchitecture: "",
|
||||
currentBlockers: "",
|
||||
recommendedNextStep: "",
|
||||
keyFiles: [],
|
||||
keyCommands: [],
|
||||
updatedAt: "2026-04-26T12:00:00+08:00",
|
||||
sourceTaskId: "visible-task",
|
||||
sourceKind: "full_sync",
|
||||
},
|
||||
{
|
||||
documentId: "secret-status",
|
||||
projectId: "cloud-only-project",
|
||||
threadId: "thread-cloud-only",
|
||||
threadDisplayName: "Secret Status",
|
||||
folderName: "Unauthorized Folder",
|
||||
deviceId: "cloud-backup",
|
||||
projectGoal: "UNAUTHORIZED_STATUS_DOCUMENT_SHOULD_NOT_LEAK",
|
||||
currentPhase: "秘密阶段",
|
||||
currentProgress: "",
|
||||
technicalArchitecture: "",
|
||||
currentBlockers: "",
|
||||
recommendedNextStep: "",
|
||||
keyFiles: [],
|
||||
keyCommands: [],
|
||||
updatedAt: "2026-04-26T12:00:00+08:00",
|
||||
sourceTaskId: "secret-task",
|
||||
sourceKind: "full_sync",
|
||||
},
|
||||
{
|
||||
documentId: "cross-device-secret-status",
|
||||
projectId: "master-agent",
|
||||
threadId: "thread-cross-device-secret",
|
||||
threadDisplayName: "Cross Device Secret Status",
|
||||
folderName: "Visible Project Unauthorized Device",
|
||||
deviceId: "cloud-backup",
|
||||
projectGoal: "UNAUTHORIZED_DEVICE_STATUS_DOCUMENT_SHOULD_NOT_LEAK",
|
||||
currentPhase: "秘密设备阶段",
|
||||
currentProgress: "",
|
||||
technicalArchitecture: "",
|
||||
currentBlockers: "",
|
||||
recommendedNextStep: "",
|
||||
keyFiles: [],
|
||||
keyCommands: [],
|
||||
updatedAt: "2026-04-26T12:00:00+08:00",
|
||||
sourceTaskId: "cross-device-secret-task",
|
||||
sourceKind: "full_sync",
|
||||
},
|
||||
];
|
||||
state.threadProgressEvents = [
|
||||
{
|
||||
eventId: "secret-progress",
|
||||
projectId: "cloud-only-project",
|
||||
threadId: "thread-cloud-only",
|
||||
threadDisplayName: "Secret Progress",
|
||||
deviceId: "cloud-backup",
|
||||
summary: "UNAUTHORIZED_PROGRESS_EVENT_SHOULD_NOT_LEAK",
|
||||
eventType: "progress_updated",
|
||||
createdAt: "2026-04-26T12:00:00+08:00",
|
||||
sourceTaskId: "secret-task",
|
||||
},
|
||||
{
|
||||
eventId: "cross-device-secret-progress",
|
||||
projectId: "master-agent",
|
||||
threadId: "thread-cross-device-secret",
|
||||
threadDisplayName: "Cross Device Secret Progress",
|
||||
deviceId: "cloud-backup",
|
||||
summary: "UNAUTHORIZED_DEVICE_PROGRESS_EVENT_SHOULD_NOT_LEAK",
|
||||
eventType: "progress_updated",
|
||||
createdAt: "2026-04-26T12:00:00+08:00",
|
||||
sourceTaskId: "cross-device-secret-task",
|
||||
},
|
||||
];
|
||||
state.threadContextSnapshots = [
|
||||
{
|
||||
snapshotId: "secret-snapshot",
|
||||
projectId: "cloud-only-project",
|
||||
taskId: "secret-task",
|
||||
threadId: "thread-cloud-only",
|
||||
title: "Unauthorized Secret Context",
|
||||
summary: "UNAUTHORIZED_CONTEXT_SNAPSHOT_SHOULD_NOT_LEAK",
|
||||
nodeId: "cloud-backup",
|
||||
workerId: "worker-secret",
|
||||
sourceKind: "worker_estimator",
|
||||
status: "context_urgent",
|
||||
contextBudgetRemainingPct: 7,
|
||||
contextBudgetLevel: "critical",
|
||||
mustFinishBeforeCompaction: true,
|
||||
estimatedRemainingTurns: 1,
|
||||
estimatedRemainingLargeMessages: 1,
|
||||
compactionCount: 0,
|
||||
patchPending: false,
|
||||
testsPending: false,
|
||||
evidencePending: false,
|
||||
checklist: [],
|
||||
capturedAt: "2026-04-26T12:00:00+08:00",
|
||||
},
|
||||
{
|
||||
snapshotId: "cross-device-secret-snapshot",
|
||||
projectId: "master-agent",
|
||||
taskId: "cross-device-secret-task",
|
||||
threadId: "thread-cross-device-secret",
|
||||
title: "Cross Device Secret Context",
|
||||
summary: "UNAUTHORIZED_DEVICE_CONTEXT_SNAPSHOT_SHOULD_NOT_LEAK",
|
||||
nodeId: "cloud-backup",
|
||||
workerId: "worker-cross-device-secret",
|
||||
sourceKind: "worker_estimator",
|
||||
status: "context_urgent",
|
||||
contextBudgetRemainingPct: 9,
|
||||
contextBudgetLevel: "critical",
|
||||
mustFinishBeforeCompaction: true,
|
||||
estimatedRemainingTurns: 1,
|
||||
estimatedRemainingLargeMessages: 1,
|
||||
compactionCount: 0,
|
||||
patchPending: false,
|
||||
testsPending: false,
|
||||
evidencePending: false,
|
||||
checklist: [],
|
||||
capturedAt: "2026-04-26T12:00:00+08:00",
|
||||
},
|
||||
];
|
||||
state.deviceSkills = [
|
||||
{
|
||||
skillId: "mac-studio:boss-server-debug",
|
||||
deviceId: "mac-studio",
|
||||
name: "boss-server-debug",
|
||||
description: "VISIBLE_SKILL",
|
||||
path: "/Users/kris/.codex/skills/boss-server-debug/SKILL.md",
|
||||
invocation: "$boss-server-debug",
|
||||
category: "Mac Studio",
|
||||
updatedAt: "2026-04-26T12:00:00+08:00",
|
||||
},
|
||||
{
|
||||
skillId: "cloud-backup:secret-skill",
|
||||
deviceId: "cloud-backup",
|
||||
name: "secret-skill",
|
||||
description: "UNAUTHORIZED_SKILL_SHOULD_NOT_LEAK",
|
||||
path: "/tmp/secret/SKILL.md",
|
||||
invocation: "$secret-skill",
|
||||
category: "Secret",
|
||||
updatedAt: "2026-04-26T12:00:00+08:00",
|
||||
},
|
||||
];
|
||||
state.accountDeviceGrants = [
|
||||
{
|
||||
grantId: "grant-worker-mac-view",
|
||||
account: "worker@example.com",
|
||||
deviceId: "mac-studio",
|
||||
permissions: ["device.view"],
|
||||
grantedBy: "krisolo",
|
||||
grantedAt: "2026-04-26T12:00:00+08:00",
|
||||
},
|
||||
];
|
||||
state.accountProjectGrants = [
|
||||
{
|
||||
grantId: "grant-worker-master-ask",
|
||||
account: "worker@example.com",
|
||||
projectId: "master-agent",
|
||||
permissions: ["project.view", "master_agent.ask"],
|
||||
grantedBy: "krisolo",
|
||||
grantedAt: "2026-04-26T12:00:00+08:00",
|
||||
},
|
||||
];
|
||||
state.accountSkillGrants = [
|
||||
{
|
||||
grantId: "grant-worker-visible-skill",
|
||||
account: "worker@example.com",
|
||||
skillId: "mac-studio:boss-server-debug",
|
||||
deviceId: "mac-studio",
|
||||
permissions: ["skill.view", "skill.use"],
|
||||
grantedBy: "krisolo",
|
||||
grantedAt: "2026-04-26T12:00:00+08:00",
|
||||
},
|
||||
];
|
||||
await data.writeState(state);
|
||||
});
|
||||
|
||||
test("main agent prompt is built from authorized devices projects and skills only", async () => {
|
||||
const state = await data.readState();
|
||||
const result = masterAgent.buildAuthorizedMasterAgentPromptForTest({
|
||||
state,
|
||||
session: {
|
||||
account: "worker@example.com",
|
||||
role: "member",
|
||||
displayName: "Worker",
|
||||
},
|
||||
projectId: "master-agent",
|
||||
requestText: "总结我能看到的项目和运行状态",
|
||||
});
|
||||
|
||||
assert.deepEqual(result.authorizedDeviceIds, ["mac-studio"]);
|
||||
assert.equal(result.authorizedProjectIds.includes("master-agent"), true);
|
||||
assert.equal(result.authorizedProjectIds.includes("cloud-only-project"), false);
|
||||
assert.deepEqual(result.authorizedSkillIds, ["mac-studio:boss-server-debug"]);
|
||||
assert.equal(result.prompt.includes("VISIBLE_STATUS_DOCUMENT"), true);
|
||||
assert.equal(result.prompt.includes("UNAUTHORIZED_PROJECT_PREVIEW_SHOULD_NOT_LEAK"), false);
|
||||
assert.equal(result.prompt.includes("UNAUTHORIZED_STATUS_DOCUMENT_SHOULD_NOT_LEAK"), false);
|
||||
assert.equal(result.prompt.includes("UNAUTHORIZED_DEVICE_STATUS_DOCUMENT_SHOULD_NOT_LEAK"), false);
|
||||
assert.equal(result.prompt.includes("UNAUTHORIZED_PROGRESS_EVENT_SHOULD_NOT_LEAK"), false);
|
||||
assert.equal(result.prompt.includes("UNAUTHORIZED_DEVICE_PROGRESS_EVENT_SHOULD_NOT_LEAK"), false);
|
||||
assert.equal(result.prompt.includes("UNAUTHORIZED_CONTEXT_SNAPSHOT_SHOULD_NOT_LEAK"), false);
|
||||
assert.equal(result.prompt.includes("UNAUTHORIZED_DEVICE_CONTEXT_SNAPSHOT_SHOULD_NOT_LEAK"), false);
|
||||
assert.equal(result.prompt.includes("UNAUTHORIZED_SKILL_SHOULD_NOT_LEAK"), false);
|
||||
assert.equal(result.prompt.includes("Cloud Backup Secret Mac"), false);
|
||||
});
|
||||
412
tests/rbac-route-filtering.test.ts
Normal file
412
tests/rbac-route-filtering.test.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
let runtimeRoot = "";
|
||||
let data: typeof import("../src/lib/boss-data");
|
||||
let authCookie = "";
|
||||
let getDevices: (typeof import("../src/app/api/v1/devices/route"))["GET"];
|
||||
let getConversations: (typeof import("../src/app/api/v1/conversations/route"))["GET"];
|
||||
let getProject: (typeof import("../src/app/api/v1/projects/[projectId]/route"))["GET"];
|
||||
let getMessages: (typeof import("../src/app/api/v1/projects/[projectId]/messages/route"))["GET"];
|
||||
let postMessages: (typeof import("../src/app/api/v1/projects/[projectId]/messages/route"))["POST"];
|
||||
let deleteMessage: (typeof import("../src/app/api/v1/projects/[projectId]/messages/route"))["DELETE"];
|
||||
let getDeviceSkills: (typeof import("../src/app/api/v1/devices/[deviceId]/skills/route"))["GET"];
|
||||
let baseState: Awaited<ReturnType<typeof import("../src/lib/boss-data")["readState"]>>;
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) return;
|
||||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-rbac-route-filtering-"));
|
||||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||||
const [dataModule, authModule, devicesRoute, conversationsRoute, projectRoute, messagesRoute, skillsRoute] =
|
||||
await Promise.all([
|
||||
import("../src/lib/boss-data.ts"),
|
||||
import("../src/lib/boss-auth.ts"),
|
||||
import("../src/app/api/v1/devices/route.ts"),
|
||||
import("../src/app/api/v1/conversations/route.ts"),
|
||||
import("../src/app/api/v1/projects/[projectId]/route.ts"),
|
||||
import("../src/app/api/v1/projects/[projectId]/messages/route.ts"),
|
||||
import("../src/app/api/v1/devices/[deviceId]/skills/route.ts"),
|
||||
]);
|
||||
data = dataModule;
|
||||
authCookie = authModule.AUTH_SESSION_COOKIE;
|
||||
getDevices = devicesRoute.GET;
|
||||
getConversations = conversationsRoute.GET;
|
||||
getProject = projectRoute.GET;
|
||||
getMessages = messagesRoute.GET;
|
||||
postMessages = messagesRoute.POST;
|
||||
deleteMessage = messagesRoute.DELETE;
|
||||
getDeviceSkills = skillsRoute.GET;
|
||||
baseState = structuredClone(await data.readState());
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
if (runtimeRoot) await rm(runtimeRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await setup();
|
||||
const state = structuredClone(baseState);
|
||||
state.projects.push({
|
||||
id: "cloud-only-project",
|
||||
name: "Cloud Only Project",
|
||||
pinned: false,
|
||||
systemPinned: false,
|
||||
deviceIds: ["cloud-backup"],
|
||||
preview: "只绑定未授权设备的项目。",
|
||||
updatedAt: "2026-04-26T12:00:00+08:00",
|
||||
lastMessageAt: "2026-04-26T12:00:00+08:00",
|
||||
isGroup: false,
|
||||
threadMeta: {
|
||||
projectId: "cloud-only-project",
|
||||
threadId: "thread-cloud-only",
|
||||
threadDisplayName: "Cloud Only",
|
||||
folderName: "Cloud",
|
||||
activityIconCount: 0,
|
||||
updatedAt: "2026-04-26T12:00:00+08:00",
|
||||
codexThreadRef: "thread-cloud-only",
|
||||
codexFolderRef: "cloud",
|
||||
},
|
||||
groupMembers: [],
|
||||
createdByAgent: true,
|
||||
collaborationMode: "development",
|
||||
approvalState: "not_required",
|
||||
unreadCount: 0,
|
||||
riskLevel: "low",
|
||||
messages: [
|
||||
{
|
||||
id: "cloud-only-message",
|
||||
sender: "assistant",
|
||||
senderLabel: "Codex",
|
||||
body: "这个项目不应该被 worker 看到。",
|
||||
sentAt: "2026-04-26T12:00:00+08:00",
|
||||
kind: "text",
|
||||
},
|
||||
],
|
||||
goals: [],
|
||||
versions: [],
|
||||
});
|
||||
state.projects.push({
|
||||
id: "rbac-thread",
|
||||
name: "RBAC Authorized Thread",
|
||||
pinned: false,
|
||||
systemPinned: false,
|
||||
deviceIds: ["mac-studio"],
|
||||
preview: "授权线程。",
|
||||
updatedAt: "2026-04-26T12:00:00+08:00",
|
||||
lastMessageAt: "2026-04-26T12:00:00+08:00",
|
||||
isGroup: false,
|
||||
threadMeta: {
|
||||
projectId: "rbac-thread",
|
||||
threadId: "thread-rbac-authorized",
|
||||
threadDisplayName: "RBAC Authorized Thread",
|
||||
folderName: "RBAC",
|
||||
activityIconCount: 0,
|
||||
updatedAt: "2026-04-26T12:00:00+08:00",
|
||||
codexThreadRef: "thread-rbac-authorized",
|
||||
codexFolderRef: "rbac",
|
||||
},
|
||||
groupMembers: [],
|
||||
createdByAgent: true,
|
||||
collaborationMode: "development",
|
||||
approvalState: "not_required",
|
||||
unreadCount: 0,
|
||||
riskLevel: "low",
|
||||
messages: [
|
||||
{
|
||||
id: "rbac-thread-message",
|
||||
sender: "assistant",
|
||||
senderLabel: "Codex",
|
||||
body: "这条消息用于验证授权删除。",
|
||||
sentAt: "2026-04-26T12:00:00+08:00",
|
||||
kind: "text",
|
||||
},
|
||||
],
|
||||
goals: [],
|
||||
versions: [],
|
||||
});
|
||||
state.accountDeviceGrants = [
|
||||
{
|
||||
grantId: "grant-worker-mac-view",
|
||||
account: "worker@example.com",
|
||||
deviceId: "mac-studio",
|
||||
permissions: ["device.view"],
|
||||
grantedBy: "krisolo",
|
||||
grantedAt: "2026-04-26T12:00:00+08:00",
|
||||
},
|
||||
];
|
||||
state.accountProjectGrants = [];
|
||||
state.accountSkillGrants = [];
|
||||
state.skillCatalog = [];
|
||||
state.permissionAuditLogs = [];
|
||||
await data.writeState(state);
|
||||
});
|
||||
|
||||
async function authedRequest(
|
||||
account: string,
|
||||
role: "member" | "admin" | "highest_admin",
|
||||
url: string,
|
||||
init: RequestInit = {},
|
||||
) {
|
||||
const session = await data.createAuthSession({
|
||||
account,
|
||||
role,
|
||||
displayName: account,
|
||||
loginMethod: "password",
|
||||
});
|
||||
return new NextRequest(url, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.headers ?? {}),
|
||||
cookie: `${authCookie}=${session.sessionToken}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test("device list only includes devices visible to member", async () => {
|
||||
const response = await getDevices(
|
||||
await authedRequest("worker@example.com", "member", "http://127.0.0.1:3000/api/v1/devices"),
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
const body = await response.json();
|
||||
assert.deepEqual(
|
||||
body.devices.map((device: { id: string }) => device.id),
|
||||
["mac-studio"],
|
||||
);
|
||||
});
|
||||
|
||||
test("conversation list only includes projects visible to member", async () => {
|
||||
const response = await getConversations(
|
||||
await authedRequest("worker@example.com", "member", "http://127.0.0.1:3000/api/v1/conversations"),
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
const body = await response.json();
|
||||
assert.equal(
|
||||
body.conversations.some((item: { projectId: string }) => item.projectId === "master-agent"),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
body.conversations.some((item: { projectId: string }) => item.projectId === "cloud-only-project"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("project detail returns 403 when member lacks project view", async () => {
|
||||
const response = await getProject(
|
||||
await authedRequest(
|
||||
"worker@example.com",
|
||||
"member",
|
||||
"http://127.0.0.1:3000/api/v1/projects/cloud-only-project",
|
||||
),
|
||||
{ params: Promise.resolve({ projectId: "cloud-only-project" }) },
|
||||
);
|
||||
assert.equal(response.status, 403);
|
||||
});
|
||||
|
||||
test("messages GET requires project view", async () => {
|
||||
const response = await getMessages(
|
||||
await authedRequest(
|
||||
"worker@example.com",
|
||||
"member",
|
||||
"http://127.0.0.1:3000/api/v1/projects/cloud-only-project/messages",
|
||||
),
|
||||
{ params: Promise.resolve({ projectId: "cloud-only-project" }) },
|
||||
);
|
||||
assert.equal(response.status, 403);
|
||||
});
|
||||
|
||||
test("messages POST requires explicit thread.chat or master_agent.ask", async () => {
|
||||
const denied = await postMessages(
|
||||
await authedRequest(
|
||||
"worker@example.com",
|
||||
"member",
|
||||
"http://127.0.0.1:3000/api/v1/projects/master-agent/messages",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body: "总结一下当前进度" }),
|
||||
},
|
||||
),
|
||||
{ params: Promise.resolve({ projectId: "master-agent" }) },
|
||||
);
|
||||
assert.equal(denied.status, 403);
|
||||
|
||||
const state = await data.readState();
|
||||
state.accountProjectGrants.push({
|
||||
grantId: "grant-master-chat",
|
||||
account: "worker@example.com",
|
||||
projectId: "master-agent",
|
||||
permissions: ["project.view", "thread.chat", "master_agent.ask"],
|
||||
grantedBy: "krisolo",
|
||||
grantedAt: "2026-04-26T12:30:00+08:00",
|
||||
});
|
||||
await data.writeState(state);
|
||||
|
||||
const allowed = await postMessages(
|
||||
await authedRequest(
|
||||
"worker@example.com",
|
||||
"member",
|
||||
"http://127.0.0.1:3000/api/v1/projects/master-agent/messages",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body: "总结一下当前进度" }),
|
||||
},
|
||||
),
|
||||
{ params: Promise.resolve({ projectId: "master-agent" }) },
|
||||
);
|
||||
assert.notEqual(allowed.status, 403);
|
||||
});
|
||||
|
||||
test("authorized single thread can chat and unauthorized single thread is rejected", async () => {
|
||||
const state = await data.readState();
|
||||
state.accountProjectGrants = [
|
||||
{
|
||||
grantId: "grant-thread-ui-chat",
|
||||
account: "worker@example.com",
|
||||
projectId: "rbac-thread",
|
||||
permissions: ["project.view", "thread.chat"],
|
||||
grantedBy: "krisolo",
|
||||
grantedAt: "2026-04-26T12:30:00+08:00",
|
||||
},
|
||||
];
|
||||
await data.writeState(state);
|
||||
|
||||
const allowed = await postMessages(
|
||||
await authedRequest(
|
||||
"worker@example.com",
|
||||
"member",
|
||||
"http://127.0.0.1:3000/api/v1/projects/rbac-thread/messages",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body: "继续处理授权线程" }),
|
||||
},
|
||||
),
|
||||
{ params: Promise.resolve({ projectId: "rbac-thread" }) },
|
||||
);
|
||||
assert.notEqual(allowed.status, 403);
|
||||
const allowedPayload = await allowed.json();
|
||||
assert.equal(allowedPayload.ok, true);
|
||||
assert.equal(allowedPayload.message.body, "继续处理授权线程");
|
||||
|
||||
const denied = await postMessages(
|
||||
await authedRequest(
|
||||
"worker@example.com",
|
||||
"member",
|
||||
"http://127.0.0.1:3000/api/v1/projects/cloud-only-project/messages",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body: "尝试访问未授权线程" }),
|
||||
},
|
||||
),
|
||||
{ params: Promise.resolve({ projectId: "cloud-only-project" }) },
|
||||
);
|
||||
assert.equal(denied.status, 403);
|
||||
});
|
||||
|
||||
test("message delete requires explicit thread.chat permission", async () => {
|
||||
const state = await data.readState();
|
||||
state.accountProjectGrants = [
|
||||
{
|
||||
grantId: "grant-thread-ui-view-only",
|
||||
account: "worker@example.com",
|
||||
projectId: "rbac-thread",
|
||||
permissions: ["project.view"],
|
||||
grantedBy: "krisolo",
|
||||
grantedAt: "2026-04-26T12:30:00+08:00",
|
||||
},
|
||||
];
|
||||
await data.writeState(state);
|
||||
const messageId = state.projects.find((project) => project.id === "rbac-thread")?.messages[0]?.id;
|
||||
assert.ok(messageId, "expected seeded rbac-thread message");
|
||||
|
||||
const denied = await deleteMessage(
|
||||
await authedRequest(
|
||||
"worker@example.com",
|
||||
"member",
|
||||
`http://127.0.0.1:3000/api/v1/projects/rbac-thread/messages?messageId=${messageId}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
{ params: Promise.resolve({ projectId: "rbac-thread" }) },
|
||||
);
|
||||
assert.equal(denied.status, 403);
|
||||
|
||||
const nextState = await data.readState();
|
||||
nextState.accountProjectGrants = [
|
||||
{
|
||||
grantId: "grant-thread-ui-chat-delete",
|
||||
account: "worker@example.com",
|
||||
projectId: "rbac-thread",
|
||||
permissions: ["project.view", "thread.chat"],
|
||||
grantedBy: "krisolo",
|
||||
grantedAt: "2026-04-26T12:40:00+08:00",
|
||||
},
|
||||
];
|
||||
await data.writeState(nextState);
|
||||
|
||||
const allowed = await deleteMessage(
|
||||
await authedRequest(
|
||||
"worker@example.com",
|
||||
"member",
|
||||
`http://127.0.0.1:3000/api/v1/projects/rbac-thread/messages?messageId=${messageId}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
{ params: Promise.resolve({ projectId: "rbac-thread" }) },
|
||||
);
|
||||
assert.equal(allowed.status, 200);
|
||||
});
|
||||
|
||||
test("device skills route only returns skills granted to member", async () => {
|
||||
const state = await data.readState();
|
||||
state.deviceSkills = [
|
||||
{
|
||||
skillId: "mac-studio:boss-server-debug",
|
||||
deviceId: "mac-studio",
|
||||
name: "boss-server-debug",
|
||||
description: "服务器调试",
|
||||
path: "/Users/kris/.codex/skills/boss-server-debug/SKILL.md",
|
||||
invocation: "$boss-server-debug",
|
||||
category: "Mac Studio",
|
||||
updatedAt: "2026-04-26T12:00:00+08:00",
|
||||
},
|
||||
{
|
||||
skillId: "mac-studio:gitea-version-upload",
|
||||
deviceId: "mac-studio",
|
||||
name: "gitea-version-upload",
|
||||
description: "Gitea 上传",
|
||||
path: "/Users/kris/.codex/skills/gitea-version-upload/SKILL.md",
|
||||
invocation: "$gitea-version-upload",
|
||||
category: "Mac Studio",
|
||||
updatedAt: "2026-04-26T12:00:00+08:00",
|
||||
},
|
||||
];
|
||||
state.accountSkillGrants = [
|
||||
{
|
||||
grantId: "grant-skill-view",
|
||||
account: "worker@example.com",
|
||||
skillId: "mac-studio:boss-server-debug",
|
||||
deviceId: "mac-studio",
|
||||
permissions: ["skill.view", "skill.use"],
|
||||
grantedBy: "krisolo",
|
||||
grantedAt: "2026-04-26T12:30:00+08:00",
|
||||
},
|
||||
];
|
||||
await data.writeState(state);
|
||||
|
||||
const response = await getDeviceSkills(
|
||||
await authedRequest(
|
||||
"worker@example.com",
|
||||
"member",
|
||||
"http://127.0.0.1:3000/api/v1/devices/mac-studio/skills",
|
||||
),
|
||||
{ params: Promise.resolve({ deviceId: "mac-studio" }) },
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
const body = await response.json();
|
||||
assert.deepEqual(
|
||||
body.skills.map((skill: { skillId: string }) => skill.skillId),
|
||||
["mac-studio:boss-server-debug"],
|
||||
);
|
||||
});
|
||||
221
tests/rbac-tenant-isolation.test.ts
Normal file
221
tests/rbac-tenant-isolation.test.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { BossState } from "../src/lib/boss-data";
|
||||
import { canAccessDevice, canAccessProject } from "../src/lib/boss-permissions";
|
||||
|
||||
function baseState(): BossState {
|
||||
const now = "2026-04-27T17:00:00+08:00";
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
migratedAt: now,
|
||||
user: {} as BossState["user"],
|
||||
devices: [
|
||||
{
|
||||
id: "device-a",
|
||||
name: "A 公司 Mac",
|
||||
avatar: "A",
|
||||
account: "owner-a@example.com",
|
||||
companyId: "tenant-a",
|
||||
source: "production",
|
||||
status: "online",
|
||||
projects: ["project-a"],
|
||||
quota5h: 0,
|
||||
quota7d: 0,
|
||||
lastSeenAt: now,
|
||||
},
|
||||
{
|
||||
id: "device-b",
|
||||
name: "B 公司 Mac",
|
||||
avatar: "B",
|
||||
account: "owner-b@example.com",
|
||||
companyId: "tenant-b",
|
||||
source: "production",
|
||||
status: "online",
|
||||
projects: ["project-b"],
|
||||
quota5h: 0,
|
||||
quota7d: 0,
|
||||
lastSeenAt: now,
|
||||
},
|
||||
{
|
||||
id: "legacy-device",
|
||||
name: "历史设备",
|
||||
avatar: "L",
|
||||
account: "legacy@example.com",
|
||||
source: "production",
|
||||
status: "online",
|
||||
projects: ["legacy-project"],
|
||||
quota5h: 0,
|
||||
quota7d: 0,
|
||||
lastSeenAt: now,
|
||||
},
|
||||
],
|
||||
projects: [
|
||||
{
|
||||
id: "project-b",
|
||||
name: "B 公司项目",
|
||||
pinned: false,
|
||||
deviceIds: ["device-b"],
|
||||
preview: "",
|
||||
updatedAt: now,
|
||||
lastMessageAt: now,
|
||||
isGroup: false,
|
||||
threadMeta: {
|
||||
projectId: "project-b",
|
||||
threadId: "thread-b",
|
||||
threadDisplayName: "B 线程",
|
||||
folderName: "b",
|
||||
activityIconCount: 1,
|
||||
updatedAt: now,
|
||||
},
|
||||
groupMembers: [],
|
||||
createdByAgent: false,
|
||||
collaborationMode: "development",
|
||||
approvalState: "not_required",
|
||||
unreadCount: 0,
|
||||
riskLevel: "low",
|
||||
messages: [],
|
||||
goals: [],
|
||||
versions: [],
|
||||
},
|
||||
{
|
||||
id: "legacy-project",
|
||||
name: "历史项目",
|
||||
pinned: false,
|
||||
deviceIds: ["legacy-device"],
|
||||
preview: "",
|
||||
updatedAt: now,
|
||||
lastMessageAt: now,
|
||||
isGroup: false,
|
||||
threadMeta: {
|
||||
projectId: "legacy-project",
|
||||
threadId: "thread-legacy",
|
||||
threadDisplayName: "历史线程",
|
||||
folderName: "legacy",
|
||||
activityIconCount: 1,
|
||||
updatedAt: now,
|
||||
},
|
||||
groupMembers: [],
|
||||
createdByAgent: false,
|
||||
collaborationMode: "development",
|
||||
approvalState: "not_required",
|
||||
unreadCount: 0,
|
||||
riskLevel: "low",
|
||||
messages: [],
|
||||
goals: [],
|
||||
versions: [],
|
||||
},
|
||||
],
|
||||
conversationHistoryClearedAt: undefined,
|
||||
verificationCodes: [],
|
||||
verificationDispatches: [],
|
||||
adminCompanies: [],
|
||||
authAccounts: [
|
||||
{
|
||||
id: "account-a",
|
||||
account: "worker-a@example.com",
|
||||
passwordHash: "hash",
|
||||
displayName: "A 员工",
|
||||
role: "member",
|
||||
companyId: "tenant-a",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: "account-admin",
|
||||
account: "root@example.com",
|
||||
passwordHash: "hash",
|
||||
displayName: "平台管理员",
|
||||
role: "highest_admin",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
],
|
||||
authSessions: [],
|
||||
accountDeviceGrants: [
|
||||
{
|
||||
grantId: "cross-device-grant",
|
||||
account: "worker-a@example.com",
|
||||
deviceId: "device-b",
|
||||
permissions: ["device.view", "computer.control"],
|
||||
grantedBy: "root@example.com",
|
||||
grantedAt: now,
|
||||
},
|
||||
{
|
||||
grantId: "legacy-device-grant",
|
||||
account: "worker-a@example.com",
|
||||
deviceId: "legacy-device",
|
||||
permissions: ["device.view"],
|
||||
grantedBy: "root@example.com",
|
||||
grantedAt: now,
|
||||
},
|
||||
],
|
||||
accountProjectGrants: [
|
||||
{
|
||||
grantId: "cross-project-grant",
|
||||
account: "worker-a@example.com",
|
||||
projectId: "project-b",
|
||||
permissions: ["project.view", "thread.chat"],
|
||||
grantedBy: "root@example.com",
|
||||
grantedAt: now,
|
||||
},
|
||||
],
|
||||
accountSkillGrants: [],
|
||||
skillCatalog: [],
|
||||
skillLifecycleRequests: [],
|
||||
permissionAuditLogs: [],
|
||||
aiAccounts: [],
|
||||
aiAccountSwitchHistory: [],
|
||||
masterAgentTasks: [],
|
||||
dispatchPlans: [],
|
||||
dispatchExecutions: [],
|
||||
deviceImportDrafts: [],
|
||||
deviceImportResolutions: [],
|
||||
threadStatusDocuments: [],
|
||||
threadProgressEvents: [],
|
||||
otaUpdates: [],
|
||||
otaUpdateLogs: [],
|
||||
deviceSkills: [],
|
||||
appLogs: [],
|
||||
userAttachmentStorageConfigs: [],
|
||||
masterAgentPromptPolicy: null,
|
||||
userMasterPrompts: [],
|
||||
masterAgentMemories: [],
|
||||
userProjectAgentControls: [],
|
||||
threadContextSnapshots: [],
|
||||
threadHandoffPackages: [],
|
||||
threadContextAlerts: [],
|
||||
deviceEnrollments: [],
|
||||
opsFaults: [],
|
||||
opsRepairTickets: [],
|
||||
opsRepairVerifications: [],
|
||||
auditRequests: [],
|
||||
auditResults: [],
|
||||
capabilities: [],
|
||||
projectExecutionPolicies: [],
|
||||
};
|
||||
}
|
||||
|
||||
test("tenant guard blocks accidental cross-company grants for ordinary accounts", () => {
|
||||
const state = baseState();
|
||||
const session = { account: "worker-a@example.com", role: "member" as const, displayName: "A 员工" };
|
||||
|
||||
assert.equal(canAccessDevice(state, session, "device-b", "device.view"), false);
|
||||
assert.equal(canAccessProject(state, session, "project-b", "project.view"), false);
|
||||
assert.equal(canAccessProject(state, session, "project-b", "thread.chat"), false);
|
||||
});
|
||||
|
||||
test("highest admin remains globally visible across tenants", () => {
|
||||
const state = baseState();
|
||||
const session = { account: "root@example.com", role: "highest_admin" as const, displayName: "平台管理员" };
|
||||
|
||||
assert.equal(canAccessDevice(state, session, "device-b", "device.view"), true);
|
||||
assert.equal(canAccessProject(state, session, "project-b", "project.view"), true);
|
||||
});
|
||||
|
||||
test("legacy unassigned devices keep explicit grant compatibility", () => {
|
||||
const state = baseState();
|
||||
const session = { account: "worker-a@example.com", role: "member" as const, displayName: "A 员工" };
|
||||
|
||||
assert.equal(canAccessDevice(state, session, "legacy-device", "device.view"), true);
|
||||
assert.equal(canAccessProject(state, session, "legacy-project", "project.view"), true);
|
||||
});
|
||||
@@ -16,10 +16,30 @@ test("RemoteRuntimeAdapter 会把 local-agent 回写标准化成统一结果", (
|
||||
assert.equal(normalized.dispatchExecutionId, "dx-1");
|
||||
assert.equal(normalized.targetProjectId, "project-1");
|
||||
assert.equal(normalized.targetThreadId, "thread-1");
|
||||
assert.equal(normalized.targetUrl, undefined);
|
||||
assert.equal(normalized.targetApp, undefined);
|
||||
assert.equal(normalized.rawThreadReply, "链路正常");
|
||||
assert.equal(normalized.replyBody, "主 Agent 汇总:链路正常");
|
||||
});
|
||||
|
||||
test("RemoteRuntimeAdapter 会保留 browser/desktop 控制结果的目标信息", () => {
|
||||
const browser = normalizeRemoteExecutionResultForTesting({
|
||||
status: "completed",
|
||||
replyBody: "浏览器控制已完成",
|
||||
targetUrl: " https://example.com/dashboard ",
|
||||
});
|
||||
const desktop = normalizeRemoteExecutionResultForTesting({
|
||||
status: "completed",
|
||||
replyBody: "桌面控制已完成",
|
||||
targetApp: " 微信 ",
|
||||
});
|
||||
|
||||
assert.equal(browser.targetUrl, "https://example.com/dashboard");
|
||||
assert.equal(browser.targetApp, undefined);
|
||||
assert.equal(desktop.targetUrl, undefined);
|
||||
assert.equal(desktop.targetApp, "微信");
|
||||
});
|
||||
|
||||
test("RemoteRuntimeAdapter 会忽略空白字段并保留失败状态", () => {
|
||||
const normalized = normalizeRemoteExecutionResultForTesting({
|
||||
status: "failed",
|
||||
@@ -51,6 +71,56 @@ test("RemoteRuntimeAdapter 会把线程环境脏回复改写成失败", () => {
|
||||
assert.match(normalized.errorMessage ?? "", /THREAD_ENVIRONMENT_INVALID/);
|
||||
});
|
||||
|
||||
test("RemoteRuntimeAdapter 会把 Codex CLI 启动日志泄漏改写成内部执行失败", () => {
|
||||
const normalized = normalizeRemoteExecutionResultForTesting({
|
||||
status: "failed",
|
||||
errorMessage: [
|
||||
"OpenAI Codex v0.114.0 (research preview)",
|
||||
"--------",
|
||||
"workdir: /Users/kris/code/boss",
|
||||
"model: gpt-5.4",
|
||||
"provider: openai",
|
||||
"approval: never",
|
||||
"sandbox: workspace-write [workdir, /tmp, $TMPDIR, /Users/kris/.codex/memories]",
|
||||
"session id: 019da4e5-9b1d-7dc1-8aa5-a74a74b6b021",
|
||||
"--------",
|
||||
"user",
|
||||
"同步完成记得要和我说,以后也是这样。",
|
||||
"mcp: chrome-devtools starting",
|
||||
].join("\n"),
|
||||
});
|
||||
|
||||
assert.equal(normalized.status, "failed");
|
||||
assert.equal(normalized.replyBody, undefined);
|
||||
assert.equal(normalized.rawThreadReply, undefined);
|
||||
assert.equal(normalized.errorMessage, "MASTER_CODEX_NODE_OUTPUT_LEAKED");
|
||||
});
|
||||
|
||||
test("RemoteRuntimeAdapter 会把执行提示词片段泄漏改写成内部执行失败", () => {
|
||||
const normalized = normalizeRemoteExecutionResultForTesting({
|
||||
status: "failed",
|
||||
errorMessage: [
|
||||
"管理员全局主提示词:",
|
||||
"你是 Boss 控制台的主 Agent。",
|
||||
"回复风格:像专业职业经理人,先给结论,再给推进动作。",
|
||||
"",
|
||||
"用户私有主提示词:",
|
||||
"默认中文回复。",
|
||||
"",
|
||||
"当前对话附加提示词:",
|
||||
"同步项目目标和版本记录后记得告诉我。",
|
||||
"",
|
||||
"当前消息:",
|
||||
"同步完成记得要和我说,以后也是这样。",
|
||||
].join("\n"),
|
||||
});
|
||||
|
||||
assert.equal(normalized.status, "failed");
|
||||
assert.equal(normalized.replyBody, undefined);
|
||||
assert.equal(normalized.rawThreadReply, undefined);
|
||||
assert.equal(normalized.errorMessage, "MASTER_CODEX_NODE_OUTPUT_LEAKED");
|
||||
});
|
||||
|
||||
test("RemoteRuntimeAdapter 不会误杀包含路径和 sandbox 描述的有效线程回复", () => {
|
||||
const normalized = normalizeRemoteExecutionResultForTesting({
|
||||
status: "completed",
|
||||
|
||||
213
tests/ruflo-governance-foundation.test.ts
Normal file
213
tests/ruflo-governance-foundation.test.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
claimWork,
|
||||
detectStaleClaims,
|
||||
handoffWork,
|
||||
markClaimStealable,
|
||||
releaseClaim,
|
||||
type BossWorkClaim,
|
||||
} from "@/lib/boss-work-claims";
|
||||
import {
|
||||
canUseCapabilityGroup,
|
||||
DEFAULT_CAPABILITY_GROUPS,
|
||||
explainCapabilityGroupDecision,
|
||||
} from "@/lib/boss-capability-groups";
|
||||
import {
|
||||
assertDeviceTrustEnvelope,
|
||||
evaluateDeviceTrust,
|
||||
verifyDeviceTrustEnvelope,
|
||||
type BossDeviceTrustProfile,
|
||||
} from "@/lib/boss-device-trust";
|
||||
|
||||
test("work claims block conflicting actors and emit deterministic events", () => {
|
||||
const first = claimWork({
|
||||
claims: [],
|
||||
resourceId: "project:alpha",
|
||||
actorId: "user:kris",
|
||||
actorKind: "user",
|
||||
now: "2026-05-10T10:00:00.000Z",
|
||||
ttlMs: 60_000,
|
||||
});
|
||||
|
||||
assert.equal(first.ok, true);
|
||||
assert.equal(first.claim?.resourceId, "project:alpha");
|
||||
assert.equal(first.events[0]?.type, "claim_acquired");
|
||||
|
||||
const second = claimWork({
|
||||
claims: first.claim ? [first.claim] : [],
|
||||
resourceId: "project:alpha",
|
||||
actorId: "device:mac-studio",
|
||||
actorKind: "device",
|
||||
now: "2026-05-10T10:00:10.000Z",
|
||||
ttlMs: 60_000,
|
||||
});
|
||||
|
||||
assert.equal(second.ok, false);
|
||||
assert.equal(second.reason, "claim_conflict");
|
||||
assert.equal(second.events[0]?.type, "claim_conflict");
|
||||
assert.equal(second.conflictingClaim?.actorId, "user:kris");
|
||||
});
|
||||
|
||||
test("work claims support release, handoff, stealable marking, and stale detection", () => {
|
||||
const original: BossWorkClaim = {
|
||||
claimId: "claim-1",
|
||||
resourceId: "project:alpha",
|
||||
actorId: "main-agent",
|
||||
actorKind: "agent",
|
||||
acquiredAt: "2026-05-10T10:00:00.000Z",
|
||||
expiresAt: "2026-05-10T10:05:00.000Z",
|
||||
status: "active",
|
||||
};
|
||||
|
||||
const marked = markClaimStealable({
|
||||
claim: original,
|
||||
actorId: "main-agent",
|
||||
now: "2026-05-10T10:01:00.000Z",
|
||||
reason: "waiting for device",
|
||||
});
|
||||
assert.equal(marked.ok, true);
|
||||
assert.equal(marked.claim?.status, "stealable");
|
||||
assert.equal(marked.events[0]?.type, "claim_marked_stealable");
|
||||
|
||||
const handed = handoffWork({
|
||||
claim: marked.claim!,
|
||||
fromActorId: "main-agent",
|
||||
toActorId: "device:mac-studio",
|
||||
toActorKind: "device",
|
||||
now: "2026-05-10T10:02:00.000Z",
|
||||
ttlMs: 120_000,
|
||||
});
|
||||
assert.equal(handed.ok, true);
|
||||
assert.equal(handed.claim?.actorId, "device:mac-studio");
|
||||
assert.equal(handed.events.map((event) => event.type).join(","), "claim_handoff_released,claim_handoff_acquired");
|
||||
|
||||
const stale = detectStaleClaims({
|
||||
claims: [handed.claim!],
|
||||
now: "2026-05-10T10:05:01.000Z",
|
||||
});
|
||||
assert.equal(stale.staleClaims.length, 1);
|
||||
assert.equal(stale.events[0]?.type, "claim_stale_detected");
|
||||
|
||||
const released = releaseClaim({
|
||||
claim: handed.claim!,
|
||||
actorId: "device:mac-studio",
|
||||
now: "2026-05-10T10:03:00.000Z",
|
||||
});
|
||||
assert.equal(released.ok, true);
|
||||
assert.equal(released.claim?.status, "released");
|
||||
assert.equal(released.events[0]?.type, "claim_released");
|
||||
});
|
||||
|
||||
test("capability groups enforce account permissions, skill scope, and device scope", () => {
|
||||
assert.deepEqual(
|
||||
DEFAULT_CAPABILITY_GROUPS.map((group) => group.id),
|
||||
["computer_control", "codex_development", "browser_automation", "skill_operations", "admin_ops"],
|
||||
);
|
||||
|
||||
const allowed = canUseCapabilityGroup({
|
||||
groupId: "browser_automation",
|
||||
accountPermissions: ["computer.control"],
|
||||
skillIds: ["browser-use:browser"],
|
||||
deviceScopes: ["browserAutomation"],
|
||||
requestedSkillId: "browser-use:browser",
|
||||
requestedDeviceScope: "browserAutomation",
|
||||
});
|
||||
assert.equal(allowed.allowed, true);
|
||||
|
||||
const denied = canUseCapabilityGroup({
|
||||
groupId: "skill_operations",
|
||||
accountPermissions: ["skill.use"],
|
||||
skillIds: ["browser-use:browser"],
|
||||
deviceScopes: ["skillLifecycle"],
|
||||
requestedSkillId: "skill-installer",
|
||||
requestedDeviceScope: "skillLifecycle",
|
||||
});
|
||||
assert.equal(denied.allowed, false);
|
||||
assert.equal(denied.reason, "skill_scope_missing");
|
||||
|
||||
assert.match(explainCapabilityGroupDecision(denied), /skill_scope_missing/);
|
||||
});
|
||||
|
||||
test("device trust rejects over-budget, over-hop, low-trust, and invalid signed envelopes", () => {
|
||||
const device: BossDeviceTrustProfile = {
|
||||
deviceId: "mac-studio",
|
||||
trustTier: "managed",
|
||||
publicKeyFingerprint: "sha256:abc",
|
||||
maxMessageBudget: 3,
|
||||
maxHopCount: 2,
|
||||
allowedCapabilities: ["computer_control", "browser_automation"],
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
evaluateDeviceTrust({
|
||||
device,
|
||||
requiredTrustTier: "verified",
|
||||
capabilityGroupId: "browser_automation",
|
||||
messageBudgetUsed: 2,
|
||||
hopCount: 2,
|
||||
}).allowed,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
evaluateDeviceTrust({
|
||||
device,
|
||||
requiredTrustTier: "federated",
|
||||
capabilityGroupId: "browser_automation",
|
||||
messageBudgetUsed: 2,
|
||||
hopCount: 2,
|
||||
}).reason,
|
||||
"trust_tier_too_low",
|
||||
);
|
||||
assert.equal(
|
||||
evaluateDeviceTrust({
|
||||
device,
|
||||
requiredTrustTier: "verified",
|
||||
capabilityGroupId: "browser_automation",
|
||||
messageBudgetUsed: 4,
|
||||
hopCount: 2,
|
||||
}).reason,
|
||||
"message_budget_exceeded",
|
||||
);
|
||||
assert.equal(
|
||||
evaluateDeviceTrust({
|
||||
device,
|
||||
requiredTrustTier: "verified",
|
||||
capabilityGroupId: "browser_automation",
|
||||
messageBudgetUsed: 2,
|
||||
hopCount: 3,
|
||||
}).reason,
|
||||
"hop_limit_exceeded",
|
||||
);
|
||||
|
||||
const envelope = {
|
||||
deviceId: "mac-studio",
|
||||
payload: { taskId: "task-1" },
|
||||
signature: "sig-1",
|
||||
keyFingerprint: "sha256:abc",
|
||||
timestamp: "2026-05-10T10:00:00.000Z",
|
||||
nonce: "nonce-1",
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
verifyDeviceTrustEnvelope(envelope, {
|
||||
device,
|
||||
now: "2026-05-10T10:01:00.000Z",
|
||||
maxClockSkewMs: 120_000,
|
||||
verifySignature: ({ canonicalPayload, signature }) =>
|
||||
signature === "sig-1" && canonicalPayload.includes("\"taskId\":\"task-1\""),
|
||||
}).valid,
|
||||
true,
|
||||
);
|
||||
assert.throws(() =>
|
||||
assertDeviceTrustEnvelope(
|
||||
{ ...envelope, keyFingerprint: "sha256:other" },
|
||||
{
|
||||
device,
|
||||
now: "2026-05-10T10:01:00.000Z",
|
||||
maxClockSkewMs: 120_000,
|
||||
verifySignature: () => true,
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
123
tests/runtime-leak-redaction.test.ts
Normal file
123
tests/runtime-leak-redaction.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
|
||||
let runtimeRoot = "";
|
||||
let readState: (typeof import("../src/lib/boss-data"))["readState"];
|
||||
let writeState: (typeof import("../src/lib/boss-data"))["writeState"];
|
||||
|
||||
const leakedPrompt = [
|
||||
"管理员全局主提示词:",
|
||||
"你是 Boss 控制台的主 Agent。",
|
||||
"默认只说和当前问题直接相关的判断、动作和风险。",
|
||||
"",
|
||||
"用户私有主提示词:",
|
||||
"默认中文回复。",
|
||||
"",
|
||||
"当前对话附加提示词:",
|
||||
"同步项目目标和版本记录后记得告诉我。",
|
||||
"",
|
||||
"当前消息:",
|
||||
"同步完成记得要和我说,以后也是这样。",
|
||||
].join("\n");
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-runtime-leak-redaction-"));
|
||||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||||
|
||||
const data = await import("../src/lib/boss-data.ts");
|
||||
readState = data.readState;
|
||||
writeState = data.writeState;
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
if (runtimeRoot) {
|
||||
await rm(runtimeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("读取已有状态时会清洗历史提示词泄漏内容", async () => {
|
||||
await setup();
|
||||
|
||||
const state = await readState();
|
||||
const masterProject = state.projects.find((project) => project.id === "master-agent");
|
||||
assert.ok(masterProject, "expected a master-agent project");
|
||||
|
||||
masterProject.messages.push({
|
||||
id: "msg-leak-1",
|
||||
sender: "ops",
|
||||
senderLabel: "主 Agent Relay",
|
||||
body: `Master Codex Node 执行失败:\n${leakedPrompt}`,
|
||||
sentAt: "2026-04-19T08:35:01.079Z",
|
||||
kind: "text",
|
||||
});
|
||||
masterProject.preview = leakedPrompt;
|
||||
|
||||
state.masterAgentTasks.unshift({
|
||||
taskId: "task-leak-1",
|
||||
projectId: "master-agent",
|
||||
taskType: "conversation_reply",
|
||||
requestMessageId: "msg-user-1",
|
||||
requestText: "同步完成记得要和我说,以后也是这样。",
|
||||
executionPrompt: leakedPrompt,
|
||||
requestedBy: "Boss 超级管理员",
|
||||
requestedByAccount: "krisolo",
|
||||
deviceId: "mac-studio",
|
||||
status: "failed",
|
||||
requestedAt: "2026-04-19T08:35:01.079Z",
|
||||
errorMessage: leakedPrompt,
|
||||
});
|
||||
|
||||
state.appLogs.unshift({
|
||||
logId: "log-leak-1",
|
||||
deviceId: "mac-studio",
|
||||
projectId: "master-agent",
|
||||
level: "error",
|
||||
source: "local_agent",
|
||||
category: "local_agent.master_agent_task_failed",
|
||||
message: "Master Codex Node 执行主 Agent 任务失败:task-leak-1",
|
||||
detail: leakedPrompt,
|
||||
mirroredToProject: true,
|
||||
createdAt: "2026-04-19T08:35:01.079Z",
|
||||
});
|
||||
|
||||
await writeState(state);
|
||||
|
||||
const nextState = await readState();
|
||||
const nextMasterProject = nextState.projects.find((project) => project.id === "master-agent");
|
||||
assert.ok(nextMasterProject, "expected a reloaded master-agent project");
|
||||
|
||||
const leakedMessage = nextMasterProject.messages.find((message) => message.id === "msg-leak-1");
|
||||
assert.ok(leakedMessage, "expected the historical message to remain");
|
||||
assert.equal(
|
||||
/管理员全局主提示词:|用户私有主提示词:|当前对话附加提示词:/.test(leakedMessage.body),
|
||||
false,
|
||||
);
|
||||
assert.match(leakedMessage.body, /已拦截内部执行日志|原始内容已隐藏/);
|
||||
assert.equal(
|
||||
/管理员全局主提示词:|用户私有主提示词:|当前对话附加提示词:/.test(
|
||||
nextMasterProject.preview ?? "",
|
||||
),
|
||||
false,
|
||||
);
|
||||
|
||||
const sanitizedTask = nextState.masterAgentTasks.find((task) => task.taskId === "task-leak-1");
|
||||
assert.equal(sanitizedTask?.errorMessage, "MASTER_CODEX_NODE_OUTPUT_LEAKED");
|
||||
|
||||
const sanitizedLog = nextState.appLogs.find((log) => log.logId === "log-leak-1");
|
||||
assert.ok(sanitizedLog, "expected the historical app log to remain");
|
||||
assert.equal(
|
||||
/管理员全局主提示词:|用户私有主提示词:|当前对话附加提示词:/.test(
|
||||
sanitizedLog?.detail ?? "",
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.match(sanitizedLog?.detail ?? "", /已拦截内部执行日志|原始内容不再展示/);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
278
tests/skill-lifecycle-route.test.ts
Normal file
278
tests/skill-lifecycle-route.test.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
let runtimeRoot = "";
|
||||
let data: typeof import("../src/lib/boss-data");
|
||||
let authCookie = "";
|
||||
let getSkillRequests: (typeof import("../src/app/api/v1/admin/skills/requests/route"))["GET"];
|
||||
let postSkillRequest: (typeof import("../src/app/api/v1/admin/skills/requests/route"))["POST"];
|
||||
let claimSkillRequest: (typeof import("../src/app/api/v1/devices/[deviceId]/skill-requests/claim/route"))["POST"];
|
||||
let completeSkillRequest: (typeof import("../src/app/api/v1/devices/[deviceId]/skill-requests/[requestId]/complete/route"))["POST"];
|
||||
let baseState: Awaited<ReturnType<typeof import("../src/lib/boss-data")["readState"]>>;
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) return;
|
||||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-skill-lifecycle-"));
|
||||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||||
const [dataModule, authModule, routeModule, claimRouteModule, completeRouteModule] = await Promise.all([
|
||||
import("../src/lib/boss-data.ts"),
|
||||
import("../src/lib/boss-auth.ts"),
|
||||
import("../src/app/api/v1/admin/skills/requests/route.ts"),
|
||||
import("../src/app/api/v1/devices/[deviceId]/skill-requests/claim/route.ts"),
|
||||
import("../src/app/api/v1/devices/[deviceId]/skill-requests/[requestId]/complete/route.ts"),
|
||||
]);
|
||||
data = dataModule;
|
||||
authCookie = authModule.AUTH_SESSION_COOKIE;
|
||||
getSkillRequests = routeModule.GET;
|
||||
postSkillRequest = routeModule.POST;
|
||||
claimSkillRequest = claimRouteModule.POST;
|
||||
completeSkillRequest = completeRouteModule.POST;
|
||||
baseState = structuredClone(await data.readState());
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
if (runtimeRoot) await rm(runtimeRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await setup();
|
||||
const state = structuredClone(baseState);
|
||||
state.skillLifecycleRequests = [];
|
||||
state.deviceSkills = [
|
||||
{
|
||||
skillId: "mac-studio:boss-server-debug",
|
||||
deviceId: "mac-studio",
|
||||
name: "boss-server-debug",
|
||||
description: "服务器调试",
|
||||
path: "/Users/kris/.codex/skills/boss-server-debug/SKILL.md",
|
||||
invocation: "$boss-server-debug",
|
||||
category: "Mac Studio",
|
||||
updatedAt: "2026-04-26T12:00:00+08:00",
|
||||
},
|
||||
];
|
||||
await data.writeState(state);
|
||||
});
|
||||
|
||||
async function authedRequest(
|
||||
account: string,
|
||||
role: "member" | "admin" | "highest_admin",
|
||||
url: string,
|
||||
init: RequestInit = {},
|
||||
) {
|
||||
const session = await data.createAuthSession({
|
||||
account,
|
||||
role,
|
||||
displayName: account,
|
||||
loginMethod: "password",
|
||||
});
|
||||
return new NextRequest(url, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.headers ?? {}),
|
||||
cookie: `${authCookie}=${session.sessionToken}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function adminPost(body: Record<string, unknown>) {
|
||||
return postSkillRequest(
|
||||
await authedRequest("krisolo", "highest_admin", "http://127.0.0.1:3000/api/v1/admin/skills/requests", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function devicePost(
|
||||
deviceId: string,
|
||||
url: string,
|
||||
body: Record<string, unknown> = {},
|
||||
) {
|
||||
return new NextRequest(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-boss-device-token": deviceId === "mac-studio" ? "boss-mac-studio-token" : `${deviceId}-token`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
test("member cannot create or list skill lifecycle requests", async () => {
|
||||
const getResponse = await getSkillRequests(
|
||||
await authedRequest("worker@example.com", "member", "http://127.0.0.1:3000/api/v1/admin/skills/requests"),
|
||||
);
|
||||
assert.equal(getResponse.status, 403);
|
||||
|
||||
const postResponse = await postSkillRequest(
|
||||
await authedRequest("worker@example.com", "member", "http://127.0.0.1:3000/api/v1/admin/skills/requests", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
action: "install",
|
||||
deviceId: "mac-studio",
|
||||
sourceUrl: "https://git.example.com/org/skill.git",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
assert.equal(postResponse.status, 403);
|
||||
});
|
||||
|
||||
test("highest admin can create install update uninstall rollback and version lock requests", async () => {
|
||||
const cases = [
|
||||
{ action: "install", deviceId: "mac-studio", sourceUrl: "https://git.example.com/org/new-skill.git" },
|
||||
{ action: "update", deviceId: "mac-studio", skillId: "mac-studio:boss-server-debug", targetVersion: "1.2.0" },
|
||||
{ action: "uninstall", deviceId: "mac-studio", skillId: "mac-studio:boss-server-debug" },
|
||||
{ action: "rollback", deviceId: "mac-studio", skillId: "mac-studio:boss-server-debug", rollbackToVersion: "1.1.0" },
|
||||
{ action: "version_lock", deviceId: "mac-studio", skillId: "mac-studio:boss-server-debug", lockedVersion: "1.1.0" },
|
||||
];
|
||||
|
||||
for (const item of cases) {
|
||||
const response = await adminPost(item);
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.ok, true);
|
||||
assert.equal(payload.request.status, "pending");
|
||||
assert.equal(payload.request.deviceId, "mac-studio");
|
||||
assert.equal(payload.request.requestedBy, "krisolo");
|
||||
}
|
||||
|
||||
const listResponse = await getSkillRequests(
|
||||
await authedRequest("krisolo", "highest_admin", "http://127.0.0.1:3000/api/v1/admin/skills/requests"),
|
||||
);
|
||||
assert.equal(listResponse.status, 200);
|
||||
const listPayload = await listResponse.json();
|
||||
assert.deepEqual(
|
||||
listPayload.requests.map((request: { action: string }) => request.action),
|
||||
["version_lock", "rollback", "uninstall", "update", "install"],
|
||||
);
|
||||
|
||||
const state = await data.readState();
|
||||
assert.equal(state.skillLifecycleRequests.length, 5);
|
||||
assert.equal(
|
||||
state.permissionAuditLogs.filter((log) => log.action === "skill.lifecycle.requested").length,
|
||||
5,
|
||||
);
|
||||
});
|
||||
|
||||
test("skill lifecycle request must bind a device and a skill id or source url", async () => {
|
||||
const missingDevice = await adminPost({
|
||||
action: "install",
|
||||
sourceUrl: "https://git.example.com/org/new-skill.git",
|
||||
});
|
||||
assert.equal(missingDevice.status, 400);
|
||||
|
||||
const missingTarget = await adminPost({
|
||||
action: "install",
|
||||
deviceId: "mac-studio",
|
||||
});
|
||||
assert.equal(missingTarget.status, 400);
|
||||
|
||||
const invalidAction = await adminPost({
|
||||
action: "enable",
|
||||
deviceId: "mac-studio",
|
||||
skillId: "mac-studio:boss-server-debug",
|
||||
});
|
||||
assert.equal(invalidAction.status, 400);
|
||||
});
|
||||
|
||||
test("skill lifecycle request preserves trusted source and checksum for device claim", async () => {
|
||||
const createResponse = await adminPost({
|
||||
action: "install",
|
||||
deviceId: "mac-studio",
|
||||
trustedSourceId: "company-skillhub",
|
||||
expectedChecksum: "abc123",
|
||||
});
|
||||
assert.equal(createResponse.status, 200);
|
||||
const createPayload = await createResponse.json();
|
||||
assert.equal(createPayload.request.trustedSourceId, "company-skillhub");
|
||||
assert.equal(createPayload.request.expectedChecksum, "abc123");
|
||||
|
||||
const claimResponse = await claimSkillRequest(
|
||||
await devicePost("mac-studio", "http://127.0.0.1:3000/api/v1/devices/mac-studio/skill-requests/claim"),
|
||||
{ params: Promise.resolve({ deviceId: "mac-studio" }) },
|
||||
);
|
||||
assert.equal(claimResponse.status, 200);
|
||||
const claimPayload = await claimResponse.json();
|
||||
assert.equal(claimPayload.request.trustedSourceId, "company-skillhub");
|
||||
assert.equal(claimPayload.request.expectedChecksum, "abc123");
|
||||
});
|
||||
|
||||
test("skill lifecycle request rejects unknown devices and existing skill mismatches", async () => {
|
||||
const missingDevice = await adminPost({
|
||||
action: "install",
|
||||
deviceId: "missing-device",
|
||||
sourceUrl: "https://git.example.com/org/new-skill.git",
|
||||
});
|
||||
assert.equal(missingDevice.status, 404);
|
||||
assert.equal((await missingDevice.json()).message, "DEVICE_NOT_FOUND");
|
||||
|
||||
const missingSkill = await adminPost({
|
||||
action: "update",
|
||||
deviceId: "mac-studio",
|
||||
skillId: "mac-studio:missing-skill",
|
||||
});
|
||||
assert.equal(missingSkill.status, 404);
|
||||
assert.equal((await missingSkill.json()).message, "SKILL_NOT_FOUND");
|
||||
});
|
||||
|
||||
test("device can claim and complete only its own skill lifecycle requests", async () => {
|
||||
const createResponse = await adminPost({
|
||||
action: "update",
|
||||
deviceId: "mac-studio",
|
||||
skillId: "mac-studio:boss-server-debug",
|
||||
targetVersion: "1.2.0",
|
||||
});
|
||||
assert.equal(createResponse.status, 200);
|
||||
|
||||
const claimResponse = await claimSkillRequest(
|
||||
await devicePost("mac-studio", "http://127.0.0.1:3000/api/v1/devices/mac-studio/skill-requests/claim"),
|
||||
{ params: Promise.resolve({ deviceId: "mac-studio" }) },
|
||||
);
|
||||
assert.equal(claimResponse.status, 200);
|
||||
const claimPayload = await claimResponse.json();
|
||||
assert.equal(claimPayload.ok, true);
|
||||
assert.equal(claimPayload.request.action, "update");
|
||||
assert.equal(claimPayload.request.status, "running");
|
||||
assert.equal(claimPayload.request.claimedByDeviceId, "mac-studio");
|
||||
|
||||
const emptyClaimResponse = await claimSkillRequest(
|
||||
await devicePost("mac-studio", "http://127.0.0.1:3000/api/v1/devices/mac-studio/skill-requests/claim"),
|
||||
{ params: Promise.resolve({ deviceId: "mac-studio" }) },
|
||||
);
|
||||
assert.equal(emptyClaimResponse.status, 200);
|
||||
assert.equal((await emptyClaimResponse.json()).request, null);
|
||||
|
||||
const completeResponse = await completeSkillRequest(
|
||||
await devicePost(
|
||||
"mac-studio",
|
||||
`http://127.0.0.1:3000/api/v1/devices/mac-studio/skill-requests/${claimPayload.request.requestId}/complete`,
|
||||
{
|
||||
status: "completed",
|
||||
resultSummary: "Skill 已更新到 1.2.0",
|
||||
},
|
||||
),
|
||||
{
|
||||
params: Promise.resolve({
|
||||
deviceId: "mac-studio",
|
||||
requestId: claimPayload.request.requestId,
|
||||
}),
|
||||
},
|
||||
);
|
||||
assert.equal(completeResponse.status, 200);
|
||||
const completePayload = await completeResponse.json();
|
||||
assert.equal(completePayload.ok, true);
|
||||
assert.equal(completePayload.request.status, "completed");
|
||||
assert.equal(completePayload.request.resultSummary, "Skill 已更新到 1.2.0");
|
||||
|
||||
const state = await data.readState();
|
||||
assert.equal(state.skillLifecycleRequests[0]?.status, "completed");
|
||||
assert.equal(
|
||||
state.permissionAuditLogs.some((log) => log.action === "skill.lifecycle.completed"),
|
||||
true,
|
||||
);
|
||||
});
|
||||
56
tests/state-store-maintenance-script.test.ts
Normal file
56
tests/state-store-maintenance-script.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const scriptPath = new URL("../scripts/boss-state-store-maintenance.mjs", import.meta.url);
|
||||
|
||||
test("state store maintenance script exposes migration, backup, export, and rollback commands", async () => {
|
||||
const source = await readFile(scriptPath, "utf8");
|
||||
|
||||
for (const command of [
|
||||
"describe",
|
||||
"backup-file",
|
||||
"export-file",
|
||||
"migrate-file-to-postgres",
|
||||
"rollback-postgres-to-file",
|
||||
]) {
|
||||
assert.match(source, new RegExp(command));
|
||||
}
|
||||
assert.match(source, /BOSS_DATABASE_URL/);
|
||||
assert.match(source, /boss_state_snapshots/);
|
||||
});
|
||||
|
||||
test("backup-file dry run validates local state without requiring postgres", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "boss-state-maintenance-"));
|
||||
try {
|
||||
const stateFile = path.join(root, "boss-state.json");
|
||||
await writeFile(stateFile, JSON.stringify({ schemaVersion: 1, migratedAt: "2026-04-27T00:00:00.000Z" }), "utf8");
|
||||
|
||||
const { stdout } = await execFileAsync(process.execPath, [
|
||||
scriptPath.pathname,
|
||||
"backup-file",
|
||||
"--input",
|
||||
stateFile,
|
||||
"--dry-run",
|
||||
], {
|
||||
env: {
|
||||
...process.env,
|
||||
BOSS_STATE_FILE: stateFile,
|
||||
},
|
||||
});
|
||||
|
||||
const payload = JSON.parse(stdout);
|
||||
assert.equal(payload.ok, true);
|
||||
assert.equal(payload.action, "backup-file");
|
||||
assert.equal(payload.dryRun, true);
|
||||
assert.equal(payload.source, stateFile);
|
||||
assert.equal(payload.bytes > 0, true);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
467
tests/telegram-gateway.test.ts
Normal file
467
tests/telegram-gateway.test.ts
Normal file
@@ -0,0 +1,467 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
let runtimeRoot = "";
|
||||
let readState: (typeof import("../src/lib/boss-data"))["readState"];
|
||||
let writeState: (typeof import("../src/lib/boss-data"))["writeState"];
|
||||
let handleTelegramWebhookRequest: (typeof import("../src/lib/telegram-gateway"))["handleTelegramWebhookRequest"];
|
||||
let completeTaskRoute: (typeof import("../src/app/api/v1/master-agent/tasks/[taskId]/complete/route"))["POST"];
|
||||
let baseState: Awaited<ReturnType<typeof import("../src/lib/boss-data")["readState"]>>;
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) return;
|
||||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-telegram-gateway-"));
|
||||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||||
|
||||
const [data, telegramGateway, taskCompleteRoute] = await Promise.all([
|
||||
import("../src/lib/boss-data.ts"),
|
||||
import("../src/lib/telegram-gateway.ts"),
|
||||
import("../src/app/api/v1/master-agent/tasks/[taskId]/complete/route.ts"),
|
||||
]);
|
||||
|
||||
readState = data.readState;
|
||||
writeState = data.writeState;
|
||||
handleTelegramWebhookRequest = telegramGateway.handleTelegramWebhookRequest;
|
||||
completeTaskRoute = taskCompleteRoute.POST;
|
||||
baseState = structuredClone(await data.readState());
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
if (runtimeRoot) {
|
||||
await rm(runtimeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await setup();
|
||||
await writeState(structuredClone(baseState));
|
||||
const state = await readState();
|
||||
state.telegramIntegration = {
|
||||
enabled: true,
|
||||
mode: "webhook",
|
||||
botToken: "bot-token-demo",
|
||||
botUsername: "boss_demo_bot",
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: ["123456"],
|
||||
groupPolicy: "allowlist",
|
||||
groups: [],
|
||||
requireMentionInGroups: true,
|
||||
defaultProjectId: "master-agent",
|
||||
webhookSecret: "boss-telegram-secret",
|
||||
lastConfiguredAt: "2026-04-19T10:00:00+08:00",
|
||||
lastConfiguredBy: "krisolo",
|
||||
processedUpdateIds: [],
|
||||
};
|
||||
await writeState(state);
|
||||
});
|
||||
|
||||
test("Telegram webhook 会拒绝未通过 allowlist 的私聊消息", async () => {
|
||||
await setup();
|
||||
|
||||
const response = await handleTelegramWebhookRequest({
|
||||
request: new NextRequest("http://127.0.0.1:3000/api/v1/integrations/telegram/webhook", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-telegram-bot-api-secret-token": "boss-telegram-secret",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
update_id: 1001,
|
||||
message: {
|
||||
message_id: 301,
|
||||
date: 1_761_000_000,
|
||||
chat: { id: 987654, type: "private" },
|
||||
from: { id: 999999, is_bot: false, first_name: "Guest" },
|
||||
text: "你好",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(response.status, 403);
|
||||
const payload = (await response.json()) as { ok: boolean; message: string };
|
||||
assert.equal(payload.ok, false);
|
||||
assert.equal(payload.message, "TELEGRAM_SENDER_FORBIDDEN");
|
||||
});
|
||||
|
||||
test("Telegram webhook 对 allowlist 私聊会走主 Agent 快速回复并调用 sendMessage", async () => {
|
||||
await setup();
|
||||
const originalFetch = globalThis.fetch;
|
||||
const outboundCalls: Array<{ url: string; body: unknown }> = [];
|
||||
|
||||
globalThis.fetch = (async (input, init) => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url === "https://api.telegram.org/botbot-token-demo/sendMessage") {
|
||||
outboundCalls.push({
|
||||
url,
|
||||
body: JSON.parse(String(init?.body ?? "{}")),
|
||||
});
|
||||
return new Response(JSON.stringify({ ok: true, result: { message_id: 9001 } }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const response = await handleTelegramWebhookRequest({
|
||||
request: new NextRequest("http://127.0.0.1:3000/api/v1/integrations/telegram/webhook", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-telegram-bot-api-secret-token": "boss-telegram-secret",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
update_id: 1002,
|
||||
message: {
|
||||
message_id: 302,
|
||||
date: 1_761_000_001,
|
||||
chat: { id: 123456, type: "private" },
|
||||
from: { id: 123456, is_bot: false, first_name: "Kris" },
|
||||
text: "hello",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const payload = (await response.json()) as { ok: boolean; delivery: string };
|
||||
assert.equal(payload.ok, true);
|
||||
assert.equal(payload.delivery, "sent");
|
||||
assert.equal(outboundCalls.length, 1);
|
||||
assert.equal((outboundCalls[0]?.body as { chat_id: number }).chat_id, 123456);
|
||||
assert.match(String((outboundCalls[0]?.body as { text: string }).text), /主 Agent 可以开始协调/);
|
||||
|
||||
const state = await readState();
|
||||
const project = state.projects.find((item) => item.id === "master-agent");
|
||||
assert.ok(project?.messages.some((message) => message.senderLabel === "Telegram · Kris"));
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("Telegram webhook 对需要排队的消息会记录 externalReplyTarget,任务完成后自动回推 Telegram", async () => {
|
||||
await setup();
|
||||
const originalFetch = globalThis.fetch;
|
||||
const outboundCalls: Array<{ url: string; body: unknown }> = [];
|
||||
|
||||
globalThis.fetch = (async (input, init) => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url === "https://api.telegram.org/botbot-token-demo/sendMessage") {
|
||||
outboundCalls.push({
|
||||
url,
|
||||
body: JSON.parse(String(init?.body ?? "{}")),
|
||||
});
|
||||
return new Response(JSON.stringify({ ok: true, result: { message_id: 9002 } }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const webhookResponse = await handleTelegramWebhookRequest({
|
||||
request: new NextRequest("http://127.0.0.1:3000/api/v1/integrations/telegram/webhook", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-telegram-bot-api-secret-token": "boss-telegram-secret",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
update_id: 1003,
|
||||
message: {
|
||||
message_id: 303,
|
||||
date: 1_761_000_002,
|
||||
chat: { id: 123456, type: "private" },
|
||||
from: { id: 123456, is_bot: false, first_name: "Kris" },
|
||||
text: "请深入分析当前项目并给出迁移方案",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(webhookResponse.status, 200);
|
||||
const webhookPayload = (await webhookResponse.json()) as { ok: boolean; delivery: string; taskId?: string };
|
||||
assert.equal(webhookPayload.ok, true);
|
||||
assert.equal(webhookPayload.delivery, "queued");
|
||||
assert.ok(webhookPayload.taskId);
|
||||
|
||||
const queuedState = await readState();
|
||||
const task = queuedState.masterAgentTasks.find((item) => item.taskId === webhookPayload.taskId);
|
||||
assert.ok(task, "expected queued task");
|
||||
assert.equal(task?.externalReplyTarget?.provider, "telegram");
|
||||
assert.equal(task?.externalReplyTarget?.chatId, "123456");
|
||||
|
||||
const completeResponse = await completeTaskRoute(
|
||||
new NextRequest(`http://127.0.0.1:3000/api/v1/master-agent/tasks/${task?.taskId}/complete`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-boss-device-token": "boss-mac-studio-token",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
deviceId: "mac-studio",
|
||||
status: "completed",
|
||||
replyBody: "已经整理好迁移方案,稍后我会按模块推进。",
|
||||
}),
|
||||
}),
|
||||
{ params: Promise.resolve({ taskId: task!.taskId }) },
|
||||
);
|
||||
|
||||
assert.equal(completeResponse.status, 200);
|
||||
assert.equal(outboundCalls.length, 2);
|
||||
assert.match(String((outboundCalls[1]?.body as { text: string }).text), /已经整理好迁移方案/);
|
||||
|
||||
const completedState = await readState();
|
||||
const completedTask = completedState.masterAgentTasks.find((item) => item.taskId === task?.taskId);
|
||||
assert.equal(completedTask?.externalReplyTarget?.deliveredAt?.includes("T"), true);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("Telegram 群聊在 requireMentionInGroups 开启时,没有 @Bot 不允许进入主 Agent", async () => {
|
||||
await setup();
|
||||
const state = await readState();
|
||||
state.telegramIntegration = {
|
||||
...state.telegramIntegration!,
|
||||
botUsername: "boss_demo_bot",
|
||||
groupPolicy: "allowlist",
|
||||
groups: ["-100200300"],
|
||||
requireMentionInGroups: true,
|
||||
};
|
||||
await writeState(state);
|
||||
|
||||
const response = await handleTelegramWebhookRequest({
|
||||
request: new NextRequest("http://127.0.0.1:3000/api/v1/integrations/telegram/webhook", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-telegram-bot-api-secret-token": "boss-telegram-secret",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
update_id: 1004,
|
||||
message: {
|
||||
message_id: 304,
|
||||
date: 1_761_000_003,
|
||||
chat: { id: -100200300, type: "supergroup", title: "Boss 协作群" },
|
||||
from: { id: 123456, is_bot: false, first_name: "Kris" },
|
||||
text: "请总结一下今天进展",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
const payload = (await response.json()) as { ok: boolean; message: string };
|
||||
assert.equal(payload.ok, false);
|
||||
assert.equal(payload.message, "TELEGRAM_GROUP_MENTION_REQUIRED");
|
||||
});
|
||||
|
||||
test("Telegram 群聊命中 @Bot 时会清洗 mention 后再进入主 Agent", async () => {
|
||||
await setup();
|
||||
const state = await readState();
|
||||
state.telegramIntegration = {
|
||||
...state.telegramIntegration!,
|
||||
botUsername: "boss_demo_bot",
|
||||
groupPolicy: "allowlist",
|
||||
groups: ["-100200300"],
|
||||
requireMentionInGroups: true,
|
||||
};
|
||||
await writeState(state);
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const outboundCalls: Array<{ url: string; body: unknown }> = [];
|
||||
|
||||
globalThis.fetch = (async (input, init) => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url === "https://api.telegram.org/botbot-token-demo/sendMessage") {
|
||||
outboundCalls.push({
|
||||
url,
|
||||
body: JSON.parse(String(init?.body ?? "{}")),
|
||||
});
|
||||
return new Response(JSON.stringify({ ok: true, result: { message_id: 9003 } }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const response = await handleTelegramWebhookRequest({
|
||||
request: new NextRequest("http://127.0.0.1:3000/api/v1/integrations/telegram/webhook", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-telegram-bot-api-secret-token": "boss-telegram-secret",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
update_id: 1005,
|
||||
message: {
|
||||
message_id: 305,
|
||||
date: 1_761_000_004,
|
||||
chat: { id: -100200300, type: "supergroup", title: "Boss 协作群" },
|
||||
from: { id: 123456, is_bot: false, first_name: "Kris" },
|
||||
text: "@boss_demo_bot hello",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(outboundCalls.length, 1);
|
||||
const nextState = await readState();
|
||||
const project = nextState.projects.find((item) => item.id === "master-agent");
|
||||
const telegramMessage = project?.messages.find((item) => item.senderLabel === "Telegram · Boss 协作群 · Kris");
|
||||
assert.equal(telegramMessage?.body, "hello");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("Telegram 群聊回复 Bot 上一条消息时,即使没有 @Bot 也允许进入主 Agent", async () => {
|
||||
await setup();
|
||||
const state = await readState();
|
||||
state.telegramIntegration = {
|
||||
...state.telegramIntegration!,
|
||||
botUsername: "boss_demo_bot",
|
||||
groupPolicy: "allowlist",
|
||||
groups: ["-100200300"],
|
||||
requireMentionInGroups: true,
|
||||
};
|
||||
await writeState(state);
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const outboundCalls: Array<{ url: string; body: unknown }> = [];
|
||||
|
||||
globalThis.fetch = (async (input, init) => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url === "https://api.telegram.org/botbot-token-demo/sendMessage") {
|
||||
outboundCalls.push({
|
||||
url,
|
||||
body: JSON.parse(String(init?.body ?? "{}")),
|
||||
});
|
||||
return new Response(JSON.stringify({ ok: true, result: { message_id: 9004 } }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const response = await handleTelegramWebhookRequest({
|
||||
request: new NextRequest("http://127.0.0.1:3000/api/v1/integrations/telegram/webhook", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-telegram-bot-api-secret-token": "boss-telegram-secret",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
update_id: 1006,
|
||||
message: {
|
||||
message_id: 306,
|
||||
date: 1_761_000_005,
|
||||
chat: { id: -100200300, type: "supergroup", title: "Boss 协作群" },
|
||||
from: { id: 123456, is_bot: false, first_name: "Kris" },
|
||||
reply_to_message: {
|
||||
message_id: 299,
|
||||
date: 1_761_000_000,
|
||||
chat: { id: -100200300, type: "supergroup", title: "Boss 协作群" },
|
||||
from: { id: 777888, is_bot: true, username: "boss_demo_bot", first_name: "Boss" },
|
||||
text: "上一条 bot 回复",
|
||||
},
|
||||
text: "继续展开这个方案",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(outboundCalls.length, 1);
|
||||
const nextState = await readState();
|
||||
const project = nextState.projects.find((item) => item.id === "master-agent");
|
||||
const telegramMessage = project?.messages.find((item) => item.body === "继续展开这个方案");
|
||||
assert.equal(telegramMessage?.senderLabel, "Telegram · Boss 协作群 · Kris");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("Telegram 群聊可按 chat id 路由到指定 Boss 项目", async () => {
|
||||
await setup();
|
||||
const state = await readState();
|
||||
state.telegramIntegration = {
|
||||
...state.telegramIntegration!,
|
||||
botUsername: "boss_demo_bot",
|
||||
groupPolicy: "allowlist",
|
||||
groups: ["-100200300"],
|
||||
requireMentionInGroups: true,
|
||||
defaultProjectId: "master-agent",
|
||||
groupProjectRoutes: [
|
||||
{
|
||||
chatId: "-100200300",
|
||||
projectId: "audit-collab",
|
||||
label: "审计 Telegram 群",
|
||||
},
|
||||
],
|
||||
};
|
||||
await writeState(state);
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input) => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url === "https://api.telegram.org/botbot-token-demo/sendMessage") {
|
||||
return new Response(JSON.stringify({ ok: true, result: { message_id: 9005 } }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const response = await handleTelegramWebhookRequest({
|
||||
request: new NextRequest("http://127.0.0.1:3000/api/v1/integrations/telegram/webhook", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-telegram-bot-api-secret-token": "boss-telegram-secret",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
update_id: 1007,
|
||||
message: {
|
||||
message_id: 307,
|
||||
date: 1_761_000_006,
|
||||
chat: { id: -100200300, type: "supergroup", title: "Boss 协作群" },
|
||||
from: { id: 123456, is_bot: false, first_name: "Kris" },
|
||||
text: "@boss_demo_bot 汇总审计群今天的风险",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const nextState = await readState();
|
||||
const masterProject = nextState.projects.find((item) => item.id === "master-agent");
|
||||
const auditProject = nextState.projects.find((item) => item.id === "audit-collab");
|
||||
assert.equal(
|
||||
masterProject?.messages.some((message) => message.body === "汇总审计群今天的风险"),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
auditProject?.messages.some((message) => message.body === "汇总审计群今天的风险"),
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
225
tests/telegram-integration-route.test.ts
Normal file
225
tests/telegram-integration-route.test.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
let runtimeRoot = "";
|
||||
let getRoute: (typeof import("../src/app/api/v1/integrations/telegram/route"))["GET"];
|
||||
let postRoute: (typeof import("../src/app/api/v1/integrations/telegram/route"))["POST"];
|
||||
let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"];
|
||||
let AUTH_SESSION_COOKIE = "";
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) return;
|
||||
runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-telegram-route-"));
|
||||
process.env.BOSS_RUNTIME_ROOT = runtimeRoot;
|
||||
process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json");
|
||||
|
||||
const [routeModule, data, auth] = await Promise.all([
|
||||
import("../src/app/api/v1/integrations/telegram/route.ts"),
|
||||
import("../src/lib/boss-data.ts"),
|
||||
import("../src/lib/boss-auth.ts"),
|
||||
]);
|
||||
getRoute = routeModule.GET;
|
||||
postRoute = routeModule.POST;
|
||||
createAuthSession = data.createAuthSession;
|
||||
AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE;
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
if (runtimeRoot) {
|
||||
await rm(runtimeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function createAuthedRequest(url: string, method: "GET" | "POST", body?: unknown) {
|
||||
const session = await createAuthSession({
|
||||
account: "krisolo",
|
||||
role: "highest_admin",
|
||||
displayName: "Boss 超级管理员",
|
||||
loginMethod: "password",
|
||||
});
|
||||
return new NextRequest(url, {
|
||||
method,
|
||||
headers: {
|
||||
cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`,
|
||||
...(body ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
test("Telegram 配置接口返回脱敏配置视图", async () => {
|
||||
await setup();
|
||||
|
||||
const saveResponse = await postRoute(
|
||||
await createAuthedRequest("http://127.0.0.1:3000/api/v1/integrations/telegram", "POST", {
|
||||
enabled: true,
|
||||
botToken: "123:abc",
|
||||
allowFrom: ["123456"],
|
||||
webhookSecret: "secret-demo",
|
||||
}),
|
||||
);
|
||||
assert.equal(saveResponse.status, 200);
|
||||
|
||||
const getResponse = await getRoute(
|
||||
await createAuthedRequest("http://127.0.0.1:3000/api/v1/integrations/telegram", "GET"),
|
||||
);
|
||||
assert.equal(getResponse.status, 200);
|
||||
const payload = (await getResponse.json()) as {
|
||||
ok: boolean;
|
||||
telegram: {
|
||||
botTokenConfigured: boolean;
|
||||
webhookSecretConfigured: boolean;
|
||||
allowFrom: string[];
|
||||
};
|
||||
};
|
||||
assert.equal(payload.ok, true);
|
||||
assert.equal(payload.telegram.botTokenConfigured, true);
|
||||
assert.equal(payload.telegram.webhookSecretConfigured, true);
|
||||
assert.deepEqual(payload.telegram.allowFrom, ["123456"]);
|
||||
assert.equal("botToken" in payload.telegram, false);
|
||||
});
|
||||
|
||||
test("Telegram 配置保存到 webhook 模式时会自动调用 setWebhook", async () => {
|
||||
await setup();
|
||||
const originalFetch = globalThis.fetch;
|
||||
const calls: Array<{ url: string; body?: unknown }> = [];
|
||||
|
||||
globalThis.fetch = (async (input, init) => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
calls.push({
|
||||
url,
|
||||
body: init?.body ? JSON.parse(String(init.body)) : undefined,
|
||||
});
|
||||
if (url === "https://api.telegram.org/bot123:abc/setWebhook") {
|
||||
return new Response(JSON.stringify({ ok: true, result: true }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const response = await postRoute(
|
||||
await createAuthedRequest("http://127.0.0.1:3000/api/v1/integrations/telegram", "POST", {
|
||||
enabled: true,
|
||||
mode: "webhook",
|
||||
botToken: "123:abc",
|
||||
webhookSecret: "boss-telegram-secret",
|
||||
webhookUrl: "https://boss.hyzq.net/api/v1/integrations/telegram/webhook",
|
||||
}),
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
const payload = (await response.json()) as {
|
||||
ok: boolean;
|
||||
webhookSync?: { ok: boolean; action: string };
|
||||
};
|
||||
assert.equal(payload.ok, true);
|
||||
assert.equal(payload.webhookSync?.ok, true);
|
||||
assert.equal(payload.webhookSync?.action, "set_webhook");
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0]?.url, "https://api.telegram.org/bot123:abc/setWebhook");
|
||||
assert.deepEqual(calls[0]?.body, {
|
||||
url: "https://boss.hyzq.net/api/v1/integrations/telegram/webhook",
|
||||
secret_token: "boss-telegram-secret",
|
||||
drop_pending_updates: false,
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("Telegram 配置切回 polling 时会自动调用 deleteWebhook", async () => {
|
||||
await setup();
|
||||
const originalFetch = globalThis.fetch;
|
||||
const calls: string[] = [];
|
||||
|
||||
globalThis.fetch = (async (input) => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
calls.push(url);
|
||||
if (url === "https://api.telegram.org/bot123:abc/deleteWebhook") {
|
||||
return new Response(JSON.stringify({ ok: true, result: true }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const response = await postRoute(
|
||||
await createAuthedRequest("http://127.0.0.1:3000/api/v1/integrations/telegram", "POST", {
|
||||
enabled: true,
|
||||
mode: "polling",
|
||||
botToken: "123:abc",
|
||||
}),
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
const payload = (await response.json()) as {
|
||||
ok: boolean;
|
||||
webhookSync?: { ok: boolean; action: string };
|
||||
};
|
||||
assert.equal(payload.ok, true);
|
||||
assert.equal(payload.webhookSync?.ok, true);
|
||||
assert.equal(payload.webhookSync?.action, "delete_webhook");
|
||||
assert.deepEqual(calls, ["https://api.telegram.org/bot123:abc/deleteWebhook"]);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("Telegram 配置接口可以保存群聊到项目的路由表", async () => {
|
||||
await setup();
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = (async (input) => {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url === "https://api.telegram.org/bot123:abc/deleteWebhook") {
|
||||
return new Response(JSON.stringify({ ok: true, result: true }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const response = await postRoute(
|
||||
await createAuthedRequest("http://127.0.0.1:3000/api/v1/integrations/telegram", "POST", {
|
||||
enabled: true,
|
||||
groupProjectRoutes: [
|
||||
{
|
||||
chatId: "-100200300",
|
||||
projectId: "audit-collab",
|
||||
label: "审计群",
|
||||
},
|
||||
{
|
||||
chatId: "-100200300",
|
||||
threadId: 12,
|
||||
projectId: "master-agent",
|
||||
label: "主控 Topic",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const payload = (await response.json()) as {
|
||||
ok: boolean;
|
||||
telegram: {
|
||||
groupProjectRoutes: Array<{ chatId: string; threadId?: number; projectId: string; label?: string }>;
|
||||
};
|
||||
};
|
||||
assert.equal(payload.ok, true);
|
||||
assert.deepEqual(payload.telegram.groupProjectRoutes, [
|
||||
{ chatId: "-100200300", projectId: "audit-collab", label: "审计群" },
|
||||
{ chatId: "-100200300", threadId: 12, projectId: "master-agent", label: "主控 Topic" },
|
||||
]);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
@@ -79,7 +79,7 @@ function buildSingleThreadProject(projectId: string) {
|
||||
|
||||
async function createAuthedRequest(projectId: string, body: { body: string }) {
|
||||
const session = await createAuthSession({
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
role: "highest_admin",
|
||||
displayName: "Boss 超级管理员",
|
||||
loginMethod: "password",
|
||||
|
||||
@@ -40,7 +40,7 @@ test.after(async () => {
|
||||
|
||||
async function createAuthedRequest(url: string) {
|
||||
const session = await createAuthSession({
|
||||
account: "17600003315",
|
||||
account: "krisolo",
|
||||
role: "highest_admin",
|
||||
displayName: "Boss 超级管理员",
|
||||
loginMethod: "password",
|
||||
|
||||
@@ -3,18 +3,20 @@ import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import type { BossState, Project, ThreadProgressEvent, ThreadStatusDocument } from "../src/lib/boss-data.ts";
|
||||
import type { BossState, MasterAgentTask, Project, ThreadProgressEvent, ThreadStatusDocument } from "../src/lib/boss-data.ts";
|
||||
|
||||
let runtimeRoot = "";
|
||||
let readState: (typeof import("../src/lib/boss-data"))["readState"];
|
||||
let writeState: (typeof import("../src/lib/boss-data"))["writeState"];
|
||||
let appendProjectMessage: (typeof import("../src/lib/boss-data"))["appendProjectMessage"];
|
||||
let updateProjectAgentControls: (typeof import("../src/lib/boss-data"))["updateProjectAgentControls"];
|
||||
|
||||
type MutableBossState = BossState & {
|
||||
threadStatusDocuments: ThreadStatusDocument[];
|
||||
threadProgressEvents: ThreadProgressEvent[];
|
||||
projects: Project[];
|
||||
};
|
||||
threadStatusDocuments: ThreadStatusDocument[];
|
||||
threadProgressEvents: ThreadProgressEvent[];
|
||||
masterAgentTasks: MasterAgentTask[];
|
||||
projects: Project[];
|
||||
};
|
||||
|
||||
async function setup() {
|
||||
if (runtimeRoot) return;
|
||||
@@ -27,6 +29,7 @@ async function setup() {
|
||||
readState = data.readState;
|
||||
writeState = data.writeState;
|
||||
appendProjectMessage = data.appendProjectMessage;
|
||||
updateProjectAgentControls = data.updateProjectAgentControls;
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
@@ -208,3 +211,149 @@ test("thread replies append lightweight progress events and skip redundant under
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("turning off single-thread takeover clears pending project understanding sync tasks for that thread", async () => {
|
||||
await setup();
|
||||
|
||||
const projectId = "thread-sync-takeover-clear";
|
||||
const state = (await readState()) as MutableBossState;
|
||||
state.projects = state.projects.filter((project) => project.id !== projectId);
|
||||
state.userProjectAgentControls = state.userProjectAgentControls.filter(
|
||||
(item) => item.projectId !== projectId,
|
||||
);
|
||||
state.masterAgentTasks = state.masterAgentTasks.filter(
|
||||
(task) => task.projectUnderstandingTargetProjectId !== projectId,
|
||||
);
|
||||
state.projects.push({
|
||||
id: projectId,
|
||||
name: "接管关闭清理演示",
|
||||
pinned: false,
|
||||
deviceIds: ["mac-studio"],
|
||||
preview: "等待同步",
|
||||
updatedAt: "2026-04-04T18:00:00+08:00",
|
||||
lastMessageAt: "2026-04-04T18:00:00+08:00",
|
||||
isGroup: false,
|
||||
threadMeta: {
|
||||
projectId,
|
||||
threadId: "thread-sync-takeover-clear-thread",
|
||||
threadDisplayName: "接管关闭清理演示",
|
||||
folderName: "演示文件夹",
|
||||
activityIconCount: 1,
|
||||
updatedAt: "2026-04-04T18:00:00+08:00",
|
||||
codexThreadRef: "thread-sync-takeover-clear-thread",
|
||||
codexFolderRef: "thread-sync-takeover-clear-folder",
|
||||
},
|
||||
groupMembers: [],
|
||||
createdByAgent: false,
|
||||
collaborationMode: "development",
|
||||
approvalState: "not_required",
|
||||
unreadCount: 0,
|
||||
riskLevel: "low",
|
||||
messages: [],
|
||||
goals: [],
|
||||
versions: [],
|
||||
} as Project);
|
||||
state.userProjectAgentControls.push({
|
||||
account: "krisolo",
|
||||
projectId,
|
||||
controls: {
|
||||
takeoverEnabled: true,
|
||||
updatedAt: "2026-04-04T18:00:00+08:00",
|
||||
},
|
||||
});
|
||||
state.masterAgentTasks.unshift(
|
||||
{
|
||||
taskId: "queued-understanding-clear",
|
||||
projectId: "master-agent",
|
||||
taskType: "conversation_reply",
|
||||
requestMessageId: "message-understanding-clear",
|
||||
requestText: "请同步项目状态",
|
||||
executionPrompt: "你正在向主 Agent 同步当前项目状态。",
|
||||
requestedBy: "krisolo",
|
||||
requestedByAccount: "krisolo",
|
||||
deviceId: "mac-studio",
|
||||
targetProjectId: projectId,
|
||||
targetThreadId: "thread-sync-takeover-clear-thread",
|
||||
targetThreadDisplayName: "接管关闭清理演示",
|
||||
projectUnderstandingTargetProjectId: projectId,
|
||||
projectUnderstandingReason: "heartbeat_activity",
|
||||
status: "queued",
|
||||
requestedAt: "2026-04-04T18:01:00+08:00",
|
||||
},
|
||||
{
|
||||
taskId: "completed-understanding-kept",
|
||||
projectId: "master-agent",
|
||||
taskType: "conversation_reply",
|
||||
requestMessageId: "message-understanding-completed",
|
||||
requestText: "请同步项目状态",
|
||||
executionPrompt: "你正在向主 Agent 同步当前项目状态。",
|
||||
requestedBy: "krisolo",
|
||||
requestedByAccount: "krisolo",
|
||||
deviceId: "mac-studio",
|
||||
targetProjectId: projectId,
|
||||
targetThreadId: "thread-sync-takeover-clear-thread",
|
||||
targetThreadDisplayName: "接管关闭清理演示",
|
||||
projectUnderstandingTargetProjectId: projectId,
|
||||
projectUnderstandingReason: "heartbeat_activity",
|
||||
status: "completed",
|
||||
requestedAt: "2026-04-04T18:00:30+08:00",
|
||||
completedAt: "2026-04-04T18:00:45+08:00",
|
||||
replyBody: "{}",
|
||||
},
|
||||
);
|
||||
|
||||
await writeState(state);
|
||||
|
||||
const controls = await updateProjectAgentControls(projectId, { takeoverEnabled: false }, "krisolo");
|
||||
assert.equal(controls?.effectiveTakeoverEnabled, false);
|
||||
|
||||
const after = (await readState()) as MutableBossState;
|
||||
assert.equal(
|
||||
after.masterAgentTasks.some((task) => task.taskId === "queued-understanding-clear"),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
after.masterAgentTasks.some((task) => task.taskId === "completed-understanding-kept"),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("turning off global takeover clears pending project understanding sync tasks", async () => {
|
||||
await setup();
|
||||
|
||||
const projectId = "thread-sync-global-clear";
|
||||
const state = (await readState()) as MutableBossState;
|
||||
state.masterAgentTasks = state.masterAgentTasks.filter(
|
||||
(task) => task.projectUnderstandingTargetProjectId !== projectId,
|
||||
);
|
||||
state.masterAgentTasks.unshift({
|
||||
taskId: "running-global-understanding-clear",
|
||||
projectId: "master-agent",
|
||||
taskType: "conversation_reply",
|
||||
requestMessageId: "message-global-understanding-clear",
|
||||
requestText: "请同步项目状态",
|
||||
executionPrompt: "你正在向主 Agent 同步当前项目状态。",
|
||||
requestedBy: "krisolo",
|
||||
requestedByAccount: "krisolo",
|
||||
deviceId: "mac-studio",
|
||||
targetProjectId: projectId,
|
||||
targetThreadId: "thread-sync-global-clear-thread",
|
||||
targetThreadDisplayName: "全局接管清理演示",
|
||||
projectUnderstandingTargetProjectId: projectId,
|
||||
projectUnderstandingReason: "heartbeat_activity",
|
||||
status: "running",
|
||||
requestedAt: "2026-04-04T18:02:00+08:00",
|
||||
claimedAt: "2026-04-04T18:02:05+08:00",
|
||||
});
|
||||
|
||||
await writeState(state);
|
||||
await updateProjectAgentControls("master-agent", { globalTakeoverEnabled: true }, "krisolo");
|
||||
const controls = await updateProjectAgentControls("master-agent", { globalTakeoverEnabled: false }, "krisolo");
|
||||
assert.equal(controls?.globalTakeoverEnabled, false);
|
||||
|
||||
const after = (await readState()) as MutableBossState;
|
||||
assert.equal(
|
||||
after.masterAgentTasks.some((task) => task.taskId === "running-global-understanding-clear"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user