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
+4
View File
@@ -15,3 +15,7 @@ node_modules/
# OS / editor # OS / editor
.DS_Store .DS_Store
# Local operator notes / specs (not product source)
/spec/
/需求整理-*.md
+12 -1
View File
@@ -480,7 +480,18 @@ export const api = {
rotateCapabilityConnection: ( rotateCapabilityConnection: (
slug: string, slug: string,
capabilityId: string, capabilityId: string,
body: { accessKeyId: string; accessKeySecret: string; endpoint: string }, body:
| { kind?: 'docmind'; accessKeyId: string; accessKeySecret: string; endpoint: string }
| {
kind: 'pbank';
baseUrl: string;
username: string;
password: string;
rightsStatus?: string;
rightsHolder?: string;
rightsScope?: string;
rightsNote?: string;
},
) => ) =>
put(`${orgBase(slug)}/capability-connections/${encodeURIComponent(capabilityId)}`, body) as Promise<CapabilityConnection>, put(`${orgBase(slug)}/capability-connections/${encodeURIComponent(capabilityId)}`, body) as Promise<CapabilityConnection>,
disableCapabilityConnection: (slug: string, capabilityId: string) => disableCapabilityConnection: (slug: string, capabilityId: string) =>
+2
View File
@@ -18,6 +18,8 @@ export const TOOL_OPTIONS: ToolOption[] = [
{ id: 'feishu_read_context', label: '读飞书上下文', group: '飞书' }, { id: 'feishu_read_context', label: '读飞书上下文', group: '飞书' },
{ id: 'feishu_download_resource', label: '下载飞书资源', group: '飞书' }, { id: 'feishu_download_resource', label: '下载飞书资源', group: '飞书' },
{ id: 'request_approval', label: '请求审批', group: '飞书' }, { id: 'request_approval', label: '请求审批', group: '飞书' },
{ id: 'convert_pdf_to_md', label: 'PDF→Markdown', group: '能力' },
{ id: 'pbank', label: '题库 (PBank)', group: '能力' },
]; ];
/** 组织成员角色(接口枚举保持英文,界面用 orgRoleLabel */ /** 组织成员角色(接口枚举保持英文,界面用 orgRoleLabel */
@@ -13,9 +13,26 @@
const org = $derived(resolveOrg($session.me, page.url.search)); const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? ''); const slug = $derived(org?.slug ?? '');
type CapKind = 'docmind' | 'pbank';
const KNOWN_CAPABILITIES = [ const KNOWN_CAPABILITIES = [
{ id: 'pdf_to_md_bundle', label: 'PDF → Markdown', description: '将 PDF 转换为带图片的 Markdown bundle(阿里云文档智能,含公式 LaTeX 识别)' }, {
{ id: 'audio_video_to_text', label: '音视频 → 文本', description: '将音频/视频转写为文本(阿里云文档智能,按秒计费)' }, id: 'pdf_to_md_bundle',
kind: 'docmind' as const,
label: 'PDF → Markdown',
description: '将 PDF 转换为带图片的 Markdown bundle(阿里云文档智能,含公式 LaTeX 识别)'
},
{
id: 'audio_video_to_text',
kind: 'docmind' as const,
label: '音视频 → 文本',
description: '将音频/视频转写为文本(阿里云文档智能,按秒计费)'
},
{
id: 'pbank',
kind: 'pbank' as const,
label: '题库 (PBank)',
description: '搜索/拉取 Paradigm 题库题目与源工程;Agent 通过 pbank_* 工具访问,凭据不下发到 Agent 进程'
}
] as const; ] as const;
let connections = $state<Map<string, CapabilityConnection>>(new Map()); let connections = $state<Map<string, CapabilityConnection>>(new Map());
@@ -23,9 +40,17 @@
let error = $state<string | null>(null); let error = $state<string | null>(null);
let editingCap = $state<string | null>(null); let editingCap = $state<string | null>(null);
let editingKind = $state<CapKind>('docmind');
let accessKeyId = $state(''); let accessKeyId = $state('');
let accessKeySecret = $state(''); let accessKeySecret = $state('');
let endpoint = $state('docmind-api.cn-hangzhou.aliyuncs.com'); let endpoint = $state('docmind-api.cn-hangzhou.aliyuncs.com');
let baseUrl = $state('https://pbank.paradigm-edu.net/api');
let username = $state('');
let password = $state('');
let rightsStatus = $state('owned');
let rightsHolder = $state('Paradigm Education');
let rightsScope = $state('internal teaching-material production');
let rightsNote = $state('');
let saving = $state(false); let saving = $state(false);
let disabling = $state<string | null>(null); let disabling = $state<string | null>(null);
@@ -42,11 +67,19 @@
} }
} }
function startEdit(capId: string) { function startEdit(capId: string, kind: CapKind) {
editingCap = capId; editingCap = capId;
editingKind = kind;
accessKeyId = ''; accessKeyId = '';
accessKeySecret = ''; accessKeySecret = '';
endpoint = 'docmind-api.cn-hangzhou.aliyuncs.com'; endpoint = 'docmind-api.cn-hangzhou.aliyuncs.com';
baseUrl = 'https://pbank.paradigm-edu.net/api';
username = '';
password = '';
rightsStatus = 'owned';
rightsHolder = 'Paradigm Education';
rightsScope = 'internal teaching-material production';
rightsNote = '';
} }
function cancelEdit() { function cancelEdit() {
@@ -54,17 +87,36 @@
} }
async function save(capId: string) { async function save(capId: string) {
if (accessKeyId.trim() === '' || accessKeySecret.trim() === '' || endpoint.trim() === '') {
toastError('AccessKey ID、AccessKey Secret、Endpoint 均为必填');
return;
}
saving = true; saving = true;
try { try {
const result = await api.rotateCapabilityConnection(slug, capId, { let result: CapabilityConnection;
accessKeyId: accessKeyId.trim(), if (editingKind === 'docmind') {
accessKeySecret: accessKeySecret.trim(), if (accessKeyId.trim() === '' || accessKeySecret.trim() === '' || endpoint.trim() === '') {
endpoint: endpoint.trim(), toastError('AccessKey ID、AccessKey Secret、Endpoint 均为必填');
}); return;
}
result = await api.rotateCapabilityConnection(slug, capId, {
kind: 'docmind',
accessKeyId: accessKeyId.trim(),
accessKeySecret: accessKeySecret.trim(),
endpoint: endpoint.trim()
});
} else {
if (baseUrl.trim() === '' || username.trim() === '' || password.trim() === '') {
toastError('Base URL、用户名、密码均为必填');
return;
}
result = await api.rotateCapabilityConnection(slug, capId, {
kind: 'pbank',
baseUrl: baseUrl.trim(),
username: username.trim(),
password: password.trim(),
...(rightsStatus.trim() !== '' ? { rightsStatus: rightsStatus.trim() } : {}),
...(rightsHolder.trim() !== '' ? { rightsHolder: rightsHolder.trim() } : {}),
...(rightsScope.trim() !== '' ? { rightsScope: rightsScope.trim() } : {}),
...(rightsNote.trim() !== '' ? { rightsNote: rightsNote.trim() } : {})
});
}
connections.set(capId, result); connections.set(capId, result);
connections = new Map(connections); connections = new Map(connections);
editingCap = null; editingCap = null;
@@ -110,7 +162,7 @@
<PageHeader <PageHeader
title="外部能力" title="外部能力"
description="管理文档/媒体转换服务的组织级凭据(ADR-0027)。凭据按组织隔离、版本化信封存储,缺失或校验失败即 fail-closed。" description="管理文档/媒体转换与题库等外部服务的组织级凭据(ADR-0027)。凭据按组织隔离、版本化信封存储,缺失或校验失败即 fail-closed。Agent 永不接收能力凭据。"
/> />
{#if loading} {#if loading}
@@ -147,7 +199,7 @@
{/if} {/if}
<button <button
class="saas-btn-primary text-sm" class="saas-btn-primary text-sm"
onclick={() => startEdit(cap.id)} onclick={() => startEdit(cap.id, cap.kind)}
disabled={editingCap === cap.id} disabled={editingCap === cap.id}
> >
{conn ? '轮换凭据' : '配置凭据'} {conn ? '轮换凭据' : '配置凭据'}
@@ -174,23 +226,77 @@
{#if editingCap === cap.id} {#if editingCap === cap.id}
<div class="mt-4 border-t border-surface-100 pt-4"> <div class="mt-4 border-t border-surface-100 pt-4">
<p class="saas-muted mb-3 text-sm"> {#if cap.kind === 'docmind'}
阿里云 RAM 用户的 AccessKey。密钥仅写入新版本,旧版本归档。 <p class="saas-muted mb-3 text-sm">
</p> 阿里云 RAM 用户的 AccessKey。密钥仅写入新版本,旧版本归档。
<div class="grid gap-4"> </p>
<div> <div class="grid gap-4">
<Label.Root class="saas-label" for="ak-id-{cap.id}">AccessKey ID</Label.Root> <div>
<input id="ak-id-{cap.id}" class="saas-input font-mono text-sm" bind:value={accessKeyId} /> <Label.Root class="saas-label" for="ak-id-{cap.id}">AccessKey ID</Label.Root>
<input id="ak-id-{cap.id}" class="saas-input font-mono text-sm" bind:value={accessKeyId} />
</div>
<div>
<Label.Root class="saas-label" for="ak-secret-{cap.id}">AccessKey Secret</Label.Root>
<input
id="ak-secret-{cap.id}"
class="saas-input"
type="password"
bind:value={accessKeySecret}
/>
</div>
<div>
<Label.Root class="saas-label" for="endpoint-{cap.id}">Endpoint</Label.Root>
<input id="endpoint-{cap.id}" class="saas-input font-mono text-sm" bind:value={endpoint} />
</div>
</div> </div>
<div> {:else}
<Label.Root class="saas-label" for="ak-secret-{cap.id}">AccessKey Secret</Label.Root> <p class="saas-muted mb-3 text-sm">
<input id="ak-secret-{cap.id}" class="saas-input" type="password" bind:value={accessKeySecret} /> Paradigm 题库登录凭据。激活前会探测 /login;凭据仅写入信封新版本。
</p>
<div class="grid gap-4">
<div>
<Label.Root class="saas-label" for="pbank-base-{cap.id}">API Base URL</Label.Root>
<input id="pbank-base-{cap.id}" class="saas-input font-mono text-sm" bind:value={baseUrl} />
</div>
<div>
<Label.Root class="saas-label" for="pbank-user-{cap.id}">用户名</Label.Root>
<input id="pbank-user-{cap.id}" class="saas-input font-mono text-sm" bind:value={username} />
</div>
<div>
<Label.Root class="saas-label" for="pbank-pass-{cap.id}">密码</Label.Root>
<input id="pbank-pass-{cap.id}" class="saas-input" type="password" bind:value={password} />
</div>
<div>
<Label.Root class="saas-label" for="pbank-rights-status-{cap.id}">权利状态</Label.Root>
<input
id="pbank-rights-status-{cap.id}"
class="saas-input font-mono text-sm"
bind:value={rightsStatus}
placeholder="owned | exclusive_license | licensed_adapt | unknown"
/>
</div>
<div>
<Label.Root class="saas-label" for="pbank-rights-holder-{cap.id}">权利主体</Label.Root>
<input
id="pbank-rights-holder-{cap.id}"
class="saas-input text-sm"
bind:value={rightsHolder}
/>
</div>
<div>
<Label.Root class="saas-label" for="pbank-rights-scope-{cap.id}">使用范围</Label.Root>
<input id="pbank-rights-scope-{cap.id}" class="saas-input text-sm" bind:value={rightsScope} />
</div>
<div>
<Label.Root class="saas-label" for="pbank-rights-note-{cap.id}">权利说明(可选)</Label.Root>
<textarea
id="pbank-rights-note-{cap.id}"
class="saas-input min-h-20 text-sm"
bind:value={rightsNote}
></textarea>
</div>
</div> </div>
<div> {/if}
<Label.Root class="saas-label" for="endpoint-{cap.id}">Endpoint</Label.Root>
<input id="endpoint-{cap.id}" class="saas-input font-mono text-sm" bind:value={endpoint} />
</div>
</div>
<div class="mt-4 flex items-center justify-end gap-3"> <div class="mt-4 flex items-center justify-end gap-3">
<button class="saas-btn-ghost" onclick={cancelEdit} disabled={saving}>取消</button> <button class="saas-btn-ghost" onclick={cancelEdit} disabled={saving}>取消</button>
<button class="saas-btn-primary" onclick={() => save(cap.id)} disabled={saving}> <button class="saas-btn-primary" onclick={() => save(cap.id)} disabled={saving}>
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@paradigm/hub", "name": "@paradigm/hub",
"version": "0.0.41", "version": "0.0.42",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
+60
View File
@@ -0,0 +1,60 @@
---
name: pbank-problem-report
description: >
Use when a teacher wants Paradigm PBank (题库) problems as lesson examples,
asks to search PBank by outline, map candidates to outline parts, inspect
downloaded PBank source zips including images, or produce a Markdown
selection/adaptation report.
---
# PBank Problem Report
Use the `cph_hub` MCP tools `pbank_search_problems`, `pbank_get_problem`, and
`pbank_get_many_problems` to support problem selection from Paradigm PBank.
The normal output is a Markdown report for the teacher to review before any
lesson source is changed.
## Workflow
1. Understand the current lesson before searching.
- Read the lesson outline, plan, or main source files in the current workspace.
- Identify the teaching parts, target concepts, required difficulty, and what kind of examples are needed.
2. Search PBank with narrow queries.
- Use `pbank_search_problems` with Chinese keywords from the outline.
- Prefer several focused searches over one broad search.
- Keep the initial result set small, then fetch only plausible candidates.
3. Fetch source projects for candidates.
- Use `pbank_get_problem` or `pbank_get_many_problems`.
- Keep `includeProjects: true`, `materializeProjects: true`, and `includeAssetImages: true` unless the user explicitly asks otherwise.
- Read the returned `rights` object first. Treat it as the authoritative rights signal for all content returned by these tools.
- Read returned `files`, `assets`, `zipPath`, and `extractDir`. Images usually live under `fig/` in the extracted source tree.
- When image or PDF assets matter, cite their workspace-relative paths under `.pbank-sources/...`.
4. Produce a Markdown report instead of editing the lesson by default.
- Save it in the current project workspace as `pbank-candidate-report.md` unless the user gives another path.
- Do not copy problems into the lesson, rewrite the lesson, or write back to PBank unless the user explicitly asks.
5. When the teacher explicitly asks for adaptation, follow the rights signal.
- If `rights.derivativeUseAllowed` is `true`, you may quote, rewrite, adapt, and reorganize the returned PBank content into the current teaching-material project.
- Do not refuse on generic copyright grounds when the tools say derivative use is allowed.
- If `rights.derivativeUseAllowed` is `false`, stay read-only and ask the teacher/admin to confirm rights first.
## Report Shape
Use concise Chinese. Include:
- Lesson outline parts and the example need for each part.
- A candidate table with PBank title/id/link, matched outline part, fit score, directly usable material, required modifications, assets, and recommendation.
- Per-problem notes explaining the mathematical/physical idea, why it matches, and what needs to be changed for this lesson.
- A short final recommendation: which problems to use directly, which to adapt, and which to discard.
## Rules
- Do not invent problem statements, diagrams, or answers. Base every claim on PBank metadata or extracted source files.
- Treat `.pbank-sources/` as a local read cache. It is safe to read from and reference in reports.
- Keep PBank itself read-only. The teacher's workflow is selection and adaptation inside the current lesson project, not modifying the bank.
- Treat rights metadata as authoritative. Do not override `rights.derivativeUseAllowed: true` with a generic copyright refusal.
- If tools fail because no ACTIVE `pbank` capability connection exists, tell the user an org admin must configure 题库 credentials under 外部能力.
- If no candidate fits, say that directly and record the searches tried.
@@ -7,8 +7,12 @@
*/ */
import type { PrismaClient } from "@prisma/client"; import type { PrismaClient } from "@prisma/client";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { CapabilityConnectionService } from "../../capability/capabilityConnectionService.js"; import {
CapabilityConnectionService,
type CapabilityCredentialInput,
} from "../../capability/capabilityConnectionService.js";
import { CapabilityReadinessError, type CapabilityReadinessProbe } from "../../capability/capabilityReadiness.js"; import { CapabilityReadinessError, type CapabilityReadinessProbe } from "../../capability/capabilityReadiness.js";
import { secretKindForCapability } from "../../capability/types.js";
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js"; import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
import { requireOrgRole, type GuardDeps } from "../auth/guards.js"; import { requireOrgRole, type GuardDeps } from "../auth/guards.js";
import { handleRouteError } from "../errors.js"; import { handleRouteError } from "../errors.js";
@@ -25,11 +29,10 @@ export async function registerCapabilityConnectionRoutes(
config: CapabilityConnectionRouteConfig, config: CapabilityConnectionRouteConfig,
): Promise<void> { ): Promise<void> {
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret }; const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
const connections = new CapabilityConnectionService( const connections =
config.prisma, config.readinessProbe === undefined
config.secretEnvelope, ? new CapabilityConnectionService(config.prisma, config.secretEnvelope)
config.readinessProbe, : new CapabilityConnectionService(config.prisma, config.secretEnvelope, config.readinessProbe);
);
app.get("/api/org/:orgSlug/capability-connections", async (request, reply) => { app.get("/api/org/:orgSlug/capability-connections", async (request, reply) => {
try { try {
@@ -60,12 +63,12 @@ export async function registerCapabilityConnectionRoutes(
const { orgSlug, capabilityId } = request.params as { orgSlug: string; capabilityId: string }; const { orgSlug, capabilityId } = request.params as { orgSlug: string; capabilityId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return; if (auth === null) return;
const body = parseBody(request.body); const credential = parseCredentialBody(capabilityId, request.body);
const result = await connections.rotate({ const result = await connections.rotate({
organizationId: auth.organization.id, organizationId: auth.organization.id,
capabilityId, capabilityId,
actorUserId: auth.user.id, actorUserId: auth.user.id,
...body, credential,
}); });
request.log.info({ request.log.info({
organizationId: auth.organization.id, organizationId: auth.organization.id,
@@ -113,19 +116,57 @@ export async function registerCapabilityConnectionRoutes(
}); });
} }
function parseBody(value: unknown): { readonly accessKeyId: string; readonly accessKeySecret: string; readonly endpoint: string } { function parseCredentialBody(capabilityId: string, value: unknown): CapabilityCredentialInput {
if (typeof value !== "object" || value === null || Array.isArray(value)) { if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error("invalid capability credential body"); throw new Error("invalid capability credential body");
} }
const body = value as Record<string, unknown>; const body = value as Record<string, unknown>;
for (const name of ["accessKeyId", "accessKeySecret", "endpoint"] as const) { const expectedKind = secretKindForCapability(capabilityId);
if (typeof body[name] !== "string" || (body[name] as string).trim() === "") { const kind =
throw new Error(`${name} is required`); body.kind === "docmind" || body.kind === "pbank"
} ? body.kind
: expectedKind;
if (kind !== expectedKind) {
throw new Error(`capability ${capabilityId} requires kind=${expectedKind}`);
} }
if (kind === "docmind") {
return {
kind: "docmind",
accessKeyId: requireStringField(body, "accessKeyId"),
accessKeySecret: requireStringField(body, "accessKeySecret"),
endpoint: requireStringField(body, "endpoint"),
};
}
const rightsStatus = optionalStringField(body, "rightsStatus");
const rightsHolder = optionalStringField(body, "rightsHolder");
const rightsScope = optionalStringField(body, "rightsScope");
const rightsNote = optionalStringField(body, "rightsNote");
return { return {
accessKeyId: body["accessKeyId"] as string, kind: "pbank",
accessKeySecret: body["accessKeySecret"] as string, baseUrl: requireStringField(body, "baseUrl"),
endpoint: body["endpoint"] as string, username: requireStringField(body, "username"),
password: requireStringField(body, "password"),
...(rightsStatus !== undefined ? { rightsStatus } : {}),
...(rightsHolder !== undefined ? { rightsHolder } : {}),
...(rightsScope !== undefined ? { rightsScope } : {}),
...(rightsNote !== undefined ? { rightsNote } : {}),
}; };
} }
function requireStringField(body: Record<string, unknown>, name: string): string {
const value = body[name];
if (typeof value !== "string" || value.trim() === "") {
throw new Error(`${name} is required`);
}
return value;
}
function optionalStringField(body: Record<string, unknown>, name: string): string | undefined {
const value = body[name];
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed === "" ? undefined : trimmed;
}
+51 -38
View File
@@ -15,6 +15,9 @@ export const CPH_HUB_MCP_TOOL_IDS = [
"feishu_download_resource", "feishu_download_resource",
"request_approval", "request_approval",
"convert_pdf_to_md", "convert_pdf_to_md",
"pbank_search_problems",
"pbank_get_problem",
"pbank_get_many_problems",
] as const; ] as const;
export type CphHubMcpToolId = (typeof CPH_HUB_MCP_TOOL_IDS)[number]; export type CphHubMcpToolId = (typeof CPH_HUB_MCP_TOOL_IDS)[number];
@@ -24,44 +27,51 @@ export interface ClaudeSdkToolConfig {
readonly allowedTools: readonly string[]; readonly allowedTools: readonly string[];
} }
const ROLE_TOOL_TO_CLAUDE_BUILT_INS = new Map<string, readonly string[]>([ const ROLE_TOOL_TO_CLAUDE_BUILT_INS: Readonly<Record<string, readonly string[]>> = {
["read_file", ["Read"]], read_file: ["Read"],
["write_file", ["Write"]], write_file: ["Write"],
["list_files", ["Glob"]], list_files: ["Glob"],
["search_files", ["Grep"]], search_files: ["Grep"],
["bash", ["Bash"]], bash: ["Bash"],
// ADR-0017 replaced cph custom tools with Bash commands. Granting either // ADR-0017 replaced cph custom tools with Bash commands. Granting either
// cph role tool therefore exposes the SDK Bash tool; cph-only Bash narrowing // cph role tool therefore exposes the SDK Bash tool; cph-only Bash narrowing
// would need a separate command-policy layer. // would need a separate command-policy layer.
["cph_check", ["Bash"]], cph_check: ["Bash"],
["cph_build", ["Bash"]], cph_build: ["Bash"],
["web_fetch", ["WebFetch"]], web_fetch: ["WebFetch"],
["web_search", ["WebSearch"]], web_search: ["WebSearch"],
["Read", ["Read"]], Read: ["Read"],
["Write", ["Write"]], Write: ["Write"],
["Bash", ["Bash"]], Bash: ["Bash"],
["Glob", ["Glob"]], Glob: ["Glob"],
["Grep", ["Grep"]], Grep: ["Grep"],
["WebFetch", ["WebFetch"]], WebFetch: ["WebFetch"],
["WebSearch", ["WebSearch"]], WebSearch: ["WebSearch"],
]); };
const ROLE_TOOL_TO_CPH_HUB_MCP_TOOL = new Map<string, CphHubMcpToolId>([ const ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS: Readonly<Record<string, readonly CphHubMcpToolId[]>> = {
["send_file", "send_file"], send_file: ["send_file"],
["feishu_read_context", "feishu_read_context"], feishu_read_context: ["feishu_read_context"],
["feishu_download_resource", "feishu_download_resource"], feishu_download_resource: ["feishu_download_resource"],
["request_approval", "request_approval"], request_approval: ["request_approval"],
["convert_pdf_to_md", "convert_pdf_to_md"], convert_pdf_to_md: ["convert_pdf_to_md"],
["mcp__cph_hub__send_file", "send_file"], pbank: ["pbank_search_problems", "pbank_get_problem", "pbank_get_many_problems"],
["mcp__cph_hub__feishu_read_context", "feishu_read_context"], pbank_search_problems: ["pbank_search_problems"],
["mcp__cph_hub__feishu_download_resource", "feishu_download_resource"], pbank_get_problem: ["pbank_get_problem"],
["mcp__cph_hub__request_approval", "request_approval"], pbank_get_many_problems: ["pbank_get_many_problems"],
["mcp__cph_hub__convert_pdf_to_md", "convert_pdf_to_md"], "mcp__cph_hub__send_file": ["send_file"],
]); "mcp__cph_hub__feishu_read_context": ["feishu_read_context"],
"mcp__cph_hub__feishu_download_resource": ["feishu_download_resource"],
"mcp__cph_hub__request_approval": ["request_approval"],
"mcp__cph_hub__convert_pdf_to_md": ["convert_pdf_to_md"],
"mcp__cph_hub__pbank_search_problems": ["pbank_search_problems"],
"mcp__cph_hub__pbank_get_problem": ["pbank_get_problem"],
"mcp__cph_hub__pbank_get_many_problems": ["pbank_get_many_problems"],
};
const SUPPORTED_ROLE_TOOLS = new Set([ const SUPPORTED_ROLE_TOOLS = new Set([
...ROLE_TOOL_TO_CLAUDE_BUILT_INS.keys(), ...Object.keys(ROLE_TOOL_TO_CLAUDE_BUILT_INS),
...ROLE_TOOL_TO_CPH_HUB_MCP_TOOL.keys(), ...Object.keys(ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS),
]); ]);
export function claudeSdkToolConfigForRole(roleTools: readonly string[] | undefined): ClaudeSdkToolConfig { export function claudeSdkToolConfigForRole(roleTools: readonly string[] | undefined): ClaudeSdkToolConfig {
@@ -77,13 +87,12 @@ export function claudeSdkToolConfigForRole(roleTools: readonly string[] | undefi
const allowedTools: string[] = []; const allowedTools: string[] = [];
for (const roleTool of roleTools) { for (const roleTool of roleTools) {
assertSupportedRoleTool(roleTool); assertSupportedRoleTool(roleTool);
for (const tool of ROLE_TOOL_TO_CLAUDE_BUILT_INS.get(roleTool) ?? []) { for (const tool of ROLE_TOOL_TO_CLAUDE_BUILT_INS[roleTool] ?? []) {
pushUnique(builtIns, tool); pushUnique(builtIns, tool);
pushUnique(allowedTools, tool); pushUnique(allowedTools, tool);
} }
const mcpTool = ROLE_TOOL_TO_CPH_HUB_MCP_TOOL.get(roleTool); for (const mcpTool of ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS[roleTool] ?? []) {
if (mcpTool !== undefined) {
pushUnique(allowedTools, claudeMcpToolName(mcpTool)); pushUnique(allowedTools, claudeMcpToolName(mcpTool));
} }
} }
@@ -97,8 +106,9 @@ export function cphHubMcpToolsForRole(roleTools: readonly string[] | undefined):
const tools: CphHubMcpToolId[] = []; const tools: CphHubMcpToolId[] = [];
for (const roleTool of roleTools) { for (const roleTool of roleTools) {
assertSupportedRoleTool(roleTool); assertSupportedRoleTool(roleTool);
const mcpTool = ROLE_TOOL_TO_CPH_HUB_MCP_TOOL.get(roleTool); for (const mcpTool of ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS[roleTool] ?? []) {
if (mcpTool !== undefined) pushUnique(tools, mcpTool); pushUnique(tools, mcpTool);
}
} }
return tools; return tools;
} }
@@ -108,7 +118,10 @@ export function roleToolsAllow(roleTools: readonly string[] | undefined, roleToo
for (const configured of roleTools) { for (const configured of roleTools) {
assertSupportedRoleTool(configured); assertSupportedRoleTool(configured);
if (configured === roleTool) return true; if (configured === roleTool) return true;
if (ROLE_TOOL_TO_CPH_HUB_MCP_TOOL.get(configured) === roleTool) return true; const mapped = ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS[configured];
if (mapped !== undefined && mapped.includes(roleTool as CphHubMcpToolId)) return true;
// Umbrella: role tool "pbank" allows any pbank_* MCP or role tool.
if (configured === "pbank" && roleTool.startsWith("pbank")) return true;
} }
return false; return false;
} }
@@ -10,22 +10,41 @@ import { randomUUID } from "node:crypto";
import type { Prisma, PrismaClient } from "@prisma/client"; import type { Prisma, PrismaClient } from "@prisma/client";
import { lockActiveOrganization } from "../org/status.js"; import { lockActiveOrganization } from "../org/status.js";
import { LocalSecretEnvelope } from "../security/secretEnvelope.js"; import { LocalSecretEnvelope } from "../security/secretEnvelope.js";
import { probeDocmindCredential, type CapabilityReadinessProbe } from "./capabilityReadiness.js"; import { probeCapabilityCredential, type CapabilityReadinessProbe } from "./capabilityReadiness.js";
import type { CapabilitySecretPayload } from "./types.js"; import {
CAPABILITY_IDS,
type CapabilitySecretPayload,
type DocmindCapabilitySecretPayload,
type PbankCapabilitySecretPayload,
secretKindForCapability,
} from "./types.js";
const CAPABILITY_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/; const CAPABILITY_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
const KNOWN_CAPABILITY_IDS = new Set(["pdf_to_md_bundle", "audio_video_to_text"]); const KNOWN_CAPABILITY_IDS = new Set<string>(CAPABILITY_IDS);
export interface CapabilityCredentialInput { export type CapabilityCredentialInput =
readonly accessKeyId: string; | {
readonly accessKeySecret: string; readonly kind: "docmind";
readonly endpoint: string; readonly accessKeyId: string;
} readonly accessKeySecret: string;
readonly endpoint: string;
}
| {
readonly kind: "pbank";
readonly baseUrl: string;
readonly username: string;
readonly password: string;
readonly rightsStatus?: string;
readonly rightsHolder?: string;
readonly rightsScope?: string;
readonly rightsNote?: string;
};
export interface RotateCapabilityInput extends CapabilityCredentialInput { export interface RotateCapabilityInput {
readonly organizationId: string; readonly organizationId: string;
readonly capabilityId: string; readonly capabilityId: string;
readonly actorUserId: string; readonly actorUserId: string;
readonly credential: CapabilityCredentialInput;
} }
export interface CapabilityConnectionMetadata { export interface CapabilityConnectionMetadata {
@@ -45,25 +64,24 @@ export interface CapabilityConnectionWriteResult extends CapabilityConnectionMet
export type CapabilitySecretPayloadV1 = CapabilitySecretPayload; export type CapabilitySecretPayloadV1 = CapabilitySecretPayload;
export class CapabilityConnectionService { export class CapabilityConnectionService {
private readonly readinessProbe: CapabilityReadinessProbe;
constructor( constructor(
private readonly prisma: PrismaClient, private readonly prisma: PrismaClient,
private readonly secrets: LocalSecretEnvelope, private readonly secrets: LocalSecretEnvelope,
private readonly readinessProbe: CapabilityReadinessProbe = probeDocmindCredential, readinessProbe: CapabilityReadinessProbe = probeCapabilityCredential,
) {} ) {
this.readinessProbe = readinessProbe;
}
async rotate(input: RotateCapabilityInput): Promise<CapabilityConnectionWriteResult> { async rotate(input: RotateCapabilityInput): Promise<CapabilityConnectionWriteResult> {
if (!CAPABILITY_ID_PATTERN.test(input.capabilityId)) { if (!CAPABILITY_ID_PATTERN.test(input.capabilityId)) {
throw new Error(`invalid capabilityId: ${input.capabilityId}`); throw new Error(`invalid capabilityId: ${input.capabilityId}`);
} }
const payload = validateCredential(input); const payload = validateCredential(input.capabilityId, input.credential);
await this.prisma.$transaction(async (tx) => { await this.prisma.$transaction(async (tx) => {
await requireCapabilityAdmin(tx, input); await requireCapabilityAdmin(tx, input);
}); });
await this.readinessProbe({ await this.readinessProbe(payload);
endpoint: payload.endpoint,
accessKeyId: payload.accessKeyId,
accessKeySecret: payload.accessKeySecret,
});
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
await requireCapabilityAdmin(tx, input); await requireCapabilityAdmin(tx, input);
@@ -142,6 +160,7 @@ export class CapabilityConnectionService {
status: "ACTIVE", status: "ACTIVE",
secretVersion: version, secretVersion: version,
keyId: envelope.keyId, keyId: envelope.keyId,
secretKind: payload.kind,
}, },
}, },
}); });
@@ -209,14 +228,47 @@ export class CapabilityConnectionService {
} }
} }
function validateCredential(input: RotateCapabilityInput): CapabilitySecretPayloadV1 { function validateCredential(
if (!KNOWN_CAPABILITY_IDS.has(input.capabilityId)) { capabilityId: string,
throw new Error(`unsupported capabilityId: ${input.capabilityId}`); input: CapabilityCredentialInput,
): CapabilitySecretPayload {
if (!KNOWN_CAPABILITY_IDS.has(capabilityId)) {
throw new Error(`unsupported capabilityId: ${capabilityId}`);
} }
const accessKeyId = nonEmpty(input.accessKeyId, "accessKeyId"); const expectedKind = secretKindForCapability(capabilityId);
const accessKeySecret = nonEmpty(input.accessKeySecret, "accessKeySecret"); if (input.kind !== expectedKind) {
const endpoint = nonEmpty(input.endpoint, "endpoint"); throw new Error(`capability ${capabilityId} requires kind=${expectedKind}, got ${input.kind}`);
return { schemaVersion: 1, accessKeyId, accessKeySecret, endpoint }; }
if (input.kind === "docmind") {
return {
schemaVersion: 1,
kind: "docmind",
accessKeyId: nonEmpty(input.accessKeyId, "accessKeyId"),
accessKeySecret: nonEmpty(input.accessKeySecret, "accessKeySecret"),
endpoint: nonEmpty(input.endpoint, "endpoint"),
};
}
const baseUrl = normalizeBaseUrl(nonEmpty(input.baseUrl, "baseUrl"));
if (!baseUrl.startsWith("https://") && !baseUrl.startsWith("http://")) {
throw new Error("baseUrl must be an absolute http(s) URL");
}
const rightsStatus = optional(input.rightsStatus);
const rightsHolder = optional(input.rightsHolder);
const rightsScope = optional(input.rightsScope);
const rightsNote = optional(input.rightsNote);
return {
schemaVersion: 1,
kind: "pbank",
baseUrl,
username: nonEmpty(input.username, "username"),
password: nonEmpty(input.password, "password"),
...(rightsStatus !== undefined ? { rightsStatus } : {}),
...(rightsHolder !== undefined ? { rightsHolder } : {}),
...(rightsScope !== undefined ? { rightsScope } : {}),
...(rightsNote !== undefined ? { rightsNote } : {}),
};
} }
function toMetadata( function toMetadata(
@@ -264,3 +316,13 @@ function nonEmpty(value: string, label: string): string {
if (trimmed === "") throw new Error(`${label} must not be empty`); if (trimmed === "") throw new Error(`${label} must not be empty`);
return trimmed; return trimmed;
} }
function optional(value: string | undefined): string | undefined {
if (value === undefined) return undefined;
const trimmed = value.trim();
return trimmed === "" ? undefined : trimmed;
}
function normalizeBaseUrl(value: string): string {
return value.replace(/\/+$/, "");
}
+13 -9
View File
@@ -12,17 +12,19 @@ import type { PrismaClient } from "@prisma/client";
import { LocalSecretEnvelope, type SecretEnvelopeV1 } from "../security/secretEnvelope.js"; import { LocalSecretEnvelope, type SecretEnvelopeV1 } from "../security/secretEnvelope.js";
import { import {
CapabilityConnectionUnavailable, CapabilityConnectionUnavailable,
normalizeCapabilitySecretPayload,
secretKindForCapability,
type CapabilitySecretPayload, type CapabilitySecretPayload,
type CapabilityId, type CapabilityId,
} from "./types.js"; } from "./types.js";
const CAPABILITY_PURPOSE = "capability"; const CAPABILITY_PURPOSE = "capability";
export interface ResolvedCapabilityCredential extends CapabilitySecretPayload { export type ResolvedCapabilityCredential = CapabilitySecretPayload & {
readonly connectionId: string; readonly connectionId: string;
readonly organizationId: string; readonly organizationId: string;
readonly capabilityId: string; readonly capabilityId: string;
} };
/** /**
* Resolve the active capability credential for an organization. Throws * Resolve the active capability credential for an organization. Throws
@@ -54,17 +56,19 @@ export async function resolveCapabilityCredential(
connectionId: connection.id, connectionId: connection.id,
secretVersionId: version.id, secretVersionId: version.id,
}; };
const payload = secrets.decryptJson<CapabilitySecretPayload>(binding, version.envelope as unknown as SecretEnvelopeV1); const payload = normalizeCapabilitySecretPayload(
if (payload.schemaVersion !== 1) { secrets.decryptJson<unknown>(binding, version.envelope as unknown as SecretEnvelopeV1),
throw new Error(`unsupported capability secret schemaVersion: ${payload.schemaVersion}`); );
const expectedKind = secretKindForCapability(input.capabilityId);
if (payload.kind !== expectedKind) {
throw new Error(
`capability ${input.capabilityId} secret kind mismatch: expected ${expectedKind}, got ${payload.kind}`,
);
} }
return { return {
connectionId: connection.id, connectionId: connection.id,
organizationId: connection.organizationId, organizationId: connection.organizationId,
capabilityId: connection.capabilityId, capabilityId: connection.capabilityId,
schemaVersion: 1, ...payload,
accessKeyId: payload.accessKeyId,
accessKeySecret: payload.accessKeySecret,
endpoint: payload.endpoint,
}; };
} }
+74 -12
View File
@@ -1,18 +1,11 @@
/** /**
* ADR-0027: Capability readiness probe. Validates the Alibaba Cloud docmind * ADR-0027: Capability readiness probes. Validate credentials before
* credential by calling QueryDocParserStatus with a dummy id — a 400 (bad * activation. Docmind uses QueryDocParserStatus; PBank uses /login.
* request) means the credential is valid (the API accepted auth but rejected
* the id); a 401/403 means the credential is bad.
*/ */
import { classifyNetworkFailure, type NetworkFailureCategory } from "../connections/networkFailure.js"; import { classifyNetworkFailure, type NetworkFailureCategory } from "../connections/networkFailure.js";
import type { CapabilitySecretPayload, DocmindCapabilitySecretPayload, PbankCapabilitySecretPayload } from "./types.js";
export interface CapabilityReadinessInput { export type CapabilityReadinessProbe = (payload: CapabilitySecretPayload) => Promise<void>;
readonly endpoint: string;
readonly accessKeyId: string;
readonly accessKeySecret: string;
}
export type CapabilityReadinessProbe = (input: CapabilityReadinessInput) => Promise<void>;
export class CapabilityReadinessError extends Error { export class CapabilityReadinessError extends Error {
constructor( constructor(
@@ -33,7 +26,7 @@ export class CapabilityReadinessError extends Error {
* - 401/403 (InvalidAccessKey/Forbidden) → credential invalid → probe fails * - 401/403 (InvalidAccessKey/Forbidden) → credential invalid → probe fails
* - network error → unreachable * - network error → unreachable
*/ */
export const probeDocmindCredential: CapabilityReadinessProbe = async (input) => { export async function probeDocmindCredentialPayload(input: DocmindCapabilitySecretPayload): Promise<void> {
const url = `https://${input.endpoint}/?Action=QueryDocParserStatus&Id=probe-test&Version=2022-07-11`; const url = `https://${input.endpoint}/?Action=QueryDocParserStatus&Id=probe-test&Version=2022-07-11`;
const authHeader = makeBasicAuth(input.accessKeyId, input.accessKeySecret); const authHeader = makeBasicAuth(input.accessKeyId, input.accessKeySecret);
@@ -61,6 +54,75 @@ export const probeDocmindCredential: CapabilityReadinessProbe = async (input) =>
response.status, response.status,
); );
} }
}
/** Probe PBank by logging in and ensuring a token is returned. */
export async function probePbankCredentialPayload(input: PbankCapabilitySecretPayload): Promise<void> {
const baseUrl = input.baseUrl.replace(/\/+$/, "");
let response: Response;
try {
response = await fetch(`${baseUrl}/login`, {
method: "POST",
headers: { accept: "application/json", "content-type": "application/json" },
body: JSON.stringify({ username: input.username, password: input.password }),
redirect: "manual",
signal: AbortSignal.timeout(15_000),
});
} catch (error) {
throw new CapabilityReadinessError(
"capability_readiness_unreachable",
"PBank credential readiness check could not reach the API",
classifyNetworkFailure(error),
);
}
const data: unknown = await response.json().catch(() => null);
if (response.status === 401 || response.status === 403) {
throw new CapabilityReadinessError(
"capability_readiness_rejected",
`PBank credential rejected: status ${response.status}`,
"http",
response.status,
);
}
if (!response.ok) {
throw new CapabilityReadinessError(
"capability_readiness_rejected",
`PBank login failed: status ${response.status}`,
"http",
response.status,
);
}
if (
typeof data !== "object" ||
data === null ||
!("token" in data) ||
typeof data.token !== "string" ||
data.token.trim() === ""
) {
throw new CapabilityReadinessError(
"capability_readiness_rejected",
"PBank login did not return a token",
"http",
response.status,
);
}
}
/** Default readiness probe: dispatch by secret kind. */
export const probeCapabilityCredential: CapabilityReadinessProbe = async (payload) => {
if (payload.kind === "docmind") {
await probeDocmindCredentialPayload(payload);
return;
}
if (payload.kind === "pbank") {
await probePbankCredentialPayload(payload);
return;
}
throw new CapabilityReadinessError(
"capability_readiness_unsupported",
"unsupported capability secret kind",
"configuration",
);
}; };
function makeBasicAuth(accessKeyId: string, accessKeySecret: string): string { function makeBasicAuth(accessKeyId: string, accessKeySecret: string): string {
+3 -3
View File
@@ -21,7 +21,7 @@ import $DocmindClient, {
import { RuntimeOptions } from "@alicloud/tea-util"; import { RuntimeOptions } from "@alicloud/tea-util";
import { createReadStream } from "node:fs"; import { createReadStream } from "node:fs";
import { basename } from "node:path"; import { basename } from "node:path";
import type { CapabilitySecretPayload } from "./types.js"; import type { DocmindCapabilitySecretPayload } from "./types.js";
/** A single extracted image downloaded from the markdown's OSS image URLs. */ /** A single extracted image downloaded from the markdown's OSS image URLs. */
export interface DocmindExtractedImage { export interface DocmindExtractedImage {
@@ -43,7 +43,7 @@ export interface DocmindParseOptions {
} }
export interface CapabilityProviderClient { export interface CapabilityProviderClient {
parse(credential: CapabilitySecretPayload, options: DocmindParseOptions): Promise<DocmindParseResult>; parse(credential: DocmindCapabilitySecretPayload, options: DocmindParseOptions): Promise<DocmindParseResult>;
} }
export class DocmindClientError extends Error { export class DocmindClientError extends Error {
@@ -65,7 +65,7 @@ const POLL_TIMEOUT_MS = 5 * 60_000;
type DocmindConfig = ConstructorParameters<typeof $DocmindClient.default>[0]; type DocmindConfig = ConstructorParameters<typeof $DocmindClient.default>[0];
export class AliyunDocmindClient implements CapabilityProviderClient { export class AliyunDocmindClient implements CapabilityProviderClient {
async parse(credential: CapabilitySecretPayload, options: DocmindParseOptions): Promise<DocmindParseResult> { async parse(credential: DocmindCapabilitySecretPayload, options: DocmindParseOptions): Promise<DocmindParseResult> {
const config: DocmindConfig = { const config: DocmindConfig = {
endpoint: credential.endpoint, endpoint: credential.endpoint,
accessKeyId: credential.accessKeyId, accessKeyId: credential.accessKeyId,
+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;
}
+279
View File
@@ -0,0 +1,279 @@
/**
* Paradigm PBank (题库) HTTP client.
*
* Credentials are injected per call from the org capability connection
* (ADR-0024/0027) — never read from process env and never passed to the Agent.
*/
import type { PbankCapabilitySecretPayload } from "./types.js";
const DEFAULT_BASE_URL = "https://pbank.paradigm-edu.net/api";
const POSITIVE_RIGHTS_STATUSES = new Set(["owned", "exclusive_license", "licensed_adapt"]);
export interface PbankRights {
readonly status: string;
readonly holder: string;
readonly scope: string;
readonly note: string;
readonly derivativeUseAllowed: boolean;
readonly source: "operator_confirmed";
}
export interface PbankLoginResult {
readonly token: string;
readonly expiresAt: number;
}
export interface PbankSearchInput {
readonly q?: string | undefined;
readonly keywords?: readonly string[] | undefined;
readonly pageNum: number;
readonly pageSize: number;
}
export interface PbankProjectDownload {
readonly buffer: Buffer;
readonly contentType: string;
}
export class PbankClientError extends Error {
constructor(
message: string,
readonly code: "pbank_unreachable" | "pbank_rejected" | "pbank_invalid_response",
readonly upstreamStatus?: number,
) {
super(message);
this.name = "PbankClientError";
}
}
export interface PbankClient {
login(credential: PbankCapabilitySecretPayload): Promise<PbankLoginResult>;
searchProblems(
credential: PbankCapabilitySecretPayload,
token: string,
input: PbankSearchInput,
): Promise<unknown>;
getProblem(
credential: PbankCapabilitySecretPayload,
token: string,
id: string,
): Promise<unknown>;
getOccurrences(
credential: PbankCapabilitySecretPayload,
token: string,
id: string,
): Promise<unknown>;
downloadProject(
credential: PbankCapabilitySecretPayload,
token: string,
id: string,
target: "problem" | "answer",
): Promise<PbankProjectDownload>;
}
export function pbankRightsFromCredential(credential: PbankCapabilitySecretPayload): PbankRights {
const status = normalizeRightsStatus(credential.rightsStatus ?? "unknown");
const holder = (credential.rightsHolder ?? "").trim() || "Paradigm Education";
const scope = (credential.rightsScope ?? "").trim() || "internal teaching-material production";
const note =
(credential.rightsNote ?? "").trim() ||
"All content returned by this capability is operator-confirmed as owned by Paradigm Education or sufficiently licensed for excerpting, rewriting, and adaptation within current teaching-material projects.";
return {
status,
holder,
scope,
note,
derivativeUseAllowed: POSITIVE_RIGHTS_STATUSES.has(status),
source: "operator_confirmed",
};
}
export function normalizePbankBaseUrl(value: string | undefined): string {
const raw = (value ?? "").trim();
if (raw === "") return DEFAULT_BASE_URL;
return raw.replace(/\/+$/, "");
}
export function extractProblemId(value: string): string {
const input = value.trim();
if (input === "") throw new PbankClientError("urlOrId is required", "pbank_invalid_response");
const uuidPattern = /[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/i;
const direct = input.match(uuidPattern);
if (direct !== null) return direct[0]!;
try {
const url = new URL(input);
for (const key of ["id", "problemId", "problem_id"] as const) {
const fromQuery = url.searchParams.get(key);
const match = fromQuery?.match(uuidPattern);
if (match !== null && match !== undefined) return match[0]!;
}
} catch {
// Not a URL.
}
throw new PbankClientError(`Could not find a problem UUID in: ${input}`, "pbank_invalid_response");
}
export class HttpPbankClient implements PbankClient {
async login(credential: PbankCapabilitySecretPayload): Promise<PbankLoginResult> {
const data = await this.requestJson(credential, "/login", {
method: "POST",
body: { username: credential.username, password: credential.password },
timeoutMs: 30_000,
});
if (typeof data !== "object" || data === null || Array.isArray(data)) {
throw new PbankClientError("PBank login returned non-object", "pbank_invalid_response");
}
const record = data as Record<string, unknown>;
if (typeof record.token !== "string" || record.token.trim() === "") {
throw new PbankClientError("PBank login did not return a token", "pbank_invalid_response");
}
const expiresAt =
typeof record.expiresAt === "number" && Number.isFinite(record.expiresAt)
? record.expiresAt
: Date.now() + 30 * 60_000;
return { token: record.token, expiresAt };
}
async searchProblems(
credential: PbankCapabilitySecretPayload,
token: string,
input: PbankSearchInput,
): Promise<unknown> {
return this.requestJson(credential, "/problem/query", {
token,
query: {
q: input.q,
keywords: input.keywords !== undefined && input.keywords.length > 0 ? input.keywords.join(",") : undefined,
pageNum: input.pageNum,
pageSize: input.pageSize,
},
});
}
async getProblem(
credential: PbankCapabilitySecretPayload,
token: string,
id: string,
): Promise<unknown> {
return this.requestJson(credential, `/problem/${encodeURIComponent(id)}`, { token });
}
async getOccurrences(
credential: PbankCapabilitySecretPayload,
token: string,
id: string,
): Promise<unknown> {
return this.requestJson(credential, `/problem/${encodeURIComponent(id)}/occurrence`, {
token,
query: { pageNum: 1, pageSize: 20 },
});
}
async downloadProject(
credential: PbankCapabilitySecretPayload,
token: string,
id: string,
target: "problem" | "answer",
): Promise<PbankProjectDownload> {
const url = buildUrl(credential.baseUrl, `/problem/${encodeURIComponent(id)}/project/${target}`);
let response: Response;
try {
response = await fetch(url, {
headers: { authorization: `Bearer ${token}`, accept: "*/*" },
signal: AbortSignal.timeout(120_000),
});
} catch (error) {
throw new PbankClientError(
error instanceof Error ? error.message : String(error),
"pbank_unreachable",
);
}
if (!response.ok) {
throw new PbankClientError(
`PBank project download failed: ${response.status}`,
response.status === 401 || response.status === 403 ? "pbank_rejected" : "pbank_invalid_response",
response.status,
);
}
const contentType = response.headers.get("content-type") ?? "";
const arrayBuffer = await response.arrayBuffer();
return { buffer: Buffer.from(arrayBuffer), contentType };
}
private async requestJson(
credential: PbankCapabilitySecretPayload,
route: string,
options: {
readonly method?: string;
readonly token?: string;
readonly body?: unknown;
readonly query?: Record<string, string | number | undefined>;
readonly timeoutMs?: number;
},
): Promise<unknown> {
const url = buildUrl(credential.baseUrl, route, options.query);
const headers: Record<string, string> = { accept: "application/json" };
if (options.token !== undefined) headers.authorization = `Bearer ${options.token}`;
if (options.body !== undefined) headers["content-type"] = "application/json";
const init: RequestInit = {
method: options.method ?? "GET",
headers,
signal: AbortSignal.timeout(options.timeoutMs ?? 30_000),
};
if (options.body !== undefined) init.body = JSON.stringify(options.body);
let response: Response;
try {
response = await fetch(url, init);
} catch (error) {
throw new PbankClientError(
error instanceof Error ? error.message : String(error),
"pbank_unreachable",
);
}
const data = await response.json().catch(() => null);
if (!response.ok) {
throw new PbankClientError(
extractErrorMessage(data, `PBank API request failed: ${response.status}`),
response.status === 401 || response.status === 403 ? "pbank_rejected" : "pbank_invalid_response",
response.status,
);
}
return data;
}
}
function buildUrl(
baseUrl: string,
route: string,
query?: Record<string, string | number | undefined>,
): string {
const url = new URL(route.replace(/^\/+/, ""), `${normalizePbankBaseUrl(baseUrl)}/`);
if (query !== undefined) {
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === "") continue;
url.searchParams.set(key, String(value));
}
}
return url.toString();
}
function extractErrorMessage(data: unknown, fallback: string): string {
if (typeof data === "object" && data !== null) {
const record = data as Record<string, unknown>;
if (typeof record.message === "string" && record.message !== "") return record.message;
if (typeof record.error === "string" && record.error !== "") return record.error;
}
if (typeof data === "string" && data !== "") return data;
return fallback;
}
function normalizeRightsStatus(value: string): string {
const normalized = value.trim().toLowerCase().replace(/[\s-]+/g, "_");
if (normalized === "") return "unknown";
return normalized;
}
+2 -1
View File
@@ -23,6 +23,7 @@ import { resolveCapabilityCredential } from "./capabilityConnections.js";
import { DocmindClientError, type CapabilityProviderClient } from "./docmindClient.js"; import { DocmindClientError, type CapabilityProviderClient } from "./docmindClient.js";
import { import {
CAPABILITIES, CAPABILITIES,
asDocmindSecret,
type CapabilityAdapter, type CapabilityAdapter,
type CapabilityInvocationInput, type CapabilityInvocationInput,
type CapabilityInvocationResult, type CapabilityInvocationResult,
@@ -212,7 +213,7 @@ export function createPdfToMdBundleAdapter(deps: PdfToMdBundleDeps): CapabilityA
// 3. Call the backing service. // 3. Call the backing service.
let result; let result;
try { try {
result = await deps.client.parse(credential, { inputFilePath: absoluteInput }); result = await deps.client.parse(asDocmindSecret(credential), { inputFilePath: absoluteInput });
} catch (e) { } catch (e) {
if (e instanceof DocmindClientError) throw e; if (e instanceof DocmindClientError) throw e;
throw new DocmindClientError( throw new DocmindClientError(
+123 -7
View File
@@ -1,11 +1,11 @@
/** /**
* ADR-0027: External capability types shared across the adapter layer. * ADR-0027: External capability types shared across the adapter layer.
* *
* A capability is a platform-registered, org-enabled document/media transform * A capability is a platform-registered, org-enabled external service
* invoked as a side effect of an AgentRun. The adapter resolves the org's * invoked as a side effect of an AgentRun. The adapter resolves the org's
* active capability connection, calls the backing service via an injectable * active capability connection, calls the backing service via an injectable
* client, writes output into the run's workspace (AgentSurface, ADR-0018), * client, writes output into the run's workspace when applicable (AgentSurface,
* and records consumption on a UsageFact (ADR-0026). * ADR-0018), and records consumption on a UsageFact (ADR-0026).
*/ */
import type { PrismaClient, Prisma } from "@prisma/client"; import type { PrismaClient, Prisma } from "@prisma/client";
@@ -13,6 +13,7 @@ import type { PrismaClient, Prisma } from "@prisma/client";
export const CAPABILITY_IDS = [ export const CAPABILITY_IDS = [
"pdf_to_md_bundle", "pdf_to_md_bundle",
"audio_video_to_text", "audio_video_to_text",
"pbank",
] as const; ] as const;
export type CapabilityId = (typeof CAPABILITY_IDS)[number]; export type CapabilityId = (typeof CAPABILITY_IDS)[number];
@@ -27,6 +28,7 @@ export interface CapabilityDescriptor {
export const CAPABILITIES: Readonly<Record<CapabilityId, CapabilityDescriptor>> = { export const CAPABILITIES: Readonly<Record<CapabilityId, CapabilityDescriptor>> = {
pdf_to_md_bundle: { id: "pdf_to_md_bundle", meteringUnit: "pages" }, pdf_to_md_bundle: { id: "pdf_to_md_bundle", meteringUnit: "pages" },
audio_video_to_text: { id: "audio_video_to_text", meteringUnit: "audio_seconds" }, audio_video_to_text: { id: "audio_video_to_text", meteringUnit: "audio_seconds" },
pbank: { id: "pbank", meteringUnit: "requests" },
}; };
/** Input passed to a capability adapter invocation. */ /** Input passed to a capability adapter invocation. */
@@ -59,7 +61,7 @@ export interface CapabilityConsumption {
readonly model: string | null; readonly model: string | null;
readonly inputTokens: number | null; readonly inputTokens: number | null;
readonly outputTokens: number | null; readonly outputTokens: number | null;
/** Non-token meter (page count, audio seconds). */ /** Non-token meter (page count, audio seconds, request count). */
readonly quantity: number; readonly quantity: number;
readonly unit: string; readonly unit: string;
/** USD cost if the service reported one; null = unknown (ADR-0022). */ /** USD cost if the service reported one; null = unknown (ADR-0022). */
@@ -80,15 +82,39 @@ export interface CapabilityAdapter {
invoke(input: CapabilityInvocationInput): Promise<CapabilityInvocationResult>; invoke(input: CapabilityInvocationInput): Promise<CapabilityInvocationResult>;
} }
/** Decrypted capability credential (CapabilitySecretPayloadV1, ADR-0027). /** Alibaba Cloud Document Mind (docmind) AccessKey + endpoint. */
* Alibaba Cloud Document Mind (docmind) uses AccessKey ID + Secret + endpoint. */ export interface DocmindCapabilitySecretPayload {
export interface CapabilitySecretPayload {
readonly schemaVersion: 1; readonly schemaVersion: 1;
readonly kind: "docmind";
readonly accessKeyId: string; readonly accessKeyId: string;
readonly accessKeySecret: string; readonly accessKeySecret: string;
readonly endpoint: string; readonly endpoint: string;
} }
/**
* Paradigm PBank (题库) login credentials.
* Rights fields are operator-confirmed license signals echoed to the agent;
* the credential secret itself never reaches the agent process (ADR-0024/0027).
*/
export interface PbankCapabilitySecretPayload {
readonly schemaVersion: 1;
readonly kind: "pbank";
readonly baseUrl: string;
readonly username: string;
readonly password: string;
readonly rightsStatus?: string;
readonly rightsHolder?: string;
readonly rightsScope?: string;
readonly rightsNote?: string;
}
/**
* Decrypted capability credential (CapabilitySecretPayloadV1, ADR-0027).
* Discriminated by `kind`. Legacy envelopes without `kind` are normalized to
* `docmind` when accessKey fields are present.
*/
export type CapabilitySecretPayload = DocmindCapabilitySecretPayload | PbankCapabilitySecretPayload;
/** Thrown when an org has no ACTIVE capability connection (fail-closed, ADR-0024). */ /** Thrown when an org has no ACTIVE capability connection (fail-closed, ADR-0024). */
export class CapabilityConnectionUnavailable extends Error { export class CapabilityConnectionUnavailable extends Error {
constructor(readonly capabilityId: string, readonly organizationId: string) { constructor(readonly capabilityId: string, readonly organizationId: string) {
@@ -99,3 +125,93 @@ export class CapabilityConnectionUnavailable extends Error {
/** Prisma transaction client type alias (for resolver signatures). */ /** Prisma transaction client type alias (for resolver signatures). */
export type TxClient = Prisma.TransactionClient; export type TxClient = Prisma.TransactionClient;
/** Map capability id → expected secret kind. */
export function secretKindForCapability(capabilityId: string): "docmind" | "pbank" {
switch (capabilityId) {
case "pdf_to_md_bundle":
case "audio_video_to_text":
return "docmind";
case "pbank":
return "pbank";
default:
throw new Error(`unsupported capabilityId: ${capabilityId}`);
}
}
/**
* Normalize a decrypted envelope payload into a full CapabilitySecretPayload.
* Accepts legacy docmind payloads that omit `kind`.
*/
export function normalizeCapabilitySecretPayload(raw: unknown): CapabilitySecretPayload {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
throw new Error("invalid capability secret payload");
}
const payload = raw as Record<string, unknown>;
if (payload.schemaVersion !== 1) {
throw new Error(`unsupported capability secret schemaVersion: ${String(payload.schemaVersion)}`);
}
const kind =
payload.kind === "pbank" || payload.kind === "docmind"
? payload.kind
: typeof payload.accessKeyId === "string"
? "docmind"
: typeof payload.username === "string"
? "pbank"
: null;
if (kind === null) throw new Error("capability secret payload missing kind");
if (kind === "docmind") {
return {
schemaVersion: 1,
kind: "docmind",
accessKeyId: requireString(payload.accessKeyId, "accessKeyId"),
accessKeySecret: requireString(payload.accessKeySecret, "accessKeySecret"),
endpoint: requireString(payload.endpoint, "endpoint"),
};
}
const rightsStatus = optionalString(payload.rightsStatus);
const rightsHolder = optionalString(payload.rightsHolder);
const rightsScope = optionalString(payload.rightsScope);
const rightsNote = optionalString(payload.rightsNote);
return {
schemaVersion: 1,
kind: "pbank",
baseUrl: requireString(payload.baseUrl, "baseUrl"),
username: requireString(payload.username, "username"),
password: requireString(payload.password, "password"),
...(rightsStatus !== undefined ? { rightsStatus } : {}),
...(rightsHolder !== undefined ? { rightsHolder } : {}),
...(rightsScope !== undefined ? { rightsScope } : {}),
...(rightsNote !== undefined ? { rightsNote } : {}),
};
}
export function asDocmindSecret(payload: CapabilitySecretPayload): DocmindCapabilitySecretPayload {
if (payload.kind !== "docmind") {
throw new Error(`expected docmind capability secret, got ${payload.kind}`);
}
return payload;
}
export function asPbankSecret(payload: CapabilitySecretPayload): PbankCapabilitySecretPayload {
if (payload.kind !== "pbank") {
throw new Error(`expected pbank capability secret, got ${payload.kind}`);
}
return payload;
}
function requireString(value: unknown, label: string): string {
if (typeof value !== "string" || value.trim() === "") {
throw new Error(`${label} must not be empty`);
}
return value.trim();
}
function optionalString(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed === "" ? undefined : trimmed;
}
+121
View File
@@ -17,6 +17,8 @@ import {
type PdfToMdBatchItemResult, type PdfToMdBatchItemResult,
} from "../capability/pdfToMdBundle.js"; } from "../capability/pdfToMdBundle.js";
import { AliyunDocmindClient } from "../capability/docmindClient.js"; import { AliyunDocmindClient } from "../capability/docmindClient.js";
import { createPbankService, type PbankToolResult } from "../capability/pbank.js";
import { CapabilityConnectionUnavailable } from "../capability/types.js";
export interface FileDeliveryToolOptions { export interface FileDeliveryToolOptions {
readonly rt: FeishuRuntime; readonly rt: FeishuRuntime;
@@ -321,6 +323,83 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
); );
} }
const pbankEnabled =
enabledTools.has("pbank_search_problems") ||
enabledTools.has("pbank_get_problem") ||
enabledTools.has("pbank_get_many_problems");
if (pbankEnabled) {
const pbank = createPbankService({
prisma: options.prisma,
secrets: options.secretEnvelope,
});
const pbankCtx = {
organizationId: options.organizationId,
runId: options.runId,
workspaceDir: options.workspaceDir,
};
if (enabledTools.has("pbank_search_problems")) {
tools.push(
tool(
"pbank_search_problems",
"Search Paradigm PBank (题库) by title/keyword. Returns page metadata, operator-confirmed rights guidance, and matching problem summaries. Requires an ACTIVE org capability connection for `pbank`.",
{
q: z.string().optional().describe("Search text."),
keywords: z.array(z.string()).optional().describe("Exact keywords to filter by."),
pageNum: z.number().int().min(1).optional().describe("Page number (default 1)."),
pageSize: z.number().int().min(1).max(50).optional().describe("Page size (default 10, max 50)."),
},
async (args) =>
runPbankTool(() =>
pbank.searchProblems(pbankCtx, {
q: args.q,
keywords: args.keywords,
pageNum: args.pageNum,
pageSize: args.pageSize,
}),
),
{ alwaysLoad: true },
),
);
}
if (enabledTools.has("pbank_get_problem")) {
tools.push(
tool(
"pbank_get_problem",
"Fetch one PBank problem by URL or UUID. Returns metadata, rights guidance, text-like source files, local zip/extract paths under .pbank-sources/, and optional image assets. Requires ACTIVE org capability `pbank`.",
{
urlOrId: z.string().min(1).describe("PBank problem URL or UUID."),
includeProjects: z.boolean().optional().describe("Include problem/answer project archives (default true)."),
materializeProjects: z.boolean().optional().describe("Write archives under workspace .pbank-sources/ (default true)."),
includeAssetImages: z.boolean().optional().describe("Inline small image assets when possible (default true)."),
includeOccurrences: z.boolean().optional().describe("Include occurrence list (default false)."),
},
async (args) => runPbankTool(() => pbank.getProblem(pbankCtx, args)),
{ alwaysLoad: true },
),
);
}
if (enabledTools.has("pbank_get_many_problems")) {
tools.push(
tool(
"pbank_get_many_problems",
"Fetch several PBank problems by URL or UUID. Use when the teacher pastes multiple example links. Returns rights guidance together with each problem. Requires ACTIVE org capability `pbank`.",
{
urlsOrIds: z.array(z.string().min(1)).min(1).max(20).describe("PBank problem URLs or UUIDs."),
includeProjects: z.boolean().optional().describe("Include problem/answer project archives (default true)."),
materializeProjects: z.boolean().optional().describe("Write archives under workspace .pbank-sources/ (default true)."),
includeAssetImages: z.boolean().optional().describe("Inline small image assets when possible (default true)."),
includeOccurrences: z.boolean().optional().describe("Include occurrence list (default false)."),
},
async (args) => runPbankTool(() => pbank.getManyProblems(pbankCtx, args)),
{ alwaysLoad: true },
),
);
}
}
const instructions = mcpInstructions(enabledTools); const instructions = mcpInstructions(enabledTools);
return createSdkMcpServer({ return createSdkMcpServer({
name: "cph_hub", name: "cph_hub",
@@ -357,6 +436,36 @@ function formatPdfToMdBatchResult(results: readonly PdfToMdBatchItemResult[]): s
return lines.join("\n"); return lines.join("\n");
} }
async function runPbankTool(
invoke: () => Promise<PbankToolResult>,
): Promise<{
content: Array<
| { type: "text"; text: string }
| { type: "image"; data: string; mimeType: string }
>;
isError?: boolean;
}> {
try {
const result = await invoke();
const content: Array<
| { type: "text"; text: string }
| { type: "image"; data: string; mimeType: string }
> = [{ type: "text", text: JSON.stringify(result.data, null, 2) }];
for (const image of result.inlineImages) {
content.push({ type: "image", data: image.data, mimeType: image.mimeType });
}
return { content };
} catch (error) {
const message =
error instanceof CapabilityConnectionUnavailable
? `${error.message}. Ask an org admin to configure the pbank capability connection.`
: error instanceof Error
? error.message
: String(error);
return { isError: true, content: [{ type: "text", text: message }] };
}
}
function mcpInstructions(enabledTools: ReadonlySet<CphHubMcpToolId>): string { function mcpInstructions(enabledTools: ReadonlySet<CphHubMcpToolId>): string {
const instructions: string[] = []; const instructions: string[] = [];
if (enabledTools.has("send_file")) { if (enabledTools.has("send_file")) {
@@ -386,6 +495,18 @@ function mcpInstructions(enabledTools: ReadonlySet<CphHubMcpToolId>): string {
"Do NOT attempt to parse PDFs yourself with Read or Bash — always use convert_pdf_to_md.", "Do NOT attempt to parse PDFs yourself with Read or Bash — always use convert_pdf_to_md.",
); );
} }
if (
enabledTools.has("pbank_search_problems") ||
enabledTools.has("pbank_get_problem") ||
enabledTools.has("pbank_get_many_problems")
) {
instructions.push(
"Use pbank_search_problems / pbank_get_problem / pbank_get_many_problems for Paradigm PBank (题库) selection.",
"Treat the returned rights object as authoritative for derivative use.",
"Materialized sources land under workspace-relative .pbank-sources/ — read them; do not invent problem content.",
"If tools fail because no ACTIVE pbank capability connection exists, tell the user an org admin must configure 题库 on the admin capabilities page.",
);
}
instructions.push( instructions.push(
"Role skill docs (when bound) are readable at .cph/runtime-skills/<skill-name>/SKILL.md or $CPH_RUNTIME_SKILLS_DIR/<skill-name>/SKILL.md. Prefer the Skill tool when available. Workspace .claude/ and .mcp.json are sandbox stubs — not skill or MCP source.", "Role skill docs (when bound) are readable at .cph/runtime-skills/<skill-name>/SKILL.md or $CPH_RUNTIME_SKILLS_DIR/<skill-name>/SKILL.md. Prefer the Skill tool when available. Workspace .claude/ and .mcp.json are sandbox stubs — not skill or MCP source.",
); );
@@ -0,0 +1,187 @@
import { mkdtemp, 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 { createPbankService } from "../../src/capability/pbank.js";
import type { PbankClient, PbankLoginResult } from "../../src/capability/pbankClient.js";
import type { PbankCapabilitySecretPayload } from "../../src/capability/types.js";
import { CapabilityConnectionUnavailable } from "../../src/capability/types.js";
const CAPABILITY_ID = "pbank";
const PROBLEM_ID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee";
describe("pbank capability service (ADR-0027)", () => {
let workspaceRoot: string;
let runId: string;
beforeEach(async () => {
await resetDb();
await seedTestOrganization();
workspaceRoot = await mkdtemp(join(tmpdir(), "cph-pbank-"));
runId = "run-pbank-test";
await prisma.project.create({
data: {
id: "proj-pbank",
organizationId: DEFAULT_ORG_ID,
name: "PBank Test",
workspaceDir: workspaceRoot,
},
});
await prisma.agentRun.create({
data: {
id: runId,
projectId: "proj-pbank",
entrypoint: "FEISHU",
provider: "openrouter",
model: "mock-model",
status: "ACTIVE",
prompt: "search pbank",
metadata: {},
},
});
});
afterEach(async () => {
await rm(workspaceRoot, { recursive: true, force: true }).catch(() => {});
});
async function seedActiveConnection(): Promise<void> {
const payload: PbankCapabilitySecretPayload = {
schemaVersion: 1,
kind: "pbank",
baseUrl: "https://pbank.example/api",
username: "teacher",
password: "secret-never-log",
rightsStatus: "owned",
rightsHolder: "Paradigm Education",
};
const connection = await prisma.organizationCapabilityConnection.create({
data: {
id: "pbank-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: "pbank-sv-1",
},
payload,
);
await prisma.capabilityCredentialVersion.create({
data: {
id: "pbank-sv-1",
connectionId: connection.id,
version: 1,
envelope: envelope as object,
keyId: envelope.keyId,
},
});
await prisma.organizationCapabilityConnection.update({
where: { id: connection.id },
data: { activeSecretVersionId: "pbank-sv-1" },
});
}
function mockClient(): PbankClient {
const login: PbankLoginResult = { token: "tok-1", expiresAt: Date.now() + 60_000 };
return {
login: vi.fn(async () => login),
searchProblems: vi.fn(async () => ({
pageNum: 1,
pageSize: 10,
total: 1,
items: [{ id: PROBLEM_ID, title: "示例题" }],
})),
getProblem: vi.fn(async () => ({ id: PROBLEM_ID, title: "示例题" })),
getOccurrences: vi.fn(async () => ({ items: [] })),
downloadProject: vi.fn(async () => ({
buffer: Buffer.from("# problem\n"),
contentType: "text/plain",
})),
};
}
it("fails closed when no ACTIVE connection exists", async () => {
const service = createPbankService({
prisma,
secrets: testSecretEnvelope,
client: mockClient(),
});
await expect(
service.searchProblems(
{ organizationId: DEFAULT_ORG_ID, runId, workspaceDir: workspaceRoot },
{ q: "函数" },
),
).rejects.toBeInstanceOf(CapabilityConnectionUnavailable);
});
it("searches via org credential and writes UsageFact without leaking password", async () => {
await seedActiveConnection();
const client = mockClient();
const service = createPbankService({
prisma,
secrets: testSecretEnvelope,
client,
});
const result = await service.searchProblems(
{ organizationId: DEFAULT_ORG_ID, runId, workspaceDir: workspaceRoot },
{ q: "函数", pageNum: 1, pageSize: 10 },
);
expect(client.login).toHaveBeenCalledTimes(1);
const loginArg = (client.login as ReturnType<typeof vi.fn>).mock.calls[0]?.[0] as PbankCapabilitySecretPayload;
expect(loginArg.username).toBe("teacher");
expect(loginArg.password).toBe("secret-never-log");
expect(loginArg.kind).toBe("pbank");
expect(result.data).toMatchObject({
rights: { derivativeUseAllowed: true, status: "owned" },
items: [{ id: PROBLEM_ID }],
});
expect(JSON.stringify(result.data)).not.toContain("secret-never-log");
const facts = await prisma.usageFact.findMany({ where: { runId } });
expect(facts).toHaveLength(1);
expect(facts[0]).toMatchObject({
kind: "external_capability",
capabilityId: CAPABILITY_ID,
provider: "paradigm_pbank",
unit: "requests",
quantity: expect.anything(),
costUsd: null,
costSource: "unknown",
});
});
it("fetches one problem and attaches rights", async () => {
await seedActiveConnection();
const client = mockClient();
const service = createPbankService({
prisma,
secrets: testSecretEnvelope,
client,
});
const result = await service.getProblem(
{ organizationId: DEFAULT_ORG_ID, runId, workspaceDir: workspaceRoot },
{ urlOrId: PROBLEM_ID, includeProjects: true, materializeProjects: false },
);
expect(result.data).toMatchObject({
id: PROBLEM_ID,
rights: { derivativeUseAllowed: true },
problem: { id: PROBLEM_ID },
projects: {
problem: { status: "text" },
answer: { status: "text" },
},
});
expect(client.getProblem).toHaveBeenCalled();
expect(client.downloadProject).toHaveBeenCalled();
});
});
@@ -58,6 +58,7 @@ describe("pdf_to_md_bundle capability adapter (ADR-0027)", () => {
async function seedActiveCapabilityConnection(): Promise<void> { async function seedActiveCapabilityConnection(): Promise<void> {
const payload: CapabilitySecretPayload = { const payload: CapabilitySecretPayload = {
schemaVersion: 1, schemaVersion: 1,
kind: "docmind",
accessKeyId: "LTAI-test-key-id", accessKeyId: "LTAI-test-key-id",
accessKeySecret: "test-secret-never-log", accessKeySecret: "test-secret-never-log",
endpoint: "docmind-api.cn-hangzhou.aliyuncs.com", endpoint: "docmind-api.cn-hangzhou.aliyuncs.com",
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import {
extractProblemId,
normalizePbankBaseUrl,
pbankRightsFromCredential,
} from "../../src/capability/pbankClient.js";
import type { PbankCapabilitySecretPayload } from "../../src/capability/types.js";
import { claudeSdkToolConfigForRole, cphHubMcpToolsForRole } from "../../src/agent/roleTools.js";
describe("pbank client helpers", () => {
it("extracts problem UUID from bare id and URL", () => {
const id = "01234567-89ab-4def-8abc-0123456789ab";
expect(extractProblemId(id)).toBe(id);
expect(extractProblemId(`https://pbank.paradigm-edu.net/problem/${id}`)).toBe(id);
expect(extractProblemId(`https://pbank.example/x?id=${id}`)).toBe(id);
});
it("normalizes base URL trailing slashes", () => {
expect(normalizePbankBaseUrl("https://pbank.paradigm-edu.net/api/")).toBe(
"https://pbank.paradigm-edu.net/api",
);
expect(normalizePbankBaseUrl("")).toBe("https://pbank.paradigm-edu.net/api");
});
it("marks derivative use from operator rights status", () => {
const owned: PbankCapabilitySecretPayload = {
schemaVersion: 1,
kind: "pbank",
baseUrl: "https://pbank.paradigm-edu.net/api",
username: "u",
password: "p",
rightsStatus: "owned",
};
expect(pbankRightsFromCredential(owned).derivativeUseAllowed).toBe(true);
const unknown: PbankCapabilitySecretPayload = {
...owned,
rightsStatus: "unknown",
};
expect(pbankRightsFromCredential(unknown).derivativeUseAllowed).toBe(false);
});
});
describe("role tool mapping for pbank", () => {
it("maps umbrella pbank role tool to three MCP tools", () => {
expect(cphHubMcpToolsForRole(["pbank"])).toEqual([
"pbank_search_problems",
"pbank_get_problem",
"pbank_get_many_problems",
]);
const cfg = claudeSdkToolConfigForRole(["pbank", "Read"]);
expect(cfg.tools).toContain("Read");
expect(cfg.allowedTools).toContain("mcp__cph_hub__pbank_search_problems");
expect(cfg.allowedTools).toContain("mcp__cph_hub__pbank_get_problem");
expect(cfg.allowedTools).toContain("mcp__cph_hub__pbank_get_many_problems");
});
});