Merge remote-tracking branch 'educraft/main' into merge/educraft-cph

# Conflicts:
#	.gitignore
#	hub/.env.example
#	hub/deploy/deploy_fleet_release.sh
#	hub/deploy/deploy_platform.sh
#	hub/test/integration/helpers.ts
This commit is contained in:
2026-08-06 00:49:02 +08:00
262 changed files with 10630 additions and 1377 deletions
+2 -1
View File
@@ -242,7 +242,8 @@ describe("admin auth + org API guards", () => {
headers: { cookie: `${OAUTH_STATE_COOKIE_NAME}=${nonce}` },
});
expect(res.statusCode).toBe(302);
expect(res.headers.location).toBe("/admin");
// New users without membership land on login error; session is still set.
expect(res.headers.location).toBe("/admin/login?error=no_organization");
expect(JSON.stringify(res.headers["set-cookie"])).toContain("cph_session=");
const user = await prisma.user.findUnique({ where: { feishuOpenId: "ou_new" } });
@@ -43,7 +43,7 @@ describe("Organization Agent configuration management", () => {
provider: "openrouter",
roleId: "draft",
model: "anthropic/claude-sonnet-5",
metadata: {},
metadata: { claudeSessionId: "sdk-session-old", userResumable: true },
},
});
await configuration.setRoleSkills({
@@ -59,11 +59,12 @@ describe("Organization Agent configuration management", () => {
expect(role).toMatchObject({ label: "课程草稿", systemPrompt: "write carefully" });
expect(role.tools).toEqual(["read_file", "write_file", "cph_build"]);
expect(role.skillBindings.map((binding) => binding.skill.name)).toEqual(["outline", "typst"]);
await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: "session-old-role-config" } }))
.resolves.toMatchObject({
archivedAt: expect.any(Date),
metadata: expect.objectContaining({ userResumable: false }),
});
// Execution-surface change invalidates the provider session cursor but
// keeps the Hub session alive so its transcript stays reachable.
const invalidated = await prisma.agentSession.findUniqueOrThrow({ where: { id: "session-old-role-config" } });
expect(invalidated.archivedAt).toBeNull();
expect(invalidated.metadata).toEqual(expect.objectContaining({ userResumable: false }));
expect(invalidated.metadata).not.toHaveProperty("claudeSessionId");
});
it("rejects unknown, disabled and cross-Organization skills", async () => {
@@ -118,6 +119,105 @@ describe("Organization Agent configuration management", () => {
})).rejects.toThrow("must have exactly one active default Agent role");
});
it("groups roles and skills in the shared folder tree (ADR-0028)", async () => {
const teaching = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "教学" });
const lessonPrep = await configuration.createFolder({
organizationId: DEFAULT_ORG_ID,
name: "备课",
parentId: teaching.id,
});
expect(lessonPrep.parentId).toBe(teaching.id);
const listed = await configuration.listFolders({ organizationId: DEFAULT_ORG_ID });
expect(listed.map((folder) => folder.id).sort()).toEqual([teaching.id, lessonPrep.id].sort());
const typst = await makeSkill(root, "typst");
await configuration.installSkill({ organizationId: DEFAULT_ORG_ID, sourceDir: typst, version: "1" });
await configuration.setSkillFolder({ organizationId: DEFAULT_ORG_ID, name: "typst", folderId: lessonPrep.id });
await configuration.setRoleFolder({ organizationId: DEFAULT_ORG_ID, roleId: "draft", folderId: teaching.id });
const skills = await configuration.listSkills({ organizationId: DEFAULT_ORG_ID });
expect(skills.find((skill) => skill.name === "typst")?.folderId).toBe(lessonPrep.id);
const roles = await configuration.listRoles({ organizationId: DEFAULT_ORG_ID });
expect(roles.find((role) => role.roleId === "draft")?.folderId).toBe(teaching.id);
const renamed = await configuration.updateFolder({
organizationId: DEFAULT_ORG_ID,
folderId: teaching.id,
name: "教研",
});
expect(renamed.name).toBe("教研");
});
it("moves folders within the tree and rejects moves below a descendant", async () => {
const a = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "a" });
const b = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "b", parentId: a.id });
const c = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "c" });
const moved = await configuration.updateFolder({ organizationId: DEFAULT_ORG_ID, folderId: c.id, parentId: b.id });
expect(moved.parentId).toBe(b.id);
await expect(configuration.updateFolder({ organizationId: DEFAULT_ORG_ID, folderId: a.id, parentId: c.id }))
.rejects.toThrow("folder cannot be moved below its descendant");
await expect(configuration.updateFolder({ organizationId: DEFAULT_ORG_ID, folderId: a.id, parentId: a.id }))
.rejects.toThrow("folder cannot be its own parent");
});
it("deletes only empty folders", async () => {
const folder = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "非空" });
const typst = await makeSkill(root, "typst");
await configuration.installSkill({ organizationId: DEFAULT_ORG_ID, sourceDir: typst, version: "1" });
await configuration.setSkillFolder({ organizationId: DEFAULT_ORG_ID, name: "typst", folderId: folder.id });
await expect(configuration.deleteFolder({ organizationId: DEFAULT_ORG_ID, folderId: folder.id }))
.rejects.toThrow("still has");
const parent = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "父" });
await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "子", parentId: parent.id });
await expect(configuration.deleteFolder({ organizationId: DEFAULT_ORG_ID, folderId: parent.id }))
.rejects.toThrow("child folder");
await configuration.setSkillFolder({ organizationId: DEFAULT_ORG_ID, name: "typst", folderId: null });
await configuration.deleteFolder({ organizationId: DEFAULT_ORG_ID, folderId: folder.id });
const remaining = await configuration.listFolders({ organizationId: DEFAULT_ORG_ID });
expect(remaining.map((f) => f.id)).not.toContain(folder.id);
const skill = (await configuration.listSkills({ organizationId: DEFAULT_ORG_ID })).find((s) => s.name === "typst");
expect(skill?.folderId).toBeNull();
});
it("rejects cross-Organization folder assignment", async () => {
await seedTestOrganization("org_other", "other");
const otherFolder = await configuration.createFolder({ organizationId: "org_other", name: "外部" });
const typst = await makeSkill(root, "typst");
await configuration.installSkill({ organizationId: DEFAULT_ORG_ID, sourceDir: typst, version: "1" });
await expect(configuration.setSkillFolder({
organizationId: DEFAULT_ORG_ID,
name: "typst",
folderId: otherFolder.id,
})).rejects.toThrow("folder not found in organization");
await expect(configuration.setRoleFolder({
organizationId: DEFAULT_ORG_ID,
roleId: "draft",
folderId: otherFolder.id,
})).rejects.toThrow("folder not found in organization");
});
it("treats folder assignment as a label-class change (no session archival)", async () => {
await prisma.project.create({
data: { id: "project-a", organizationId: DEFAULT_ORG_ID, name: "A", workspaceDir: "/tmp/a" },
});
await prisma.agentSession.create({
data: {
id: "session-folder-assignment",
projectId: "project-a",
provider: "openrouter",
roleId: "draft",
model: "anthropic/claude-sonnet-5",
metadata: {},
},
});
const folder = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "分组" });
await configuration.setRoleFolder({ organizationId: DEFAULT_ORG_ID, roleId: "draft", folderId: folder.id });
await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: "session-folder-assignment" } }))
.resolves.toMatchObject({ archivedAt: null });
});
async function makeSkill(parent: string, name: string): Promise<string> {
const source = join(parent, "sources", name);
await mkdir(source, { recursive: true });
@@ -157,9 +157,10 @@ describe("real Claude SDK sandbox boundary", () => {
[result.error, sdkStderr.join(""), JSON.stringify(streamEvents)].filter(Boolean).join("\n"),
).toBe("completed");
expect(stub.requestCount()).toBeGreaterThanOrEqual(3);
expect(new Set(result.initializedSkillIds)).toEqual(new Set([
"cph-runtime:outline",
]));
const skillIds = new Set(result.initializedSkillIds ?? []);
expect(skillIds.has("cph-runtime:outline")).toBe(true);
// Workspace-local untrusted skills must never load (ADR-0018).
expect([...skillIds].some((id) => id.includes("untrusted"))).toBe(false);
const toolResults = streamEvents.filter((event) => event.type === "tool-result");
expect(toolResults).toHaveLength(2);
const rejectedOptOut = toolResults[0];
@@ -0,0 +1,187 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { prisma, resetDb, seedTestOrganization, testSecretEnvelope, DEFAULT_ORG_ID } from "./helpers.js";
import { createPbankService } from "../../src/capability/pbank.js";
import type { PbankClient, PbankLoginResult } from "../../src/capability/pbankClient.js";
import type { PbankCapabilitySecretPayload } from "../../src/capability/types.js";
import { CapabilityConnectionUnavailable } from "../../src/capability/types.js";
const CAPABILITY_ID = "pbank";
const PROBLEM_ID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee";
describe("pbank capability service (ADR-0027)", () => {
let workspaceRoot: string;
let runId: string;
beforeEach(async () => {
await resetDb();
await seedTestOrganization();
workspaceRoot = await mkdtemp(join(tmpdir(), "cph-pbank-"));
runId = "run-pbank-test";
await prisma.project.create({
data: {
id: "proj-pbank",
organizationId: DEFAULT_ORG_ID,
name: "PBank Test",
workspaceDir: workspaceRoot,
},
});
await prisma.agentRun.create({
data: {
id: runId,
projectId: "proj-pbank",
entrypoint: "FEISHU",
provider: "openrouter",
model: "mock-model",
status: "ACTIVE",
prompt: "search pbank",
metadata: {},
},
});
});
afterEach(async () => {
await rm(workspaceRoot, { recursive: true, force: true }).catch(() => {});
});
async function seedActiveConnection(): Promise<void> {
const payload: PbankCapabilitySecretPayload = {
schemaVersion: 1,
kind: "pbank",
baseUrl: "https://pbank.example/api",
username: "teacher",
password: "secret-never-log",
rightsStatus: "owned",
rightsHolder: "Paradigm Education",
};
const connection = await prisma.organizationCapabilityConnection.create({
data: {
id: "pbank-conn-1",
organizationId: DEFAULT_ORG_ID,
capabilityId: CAPABILITY_ID,
status: "ACTIVE",
activatedAt: new Date(),
},
});
const envelope = testSecretEnvelope.encryptJson(
{
purpose: "capability",
organizationId: DEFAULT_ORG_ID,
connectionId: connection.id,
secretVersionId: "pbank-sv-1",
},
payload,
);
await prisma.capabilityCredentialVersion.create({
data: {
id: "pbank-sv-1",
connectionId: connection.id,
version: 1,
envelope: envelope as object,
keyId: envelope.keyId,
},
});
await prisma.organizationCapabilityConnection.update({
where: { id: connection.id },
data: { activeSecretVersionId: "pbank-sv-1" },
});
}
function mockClient(): PbankClient {
const login: PbankLoginResult = { token: "tok-1", expiresAt: Date.now() + 60_000 };
return {
login: vi.fn(async () => login),
searchProblems: vi.fn(async () => ({
pageNum: 1,
pageSize: 10,
total: 1,
items: [{ id: PROBLEM_ID, title: "示例题" }],
})),
getProblem: vi.fn(async () => ({ id: PROBLEM_ID, title: "示例题" })),
getOccurrences: vi.fn(async () => ({ items: [] })),
downloadProject: vi.fn(async () => ({
buffer: Buffer.from("# problem\n"),
contentType: "text/plain",
})),
};
}
it("fails closed when no ACTIVE connection exists", async () => {
const service = createPbankService({
prisma,
secrets: testSecretEnvelope,
client: mockClient(),
});
await expect(
service.searchProblems(
{ organizationId: DEFAULT_ORG_ID, runId, workspaceDir: workspaceRoot },
{ q: "函数" },
),
).rejects.toBeInstanceOf(CapabilityConnectionUnavailable);
});
it("searches via org credential and writes UsageFact without leaking password", async () => {
await seedActiveConnection();
const client = mockClient();
const service = createPbankService({
prisma,
secrets: testSecretEnvelope,
client,
});
const result = await service.searchProblems(
{ organizationId: DEFAULT_ORG_ID, runId, workspaceDir: workspaceRoot },
{ q: "函数", pageNum: 1, pageSize: 10 },
);
expect(client.login).toHaveBeenCalledTimes(1);
const loginArg = (client.login as ReturnType<typeof vi.fn>).mock.calls[0]?.[0] as PbankCapabilitySecretPayload;
expect(loginArg.username).toBe("teacher");
expect(loginArg.password).toBe("secret-never-log");
expect(loginArg.kind).toBe("pbank");
expect(result.data).toMatchObject({
rights: { derivativeUseAllowed: true, status: "owned" },
items: [{ id: PROBLEM_ID }],
});
expect(JSON.stringify(result.data)).not.toContain("secret-never-log");
const facts = await prisma.usageFact.findMany({ where: { runId } });
expect(facts).toHaveLength(1);
expect(facts[0]).toMatchObject({
kind: "external_capability",
capabilityId: CAPABILITY_ID,
provider: "paradigm_pbank",
unit: "requests",
quantity: expect.anything(),
costUsd: null,
costSource: "unknown",
});
});
it("fetches one problem and attaches rights", async () => {
await seedActiveConnection();
const client = mockClient();
const service = createPbankService({
prisma,
secrets: testSecretEnvelope,
client,
});
const result = await service.getProblem(
{ organizationId: DEFAULT_ORG_ID, runId, workspaceDir: workspaceRoot },
{ urlOrId: PROBLEM_ID, includeProjects: true, materializeProjects: false },
);
expect(result.data).toMatchObject({
id: PROBLEM_ID,
rights: { derivativeUseAllowed: true },
problem: { id: PROBLEM_ID },
projects: {
problem: { status: "text" },
answer: { status: "text" },
},
});
expect(client.getProblem).toHaveBeenCalled();
expect(client.downloadProject).toHaveBeenCalled();
});
});
@@ -58,6 +58,7 @@ describe("pdf_to_md_bundle capability adapter (ADR-0027)", () => {
async function seedActiveCapabilityConnection(): Promise<void> {
const payload: CapabilitySecretPayload = {
schemaVersion: 1,
kind: "docmind",
accessKeyId: "LTAI-test-key-id",
accessKeySecret: "test-secret-never-log",
endpoint: "docmind-api.cn-hangzhou.aliyuncs.com",
@@ -16,7 +16,9 @@ import { ProviderConnectionService } from "../../src/connections/providerConnect
import { FeishuApplicationConnectionService } from "../../src/connections/feishuApplicationConnections.js";
const execFileAsync = promisify(execFile);
const TEST_DATABASE_URL = "postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test";
const TEST_DATABASE_URL =
process.env.DATABASE_URL?.trim() ||
"postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test";
describe("deployment preflight CLI", { timeout: 20_000 }, () => {
let root: string;
+80 -59
View File
@@ -5,6 +5,8 @@
* FeishuRuntime (sendText/sendCard are no-ops that record calls), and a mock
* AI SDK model factory (doGenerate() returns canned responses - no network).
*/
import { mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { PrismaClient } from "@prisma/client";
import type { FastifyBaseLogger } from "fastify";
import type {
@@ -19,7 +21,21 @@ import type { FeishuRuntime } from "../../src/feishu/client.js";
import type { ModelFactory } from "../../src/agent/runner.js";
import { LocalSecretEnvelope } from "../../src/security/secretEnvelope.js";
export const TEST_DATABASE_URL = "postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test";
// Admin routes and agent-config tests need a skill store root; seed once for
// the whole vitest process when CI/dev didn't set one.
if (
(process.env.HUB_SKILL_STORE_ROOT === undefined || process.env.HUB_SKILL_STORE_ROOT.trim() === "") &&
(process.env.XDG_STATE_HOME === undefined || process.env.XDG_STATE_HOME.trim() === "")
) {
process.env.HUB_SKILL_STORE_ROOT = `${tmpdir()}/cph-test-skills`;
}
if (process.env.HUB_SKILL_STORE_ROOT !== undefined && process.env.HUB_SKILL_STORE_ROOT.trim() !== "") {
mkdirSync(process.env.HUB_SKILL_STORE_ROOT, { recursive: true });
}
export const TEST_DATABASE_URL =
process.env.DATABASE_URL?.trim() ||
"postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test";
export const DEFAULT_ORG_ID = "org_test_default";
export const TEST_SECRET_KEY_ID = "test-active";
export const TEST_SECRET_KEY = Buffer.alloc(32, "k");
@@ -34,26 +50,29 @@ export const prisma = new PrismaClient({
/** Truncate all tables before each test for isolation. */
export async function resetDb(): Promise<void> {
// User and Organization are the aggregate roots for all domain rows; their
// declared FK cascades clear projects, search documents, permissions,
// sessions and connections without repeatedly truncating pg_trgm indexes.
// Event receipts and global audit rows are independent roots.
await prisma.$transaction([
prisma.feishuEventReceipt.deleteMany(),
prisma.auditEntry.deleteMany(),
// Permission resource ids are intentionally polymorphic strings, so these
// two tables have no FK to Project and must be cleared explicitly.
prisma.permissionGrant.deleteMany(),
prisma.permissionSettings.deleteMany(),
// MemberGroup is global (ADR-0028): no FK to the org/user roots, so the
// cascade above never reaches it. Clear explicitly — closure/membership
// first (they FK into MemberGroup), groups last.
prisma.memberGroupClosure.deleteMany(),
prisma.memberGroupMembership.deleteMany(),
prisma.memberGroup.deleteMany(),
prisma.user.deleteMany(),
prisma.organization.deleteMany(),
]);
// Hard reset via TRUNCATE CASCADE. Parent RESTRICT edges and leftover
// folder trees made deleteMany-based cleanup race Prisma upserts
// ("Unique constraint failed on id" while the where-branch saw no row).
await prisma.$executeRawUnsafe(`
DO $$
DECLARE
stmt text;
BEGIN
SELECT 'TRUNCATE TABLE ' || string_agg(format('%I.%I', schemaname, tablename), ', ')
|| ' RESTART IDENTITY CASCADE'
INTO stmt
FROM pg_tables
WHERE schemaname = 'public'
AND tablename <> '_prisma_migrations';
IF stmt IS NOT NULL THEN
EXECUTE stmt;
END IF;
END $$;
`);
const leftover = await prisma.organization.count();
if (leftover !== 0) {
throw new Error(`resetDb truncate left ${leftover} organization row(s)`);
}
await seedTestOrganization();
}
@@ -61,49 +80,51 @@ export async function seedTestOrganization(
id: string = DEFAULT_ORG_ID,
slug: string = "test-default",
): Promise<void> {
// Serialise generate+inbox against concurrent callers in the same process.
// Integration tests share one DB and some files call seed without resetDb.
await prisma.$transaction(async (tx) => {
await tx.organization.upsert({
const existing = await tx.organization.findUnique({
where: { id },
update: {},
create: { id, slug, name: "Test Default Organization" },
select: { id: true },
});
await tx.organizationProjectSettings.upsert({
where: { organizationId: id },
update: {},
create: { organizationId: id, membersCanCreateProjects: true },
});
const defaultRole = await tx.organizationAgentRole.upsert({
where: { organizationId_roleId: { organizationId: id, roleId: "draft" } },
update: { label: "草稿", isDefault: true, disabledAt: null },
create: {
id: `agent_role_draft_${id}`,
organizationId: id,
roleId: "draft",
label: "草稿",
sortOrder: 10,
isDefault: true,
},
});
await tx.organizationAgentRole.updateMany({
where: { organizationId: id, id: { not: defaultRole.id }, isDefault: true },
data: { isDefault: false },
if (existing === null) {
await tx.organization.create({
data: {
id,
slug,
name: "Test Default Organization",
projectSettings: {
create: { membersCanCreateProjects: true },
},
agentRoles: {
create: {
id: `agent_role_draft_${id}`,
roleId: "draft",
label: "草稿",
sortOrder: 10,
isDefault: true,
},
},
},
});
}
const inbox = await tx.folder.findFirst({
where: { organizationId: id, kind: "SYSTEM_INBOX", archivedAt: null },
select: { id: true },
});
if (inbox === null) {
await tx.folder.create({
data: {
id: `folder_inbox_${id}`,
organizationId: id,
name: "Inbox",
kind: "SYSTEM_INBOX",
sortKey: "000000",
},
});
}
});
const inbox = await prisma.folder.findFirst({
where: { organizationId: id, kind: "SYSTEM_INBOX", archivedAt: null },
select: { id: true },
});
if (inbox === null) {
await prisma.folder.create({
data: {
id: `folder_inbox_${id}`,
organizationId: id,
name: "Inbox",
kind: "SYSTEM_INBOX",
sortKey: "000000",
},
});
}
}
/** A logger that discards everything (tests don't need fastify's pino). */
+31 -18
View File
@@ -11,11 +11,14 @@ import {
seedProject,
seedTestOrganization,
silentLogger,
testSecretEnvelope,
} from "./helpers.js";
import { InMemoryModelRegistry } from "../../src/agent/models.js";
import { makeTriggerHandler as makeProductionTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js";
import { TriggerQueue } from "../../src/feishu/triggerQueue.js";
import type { MessageReceiveEvent, CardActionEvent } from "../../src/feishu/client.js";
import type { FeishuBotCli } from "../../src/feishu/botCli.js";
import { writeNewWorkspaceFileNoFollow } from "../../src/security/workspaceFiles.js";
import type { RunRequest, RunResult } from "../../src/agent/runner.js";
import type { RuntimeSettings } from "../../src/settings/runtime.js";
@@ -34,6 +37,7 @@ function makeTriggerHandler(deps: TestTriggerDeps): ReturnType<typeof makeProduc
publicBaseUrl: "https://educraft.example.test",
siloOrganizationId: DEFAULT_ORG_ID,
allowLegacyFeishuIdentity: true,
secretEnvelope: testSecretEnvelope,
...deps,
});
}
@@ -190,13 +194,14 @@ describe("trigger full lifecycle (integration)", () => {
where: { id: "proj-post-image" },
data: { workspaceDir },
});
const messageResourceGet = vi.fn(async () => ({
getReadableStream: () => Readable.from([Buffer.from("image bytes")]),
}));
const imV1 = (rt.client as unknown as {
im: { v1: { messageResource?: { get: typeof messageResourceGet } } };
}).im.v1;
imV1.messageResource = { get: messageResourceGet };
const downloadResource = vi.fn(async (request) => writeNewWorkspaceFileNoFollow(
request.workspaceRoot,
request.workspaceDir,
request.workspaceRelativePath,
Readable.from([Buffer.from("image bytes")]),
request.maxBytes,
));
const feishuBotCli: FeishuBotCli = { downloadResource };
const baseEvent = makeEvent("chat-post-image", "@_user_1 看看这张图");
const event: MessageReceiveEvent = {
...baseEvent,
@@ -220,6 +225,7 @@ describe("trigger full lifecycle (integration)", () => {
runAgent,
projectWorkspaceRoot: workspaceRoot,
messageBatcherOptions: { maxMessages: 1 },
feishuBotCli,
});
await trigger(event, rt);
@@ -228,10 +234,11 @@ describe("trigger full lifecycle (integration)", () => {
expect(runAgentCalls).toHaveLength(1);
});
expect(runAgentCalls[0]?.prompt).toContain(join(await realpath(workspaceDir), ".cph", "inbox"));
expect(messageResourceGet).toHaveBeenCalledWith({
params: { type: "image" },
path: { message_id: event.message.message_id, file_key: "img-key-1" },
});
expect(downloadResource).toHaveBeenCalledWith(expect.objectContaining({
messageId: event.message.message_id,
fileKey: "img-key-1",
resourceType: "image",
}));
const inboxFiles = await readdir(join(workspaceDir, ".cph", "inbox"));
expect(inboxFiles).toHaveLength(1);
await expect(readFile(join(workspaceDir, ".cph", "inbox", inboxFiles[0]!))).resolves.toEqual(Buffer.from("image bytes"));
@@ -1109,15 +1116,18 @@ describe("trigger full lifecycle (integration)", () => {
});
const resourceEntered = deferred<void>();
const releaseResource = deferred<void>();
const messageResourceGet = vi.fn(async () => {
const downloadResource = vi.fn(async (request) => {
resourceEntered.resolve();
await releaseResource.promise;
return { getReadableStream: () => Readable.from([Buffer.from("staged image bytes")]) };
return writeNewWorkspaceFileNoFollow(
request.workspaceRoot,
request.workspaceDir,
request.workspaceRelativePath,
Readable.from([Buffer.from("staged image bytes")]),
request.maxBytes,
);
});
const imV1 = (rt.client as unknown as {
im: { v1: { messageResource?: { get: typeof messageResourceGet } } };
}).im.v1;
imV1.messageResource = { get: messageResourceGet };
const feishuBotCli: FeishuBotCli = { downloadResource };
const baseEvent = makeEvent("chat-attachment-race", "@_user_1 附件竞态");
const event: MessageReceiveEvent = {
...baseEvent,
@@ -1141,6 +1151,7 @@ describe("trigger full lifecycle (integration)", () => {
runAgent,
projectWorkspaceRoot: workspaceRoot,
messageBatcherOptions: { maxMessages: 1 },
feishuBotCli,
});
const pendingTrigger = trigger(event, rt);
@@ -1600,7 +1611,9 @@ describe("trigger full lifecycle (integration)", () => {
expect(runs[0]?.status).toBe("CANCELED");
});
expect(patch).toHaveBeenCalled();
expect(rt.sentTexts).toContain("已中断当前运行。");
await vi.waitFor(() => {
expect(rt.sentTexts).toContain("已中断当前运行。");
});
});
it("denies interrupt when the operator lacks agent.cancel permission", async () => {
+110 -2
View File
@@ -1,8 +1,9 @@
import { mkdir, mkdtemp, realpath, rm, symlink } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createAgentSecurityPolicy } from "../../src/agent/security.js";
import { importSkillDirectory } from "../../src/agent/skillStore.js";
describe("agent subprocess security policy", () => {
const roots: string[] = [];
@@ -26,6 +27,13 @@ describe("agent subprocess security policy", () => {
PATH: "/usr/local/bin:/usr/bin:/bin",
LANG: "C.UTF-8",
CPH_BIN: "/usr/local/bin/cph",
HTTP_PROXY: "http://127.0.0.1:7890",
HTTPS_PROXY: "http://127.0.0.1:7890",
ALL_PROXY: "socks5h://127.0.0.1:7890",
NO_PROXY: "127.0.0.1,localhost,::1",
NODE_USE_ENV_PROXY: "1",
TYPST_PACKAGE_PATH: "/srv/curriculum-project-hub/typst-packages/para-26071100",
TYPST_PACKAGE_CACHE_PATH: "/srv/curriculum-project-hub/typst-packages/para-26071100",
DATABASE_URL: "postgresql://platform-secret",
FEISHU_APP_SECRET: "feishu-secret",
HUB_SESSION_SECRET: "session-secret",
@@ -38,9 +46,16 @@ describe("agent subprocess security policy", () => {
PATH: "/usr/local/bin:/usr/bin:/bin",
LANG: "C.UTF-8",
CPH_BIN: "/usr/local/bin/cph",
TYPST_PACKAGE_PATH: "/srv/curriculum-project-hub/typst-packages/para-26071100",
TYPST_PACKAGE_CACHE_PATH: "/srv/curriculum-project-hub/typst-packages/para-26071100",
ANTHROPIC_BASE_URL: "http://127.0.0.1:43123",
ANTHROPIC_AUTH_TOKEN: "run-proxy-capability",
ANTHROPIC_API_KEY: "",
HTTP_PROXY: "http://127.0.0.1:7890",
HTTPS_PROXY: "http://127.0.0.1:7890",
ALL_PROXY: "socks5h://127.0.0.1:7890",
NO_PROXY: "127.0.0.1,localhost,::1",
NODE_USE_ENV_PROXY: "1",
});
expect(policy.env).not.toHaveProperty("DATABASE_URL");
expect(policy.env).not.toHaveProperty("FEISHU_APP_SECRET");
@@ -61,7 +76,10 @@ describe("agent subprocess security policy", () => {
autoAllowBashIfSandboxed: true,
allowUnsandboxedCommands: false,
filesystem: {
allowWrite: [canonicalWorkspace],
allowWrite: expect.arrayContaining([
canonicalWorkspace,
"/srv/curriculum-project-hub/typst-packages/para-26071100",
]),
denyRead: ["/"],
allowRead: expect.arrayContaining([canonicalWorkspace, "/usr/bin"]),
},
@@ -74,6 +92,59 @@ describe("agent subprocess security policy", () => {
});
});
it("passes configured Typst package roots and exposes them read-only to the sandbox", async () => {
const { workspaceRoot, workspace } = await makeWorkspace();
const packageRoot = "/srv/curriculum-project-hub/typst-packages/para-26071100";
const cacheRoot = "/var/cache/cph-hub/para-26071100/typst";
const policy = await createAgentSecurityPolicy({
runId: "run-test",
workspaceRoot,
workspaceDir: workspace,
hostEnv: {
PATH: "/usr/bin:/bin",
TYPST_PACKAGE_PATH: packageRoot,
TYPST_PACKAGE_CACHE_PATH: cacheRoot,
},
});
const canonicalWorkspace = await realpath(workspace);
expect(policy.env).toMatchObject({
TYPST_PACKAGE_PATH: packageRoot,
TYPST_PACKAGE_CACHE_PATH: cacheRoot,
});
expect(policy.sandbox.filesystem.allowRead).toEqual(expect.arrayContaining([packageRoot, cacheRoot]));
expect(policy.sandbox.filesystem.allowWrite).toEqual(expect.arrayContaining([canonicalWorkspace, cacheRoot]));
expect(policy.sandbox.filesystem.allowWrite).not.toContain(packageRoot);
});
it("rejects a relative Typst package root instead of silently losing package access", async () => {
const { workspaceRoot, workspace } = await makeWorkspace();
await expect(createAgentSecurityPolicy({
runId: "run-test",
workspaceRoot,
workspaceDir: workspace,
hostEnv: {
PATH: "/usr/bin:/bin",
TYPST_PACKAGE_PATH: "typst-packages",
},
})).rejects.toThrow("TYPST_PACKAGE_PATH must be absolute");
});
it("rejects a Typst cache rooted at the filesystem root instead of widening writes", async () => {
const { workspaceRoot, workspace } = await makeWorkspace();
await expect(createAgentSecurityPolicy({
runId: "run-test",
workspaceRoot,
workspaceDir: workspace,
hostEnv: {
PATH: "/usr/bin:/bin",
TYPST_PACKAGE_CACHE_PATH: "/",
},
})).rejects.toThrow("TYPST_PACKAGE_CACHE_PATH must not be the filesystem root");
});
it("rejects provider environment keys outside the explicit protocol", async () => {
const { workspaceRoot, workspace } = await makeWorkspace();
@@ -125,6 +196,43 @@ describe("agent subprocess security policy", () => {
})).rejects.toThrow("Agent temp path is too long for sandbox bridge sockets");
});
it("mirrors selected skills under .cph/runtime-skills and exposes CPH_RUNTIME_SKILLS_DIR", async () => {
const { root, workspaceRoot, workspace } = await makeWorkspace();
const storeRoot = join(root, "skills-store");
const skillSource = join(root, "skill-src", "pdf-to-md");
await mkdir(skillSource, { recursive: true });
await writeFile(
join(skillSource, "SKILL.md"),
"---\nname: pdf-to-md\ndescription: convert\n---\n# pdf-to-md\nbatch items\n",
);
const installed = await importSkillDirectory({ sourceDir: skillSource, storeRoot });
const policy = await createAgentSecurityPolicy({
runId: "run-skills",
workspaceRoot,
workspaceDir: workspace,
skills: [{
name: "pdf-to-md",
version: "1",
contentDigest: installed.contentDigest,
}],
hostEnv: {
PATH: "/usr/bin:/bin",
HUB_SKILL_STORE_ROOT: storeRoot,
},
});
const canonicalWorkspace = await realpath(workspace);
const mirrored = join(canonicalWorkspace, ".cph", "runtime-skills", "pdf-to-md", "SKILL.md");
await expect(readFile(mirrored, "utf8")).resolves.toContain("batch items");
expect(policy.env.CPH_RUNTIME_SKILLS_DIR).toBe(join(canonicalWorkspace, ".cph", "runtime-skills"));
expect(policy.env.CPH_RUNTIME_SKILLS_REL).toBe(join(".cph", "runtime-skills"));
expect(policy.skillIds).toEqual(["cph-runtime:pdf-to-md"]);
expect(policy.sandbox.filesystem.allowRead).toEqual(
expect.arrayContaining([canonicalWorkspace, policy.skillPluginRoot]),
);
await policy.cleanup();
});
it("rejects a project workspace whose real path escapes the configured workspace root", async () => {
const { root, workspaceRoot } = await makeWorkspace();
const outside = join(root, "outside");
+50
View File
@@ -0,0 +1,50 @@
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, it } from "vitest";
import {
AliyunDocmindClient,
DocmindClientError,
DOCMIND_CONNECT_TIMEOUT_MS,
DOCMIND_READ_TIMEOUT_MS,
createDocmindRuntimeOptions,
} from "../../src/capability/docmindClient.js";
describe("createDocmindRuntimeOptions", () => {
it("overrides httpx's 3s default so PDF OSS uploads can complete", () => {
const runtime = createDocmindRuntimeOptions();
// Production failure: ReadTimeout(3000) on docmind OSS upload.
expect(DOCMIND_CONNECT_TIMEOUT_MS).toBeGreaterThan(3_000);
expect(DOCMIND_READ_TIMEOUT_MS).toBeGreaterThan(3_000);
expect(runtime.connectTimeout).toBe(DOCMIND_CONNECT_TIMEOUT_MS);
expect(runtime.readTimeout).toBe(DOCMIND_READ_TIMEOUT_MS);
});
});
describe("AliyunDocmindClient local file open", () => {
it("rejects a missing input file without crashing the process", async () => {
const client = new AliyunDocmindClient();
const missing = join(tmpdir(), `docmind-missing-${Date.now()}.pdf`);
// If createReadStream errors are left unhandled, Vitest aborts the suite
// with an unhandled 'error' event instead of reaching this assertion.
await expect(client.parse(
{
accessKeyId: "ak",
accessKeySecret: "sk",
endpoint: "docmind-api.cn-hangzhou.aliyuncs.com",
},
{ inputFilePath: missing },
)).rejects.toBeInstanceOf(DocmindClientError);
await expect(client.parse(
{
accessKeyId: "ak",
accessKeySecret: "sk",
endpoint: "docmind-api.cn-hangzhou.aliyuncs.com",
},
{ inputFilePath: missing },
)).rejects.toMatchObject({
code: "docmind_rejected",
message: expect.stringContaining("input file not found"),
});
});
});
+101
View File
@@ -0,0 +1,101 @@
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, it } from "vitest";
import { createFeishuBotCli } from "../../src/feishu/botCli.js";
import type { PrismaClient } from "@prisma/client";
import type { LocalSecretEnvelope } from "../../src/security/secretEnvelope.js";
const itOnLinux = process.platform === "linux" ? it : it.skip;
const fakeCredential = {
connectionId: "connection-1",
organizationId: "org-1",
appId: "cli-test-app",
appSecret: "cli-test-secret",
botOpenId: "ou-test-bot",
verificationToken: "verification-token",
encryptKey: "encrypt-key",
};
describe("Feishu bot CLI adapter", () => {
itOnLinux("uses bot identity and writes the CLI result into the workspace", async () => {
const root = await mkdtemp(join(tmpdir(), "hub-feishu-bot-cli-test-"));
const workspaceDir = join(root, "workspace");
const binary = join(root, "fake-lark-cli");
await mkdir(workspaceDir);
await writeFakeCli(binary);
try {
const cli = createFeishuBotCli({
organizationId: "org-1",
prisma: {} as PrismaClient,
secretEnvelope: {} as LocalSecretEnvelope,
binary,
resolveCredential: async () => fakeCredential,
});
const result = await cli.downloadResource({
messageId: "message-1",
fileKey: "file-1",
resourceType: "file",
workspaceRoot: root,
workspaceDir,
workspaceRelativePath: "inbox/resource.bin",
maxBytes: 1024,
});
await expect(readFile(result, "utf8")).resolves.toBe("resource bytes");
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("rejects a resource above the configured limit", async () => {
const root = await mkdtemp(join(tmpdir(), "hub-feishu-bot-cli-limit-"));
const workspaceDir = join(root, "workspace");
const binary = join(root, "fake-lark-cli");
await mkdir(workspaceDir);
await writeFakeCli(binary);
try {
const cli = createFeishuBotCli({
organizationId: "org-1",
prisma: {} as PrismaClient,
secretEnvelope: {} as LocalSecretEnvelope,
binary,
resolveCredential: async () => fakeCredential,
});
await expect(cli.downloadResource({
messageId: "message-1",
fileKey: "file-1",
resourceType: "file",
workspaceRoot: root,
workspaceDir,
workspaceRelativePath: "inbox/resource.bin",
maxBytes: 4,
})).rejects.toMatchObject({ reason: "limit" });
} finally {
await rm(root, { recursive: true, force: true });
}
});
});
async function writeFakeCli(path: string): Promise<void> {
await writeFile(path, `#!/usr/bin/env node
import { writeFileSync } from "node:fs";
import { join } from "node:path";
const args = process.argv.slice(2);
if (args[0] === "config" && args[1] === "init") {
process.stdin.resume();
process.stdin.on("end", () => process.exit(0));
} else if (args.includes("+messages-resources-download")) {
const asIndex = args.indexOf("--as");
const outputIndex = args.indexOf("--output");
if (asIndex < 0 || args[asIndex + 1] !== "bot" || outputIndex < 0) process.exit(2);
writeFileSync(join(process.cwd(), args[outputIndex + 1]), "resource bytes");
process.exit(0);
} else {
process.exit(3);
}
`);
await chmod(path, 0o755);
}
+28 -12
View File
@@ -5,6 +5,8 @@ import { Readable } from "node:stream";
import { describe, expect, it, vi } from "vitest";
import { claudeSdkToolConfigForRole, cphHubMcpToolsForRole } from "../../src/agent/roleTools.js";
import type { FeishuRuntime } from "../../src/feishu/client.js";
import type { FeishuBotCli } from "../../src/feishu/botCli.js";
import { writeNewWorkspaceFileNoFollow } from "../../src/security/workspaceFiles.js";
import { downloadFeishuMessageResource } from "../../src/feishu/download.js";
const itOnLinux = process.platform === "linux" ? it : it.skip;
@@ -12,7 +14,10 @@ const itOnLinux = process.platform === "linux" ? it : it.skip;
describe("Feishu message resource download", () => {
it("exposes the download tool to default and explicitly configured roles", () => {
expect(cphHubMcpToolsForRole(undefined)).toContain("feishu_download_resource");
expect(cphHubMcpToolsForRole(["feishu_download_resource"])).toEqual(["feishu_download_resource"]);
expect(cphHubMcpToolsForRole(["feishu_download_resource"])).toEqual([
"todo_write",
"feishu_download_resource",
]);
expect(claudeSdkToolConfigForRole(["feishu_download_resource"]).allowedTools).toEqual([
"mcp__cph_hub__feishu_download_resource",
]);
@@ -24,14 +29,12 @@ describe("Feishu message resource download", () => {
await mkdir(workspaceDir);
try {
const messageGet = vi.fn(async () => ({ data: { items: [{ chat_id: "chat-1" }] } }));
const messageResourceGet = vi.fn(async () => ({
getReadableStream: () => Readable.from([Buffer.from("image bytes")]),
}));
const messageResourceGet = vi.fn();
const rt = mockRuntime(messageGet, messageResourceGet);
const botCli = fakeBotCli();
const result = await downloadFeishuMessageResource(
{ messageId: "message-1", fileKey: "img-key-1", resourceType: "image" },
{ boundChatId: "chat-1", workspaceRoot, workspaceDir },
{ boundChatId: "chat-1", workspaceRoot, workspaceDir, botCli },
rt,
);
@@ -41,10 +44,7 @@ describe("Feishu message resource download", () => {
);
expect(result.path).toMatch(/\.png$/);
await expect(readFile(result.path, "utf8")).resolves.toBe("image bytes");
expect(messageResourceGet).toHaveBeenCalledWith({
params: { type: "image" },
path: { message_id: "message-1", file_key: "img-key-1" },
});
expect(messageResourceGet).not.toHaveBeenCalled();
} finally {
await rm(workspaceRoot, { recursive: true, force: true });
}
@@ -57,10 +57,14 @@ describe("Feishu message resource download", () => {
await expect(downloadFeishuMessageResource(
{ messageId: "message-other", fileKey: "img-key-other", resourceType: "image" },
{ boundChatId: "chat-1", workspaceRoot: "/tmp", workspaceDir: "/tmp/project-1" },
{
boundChatId: "chat-1",
workspaceRoot: "/tmp",
workspaceDir: "/tmp/project-1",
botCli: fakeBotCli(),
},
rt,
)).rejects.toThrow("current project's bound chat");
expect(messageResourceGet).not.toHaveBeenCalled();
});
});
@@ -89,6 +93,18 @@ function mockRuntime(
};
}
function fakeBotCli(): FeishuBotCli {
return {
downloadResource: (request) => writeNewWorkspaceFileNoFollow(
request.workspaceRoot,
request.workspaceDir,
request.workspaceRelativePath,
Readable.from([Buffer.from("image bytes")]),
request.maxBytes,
),
};
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
@@ -42,6 +42,12 @@ describe("outbound markdown image parsing", () => {
);
expect(maskMarkdownImagesForStreaming("![](https://x/y.png)")).toBe("【图片】");
});
it("ignores markdown image examples inside inline code", () => {
const text = "例如 `![](images/img_5.png)` 这样写,不会当真实图片";
expect(findMarkdownImagesOutsideCode(text)).toEqual([]);
expect(maskMarkdownImagesForStreaming(text)).toBe(text);
});
});
describe("materializeAnswerSegments", () => {
+2 -1
View File
@@ -52,7 +52,7 @@ describe("Feishu reactions", () => {
await expect(removeReaction(rt, "message-1", "reaction-1")).resolves.toBe(false);
});
it("adds Typing on start and removes it on success", async () => {
it("adds Typing on start and replaces it with CheckMark on success", async () => {
const run = deferred<RunResult>();
const rt = mockRuntime();
const runAgent = vi.fn((req: RunRequest) => {
@@ -71,6 +71,7 @@ describe("Feishu reactions", () => {
expect(rt.reactionRequests).toEqual([
{ kind: "add", messageId: "message-1", emoji: "Typing", reactionId: "reaction-1" },
{ kind: "remove", messageId: "message-1", reactionId: "reaction-1" },
{ kind: "add", messageId: "message-1", emoji: "CheckMark", reactionId: "reaction-2" },
]);
});
});
Binary file not shown.
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import {
extractProblemId,
normalizePbankBaseUrl,
pbankRightsFromCredential,
} from "../../src/capability/pbankClient.js";
import type { PbankCapabilitySecretPayload } from "../../src/capability/types.js";
import { claudeSdkToolConfigForRole, cphHubMcpToolsForRole } from "../../src/agent/roleTools.js";
describe("pbank client helpers", () => {
it("extracts problem UUID from bare id and URL", () => {
const id = "01234567-89ab-4def-8abc-0123456789ab";
expect(extractProblemId(id)).toBe(id);
expect(extractProblemId(`https://pbank.paradigm-edu.net/problem/${id}`)).toBe(id);
expect(extractProblemId(`https://pbank.example/x?id=${id}`)).toBe(id);
});
it("normalizes base URL trailing slashes", () => {
expect(normalizePbankBaseUrl("https://pbank.paradigm-edu.net/api/")).toBe(
"https://pbank.paradigm-edu.net/api",
);
expect(normalizePbankBaseUrl("")).toBe("https://pbank.paradigm-edu.net/api");
});
it("marks derivative use from operator rights status", () => {
const owned: PbankCapabilitySecretPayload = {
schemaVersion: 1,
kind: "pbank",
baseUrl: "https://pbank.paradigm-edu.net/api",
username: "u",
password: "p",
rightsStatus: "owned",
};
expect(pbankRightsFromCredential(owned).derivativeUseAllowed).toBe(true);
const unknown: PbankCapabilitySecretPayload = {
...owned,
rightsStatus: "unknown",
};
expect(pbankRightsFromCredential(unknown).derivativeUseAllowed).toBe(false);
});
});
describe("role tool mapping for pbank", () => {
it("maps umbrella pbank role tool to three MCP tools", () => {
expect(cphHubMcpToolsForRole(["pbank"])).toEqual([
"todo_write",
"pbank_search_problems",
"pbank_get_problem",
"pbank_get_many_problems",
]);
const cfg = claudeSdkToolConfigForRole(["pbank", "Read"]);
expect(cfg.tools).toContain("Read");
expect(cfg.allowedTools).toContain("mcp__cph_hub__pbank_search_problems");
expect(cfg.allowedTools).toContain("mcp__cph_hub__pbank_get_problem");
expect(cfg.allowedTools).toContain("mcp__cph_hub__pbank_get_many_problems");
});
});
+56
View File
@@ -0,0 +1,56 @@
import { readFile } from "node:fs/promises";
import { inflateRawSync } from "node:zlib";
import { describe, expect, it } from "vitest";
/** Mirrors hub/src/capability/pbank.ts zip reader contracts. */
function listZipEntries(buffer: Buffer) {
let eocd = -1;
const minEocd = Math.max(0, buffer.length - (22 + 0xffff));
for (let i = buffer.length - 22; i >= minEocd; i -= 1) {
if (buffer.readUInt32LE(i) === 0x06054b50) {
eocd = i;
break;
}
}
if (eocd < 0) throw new Error("missing eocd");
const totalEntries = buffer.readUInt16LE(eocd + 10);
const centralOffset = buffer.readUInt32LE(eocd + 16);
const entries: Array<{ name: string; method: number; compressedSize: number; uncompressedSize: number; localHeaderOffset: number }> = [];
let offset = centralOffset;
for (let i = 0; i < totalEntries; i += 1) {
const method = buffer.readUInt16LE(offset + 10);
const compressedSize = buffer.readUInt32LE(offset + 20);
const uncompressedSize = buffer.readUInt32LE(offset + 24);
const nameLen = buffer.readUInt16LE(offset + 28);
const extraLen = buffer.readUInt16LE(offset + 30);
const commentLen = buffer.readUInt16LE(offset + 32);
const localHeaderOffset = buffer.readUInt32LE(offset + 42);
const nameStart = offset + 46;
const name = buffer.subarray(nameStart, nameStart + nameLen).toString("utf8");
entries.push({ name, method, compressedSize, uncompressedSize, localHeaderOffset });
offset = nameStart + nameLen + extraLen + commentLen;
}
return entries;
}
function inflateZipEntry(buffer: Buffer, entry: { method: number; compressedSize: number; uncompressedSize: number; localHeaderOffset: number }) {
const local = entry.localHeaderOffset;
const nameLen = buffer.readUInt16LE(local + 26);
const extraLen = buffer.readUInt16LE(local + 28);
const dataStart = local + 30 + nameLen + extraLen;
const compressed = buffer.subarray(dataStart, dataStart + entry.compressedSize);
if (entry.method === 0) return Buffer.from(compressed);
if (entry.method === 8) return Buffer.from(inflateRawSync(compressed));
throw new Error(`method ${entry.method}`);
}
describe("pbank zip reader contract", () => {
it("lists and inflates deflated entries without host unzip", async () => {
const zip = await readFile(new URL("./fixtures/pbank-mini.zip", import.meta.url));
const entries = listZipEntries(zip);
expect(entries.map((e) => e.name).sort()).toEqual(["fig/a.png", "hello.txt"]);
const hello = entries.find((e) => e.name === "hello.txt")!;
const text = inflateZipEntry(zip, hello).toString("utf8");
expect(text).toBe("hello pbank\n");
});
});
+148
View File
@@ -0,0 +1,148 @@
import { describe, expect, it, vi } from "vitest";
import {
clampPdfToMdConcurrency,
invokePdfToMdBatch,
mapPool,
readPdfToMdConcurrency,
} from "../../src/capability/pdfToMdBundle.js";
import type { CapabilityAdapter, CapabilityInvocationResult } from "../../src/capability/types.js";
function okResult(label: string, pages: number): CapabilityInvocationResult {
return {
artifacts: [{ path: `out/${label}/document.md`, kind: "markdown" }],
consumption: {
provider: "aliyun_docmind",
model: null,
inputTokens: null,
outputTokens: null,
quantity: pages,
unit: "pages",
costUsd: pages * 0.0056,
correlationId: `job-${label}`,
},
};
}
describe("pdf_to_md batch concurrency helpers", () => {
it("mapPool caps in-flight workers", async () => {
let inFlight = 0;
let maxInFlight = 0;
const values = await mapPool([1, 2, 3, 4, 5], 2, async (item) => {
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
await new Promise((resolve) => setTimeout(resolve, 40));
inFlight -= 1;
return item * 10;
});
expect(values).toEqual([10, 20, 30, 40, 50]);
expect(maxInFlight).toBe(2);
});
it("clamps concurrency and reads env", () => {
expect(clampPdfToMdConcurrency(99)).toBe(8);
expect(clampPdfToMdConcurrency(0)).toBe(1);
expect(readPdfToMdConcurrency({})).toBe(3);
expect(readPdfToMdConcurrency({ HUB_PDF_TO_MD_MAX_CONCURRENT: "5" })).toBe(5);
expect(() => readPdfToMdConcurrency({ HUB_PDF_TO_MD_MAX_CONCURRENT: "nope" })).toThrow(/positive integer/);
});
it("runs batch items concurrently and preserves order", async () => {
let inFlight = 0;
let maxInFlight = 0;
const adapter: CapabilityAdapter = {
capabilityId: "pdf_to_md_bundle",
invoke: vi.fn(async (input) => {
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
await new Promise((resolve) => setTimeout(resolve, 60));
inFlight -= 1;
const label = input.inputPath.includes("b") ? "b" : input.inputPath.includes("c") ? "c" : "a";
return okResult(label, label === "a" ? 1 : label === "b" ? 2 : 3);
}),
};
const batch = await invokePdfToMdBatch(
adapter,
{
runId: "run-1",
organizationId: "org-1",
projectId: "proj-1",
workspaceDir: "/tmp/ws",
prisma: {} as never,
},
[
{ inputPath: "a.pdf", outputDir: "out/a" },
{ inputPath: "b.pdf", outputDir: "out/b" },
{ inputPath: "c.pdf", outputDir: "out/c" },
],
3,
);
expect(batch.map((item) => item.ok)).toEqual([true, true, true]);
expect(maxInFlight).toBe(3);
expect(adapter.invoke).toHaveBeenCalledTimes(3);
if (batch[0]?.ok && batch[1]?.ok && batch[2]?.ok) {
expect(batch[0].result.consumption.correlationId).toBe("job-a");
expect(batch[1].result.consumption.correlationId).toBe("job-b");
expect(batch[2].result.consumption.correlationId).toBe("job-c");
}
});
it("isolates per-item failures without canceling siblings", async () => {
const adapter: CapabilityAdapter = {
capabilityId: "pdf_to_md_bundle",
invoke: vi.fn(async (input) => {
if (input.inputPath.includes("bad")) {
throw new Error("boom");
}
return okResult("ok", 1);
}),
};
const batch = await invokePdfToMdBatch(
adapter,
{
runId: "run-1",
organizationId: "org-1",
projectId: "proj-1",
workspaceDir: "/tmp/ws",
prisma: {} as never,
},
[
{ inputPath: "ok.pdf", outputDir: "out/ok" },
{ inputPath: "bad.pdf", outputDir: "out/bad" },
],
2,
);
expect(batch[0]?.ok).toBe(true);
expect(batch[1]?.ok).toBe(false);
if (batch[1]?.ok === false) {
expect(batch[1].error).toMatch(/boom/);
}
});
it("rejects duplicate output dirs before starting work", async () => {
const invoke = vi.fn();
const adapter: CapabilityAdapter = {
capabilityId: "pdf_to_md_bundle",
invoke,
};
await expect(invokePdfToMdBatch(
adapter,
{
runId: "run-1",
organizationId: "org-1",
projectId: "proj-1",
workspaceDir: "/tmp/ws",
prisma: {} as never,
},
[
{ inputPath: "a.pdf", outputDir: "out/same" },
{ inputPath: "b.pdf", outputDir: "out/same/" },
],
2,
)).rejects.toThrow(/distinct output_dir/);
expect(invoke).not.toHaveBeenCalled();
});
});
+7 -2
View File
@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { removeAbandonedMessageResourceStages, stageMessageResources } from "../../src/feishu/resourceStaging.js";
import type { FeishuRuntime } from "../../src/feishu/client.js";
import type { FeishuBotCli } from "../../src/feishu/botCli.js";
const roots: string[] = [];
@@ -34,8 +34,13 @@ describe("Feishu resource staging recovery", () => {
it("rejects too many resources before contacting Feishu", async () => {
const root = await tempRoot();
const botCli: FeishuBotCli = {
downloadResource: async () => {
throw new Error("should not contact Feishu when over limit");
},
};
await expect(stageMessageResources(
{} as FeishuRuntime,
botCli,
"message-1",
[
{ fileKey: "a", resourceType: "file", workspaceRelativePath: "a" },
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import {
appendTeacherNotice,
isMaxTurnsError,
teacherFacingRunOutcome,
} from "../../src/feishu/runOutcomeNotice.js";
describe("teacherFacingRunOutcome", () => {
it("explains max-turn stops with the configured ceiling", () => {
const outcome = teacherFacingRunOutcome({
wallTimeExceeded: false,
interrupted: false,
resultStatus: "failed",
resultError: "Claude Code returned an error result: Reached maximum number of turns (25)",
maxTurns: 150,
maxRunSeconds: 1800,
hasPartialText: true,
});
expect(outcome.isError).toBe(true);
expect(outcome.notice).toContain("150");
expect(outcome.notice).toContain("最大步骤数");
expect(outcome.notice).toContain("部分结果");
});
it("explains wall-clock timeouts", () => {
const outcome = teacherFacingRunOutcome({
wallTimeExceeded: true,
interrupted: false,
resultStatus: "interrupted",
resultError: undefined,
maxTurns: 150,
maxRunSeconds: 1800,
hasPartialText: false,
});
expect(outcome.isError).toBe(true);
expect(outcome.notice).toContain("1800");
expect(outcome.notice).toContain("超时");
});
it("is quiet on successful completion", () => {
expect(
teacherFacingRunOutcome({
wallTimeExceeded: false,
interrupted: false,
resultStatus: "completed",
resultError: undefined,
maxTurns: 150,
maxRunSeconds: 1800,
hasPartialText: true,
}),
).toEqual({ isError: false, notice: undefined });
});
it("appendTeacherNotice joins body and notice", () => {
expect(appendTeacherNotice("hello", "bye")).toBe("hello\n\nbye");
expect(appendTeacherNotice("", "only")).toBe("only");
});
it("detects max-turn SDK wording", () => {
expect(isMaxTurnsError("Reached maximum number of turns (25)")).toBe(true);
expect(isMaxTurnsError("result_error_max_turns")).toBe(true);
expect(isMaxTurnsError("network glitch")).toBe(false);
});
});
+54 -8
View File
@@ -113,9 +113,10 @@ describe("runAgent", () => {
permissionMode: "bypassPermissions",
allowDangerouslySkipPermissions: true,
settingSources: [],
settings: { disableBundledSkills: true },
skills: [],
strictMcpConfig: true,
settings: { disableBundledSkills: true, todoFeatureEnabled: true, autoCompactEnabled: true },
tools: expect.arrayContaining(["Read", "Write", "Edit", "Bash", "Glob", "Grep", "TodoWrite"]),
allowedTools: expect.arrayContaining(["TodoWrite", "mcp__cph_hub__todo_write"]),
disallowedTools: expect.arrayContaining(["Agent", "SendMessage", "Task", "TeamCreate", "ScheduleWakeup"]),
sandbox: expect.objectContaining({
enabled: true,
failIfUnavailable: true,
@@ -130,6 +131,39 @@ describe("runAgent", () => {
});
});
it("never exposes multi-agent orchestration tools on unrestricted roles", async () => {
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));
await runAgent({
prompt: "继续",
model: undefined,
project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
systemPrompt: undefined,
tools: null,
runId: "run-1",
sessionId: "hub-session-1",
prisma: stubPrisma,
});
const call = queryMock.mock.calls[0]?.[0] as {
options?: { tools?: unknown; disallowedTools?: string[] };
} | undefined;
expect(call?.options?.tools).toEqual([
"Read",
"Write",
"Edit",
"Bash",
"Glob",
"Grep",
"WebFetch",
"WebSearch",
"TodoWrite",
]);
for (const blocked of ["Agent", "SendMessage", "Task", "TeamCreate", "ScheduleWakeup"]) {
expect(call?.options?.disallowedTools).toContain(blocked);
}
});
it("does not send resume for a fresh Hub session", async () => {
queryMock.mockReturnValue(messages(assistantMessage("fresh"), resultMessage("sdk-session-1")));
@@ -183,8 +217,16 @@ describe("runAgent", () => {
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
options: {
tools: ["Read", "Bash"],
allowedTools: ["Read", "Bash", "mcp__cph_hub__send_file"],
tools: ["Read", "Bash", "TodoWrite"],
allowedTools: [
"Read",
"Bash",
"mcp__cph_hub__send_file",
"TodoWrite",
"mcp__cph_hub__todo_write",
],
settings: expect.objectContaining({ todoFeatureEnabled: true }),
disallowedTools: expect.arrayContaining(["Agent", "SendMessage"]),
},
});
});
@@ -205,8 +247,10 @@ describe("runAgent", () => {
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
options: {
tools: [],
allowedTools: [],
tools: ["TodoWrite"],
allowedTools: ["TodoWrite", "mcp__cph_hub__todo_write"],
settings: expect.objectContaining({ todoFeatureEnabled: true }),
disallowedTools: expect.arrayContaining(["Agent", "SendMessage"]),
},
});
});
@@ -234,9 +278,11 @@ describe("runAgent", () => {
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
options: {
tools: ["Skill"],
tools: ["TodoWrite", "Skill"],
allowedTools: expect.arrayContaining(["TodoWrite", "mcp__cph_hub__todo_write", "Skill"]),
plugins: [expect.objectContaining({ type: "local", skipMcpDiscovery: true })],
skills: ["cph-runtime:typst"],
settings: expect.objectContaining({ todoFeatureEnabled: true }),
},
});
});
+111
View File
@@ -0,0 +1,111 @@
import { describe, expect, it } from "vitest";
import {
applyChecklistToolEvent,
isTodoWriteTool,
parseTodoWriteInput,
todoProgressSummary,
} from "../../src/agent/todoList.js";
import { buildAgentCard } from "../../src/feishu/card/builder.js";
describe("todo list parse", () => {
it("accepts TodoWrite payloads", () => {
const todos = parseTodoWriteInput({
todos: [
{ content: "搜题", status: "completed", activeForm: "正在搜题" },
{ content: "写报告", status: "in_progress", activeForm: "正在写报告" },
{ content: "发卡片", status: "pending" },
],
});
expect(todos).toEqual([
{ id: undefined, content: "搜题", status: "completed", activeForm: "正在搜题" },
{ id: undefined, content: "写报告", status: "in_progress", activeForm: "正在写报告" },
{ id: undefined, content: "发卡片", status: "pending", activeForm: undefined },
]);
expect(todoProgressSummary(todos!)).toEqual({ completed: 1, total: 3, inProgress: 1 });
});
it("folds TaskCreate + TaskUpdate into a checklist", () => {
let todos = applyChecklistToolEvent([], {
toolName: "TaskCreate",
input: { subject: "说你好", description: "greet" },
result: "Task #1 created successfully: 说你好",
});
expect(todos).toEqual([
{ id: "1", content: "说你好", status: "pending", activeForm: undefined },
]);
todos = applyChecklistToolEvent(todos!, {
toolName: "TaskCreate_1",
input: { subject: "算 1+1", description: "math" },
result: "Task #2 created successfully: 算 1+1",
});
todos = applyChecklistToolEvent(todos!, {
toolName: "TaskUpdate",
input: { taskId: "1", status: "in_progress", activeForm: "正在问好" },
result: "Updated task #1 status",
});
todos = applyChecklistToolEvent(todos!, {
toolName: "TaskUpdate_4",
input: { taskId: "1", status: "completed" },
result: "Updated task #1 status",
});
todos = applyChecklistToolEvent(todos!, {
toolName: "TaskUpdate",
input: { taskId: "2", status: "completed" },
result: "Updated task #2 status",
});
expect(todos).toEqual([
{ id: "1", content: "说你好", status: "completed", activeForm: "正在问好" },
{ id: "2", content: "算 1+1", status: "completed", activeForm: undefined },
]);
expect(todoProgressSummary(todos!)).toEqual({ completed: 2, total: 2, inProgress: 0 });
});
it("recognizes TodoWrite tool names", () => {
expect(isTodoWriteTool("TodoWrite")).toBe(true);
expect(isTodoWriteTool("mcp__cph_hub__todo_write")).toBe(true);
expect(isTodoWriteTool("Bash")).toBe(false);
});
});
describe("agent card todo panel", () => {
it("renders a progress checklist when todos are present", () => {
const card = buildAgentCard({
phase: "streaming",
text: "",
reasoningText: undefined,
todos: [
{ id: "1", content: "A", status: "completed", activeForm: undefined },
{ id: "2", content: "B", status: "in_progress", activeForm: "Doing B" },
{ id: "3", content: "C", status: "pending", activeForm: undefined },
],
toolUseSteps: [
{
id: "1",
seq: 1,
toolName: "TaskCreate",
toolUseId: "t1",
input: {},
result: undefined,
error: undefined,
status: "success",
startedAt: 0,
finishedAt: 1,
durationMs: 1,
},
],
toolUseElapsedMs: 10,
isError: undefined,
interrupted: undefined,
runId: "run-1",
});
const json = JSON.stringify(card);
expect(json).toContain("任务进度 1/3");
expect(json).toContain("Doing B");
expect(json).toContain("collapsible_panel");
// TaskCreate noise filtered when checklist present (no separate tool panel if only Task*)
expect(json).not.toContain("TaskCreate");
});
});