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,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;