feat(hub): external capability registry for PDF/ASR transforms (ADR-0027) (#5)

Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
This commit is contained in:
2026-07-18 15:55:02 +08:00
committed by 洪佳荣
parent aaa098bb8b
commit b673dd1fe9
10 changed files with 1057 additions and 1 deletions
@@ -0,0 +1,69 @@
-- ADR-0027: org-scoped capability connections for external document/media
-- transforms (PDF→MD, audio/video→text, …). Mirrors the Feishu Application
-- Connection shape: reuses the ADR-0024 envelope machinery with its own
-- payload schema and readiness probe, distinct from the model-provider
-- connection.
CREATE TABLE "OrganizationCapabilityConnection" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"capabilityId" TEXT NOT NULL,
"status" "OrganizationConnectionStatus" NOT NULL DEFAULT 'DRAFT',
"activeSecretVersionId" TEXT,
"activatedAt" TIMESTAMP(3),
"disabledAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OrganizationCapabilityConnection_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "OrganizationCapabilityConnection_organizationId_capabilityId_key"
ON "OrganizationCapabilityConnection"("organizationId", "capabilityId");
CREATE UNIQUE INDEX "OrganizationCapabilityConnection_activeSecretVersionId_key"
ON "OrganizationCapabilityConnection"("activeSecretVersionId");
CREATE INDEX "OrganizationCapabilityConnection_organizationId_status_idx"
ON "OrganizationCapabilityConnection"("organizationId", "status");
CREATE INDEX "OrganizationCapabilityConnection_capabilityId_status_idx"
ON "OrganizationCapabilityConnection"("capabilityId", "status");
CREATE TABLE "CapabilityCredentialVersion" (
"id" TEXT NOT NULL,
"connectionId" TEXT NOT NULL,
"version" INTEGER NOT NULL,
"envelopeVersion" INTEGER NOT NULL DEFAULT 1,
"keyId" TEXT NOT NULL,
"envelope" JSONB NOT NULL,
"createdByUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"retiredAt" TIMESTAMP(3),
CONSTRAINT "CapabilityCredentialVersion_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "CapabilityCredentialVersion_connectionId_version_key"
ON "CapabilityCredentialVersion"("connectionId", "version");
CREATE INDEX "CapabilityCredentialVersion_connectionId_retiredAt_idx"
ON "CapabilityCredentialVersion"("connectionId", "retiredAt");
CREATE INDEX "CapabilityCredentialVersion_keyId_idx"
ON "CapabilityCredentialVersion"("keyId");
CREATE INDEX "CapabilityCredentialVersion_createdByUserId_idx"
ON "CapabilityCredentialVersion"("createdByUserId");
ALTER TABLE "OrganizationCapabilityConnection"
ADD CONSTRAINT "OrganizationCapabilityConnection_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "OrganizationCapabilityConnection"
ADD CONSTRAINT "OrganizationCapabilityConnection_activeSecretVersionId_fkey"
FOREIGN KEY ("activeSecretVersionId") REFERENCES "CapabilityCredentialVersion"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "CapabilityCredentialVersion"
ADD CONSTRAINT "CapabilityCredentialVersion_connectionId_fkey"
FOREIGN KEY ("connectionId") REFERENCES "OrganizationCapabilityConnection"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "CapabilityCredentialVersion"
ADD CONSTRAINT "CapabilityCredentialVersion_createdByUserId_fkey"
FOREIGN KEY ("createdByUserId") REFERENCES "User"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
+54
View File
@@ -44,6 +44,7 @@ model Organization {
externalDirectoryConnections ExternalDirectoryConnection[]
providerConnections OrganizationProviderConnection[]
feishuApplicationConnection OrganizationFeishuApplicationConnection?
capabilityConnections OrganizationCapabilityConnection[]
agentSkills OrganizationAgentSkill[]
agentRoles OrganizationAgentRole[]
projectGroupBindings ProjectGroupBinding[]
@@ -198,6 +199,7 @@ model User {
auditEntries AuditEntry[] @relation("auditActor")
providerCredentialVersions ProviderCredentialVersion[] @relation("providerCredentialVersionCreator")
feishuCredentialVersions FeishuApplicationCredentialVersion[] @relation("feishuCredentialVersionCreator")
capabilityCredentialVersions CapabilityCredentialVersion[] @relation("capabilityCredentialVersionCreator")
feishuIdentities FeishuUserIdentity[]
}
@@ -869,3 +871,55 @@ model UsageFact {
@@index([provider, model, occurredAt])
@@index([capabilityId, occurredAt])
}
// --- External capability connections (ADR-0027) -------------------------
/// ADR-0027: org-scoped credential connection for an external capability
/// (PDF→MD, audio/video→text, …). Structurally mirrors the Feishu Application
/// Connection: reuses the ADR-0024 envelope (KEK→DEK→AES-256-GCM, AAD-bound)
/// but has its own payload schema and readiness probe, distinct from the
/// model-provider connection. Unique by (organizationId, capabilityId).
model OrganizationCapabilityConnection {
id String @id @default(cuid())
organizationId String
capabilityId String
status OrganizationConnectionStatus @default(DRAFT)
activeSecretVersionId String? @unique
activatedAt DateTime?
disabledAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
secretVersions CapabilityCredentialVersion[] @relation("capabilityCredentialVersions")
activeSecretVersion CapabilityCredentialVersion? @relation("activeCapabilityCredentialVersion", fields: [activeSecretVersionId], references: [id], onDelete: Restrict)
@@unique([organizationId, capabilityId])
@@index([organizationId, status])
@@index([capabilityId, status])
}
/// ADR-0024/0027: one immutable authenticated envelope per capability secret
/// version. Same encryption machinery as Provider/Feishu credential versions;
/// the payload inside is CapabilitySecretPayloadV1 (baseUrl, apiToken,
/// optional projectId).
model CapabilityCredentialVersion {
id String @id @default(cuid())
connectionId String
version Int
envelopeVersion Int @default(1)
keyId String
envelope Json
createdByUserId String?
createdAt DateTime @default(now())
retiredAt DateTime?
connection OrganizationCapabilityConnection @relation("capabilityCredentialVersions", fields: [connectionId], references: [id], onDelete: Cascade)
activeFor OrganizationCapabilityConnection? @relation("activeCapabilityCredentialVersion")
createdBy User? @relation("capabilityCredentialVersionCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
@@unique([connectionId, version])
@@index([connectionId, retiredAt])
@@index([keyId])
@@index([createdByUserId])
}
@@ -0,0 +1,70 @@
/**
* ADR-0027: org-scoped capability credential resolver. Reuses the ADR-0024
* envelope decryption machinery (LocalSecretEnvelope) with
* purpose="capability", distinct from the model-provider and Feishu
* application connections. Fail-closed: no process-global fallback.
*
* Mirrors the Feishu application connection resolver shape, minus the
* readiness probe (capability probes are per-capability and injected by the
* adapter wiring, not this resolver).
*/
import type { PrismaClient } from "@prisma/client";
import { LocalSecretEnvelope, type SecretEnvelopeV1 } from "../security/secretEnvelope.js";
import {
CapabilityConnectionUnavailable,
type CapabilitySecretPayload,
type CapabilityId,
} from "./types.js";
const CAPABILITY_PURPOSE = "capability";
export interface ResolvedCapabilityCredential extends CapabilitySecretPayload {
readonly connectionId: string;
readonly organizationId: string;
readonly capabilityId: string;
}
/**
* Resolve the active capability credential for an organization. Throws
* CapabilityConnectionUnavailable when the org has no ACTIVE connection
* (fail-closed, ADR-0024). Decrypted plaintext exists only in the returned
* object for the duration of the capability call; it is never cached, logged,
* or passed to the Agent process.
*/
export async function resolveCapabilityCredential(
prisma: PrismaClient,
secrets: LocalSecretEnvelope,
input: { readonly organizationId: string; readonly capabilityId: CapabilityId },
): Promise<ResolvedCapabilityCredential> {
const connection = await prisma.organizationCapabilityConnection.findFirst({
where: {
organizationId: input.organizationId,
capabilityId: input.capabilityId,
status: "ACTIVE",
},
include: { activeSecretVersion: true },
});
if (connection === null || connection.activeSecretVersion === null) {
throw new CapabilityConnectionUnavailable(input.capabilityId, input.organizationId);
}
const version = connection.activeSecretVersion;
const binding = {
purpose: CAPABILITY_PURPOSE,
organizationId: connection.organizationId,
connectionId: connection.id,
secretVersionId: version.id,
};
const payload = secrets.decryptJson<CapabilitySecretPayload>(binding, version.envelope as unknown as SecretEnvelopeV1);
if (payload.schemaVersion !== 1) {
throw new Error(`unsupported capability secret schemaVersion: ${payload.schemaVersion}`);
}
return {
connectionId: connection.id,
organizationId: connection.organizationId,
capabilityId: connection.capabilityId,
schemaVersion: 1,
baseUrl: payload.baseUrl,
apiToken: payload.apiToken,
projectId: payload.projectId,
};
}
+66
View File
@@ -0,0 +1,66 @@
/**
* ADR-0027: MinerU client interface. Isolates the real HTTP client so the
* adapter is testable without network access. The real implementation (filling
* in actual MinerU API calls) is deferred until credentials and pricing are
* confirmed; the interface and a mock implementation land now so the adapter
* wiring, UsageFact attribution, and workspace containment are provable.
*
* MinerU cloud API shape (from mineru.net docs / GitHub README):
* - REST API, async task endpoint POST /tasks (v3.0+) + sync POST /file_parse
* - Auth: Bearer token (apiToken)
* - Output: Markdown + extracted images, returned as a zip or structured JSON
* - Cost: reported per-page (pricing requires account confirmation)
*
* The interface models the synchronous parse path for simplicity; the real
* client may poll the async endpoint internally and is free to do so behind
* this signature.
*/
import type { CapabilitySecretPayload } from "./types.js";
/** A single extracted image from the parsed document. */
export interface MineruExtractedImage {
/** Suggested relative filename (e.g. "page_1_fig_0.jpg"). */
readonly filename: string;
/** Raw image bytes. */
readonly data: Uint8Array;
}
/** The structured result of parsing one document. */
export interface MineruParseResult {
/** Markdown text with image references (relative to output dir). */
readonly markdown: string;
/** Images extracted from the document, to be written alongside the md. */
readonly images: readonly MineruExtractedImage[];
/** Number of pages processed (for UsageFact quantity, unit "pages"). */
readonly pageCount: number;
/** USD cost if the API reported it; null if unknown (ADR-0022). */
readonly costUsd: number | null;
/** External task/request id for the UsageFact correlationId. */
readonly requestId: string | null;
}
/** Options passed to the client. */
export interface MineruParseOptions {
/** Absolute path to the input PDF on the Hub's filesystem. */
readonly inputFilePath: string;
}
/**
* Client interface for the MinerU document parsing service. The real
* implementation makes authenticated HTTP calls; tests inject a mock.
*/
export interface MineruClient {
parse(credential: CapabilitySecretPayload, options: MineruParseOptions): Promise<MineruParseResult>;
}
/** Errors raised by the MinerU client. */
export class MineruClientError extends Error {
constructor(
message: string,
readonly code: "mineru_unreachable" | "mineru_rejected" | "mineru_invalid_response" | "mineru_no_output",
readonly upstreamStatus?: number,
) {
super(message);
this.name = "MineruClientError";
}
}
+152
View File
@@ -0,0 +1,152 @@
/**
* ADR-0027: pdf_to_md_bundle capability adapter.
*
* Converts a PDF in the run's workspace into a Markdown bundle (md + extracted
* images) by calling the MinerU document parsing service, writing outputs into
* the workspace, and recording consumption on a UsageFact (ADR-0026).
*
* Invariants (ADR-0027):
* 1. Credential isolation — the capability credential is resolved in Hub and
* never reaches the Agent process. The MineruClient receives it as a
* call argument, not from the environment.
* 2. Workspace containment — input and output paths are confined to the
* run's workspace dir (ADR-0018 AgentSurface). Escapes are rejected.
* 3. Mandatory fact — a successful invocation always writes ≥1 UsageFact
* with kind=external_capability, even when costUsd is null (ADR-0022:
* missing cost ≠ zero).
*/
import { mkdir, writeFile } from "node:fs/promises";
import { join, resolve, relative, isAbsolute } from "node:path";
import type { PrismaClient } from "@prisma/client";
import { LocalSecretEnvelope } from "../security/secretEnvelope.js";
import { resolveCapabilityCredential } from "./capabilityConnections.js";
import { MineruClientError, type MineruClient } from "./mineruClient.js";
import {
CAPABILITIES,
type CapabilityAdapter,
type CapabilityInvocationInput,
type CapabilityInvocationResult,
type CapabilityOutputArtifact,
} from "./types.js";
const CAPABILITY_ID = "pdf_to_md_bundle" as const;
const PROVIDER_ID = "mineru";
/** Thrown when a requested path escapes the workspace root (ADR-0018). */
export class CapabilityPathEscape extends Error {
constructor(readonly requested: string, readonly workspaceDir: string) {
super(`capability path escapes workspace: ${requested} (root ${workspaceDir})`);
this.name = "CapabilityPathEscape";
}
}
/** Resolve a workspace-relative path, rejecting escapes (ADR-0018 AgentSurface). */
function confineToWorkspace(requestedPath: string, workspaceDir: string): string {
if (isAbsolute(requestedPath)) {
const rel = relative(workspaceDir, requestedPath);
if (rel.startsWith("..") || rel === "") {
throw new CapabilityPathEscape(requestedPath, workspaceDir);
}
return requestedPath;
}
const resolved = resolve(workspaceDir, requestedPath);
const rel = relative(workspaceDir, resolved);
if (rel.startsWith("..")) {
throw new CapabilityPathEscape(requestedPath, workspaceDir);
}
return resolved;
}
export interface PdfToMdBundleDeps {
readonly secrets: LocalSecretEnvelope;
readonly client: MineruClient;
readonly prisma: PrismaClient;
}
/** Build the pdf_to_md_bundle adapter. The MineruClient is injectable for testing. */
export function createPdfToMdBundleAdapter(deps: PdfToMdBundleDeps): CapabilityAdapter {
return {
capabilityId: CAPABILITY_ID,
async invoke(input: CapabilityInvocationInput): Promise<CapabilityInvocationResult> {
const descriptor = CAPABILITIES[CAPABILITY_ID];
// 1. Resolve org-scoped credential (fail-closed, ADR-0024/0027).
const credential = await resolveCapabilityCredential(deps.prisma, deps.secrets, {
organizationId: input.organizationId,
capabilityId: CAPABILITY_ID,
});
// 2. Confine input + output paths to the workspace (ADR-0018).
const absoluteInput = confineToWorkspace(input.inputPath, input.workspaceDir);
const absoluteOutputDir = confineToWorkspace(input.outputDir, input.workspaceDir);
await mkdir(absoluteOutputDir, { recursive: true });
// 3. Call the backing service.
let result;
try {
result = await deps.client.parse(credential, { inputFilePath: absoluteInput });
} catch (e) {
if (e instanceof MineruClientError) throw e;
throw new MineruClientError(
e instanceof Error ? e.message : String(e),
"mineru_unreachable",
);
}
if (result.markdown === "") {
throw new MineruClientError("MinerU returned empty markdown", "mineru_no_output");
}
// 4. Write outputs into the workspace.
const artifacts: CapabilityOutputArtifact[] = [];
const mdPath = join(absoluteOutputDir, "document.md");
await writeFile(mdPath, result.markdown, "utf8");
artifacts.push({ path: relative(input.workspaceDir, mdPath), kind: "markdown" });
for (const image of result.images) {
const imagePath = join(absoluteOutputDir, image.filename);
const rel = relative(absoluteOutputDir, imagePath);
if (rel.startsWith("..")) {
// Defensive: image filename must not escape the output dir.
throw new CapabilityPathEscape(image.filename, absoluteOutputDir);
}
await writeFile(imagePath, image.data);
artifacts.push({ path: relative(input.workspaceDir, imagePath), kind: "image" });
}
// 5. Write the UsageFact (ADR-0026/0027). Always written on success;
// costUsd null means unknown, NOT zero (ADR-0022).
const occurredAt = new Date();
await deps.prisma.usageFact.create({
data: {
runId: input.runId,
occurredAt,
kind: "external_capability",
provider: PROVIDER_ID,
model: null,
inputTokens: null,
outputTokens: null,
quantity: result.pageCount,
unit: descriptor.meteringUnit,
costUsd: result.costUsd,
costSource: result.costUsd !== null ? "provider_reported" : "unknown",
capabilityId: CAPABILITY_ID,
correlationId: result.requestId,
metadata: {},
},
});
return {
artifacts,
consumption: {
provider: PROVIDER_ID,
model: null,
inputTokens: null,
outputTokens: null,
quantity: result.pageCount,
unit: descriptor.meteringUnit,
costUsd: result.costUsd,
correlationId: result.requestId,
},
};
},
};
}
+100
View File
@@ -0,0 +1,100 @@
/**
* ADR-0027: External capability types shared across the adapter layer.
*
* A capability is a platform-registered, org-enabled document/media transform
* invoked as a side effect of an AgentRun. The adapter resolves the org's
* active capability connection, calls the backing service via an injectable
* client, writes output into the run's workspace (AgentSurface, ADR-0018),
* and records consumption on a UsageFact (ADR-0026).
*/
import type { PrismaClient, Prisma } from "@prisma/client";
/** Stable capability identifiers registered with the platform (ADR-0027). */
export const CAPABILITY_IDS = [
"pdf_to_md_bundle",
"audio_video_to_text",
] as const;
export type CapabilityId = (typeof CAPABILITY_IDS)[number];
/** Non-token metering unit for a capability (ADR-0026/0027). */
export interface CapabilityDescriptor {
readonly id: CapabilityId;
readonly meteringUnit: string;
}
/** Known capabilities and their metering units. Code-level registry. */
export const CAPABILITIES: Readonly<Record<CapabilityId, CapabilityDescriptor>> = {
pdf_to_md_bundle: { id: "pdf_to_md_bundle", meteringUnit: "pages" },
audio_video_to_text: { id: "audio_video_to_text", meteringUnit: "audio_seconds" },
};
/** Input passed to a capability adapter invocation. */
export interface CapabilityInvocationInput {
readonly runId: string;
readonly organizationId: string;
readonly projectId: string;
/** Absolute workspace dir of the run's project (ADR-0018 surface root). */
readonly workspaceDir: string;
/** Workspace-relative path to the input file (PDF, audio, …). */
readonly inputPath: string;
/** Workspace-relative directory to write outputs into. Created if absent. */
readonly outputDir: string;
/** Prisma client for UsageFact writes. */
readonly prisma: PrismaClient;
}
/** A successfully produced output artifact (file written into workspace). */
export interface CapabilityOutputArtifact {
/** Workspace-relative path of the written artifact. */
readonly path: string;
readonly kind: "markdown" | "image" | "metadata" | "other";
}
/** Consumption recorded for one invocation (written to UsageFact). */
export interface CapabilityConsumption {
/** The backing service provider id (e.g. "mineru", "openai_whisper"). */
readonly provider: string;
/** Model id if the service reports one; null for non-model services. */
readonly model: string | null;
readonly inputTokens: number | null;
readonly outputTokens: number | null;
/** Non-token meter (page count, audio seconds). */
readonly quantity: number;
readonly unit: string;
/** USD cost if the service reported one; null = unknown (ADR-0022). */
readonly costUsd: number | null;
/** External request id for reconciliation. */
readonly correlationId: string | null;
}
/** Result of a successful capability invocation. */
export interface CapabilityInvocationResult {
readonly artifacts: readonly CapabilityOutputArtifact[];
readonly consumption: CapabilityConsumption;
}
/** A capability adapter: resolves credentials, calls the service, writes output. */
export interface CapabilityAdapter {
readonly capabilityId: CapabilityId;
invoke(input: CapabilityInvocationInput): Promise<CapabilityInvocationResult>;
}
/** Decrypted capability credential (CapabilitySecretPayloadV1, ADR-0027). */
export interface CapabilitySecretPayload {
readonly schemaVersion: 1;
readonly baseUrl: string;
readonly apiToken: string;
readonly projectId: string | null;
}
/** Thrown when an org has no ACTIVE capability connection (fail-closed, ADR-0024). */
export class CapabilityConnectionUnavailable extends Error {
constructor(readonly capabilityId: string, readonly organizationId: string) {
super(`no ACTIVE capability connection for ${capabilityId} in org ${organizationId}`);
this.name = "CapabilityConnectionUnavailable";
}
}
/** Prisma transaction client type alias (for resolver signatures). */
export type TxClient = Prisma.TransactionClient;
@@ -0,0 +1,285 @@
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![fig](page_1_fig_0.jpg)\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/);
});
});