feat(hub): built-in PBank 题库 capability + role tools (v0.0.42)

Register pbank as an ADR-0027 external capability with org-scoped
username/password envelopes, readiness via /login, and in-process
cph_hub MCP tools (search/get/get_many) that materialize sources under
the run workspace. Extend the capability secret payload for docmind vs
pbank kinds, admin capabilities UI, role tool umbrella `pbank`, and the
pbank-problem-report skill. Credentials never reach the Agent process.
This commit is contained in:
2026-07-23 20:13:00 +08:00
parent 2285ae871e
commit 54837717fd
20 changed files with 1898 additions and 142 deletions
+629
View File
@@ -0,0 +1,629 @@
/**
* 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 { randomUUID } from "node:crypto";
import { execFile as execFileCb } from "node:child_process";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, relative, resolve } from "node:path";
import { promisify } from "node:util";
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";
const execFile = promisify(execFileCb);
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<PbankToolResult>;
getProblem(ctx: PbankToolContext, args: PbankGetProblemArgs): Promise<PbankToolResult>;
getManyProblems(ctx: PbankToolContext, args: PbankGetManyArgs): Promise<PbankToolResult>;
}
export function createPbankService(deps: PbankServiceDeps): PbankService {
const client = deps.client ?? new HttpPbankClient();
const tokenCache = new Map<string, TokenCacheEntry>();
return {
async searchProblems(ctx: PbankToolContext, args: PbankSearchArgs): Promise<PbankToolResult> {
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<PbankToolResult> {
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<PbankToolResult> {
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<PbankCapabilitySecretPayload & { connectionId: string }> {
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<string, TokenCacheEntry>,
credential: PbankCapabilitySecretPayload & { connectionId: string },
): Promise<string> {
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<string, TokenCacheEntry>,
credential: PbankCapabilitySecretPayload & { connectionId: string },
workspaceDir: string,
args: PbankGetProblemArgs,
): Promise<Record<string, unknown>> {
const id = extractProblemId(args.urlOrId);
const token = await loginCached(client, cache, credential);
const problem = await client.getProblem(credential, token, id);
const result: Record<string, unknown> = {
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<string, unknown> = {};
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<Record<string, unknown>> {
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<Record<string, unknown>> {
if (!(await commandExists("unzip"))) {
return {
target: options.target,
status: "downloaded",
note: "Downloaded a zip project, but unzip is not installed on the server.",
bytes: buffer.length,
files: [],
};
}
const tempDir = await mkdtemp(join(tmpdir(), "pbank-project-"));
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`)
: join(tempDir, `${randomUUID()}.zip`);
try {
if (options.materialize) {
await mkdir(cacheDir, { recursive: true });
if (extractDir !== null) {
await rm(extractDir, { recursive: true, force: true });
await mkdir(extractDir, { recursive: true });
}
}
await writeFile(zipPath, buffer);
const { stdout } = await execFile("unzip", ["-Z1", zipPath], {
timeout: 10_000,
maxBuffer: 1024 * 1024,
});
const entryNames = String(stdout)
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line !== "");
const files: Array<Record<string, unknown>> = [];
const assets: Array<{
path: string;
localPath: string | null;
bytes: number;
mimeType: string;
inlineData?: string;
}> = [];
const extractedFiles: Array<Record<string, unknown>> = [];
const omitted: Array<Record<string, unknown>> = [];
let usedTextBytes = 0;
let usedExtractedBytes = 0;
let inlineAssetCount = 0;
for (const entry of entryNames) {
if (!safeZipPath(entry)) {
omitted.push({ path: entry, reason: "unsafe path" });
continue;
}
if (entry.endsWith("/")) continue;
if (usedExtractedBytes >= MAX_EXTRACTED_PROJECT_BYTES) {
omitted.push({ path: entry, reason: "extracted byte limit reached" });
continue;
}
const maxEntryBytes = Math.max(
1,
Math.min(MAX_PROJECT_BYTES, MAX_EXTRACTED_PROJECT_BYTES - usedExtractedBytes),
);
try {
const entryBuffer = await readZipEntry(zipPath, entry, maxEntryBytes);
usedExtractedBytes += entryBuffer.length;
let localPath: string | null = null;
if (extractDir !== null) {
localPath = join(extractDir, ...entry.split(/[\\/]+/));
await mkdir(dirname(localPath), { recursive: true });
await writeFile(localPath, entryBuffer);
extractedFiles.push({
path: entry,
localPath: toWorkspaceRelative(options.workspaceDir, localPath),
bytes: entryBuffer.length,
});
}
const mimeType = assetMimeType(entry);
if (mimeType !== null) {
const asset: {
path: string;
localPath: string | null;
bytes: number;
mimeType: string;
inlineData?: string;
} = {
path: entry,
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)) {
if (usedTextBytes >= MAX_PROJECT_TEXT_BYTES) {
omitted.push({ path: entry, reason: "text byte limit reached" });
continue;
}
const content = decodeUtf8IfText(entryBuffer);
if (content === null) {
omitted.push({ path: entry, reason: "text decode failed" });
continue;
}
usedTextBytes += Buffer.byteLength(content, "utf8");
files.push({
path: entry,
localPath: localPath === null ? null : toWorkspaceRelative(options.workspaceDir, localPath),
content,
});
}
} catch (error) {
omitted.push({
path: entry,
reason: error instanceof Error ? error.message : String(error),
});
}
}
return {
target: options.target,
status: "downloaded",
bytes: buffer.length,
zipPath: options.materialize ? toWorkspaceRelative(options.workspaceDir, zipPath) : null,
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,
};
} finally {
await rm(tempDir, { recursive: true, force: true }).catch(() => {});
}
}
async function writeUsageFact(
prisma: PrismaClient,
runId: string,
correlationId: string,
quantity: number,
): Promise<void> {
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<string, unknown>;
if (Array.isArray(record.inlineAssets)) {
for (const asset of record.inlineAssets) {
if (typeof asset !== "object" || asset === null) continue;
const item = asset as Record<string, unknown>;
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;
}
async function commandExists(command: string): Promise<boolean> {
try {
await execFile("sh", ["-lc", `command -v ${command}`], { timeout: 5_000 });
return true;
} catch {
return false;
}
}
async function readZipEntry(zipPath: string, entry: string, maxBuffer: number): Promise<Buffer> {
const { stdout } = await execFile("unzip", ["-p", zipPath, entry], {
timeout: 10_000,
maxBuffer,
encoding: "buffer",
});
return Buffer.from(stdout as Buffer);
}
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;
}