forked from EduCraft/curriculum-project-hub
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:
@@ -15,3 +15,7 @@ node_modules/
|
|||||||
|
|
||||||
# OS / editor
|
# OS / editor
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Local operator notes / specs (not product source)
|
||||||
|
/spec/
|
||||||
|
/需求整理-*.md
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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
@@ -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(/\/+$/, "");
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user