From 8df81569691078606c571f0ff3dfb63e58149702 Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Sat, 18 Jul 2026 15:54:00 +0800 Subject: [PATCH] feat(hub): external capability registry for PDF/ASR transforms (ADR-0027) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- docs/adr/0027-external-capability-registry.md | 152 ++++++++++ .../migration.sql | 69 +++++ hub/prisma/schema.prisma | 54 ++++ hub/src/capability/capabilityConnections.ts | 70 +++++ hub/src/capability/mineruClient.ts | 66 ++++ hub/src/capability/pdfToMdBundle.ts | 152 ++++++++++ hub/src/capability/types.ts | 100 ++++++ .../integration/capability-pdf-to-md.test.ts | 285 ++++++++++++++++++ spec/Spec/System.lean | 6 +- spec/Spec/System/Agent/Capability.lean | 104 +++++++ 10 files changed, 1057 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0027-external-capability-registry.md create mode 100644 hub/prisma/migrations/20260718130000_external_capability_connections/migration.sql create mode 100644 hub/src/capability/capabilityConnections.ts create mode 100644 hub/src/capability/mineruClient.ts create mode 100644 hub/src/capability/pdfToMdBundle.ts create mode 100644 hub/src/capability/types.ts create mode 100644 hub/test/integration/capability-pdf-to-md.test.ts create mode 100644 spec/Spec/System/Agent/Capability.lean diff --git a/docs/adr/0027-external-capability-registry.md b/docs/adr/0027-external-capability-registry.md new file mode 100644 index 0000000..0d0b6a7 --- /dev/null +++ b/docs/adr/0027-external-capability-registry.md @@ -0,0 +1,152 @@ +# ADR 0027: External Capability Registry + +## Status + +Accepted. + +## Context + +ADR-0026 introduced the `UsageFact` ledger with a `kind = external_capability` +fact and a `capabilityId` field, but deferred the capability registry itself. +Two concrete needs now force the issue: + +- **PDF→Markdown bundle** conversion (and, imminently, audio/video→text) + must run as a side effect of an `AgentRun`, bill in non-token units + (pages, seconds), use a different provider than the model loop, and report + cost through a different channel. It is not a sub-run (ADR-0026 rejected + that) and not a model-provider call (it does not speak the Anthropic/ + OpenRouter protocol). +- The Agent already has a Bash tool. Without a first-class capability seam, + the path of least resistance is for the agent to shell out to ad-hoc + scripts that embed API keys, write to arbitrary paths, and report nothing + to the ledger. That is exactly the unattributed, uncontained external + consumption ADR-0022/0026 exist to prevent. + +The model-provider connection (`OrganizationProviderConnection`, ADR-0024) +is the wrong seam for these services: + +- Its payload schema (`baseUrl` + `authToken` + `anthropicApiKey`) and + readiness probe (`/v1/models?supported_parameters=tools`) are specific to + OpenRouter/Anthropic. MinerU, Whisper, and future OCR/ASR services have + different auth shapes (an API token, optionally a project id) and no + `/v1/models` endpoint. +- Its uniqueness key is `(organizationId, providerId)` where `providerId` + is an OpenRouter-style model-routing id. A capability provider id + (`mineru`) names a *service*, not a model. +- Coupling capability credentials into the model-provider table would force + every capability's auth shape through `ProviderSecretPayloadV1` and every + readiness probe through `probeOpenRouterCredential`. + +The Feishu Application Connection (`OrganizationFeishuApplicationConnection`) +is the right structural precedent: it reuses the ADR-0024 envelope (KEK → +DEK → AES-256-GCM, AAD-bound to purpose/org/connection/version) but has its +own connection table, its own payload schema, its own readiness probe, and +its own per-org uniqueness. Capability connections follow the same pattern. + +## Decision + +### External Capability + +An **External Capability** is a platform-registered, org-enabled document or +media transform service invoked as a side effect of an `AgentRun`. It is +identified by a stable `capabilityId` (e.g. `pdf_to_md_bundle`, +`audio_video_to_text`). A capability: + +- Has an **input kind** (PDF, image, audio, video, …) and an **output + contract** (markdown bundle with extracted images, transcript text, …). +- Bills in **non-token units** (pages, audio-seconds) recorded on a + `UsageFact` with `kind = external_capability`, or in tokens when the + backing service reports them. +- Writes its output **into the invoking run's workspace** (ADR-0018 + `AgentSurface` — no escapes). +- Is invoked through a **capability adapter** in Hub, never by the Agent + shelling out with embedded credentials. + +### Capability Connection + +Credentials for a capability live in an **`OrganizationCapabilityConnection`**, +structurally identical to the Feishu Application Connection: + +- Belongs to exactly one Organization. +- Unique by `(organizationId, capabilityId)`. +- `DRAFT` / `ACTIVE` / `DISABLED`; resolution accepts only `ACTIVE` with a + valid active secret version. +- Secret material is an immutable, AAD-bound, KEK-wrapped envelope version + (`CapabilityCredentialVersion`), reusing the ADR-0024 encryption + machinery with `purpose = "capability"`. +- Its payload schema is capability-specific (`CapabilitySecretPayloadV1`: + `baseUrl`, `apiToken`, optional `projectId`). New capability types extend + the payload, not the connection table. +- A capability-specific **readiness probe** validates the credential before + activation (e.g. MinerU: a trivial authenticated GET). The probe is + injectable, matching the Feishu/provider pattern, so tests never hit the + network. + +### Capability Adapter + +The adapter is the seam between the Agent and the external service. It: + +- Resolves the org's active capability connection (fail-closed, no + process-global fallback — ADR-0024). +- Accepts a workspace-relative input path and an output directory. +- Calls the backing service (MinerU, Whisper, …) via an injectable + `Client` interface so the real HTTP client is swappable and mockable. +- Writes the produced markdown + image assets into the run's workspace. +- Writes one `UsageFact` (or more, if the service reports per-stage + consumption) with `kind = external_capability`, `capabilityId`, + `provider` (the service id), `quantity + unit` (pages / seconds), and + `costUsd + costSource = provider_reported` when the service reports cost. + +### Registry + +The platform maintains a **registry** of known capabilities: their id, +input kind, output contract, metering unit, and adapter. This is +code-level registration (like `ToolRegistry`), not a database table — a +capability is available to an Organization only when (a) the platform +knows the adapter and (b) the Organization has an `ACTIVE` connection for +it. Both gates are required. + +### What is NOT in this ADR + +- The capability invocation is **not** a first-class persisted record + (`CapabilityInvocation` table) in this ADR. The `UsageFact` row with + `capabilityId` + `correlationId` is the durable trace. If we later need + a richer invocation log (retries, partial output, multi-stage status), + that is a follow-up; for now the fact is enough. +- **Pricebook** remains deferred (ADR-0026). Capability facts use + `provider_reported` when the service returns cost; otherwise `unknown`. +- **Org-scoped enable/disable policy** beyond connection status is + deferred. An org with an `ACTIVE` connection has the capability; one + without does not. A finer "enabled but no credential" toggle is not + needed yet. +- **Agent-facing tool exposure** (how the Agent discovers and calls the + capability — MCP tool, Bash wrapper, or built-in) is an implementation + detail of the adapter wiring, not a contract concern. The contract pins + that the Agent never receives the capability credential. + +## Consequences + +- Adding a new external capability (e.g. `image_ocr`) is: register an + adapter, add a `capabilityId` constant, optionally extend the secret + payload — no schema change to `UsageFact` or `AgentRun`. +- The model-provider connection table stays focused on model routing; + capability credentials do not pollute its payload or readiness probe. +- Three connection types now share the ADR-0024 envelope: model-provider, + Feishu application, and capability. Each has its own table, payload + schema, and probe, but the same encryption, rotation, and resolver + boundary. +- The Agent's Bash tool remains available, but the intended path for + document/media transforms is the capability adapter. Whether to narrow + Bash for capability-shaped tasks is an operational policy decision, + not a contract one. +- Tests prove: workspace containment of capability output, fail-closed + credential resolution, `UsageFact` attribution with non-token metering, + and that the Agent process never receives the capability credential. + +## Deferred + +- `CapabilityInvocation` as a first-class durable record (status, retries, + partial output) — currently the `UsageFact` row is the only trace. +- Pricebook derivation for capability costs (ADR-0026 deferred). +- Org-scoped capability enable/disable policy finer than connection status. +- Agent-facing tool discovery (MCP vs built-in) for capabilities. diff --git a/hub/prisma/migrations/20260718130000_external_capability_connections/migration.sql b/hub/prisma/migrations/20260718130000_external_capability_connections/migration.sql new file mode 100644 index 0000000..267e9cd --- /dev/null +++ b/hub/prisma/migrations/20260718130000_external_capability_connections/migration.sql @@ -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; diff --git a/hub/prisma/schema.prisma b/hub/prisma/schema.prisma index be3e60d..0156a8f 100644 --- a/hub/prisma/schema.prisma +++ b/hub/prisma/schema.prisma @@ -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]) +} diff --git a/hub/src/capability/capabilityConnections.ts b/hub/src/capability/capabilityConnections.ts new file mode 100644 index 0000000..c785435 --- /dev/null +++ b/hub/src/capability/capabilityConnections.ts @@ -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 { + 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(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, + }; +} diff --git a/hub/src/capability/mineruClient.ts b/hub/src/capability/mineruClient.ts new file mode 100644 index 0000000..7628a44 --- /dev/null +++ b/hub/src/capability/mineruClient.ts @@ -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; +} + +/** 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"; + } +} diff --git a/hub/src/capability/pdfToMdBundle.ts b/hub/src/capability/pdfToMdBundle.ts new file mode 100644 index 0000000..f80d27d --- /dev/null +++ b/hub/src/capability/pdfToMdBundle.ts @@ -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 { + 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, + }, + }; + }, + }; +} diff --git a/hub/src/capability/types.ts b/hub/src/capability/types.ts new file mode 100644 index 0000000..87521d1 --- /dev/null +++ b/hub/src/capability/types.ts @@ -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> = { + 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; +} + +/** 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; diff --git a/hub/test/integration/capability-pdf-to-md.test.ts b/hub/test/integration/capability-pdf-to-md.test.ts new file mode 100644 index 0000000..9bc484e --- /dev/null +++ b/hub/test/integration/capability-pdf-to-md.test.ts @@ -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 { + 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 = {}): 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 => full), + }; + } + + function makeAdapter(client: MineruClient) { + return createPdfToMdBundleAdapter({ + secrets: testSecretEnvelope, + client, + prisma, + }); + } + + async function seedInputPdf(name: string = "input.pdf"): Promise { + 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).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 => { + 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/); + }); +}); diff --git a/spec/Spec/System.lean b/spec/Spec/System.lean index 917f114..f92b70a 100644 --- a/spec/Spec/System.lean +++ b/spec/Spec/System.lean @@ -11,6 +11,7 @@ import Spec.System.Agent.AgentRole import Spec.System.Agent.Memory import Spec.System.Agent.AgentSurface import Spec.System.Agent.Usage +import Spec.System.Agent.Capability import Spec.System.Lock import Spec.System.Permission import Spec.System.PermissionGrant @@ -43,11 +44,14 @@ likec4 已画出结构;这里补语义: - `Usage` —— Run 内 append-only 用量计量事实账本(ADR-0026);主模型 completion、 外部能力调用、代理网关旁路共用同一账本。fact 不持锁、不跨 run;`costUsd = none` 表示未知而非零(ADR-0022)。Run 上的 cost/token 标量是其 rollup cache。 +- `Capability` —— 外部能力(PDF→MD、音视频→文本等)注册与调用(ADR-0027); + org-scoped 凭证连接复用 ADR-0024 信封但独立于 model-provider;调用是 Run 内副作用, + 产物落 workspace,消耗记 UsageFact。capability credential 不进 Agent 进程。 - `Permission` —— read⊂edit⊂manage 角色体系、能力推导、单调性;force-release 在格外。 - `PermissionGrant` —— grant(resource×principal×role)与 settings(六 policy 旋钮) (ADR-0004);组合规则 OPEN。 - `Audit` —— customer Project/Run 审计从简(内容 OPEN);Platform Audit 由 `PlatformAdministration` 独立承载。 -标识符见 `Spec.Prelude`。决策出处:ADR-0001..0004, 0018, 0020..0026。 +标识符见 `Spec.Prelude`。决策出处:ADR-0001..0004, 0018, 0020..0027。 -/ diff --git a/spec/Spec/System/Agent/Capability.lean b/spec/Spec/System/Agent/Capability.lean new file mode 100644 index 0000000..f89f23e --- /dev/null +++ b/spec/Spec/System/Agent/Capability.lean @@ -0,0 +1,104 @@ +import Spec.Prelude +import Spec.System.Agent.Run +import Spec.System.Agent.Usage + +/-! +# Capability —— 外部能力注册与调用(ADR-0027) + +`AgentRun` 内可能调用**外部能力**:PDF→MD bundle、音视频→文本、OCR 等。这些能力 +不是主 agent loop 的模型 completion,不持项目锁,不占 session 语义(ADR-0026 已拒绝 +nested Run)。它们是 Run 内的副作用:读 workspace 输入、调外部服务、产物落 workspace、 +写一条 `UsageFact`。 + +本模块 pin 三件事: + +1. **ExternalCapability** —— 平台注册的、org 启用的文档/媒体转换服务,由稳定 + `capabilityId` 标识。 +2. **CapabilityConnection** —— org-scoped 凭证连接,复用 ADR-0024 信封机制但独立于 + model-provider connection。只有 `active` connection 可被 resolver 使用。 +3. **CapabilityInvocation 良构** —— 调用的输入/输出必须落在 run 的 workspace 内 + (ADR-0018 `AgentSurface`),且调用产生的消耗必须以 `UsageFact` 记录。 + +凭证不经 Agent 子进程:Agent 只通过 capability adapter 间接调用,adapter 在 Hub 侧 +解析 org-scoped 凭证并调用外部服务(ADR-0024 resolver boundary)。这与 model-provider +的 loopback proxy 是不同的机制——capability 调用不走 agent 的网络面,走 Hub 直连。 + +不变式(`PINNED`, ADR-0027): + +- **凭证隔离** —— capability credential 不进 Agent 进程环境;adapter 在 Hub 侧解析。 +- **workspace 边界** —— 输入路径与输出目录都必须落在 run 的 workspace 内(ADR-0018)。 +- **必记 fact** —— 一次成功调用必须产生 ≥1 条 `UsageFact`(`kind = externalCapability`), + 即使 `costUsd = none`(unknown ≠ 零,ADR-0022/0026)。 + +`CapabilityInvocation` 作为一等持久记录(status/retries/partial output)在 pilot 不做; +`UsageFact` 的 `capabilityId + correlationId` 是唯一可追溯痕迹(ADR-0027 Deferred)。 +-/ + +namespace Spec.System.Agent + +variable (I : Identifiers) (Path : Type) + +/-- 外部能力的输入种类(`PINNED`, ADR-0027)。集合 `OPEN`——新增 kind 须 surface。 -/ +inductive CapabilityInputKind where + /-- PDF 文档(`PINNED`)。 -/ + | pdf + /-- 图片(`PINNED`)。 -/ + | image + /-- 音频(`PINNED`)。 -/ + | audio + /-- 视频(`PINNED`)。 -/ + | video + +/-- 外部能力(`PINNED`, ADR-0027)。平台注册的文档/媒体转换服务,由 org 启用。 + +一个 capability 由稳定 `capabilityId` 标识(如 `pdf_to_md_bundle`),声明接受的输入 +种类与计量单位。org 通过 `CapabilityConnection` 启用它;adapter 是平台侧实现。 -/ +structure ExternalCapability where + /-- 稳定能力标识(`PINNED`;如 `pdf_to_md_bundle`、`audio_video_to_text`)。 -/ + capabilityId : String + /-- 接受的输入种类(`PINNED`, ADR-0027)。 -/ + inputKind : CapabilityInputKind + /-- 计量单位(`OPEN`;如 `"pages"`、`"audio_seconds"`)。与 UsageFact.unit 对齐。 -/ + meteringUnit : String + +/-- capability connection 的运行态(`PINNED`, ADR-0024/0027):与 model-provider / +Feishu connection 同构,只有 `active` 可被 resolver 使用。 -/ +inductive CapabilityConnectionStatus where + | draft + | active + | disabled + +/-- Organization 的外部能力凭证连接(`PINNED`, ADR-0027)。复用 ADR-0024 信封机制 +但独立于 model-provider connection:capability 服务(如 MinerU、Whisper)有自己的 auth +形状与 readiness probe,不走 OpenRouter `/v1/models`。唯一性键为 +`(organization, capabilityId)`。 -/ +structure OrganizationCapabilityConnection where + /-- connection 所属 organization(`PINNED`, ADR-0027)。 -/ + organization : I.OrganizationId + /-- 该 connection 启用的 capability(`PINNED`, ADR-0027)。 -/ + capability : ExternalCapability + /-- 运行态(`PINNED`, ADR-0024/0027)。 -/ + status : CapabilityConnectionStatus + +/-- 一次 capability 调用的良构约束(`PINNED`, ADR-0027)。输入路径与输出目录都必须落在 +发起 run 的 workspace 内(ADR-0018 `AgentSurface`);`runWorkspace` 与 `pathWithin` +由平台提供(表示 `OPEN`)。逃逸即越权,拒绝。 -/ +structure CapabilityInvocation where + /-- 发起调用的 run(`PINNED`;调用是 Run 内副作用,不跨 run,ADR-0026/0027)。 -/ + run : I.RunId + /-- 被调用的 capability(`PINNED`)。 -/ + capability : ExternalCapability + /-- 输入路径(workspace 内,`PINNED`, ADR-0018)。 -/ + inputPath : Path + /-- 输出目录(workspace 内,`PINNED`, ADR-0018)。 -/ + outputDir : Path + +/-- capability 调用良构:输入与输出路径都落在 run 的 workspace 内(`PINNED`, +ADR-0018/0027)。这是 `AgentFileOp.Authorized` 在 capability 调用上的对应物。 -/ +def CapabilityInvocation.Authorized + (inv : CapabilityInvocation I Path) + (runWorkspace : I.RunId → Option Path) + (pathWithin : Path → Path → Prop) : Prop := + ∃ w, runWorkspace inv.run = some w ∧ pathWithin inv.inputPath w ∧ pathWithin inv.outputDir w + +end Spec.System.Agent