/** * ADR-0027: pbank capability — Paradigm 题库 search/fetch tools for Agent runs. * * Invariants: * 1. Credential isolation — org ACTIVE connection is resolved in Hub; credentials * never reach the Agent process (ADR-0024/0027). * 2. Workspace containment — materialize path is always under the run workspace * (ADR-0018 AgentSurface). * 3. Mandatory fact — each successful tool call writes ≥1 UsageFact with * kind=external_capability and unit=requests (cost unknown unless reported). */ import { inflateRawSync } from "node:zlib"; import { mkdir, rm, writeFile } from "node:fs/promises"; import { dirname, join, relative, resolve } from "node:path"; import type { PrismaClient } from "@prisma/client"; import { LocalSecretEnvelope } from "../security/secretEnvelope.js"; import { resolveCapabilityCredential } from "./capabilityConnections.js"; import { extractProblemId, HttpPbankClient, pbankRightsFromCredential, type PbankClient, } from "./pbankClient.js"; import { CAPABILITIES, asPbankSecret, type PbankCapabilitySecretPayload, } from "./types.js"; export const PBANK_CAPABILITY_ID = "pbank" as const; const PROVIDER_ID = "paradigm_pbank"; const MAX_BATCH_SIZE = 20; const MAX_PROJECT_BYTES = 80 * 1024 * 1024; const MAX_PROJECT_TEXT_BYTES = 120 * 1024; const MAX_EXTRACTED_PROJECT_BYTES = 80 * 1024 * 1024; const MAX_INLINE_ASSET_BYTES = 1024 * 1024; const MAX_INLINE_ASSETS = 4; const CACHE_DIR_NAME = ".pbank-sources"; export class CapabilityPathEscape extends Error { constructor(readonly requested: string, readonly workspaceDir: string) { super(`capability path escapes workspace: ${requested} (root ${workspaceDir})`); this.name = "CapabilityPathEscape"; } } export interface PbankServiceDeps { readonly prisma: PrismaClient; readonly secrets: LocalSecretEnvelope; readonly client?: PbankClient; } export interface PbankToolContext { readonly organizationId: string; readonly runId: string; readonly workspaceDir: string; } export interface PbankSearchArgs { readonly q?: string | undefined; readonly keywords?: readonly string[] | undefined; readonly pageNum?: number | undefined; readonly pageSize?: number | undefined; } export interface PbankGetProblemArgs { readonly urlOrId: string; readonly includeProjects?: boolean | undefined; readonly materializeProjects?: boolean | undefined; readonly includeAssetImages?: boolean | undefined; readonly includeOccurrences?: boolean | undefined; } export interface PbankGetManyArgs { readonly urlsOrIds: readonly string[]; readonly includeProjects?: boolean | undefined; readonly materializeProjects?: boolean | undefined; readonly includeAssetImages?: boolean | undefined; readonly includeOccurrences?: boolean | undefined; } export interface PbankToolResult { readonly data: unknown; readonly inlineImages: readonly { readonly data: string; readonly mimeType: string }[]; } interface TokenCacheEntry { readonly token: string; readonly expiresAt: number; readonly password: string; readonly username: string; readonly baseUrl: string; } export interface PbankService { searchProblems(ctx: PbankToolContext, args: PbankSearchArgs): Promise; getProblem(ctx: PbankToolContext, args: PbankGetProblemArgs): Promise; getManyProblems(ctx: PbankToolContext, args: PbankGetManyArgs): Promise; } export function createPbankService(deps: PbankServiceDeps): PbankService { const client = deps.client ?? new HttpPbankClient(); const tokenCache = new Map(); return { async searchProblems(ctx: PbankToolContext, args: PbankSearchArgs): Promise { const credential = await resolvePbankCredential(deps, ctx.organizationId); const token = await loginCached(client, tokenCache, credential); const pageNum = clampInt(args.pageNum ?? 1, 1, 10_000); const pageSize = clampInt(args.pageSize ?? 10, 1, 50); const result = await client.searchProblems(credential, token, { q: args.q, keywords: args.keywords, pageNum, pageSize, }); const data = { rights: pbankRightsFromCredential(credential), ...(typeof result === "object" && result !== null ? result : { result }), }; await writeUsageFact(deps.prisma, ctx.runId, "search", 1); return { data, inlineImages: [] }; }, async getProblem(ctx: PbankToolContext, args: PbankGetProblemArgs): Promise { const credential = await resolvePbankCredential(deps, ctx.organizationId); const bundle = await getProblemBundle(client, tokenCache, credential, ctx.workspaceDir, args); await writeUsageFact(deps.prisma, ctx.runId, extractProblemId(args.urlOrId), 1); return toToolResult(bundle); }, async getManyProblems(ctx: PbankToolContext, args: PbankGetManyArgs): Promise { if (args.urlsOrIds.length === 0) { throw new Error("urlsOrIds must not be empty"); } if (args.urlsOrIds.length > MAX_BATCH_SIZE) { throw new Error(`urlsOrIds exceeds max batch size ${MAX_BATCH_SIZE}`); } const credential = await resolvePbankCredential(deps, ctx.organizationId); const problems = []; for (const urlOrId of args.urlsOrIds) { problems.push( await getProblemBundle(client, tokenCache, credential, ctx.workspaceDir, { urlOrId, includeProjects: args.includeProjects, materializeProjects: args.materializeProjects, includeAssetImages: args.includeAssetImages, includeOccurrences: args.includeOccurrences, }), ); } await writeUsageFact(deps.prisma, ctx.runId, "batch", problems.length); return toToolResult({ count: problems.length, rights: pbankRightsFromCredential(credential), problems, }); }, }; } async function resolvePbankCredential( deps: PbankServiceDeps, organizationId: string, ): Promise { const resolved = await resolveCapabilityCredential(deps.prisma, deps.secrets, { organizationId, capabilityId: PBANK_CAPABILITY_ID, }); const secret = asPbankSecret(resolved); return { ...secret, connectionId: resolved.connectionId }; } async function loginCached( client: PbankClient, cache: Map, credential: PbankCapabilitySecretPayload & { connectionId: string }, ): Promise { const now = Date.now(); const cached = cache.get(credential.connectionId); if ( cached !== undefined && cached.expiresAt - 60_000 > now && cached.username === credential.username && cached.password === credential.password && cached.baseUrl === credential.baseUrl ) { return cached.token; } const login = await client.login(credential); cache.set(credential.connectionId, { token: login.token, expiresAt: login.expiresAt, username: credential.username, password: credential.password, baseUrl: credential.baseUrl, }); return login.token; } async function getProblemBundle( client: PbankClient, cache: Map, credential: PbankCapabilitySecretPayload & { connectionId: string }, workspaceDir: string, args: PbankGetProblemArgs, ): Promise> { const id = extractProblemId(args.urlOrId); const token = await loginCached(client, cache, credential); const problem = await client.getProblem(credential, token, id); const result: Record = { id, source: `${credential.baseUrl.replace(/\/+$/, "")}/problem/${id}`, rights: pbankRightsFromCredential(credential), problem, }; const includeProjects = args.includeProjects !== false; const materializeProjects = args.materializeProjects !== false; const includeAssetImages = args.includeAssetImages !== false; if (includeProjects) { const projects: Record = {}; for (const target of ["problem", "answer"] as const) { try { projects[target] = await downloadAndMaterializeProject({ client, credential, token, id, target, workspaceDir, materialize: materializeProjects, includeAssetImages, }); } catch (error) { projects[target] = { target, status: "error", message: error instanceof Error ? error.message : String(error), }; } } result.projects = projects; } if (args.includeOccurrences === true) { try { result.occurrences = await client.getOccurrences(credential, token, id); } catch (error) { result.occurrences = { status: "error", message: error instanceof Error ? error.message : String(error), }; } } return result; } async function downloadAndMaterializeProject(input: { readonly client: PbankClient; readonly credential: PbankCapabilitySecretPayload; readonly token: string; readonly id: string; readonly target: "problem" | "answer"; readonly workspaceDir: string; readonly materialize: boolean; readonly includeAssetImages: boolean; }): Promise> { const downloaded = await input.client.downloadProject( input.credential, input.token, input.id, input.target, ); if (downloaded.buffer.byteLength > MAX_PROJECT_BYTES) { throw new Error(`project archive exceeds ${MAX_PROJECT_BYTES} bytes`); } if (!isZipBuffer(downloaded.buffer, downloaded.contentType)) { const text = decodeUtf8IfText(downloaded.buffer); if (text !== null) { return { target: input.target, status: "text", bytes: downloaded.buffer.byteLength, content: text.slice(0, MAX_PROJECT_TEXT_BYTES), omitted: text.length > MAX_PROJECT_TEXT_BYTES ? [{ reason: "text truncated" }] : [], }; } return { target: input.target, status: "binary", bytes: downloaded.buffer.byteLength, contentType: downloaded.contentType, }; } return readProjectZip(downloaded.buffer, { id: input.id, target: input.target, workspaceDir: input.workspaceDir, materialize: input.materialize, includeAssetImages: input.includeAssetImages, }); } async function readProjectZip( buffer: Buffer, options: { readonly id: string; readonly target: string; readonly workspaceDir: string; readonly materialize: boolean; readonly includeAssetImages: boolean; }, ): Promise> { // Pure Node unzip (store/deflate). Do not shell out to host `unzip` — // silo service PATH/tooling must not gate 题库 materialize. const entries = listZipEntries(buffer); const cacheRoot = confineToWorkspace(CACHE_DIR_NAME, options.workspaceDir); const cacheDir = join(cacheRoot, safePathSegment(options.id), safePathSegment(options.target)); const extractDir = options.materialize ? join(cacheDir, "source") : null; const zipPath = options.materialize ? join(cacheDir, `${options.target}.zip`) : null; if (options.materialize) { await mkdir(cacheDir, { recursive: true }); if (extractDir !== null) { await rm(extractDir, { recursive: true, force: true }); await mkdir(extractDir, { recursive: true }); } if (zipPath !== null) await writeFile(zipPath, buffer); } const files: Array> = []; const assets: Array<{ path: string; localPath: string | null; bytes: number; mimeType: string; inlineData?: string; }> = []; const extractedFiles: Array> = []; const omitted: Array> = []; let usedTextBytes = 0; let usedExtractedBytes = 0; let inlineAssetCount = 0; for (const entry of entries) { if (!safeZipPath(entry.name)) { omitted.push({ path: entry.name, reason: "unsafe path" }); continue; } if (entry.name.endsWith("/") || entry.isDirectory) continue; if (usedExtractedBytes >= MAX_EXTRACTED_PROJECT_BYTES) { omitted.push({ path: entry.name, reason: "extracted byte limit reached" }); continue; } const maxEntryBytes = Math.max( 1, Math.min(MAX_PROJECT_BYTES, MAX_EXTRACTED_PROJECT_BYTES - usedExtractedBytes), ); try { const entryBuffer = inflateZipEntry(buffer, entry, maxEntryBytes); usedExtractedBytes += entryBuffer.length; let localPath: string | null = null; if (extractDir !== null) { localPath = join(extractDir, ...entry.name.split(/[\\/]+/)); await mkdir(dirname(localPath), { recursive: true }); await writeFile(localPath, entryBuffer); extractedFiles.push({ path: entry.name, localPath: toWorkspaceRelative(options.workspaceDir, localPath), bytes: entryBuffer.length, }); } const mimeType = assetMimeType(entry.name); if (mimeType !== null) { const asset: { path: string; localPath: string | null; bytes: number; mimeType: string; inlineData?: string; } = { path: entry.name, localPath: localPath === null ? null : toWorkspaceRelative(options.workspaceDir, localPath), bytes: entryBuffer.length, mimeType, }; if ( options.includeAssetImages && isInlineImageMime(mimeType) && entryBuffer.length <= MAX_INLINE_ASSET_BYTES && inlineAssetCount < MAX_INLINE_ASSETS ) { asset.inlineData = entryBuffer.toString("base64"); inlineAssetCount += 1; } assets.push(asset); } if (isTextLikePath(entry.name)) { if (usedTextBytes >= MAX_PROJECT_TEXT_BYTES) { omitted.push({ path: entry.name, reason: "text byte limit reached" }); continue; } const content = decodeUtf8IfText(entryBuffer); if (content === null) { omitted.push({ path: entry.name, reason: "text decode failed" }); continue; } usedTextBytes += Buffer.byteLength(content, "utf8"); files.push({ path: entry.name, localPath: localPath === null ? null : toWorkspaceRelative(options.workspaceDir, localPath), content, }); } } catch (error) { omitted.push({ path: entry.name, reason: error instanceof Error ? error.message : String(error), }); } } return { target: options.target, status: "downloaded", bytes: buffer.length, zipPath: zipPath === null ? null : toWorkspaceRelative(options.workspaceDir, zipPath), extractDir: extractDir === null ? null : toWorkspaceRelative(options.workspaceDir, extractDir), extractedFiles, files, assets: assets.map(({ inlineData: _inlineData, ...asset }) => asset), inlineAssets: assets .filter((asset) => asset.inlineData !== undefined) .map((asset) => ({ path: asset.path, localPath: asset.localPath, bytes: asset.bytes, mimeType: asset.mimeType, data: asset.inlineData, })), omitted, }; } interface ZipEntryMeta { readonly name: string; readonly method: number; readonly compressedSize: number; readonly uncompressedSize: number; readonly localHeaderOffset: number; readonly isDirectory: boolean; } /** Minimal ZIP central-directory reader (store + deflate). No external unzip binary. */ function listZipEntries(buffer: Buffer): ZipEntryMeta[] { let eocd = -1; const minEocd = Math.max(0, buffer.length - (22 + 0xffff)); for (let i = buffer.length - 22; i >= minEocd; i -= 1) { if (buffer.readUInt32LE(i) === 0x06054b50) { eocd = i; break; } } if (eocd < 0) throw new Error("invalid zip: missing end of central directory"); const totalEntries = buffer.readUInt16LE(eocd + 10); const centralSize = buffer.readUInt32LE(eocd + 12); const centralOffset = buffer.readUInt32LE(eocd + 16); if (centralOffset + centralSize > buffer.length) { throw new Error("invalid zip: central directory out of range"); } const entries: ZipEntryMeta[] = []; let offset = centralOffset; for (let i = 0; i < totalEntries; i += 1) { if (offset + 46 > buffer.length || buffer.readUInt32LE(offset) !== 0x02014b50) { throw new Error("invalid zip: bad central directory entry"); } const method = buffer.readUInt16LE(offset + 10); const compressedSize = buffer.readUInt32LE(offset + 20); const uncompressedSize = buffer.readUInt32LE(offset + 24); const nameLen = buffer.readUInt16LE(offset + 28); const extraLen = buffer.readUInt16LE(offset + 30); const commentLen = buffer.readUInt16LE(offset + 32); const localHeaderOffset = buffer.readUInt32LE(offset + 42); const nameStart = offset + 46; const name = buffer.subarray(nameStart, nameStart + nameLen).toString("utf8"); entries.push({ name, method, compressedSize, uncompressedSize, localHeaderOffset, isDirectory: name.endsWith("/"), }); offset = nameStart + nameLen + extraLen + commentLen; } return entries; } function inflateZipEntry(buffer: Buffer, entry: ZipEntryMeta, maxBytes: number): Buffer { if (entry.uncompressedSize > maxBytes) { throw new Error(`zip entry exceeds ${maxBytes} bytes`); } const local = entry.localHeaderOffset; if (local + 30 > buffer.length || buffer.readUInt32LE(local) !== 0x04034b50) { throw new Error("invalid zip: bad local header"); } const nameLen = buffer.readUInt16LE(local + 26); const extraLen = buffer.readUInt16LE(local + 28); const dataStart = local + 30 + nameLen + extraLen; const dataEnd = dataStart + entry.compressedSize; if (dataEnd > buffer.length) throw new Error("invalid zip: compressed data out of range"); const compressed = buffer.subarray(dataStart, dataEnd); if (entry.method === 0) { if (compressed.length > maxBytes) throw new Error(`zip entry exceeds ${maxBytes} bytes`); return Buffer.from(compressed); } if (entry.method === 8) { return Buffer.from(inflateRawSync(compressed, { maxOutputLength: maxBytes })); } throw new Error(`unsupported zip compression method ${entry.method}`); } async function writeUsageFact( prisma: PrismaClient, runId: string, correlationId: string, quantity: number, ): Promise { const descriptor = CAPABILITIES[PBANK_CAPABILITY_ID]; await prisma.usageFact.create({ data: { runId, occurredAt: new Date(), kind: "external_capability", provider: PROVIDER_ID, model: null, inputTokens: null, outputTokens: null, quantity, unit: descriptor.meteringUnit, costUsd: null, costSource: "unknown", capabilityId: PBANK_CAPABILITY_ID, correlationId, metadata: {}, }, }); } function toToolResult(data: unknown): PbankToolResult { const inlineImages: Array<{ data: string; mimeType: string }> = []; collectInlineAssets(data, inlineImages); return { data, inlineImages }; } function collectInlineAssets( value: unknown, out: Array<{ data: string; mimeType: string }>, ): void { if (typeof value !== "object" || value === null) return; if (Array.isArray(value)) { for (const item of value) collectInlineAssets(item, out); return; } const record = value as Record; if (Array.isArray(record.inlineAssets)) { for (const asset of record.inlineAssets) { if (typeof asset !== "object" || asset === null) continue; const item = asset as Record; if (typeof item.data === "string" && typeof item.mimeType === "string") { out.push({ data: item.data, mimeType: item.mimeType }); } } delete record.inlineAssets; } if (record.projects !== undefined) collectInlineAssets(record.projects, out); if (Array.isArray(record.problems)) { for (const problem of record.problems) collectInlineAssets(problem, out); } } function confineToWorkspace(requestedPath: string, workspaceDir: string): string { const resolved = resolve(workspaceDir, requestedPath); const rel = relative(workspaceDir, resolved); if (rel.startsWith("..") || rel === "") { throw new CapabilityPathEscape(requestedPath, workspaceDir); } return resolved; } function toWorkspaceRelative(workspaceDir: string, absolutePath: string): string { const rel = relative(workspaceDir, absolutePath); if (rel.startsWith("..")) { throw new CapabilityPathEscape(absolutePath, workspaceDir); } return rel; } function decodeUtf8IfText(buffer: Buffer): string | null { const text = buffer.toString("utf8"); const replacementRatio = (text.match(/\uFFFD/g) ?? []).length / Math.max(text.length, 1); if (replacementRatio > 0.02) return null; return text; } function isZipBuffer(buffer: Buffer, contentType: string): boolean { if (contentType.includes("zip")) return true; return buffer.length >= 4 && buffer[0] === 0x50 && buffer[1] === 0x4b; } function isTextLikePath(filePath: string): boolean { const lower = filePath.toLowerCase(); return [".typ", ".md", ".txt", ".tex", ".json", ".yaml", ".yml", ".toml", ".csv"].some((ext) => lower.endsWith(ext), ); } function assetMimeType(filePath: string): string | null { const lower = filePath.toLowerCase(); if (lower.endsWith(".png")) return "image/png"; if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg"; if (lower.endsWith(".gif")) return "image/gif"; if (lower.endsWith(".webp")) return "image/webp"; if (lower.endsWith(".pdf")) return "application/pdf"; return null; } function isInlineImageMime(mimeType: string): boolean { return mimeType.startsWith("image/") && mimeType !== "image/svg+xml"; } function safeZipPath(entry: string): boolean { if (entry.includes("\0")) return false; const normalized = entry.replace(/\\/g, "/"); if (normalized.startsWith("/") || normalized.includes("://")) return false; for (const part of normalized.split("/")) { if (part === ".." || part === "") return false; } return true; } function safePathSegment(value: string): string { const cleaned = value.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, ""); if (cleaned === "" || cleaned === "." || cleaned === "..") return "item"; return cleaned.slice(0, 80); } function clampInt(value: number, min: number, max: number): number { if (!Number.isFinite(value)) return min; const n = Math.trunc(value); if (n < min) return min; if (n > max) return max; return n; }