forked from EduCraft/curriculum-project-hub
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:
@@ -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,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user