forked from EduCraft/curriculum-project-hub
8df8156969
Introduces the External Capability abstraction: platform-registered, org-enabled document/media transform services invoked as side effects of an AgentRun. The first concrete capability is pdf_to_md_bundle backed by MinerU. This lands the contract, schema, adapter interface, and a fully mock-tested adapter; the real MinerU HTTP client is deferred until credentials and pricing are confirmed. Contract: - spec/Spec/System/Agent/Capability.lean pins ExternalCapability, OrganizationCapabilityConnection (reuses ADR-0024 envelope, distinct from model-provider connection), and CapabilityInvocation.Authorized (workspace containment predicate, ADR-0018). lake build green (41 jobs). - ADR-0027 records why capability credentials are a separate connection type (MinerU/Whisper have their own auth shape, not OpenRouter /v1/models), the adapter seam, and what is deferred (capability invocation record, pricebook, agent-facing tool discovery). Schema: - OrganizationCapabilityConnection + CapabilityCredentialVersion, mirroring the Feishu Application Connection shape. Unique by (organizationId, capabilityId). Migration 20260718130000 applied. Adapter (hub/src/capability): - types.ts: CapabilityId, CapabilityAdapter interface, secret payload, CapabilityConnectionUnavailable. - mineruClient.ts: MineruClient interface + MineruParseResult + errors. Real HTTP client deferred; the interface is the seam. - capabilityConnections.ts: resolveCapabilityCredential — org-scoped, fail-closed, reuses LocalSecretEnvelope with purpose="capability". - pdfToMdBundle.ts: adapter that resolves credential, confines input/output to the run workspace (ADR-0018), calls MineruClient, writes md + images into workspace, and writes a UsageFact (kind=external_capability, quantity=pages, costSource=provider_reported|unknown per ADR-0022). Tests (7, all green): - md + images written to workspace, UsageFact attributes pages + cost - costUsd null -> costSource=unknown (missing cost != zero, ADR-0022) - no ACTIVE connection -> fail-closed, no fact written - input/output path escape -> CapabilityPathEscape - MinerU client error propagates, no fact written - empty markdown -> parse failure Deferred (needs operator): MinerU account/API key/pricing, real MineruClient HTTP implementation, org admin UI for capability connection management, agent-facing tool exposure (MCP vs built-in).
286 lines
9.4 KiB
TypeScript
286 lines
9.4 KiB
TypeScript
import { mkdtemp, mkdir, writeFile, readFile, 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 { createPdfToMdBundleAdapter, CapabilityPathEscape } from "../../src/capability/pdfToMdBundle.js";
|
|
import { MineruClientError, type MineruClient, type MineruParseResult } from "../../src/capability/mineruClient.js";
|
|
import type { CapabilitySecretPayload } from "../../src/capability/types.js";
|
|
|
|
const CAPABILITY_ID = "pdf_to_md_bundle";
|
|
const PROVIDER_ID = "mineru";
|
|
|
|
/**
|
|
* ADR-0027: pdf_to_md_bundle adapter contract tests. Proves:
|
|
* - workspace containment (input + output confined to run workspace)
|
|
* - fail-closed credential resolution (no ACTIVE connection → error)
|
|
* - UsageFact attribution with non-token meter (pages), costSource rules
|
|
* - outputs (md + images) written into workspace
|
|
* - MinerU client errors propagate
|
|
* The MineruClient is mocked; the prisma + envelope + filesystem are real.
|
|
*/
|
|
describe("pdf_to_md_bundle capability adapter (ADR-0027)", () => {
|
|
let workspaceRoot: string;
|
|
let runId: string;
|
|
|
|
beforeEach(async () => {
|
|
await resetDb();
|
|
await seedTestOrganization();
|
|
workspaceRoot = await mkdtemp(join(tmpdir(), "cph-cap-"));
|
|
runId = "run-cap-test";
|
|
// AgentRun is required for the UsageFact FK.
|
|
await prisma.project.create({
|
|
data: {
|
|
id: "proj-cap",
|
|
organizationId: DEFAULT_ORG_ID,
|
|
name: "Cap Test",
|
|
workspaceDir: workspaceRoot,
|
|
},
|
|
});
|
|
await prisma.agentRun.create({
|
|
data: {
|
|
id: runId,
|
|
projectId: "proj-cap",
|
|
entrypoint: "FEISHU",
|
|
provider: "openrouter",
|
|
model: "mock-model",
|
|
status: "ACTIVE",
|
|
prompt: "convert this pdf",
|
|
metadata: {},
|
|
},
|
|
});
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await rm(workspaceRoot, { recursive: true, force: true }).catch(() => {});
|
|
});
|
|
|
|
async function seedActiveCapabilityConnection(): Promise<void> {
|
|
const payload: CapabilitySecretPayload = {
|
|
schemaVersion: 1,
|
|
baseUrl: "https://mineru.test/api",
|
|
apiToken: "mineru-secret-token",
|
|
projectId: null,
|
|
};
|
|
const connection = await prisma.organizationCapabilityConnection.create({
|
|
data: {
|
|
id: "cap-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: "cap-sv-1",
|
|
},
|
|
payload,
|
|
);
|
|
await prisma.capabilityCredentialVersion.create({
|
|
data: {
|
|
id: "cap-sv-1",
|
|
connectionId: connection.id,
|
|
version: 1,
|
|
envelope: envelope as object,
|
|
keyId: envelope.keyId,
|
|
},
|
|
});
|
|
await prisma.organizationCapabilityConnection.update({
|
|
where: { id: connection.id },
|
|
data: { activeSecretVersionId: "cap-sv-1" },
|
|
});
|
|
}
|
|
|
|
function mockMineruClient(result: Partial<MineruParseResult> = {}): MineruClient {
|
|
const full: MineruParseResult = {
|
|
markdown: "# Parsed Document\n\nHello world.\n\n\n",
|
|
images: [
|
|
{ filename: "page_1_fig_0.jpg", data: new Uint8Array([0xff, 0xd8, 0xff, 0xe0]) },
|
|
],
|
|
pageCount: 3,
|
|
costUsd: 0.015,
|
|
requestId: "mineru-task-abc",
|
|
...result,
|
|
};
|
|
return {
|
|
parse: vi.fn(async (): Promise<MineruParseResult> => full),
|
|
};
|
|
}
|
|
|
|
function makeAdapter(client: MineruClient) {
|
|
return createPdfToMdBundleAdapter({
|
|
secrets: testSecretEnvelope,
|
|
client,
|
|
prisma,
|
|
});
|
|
}
|
|
|
|
async function seedInputPdf(name: string = "input.pdf"): Promise<string> {
|
|
const dir = join(workspaceRoot, "sources");
|
|
await mkdir(dir, { recursive: true });
|
|
const path = join(dir, name);
|
|
await writeFile(path, "%PDF-1.4 fake pdf bytes");
|
|
return join("sources", name);
|
|
}
|
|
|
|
it("writes md + images into the workspace and records a UsageFact", async () => {
|
|
await seedActiveCapabilityConnection();
|
|
const inputPath = await seedInputPdf();
|
|
const client = mockMineruClient();
|
|
const adapter = makeAdapter(client);
|
|
|
|
const result = await adapter.invoke({
|
|
runId,
|
|
organizationId: DEFAULT_ORG_ID,
|
|
projectId: "proj-cap",
|
|
workspaceDir: workspaceRoot,
|
|
inputPath,
|
|
outputDir: "output",
|
|
prisma,
|
|
});
|
|
|
|
// Outputs landed in workspace.
|
|
const md = await readFile(join(workspaceRoot, "output", "document.md"), "utf8");
|
|
expect(md).toContain("# Parsed Document");
|
|
expect(result.artifacts.some((a) => a.kind === "markdown" && a.path === "output/document.md")).toBe(true);
|
|
expect(result.artifacts.some((a) => a.kind === "image" && a.path === "output/page_1_fig_0.jpg")).toBe(true);
|
|
|
|
// Client received the decrypted credential, not the env.
|
|
const call = (client.parse as ReturnType<typeof vi.fn>).mock.calls[0]?.[0] as CapabilitySecretPayload;
|
|
expect(call.apiToken).toBe("mineru-secret-token");
|
|
expect(call.baseUrl).toBe("https://mineru.test/api");
|
|
|
|
// UsageFact written with non-token meter and provider_reported cost.
|
|
const facts = await prisma.usageFact.findMany({ where: { runId } });
|
|
expect(facts).toHaveLength(1);
|
|
const fact = facts[0]!;
|
|
expect(fact.kind).toBe("external_capability");
|
|
expect(fact.provider).toBe(PROVIDER_ID);
|
|
expect(fact.capabilityId).toBe(CAPABILITY_ID);
|
|
expect(Number(fact.quantity)).toBe(3);
|
|
expect(fact.unit).toBe("pages");
|
|
expect(Number(fact.costUsd!)).toBeCloseTo(0.015, 8);
|
|
expect(fact.costSource).toBe("provider_reported");
|
|
expect(fact.correlationId).toBe("mineru-task-abc");
|
|
});
|
|
|
|
it("writes UsageFact with costSource=unknown when MinerU reports no cost", async () => {
|
|
await seedActiveCapabilityConnection();
|
|
const inputPath = await seedInputPdf();
|
|
const client = mockMineruClient({ costUsd: null, requestId: null });
|
|
const adapter = makeAdapter(client);
|
|
|
|
await adapter.invoke({
|
|
runId,
|
|
organizationId: DEFAULT_ORG_ID,
|
|
projectId: "proj-cap",
|
|
workspaceDir: workspaceRoot,
|
|
inputPath,
|
|
outputDir: "output",
|
|
prisma,
|
|
});
|
|
|
|
const fact = (await prisma.usageFact.findFirstOrThrow({ where: { runId } }));
|
|
expect(fact.costUsd).toBeNull();
|
|
expect(fact.costSource).toBe("unknown");
|
|
// quantity still recorded — missing cost ≠ zero consumption (ADR-0022).
|
|
expect(Number(fact.quantity)).toBe(3);
|
|
});
|
|
|
|
it("fails closed when the org has no ACTIVE capability connection", async () => {
|
|
// No connection seeded.
|
|
const inputPath = await seedInputPdf();
|
|
const adapter = makeAdapter(mockMineruClient());
|
|
|
|
await expect(adapter.invoke({
|
|
runId,
|
|
organizationId: DEFAULT_ORG_ID,
|
|
projectId: "proj-cap",
|
|
workspaceDir: workspaceRoot,
|
|
inputPath,
|
|
outputDir: "output",
|
|
prisma,
|
|
})).rejects.toThrow(/no ACTIVE capability connection/);
|
|
|
|
// No UsageFact written for a failed resolution.
|
|
const facts = await prisma.usageFact.findMany({ where: { runId } });
|
|
expect(facts).toHaveLength(0);
|
|
});
|
|
|
|
it("rejects an input path that escapes the workspace", async () => {
|
|
await seedActiveCapabilityConnection();
|
|
const adapter = makeAdapter(mockMineruClient());
|
|
|
|
await expect(adapter.invoke({
|
|
runId,
|
|
organizationId: DEFAULT_ORG_ID,
|
|
projectId: "proj-cap",
|
|
workspaceDir: workspaceRoot,
|
|
inputPath: "../../../etc/passwd",
|
|
outputDir: "output",
|
|
prisma,
|
|
})).rejects.toBeInstanceOf(CapabilityPathEscape);
|
|
});
|
|
|
|
it("rejects an output path that escapes the workspace", async () => {
|
|
await seedActiveCapabilityConnection();
|
|
const inputPath = await seedInputPdf();
|
|
const adapter = makeAdapter(mockMineruClient());
|
|
|
|
await expect(adapter.invoke({
|
|
runId,
|
|
organizationId: DEFAULT_ORG_ID,
|
|
projectId: "proj-cap",
|
|
workspaceDir: workspaceRoot,
|
|
inputPath,
|
|
outputDir: "../../outside",
|
|
prisma,
|
|
})).rejects.toBeInstanceOf(CapabilityPathEscape);
|
|
});
|
|
|
|
it("propagates MinerU client errors without writing a UsageFact", async () => {
|
|
await seedActiveCapabilityConnection();
|
|
const inputPath = await seedInputPdf();
|
|
const client: MineruClient = {
|
|
parse: vi.fn(async (): Promise<MineruParseResult> => {
|
|
throw new MineruClientError("upstream 502", "mineru_rejected", 502);
|
|
}),
|
|
};
|
|
const adapter = makeAdapter(client);
|
|
|
|
await expect(adapter.invoke({
|
|
runId,
|
|
organizationId: DEFAULT_ORG_ID,
|
|
projectId: "proj-cap",
|
|
workspaceDir: workspaceRoot,
|
|
inputPath,
|
|
outputDir: "output",
|
|
prisma,
|
|
})).rejects.toThrow(/upstream 502/);
|
|
|
|
const facts = await prisma.usageFact.findMany({ where: { runId } });
|
|
expect(facts).toHaveLength(0);
|
|
});
|
|
|
|
it("rejects empty markdown output as a parse failure", async () => {
|
|
await seedActiveCapabilityConnection();
|
|
const inputPath = await seedInputPdf();
|
|
const client = mockMineruClient({ markdown: "" });
|
|
const adapter = makeAdapter(client);
|
|
|
|
await expect(adapter.invoke({
|
|
runId,
|
|
organizationId: DEFAULT_ORG_ID,
|
|
projectId: "proj-cap",
|
|
workspaceDir: workspaceRoot,
|
|
inputPath,
|
|
outputDir: "output",
|
|
prisma,
|
|
})).rejects.toThrow(/empty markdown/);
|
|
});
|
|
});
|