Merge remote-tracking branch 'educraft/main' into merge/educraft-cph

# Conflicts:
#	.gitignore
#	hub/.env.example
#	hub/deploy/deploy_fleet_release.sh
#	hub/deploy/deploy_platform.sh
#	hub/test/integration/helpers.ts
This commit is contained in:
2026-08-06 00:49:02 +08:00
262 changed files with 10630 additions and 1377 deletions
+22 -3
View File
@@ -20,12 +20,16 @@ DATABASE_URL="postgresql://paradigm:paradigm@127.0.0.1:5432/paradigm"
# Alpha Silo safety limits. max turns may use its default; every other value is
# mandatory in production and should be calibrated on the target host.
# HUB_AGENT_MAX_TURNS=25
HUB_AGENT_MAX_TURNS="150"
HUB_AGENT_MAX_CONCURRENT_RUNS="1"
HUB_AGENT_MAX_RUN_SECONDS="900"
HUB_AGENT_MAX_RUN_SECONDS="1800"
# 文件库上传把内容放在 JSON body 里,所以 body limit 与
# HUB_FILELIB_MAX_FILE_BYTES 串联(见下方注释),不能降回 1 MiB。
HUB_HTTP_BODY_LIMIT_BYTES="73400320"
HUB_MAX_FILES_PER_MESSAGE="8"
HUB_MAX_FILES_PER_MESSAGE="20"
HUB_MAX_FILE_BYTES="26214400"
# Max concurrent Alibaba Docmind jobs for one convert_pdf_to_md batch (1-8).
HUB_PDF_TO_MD_MAX_CONCURRENT="3"
HUB_HTTP_REQUESTS_PER_MINUTE="120"
HUB_FEISHU_EVENTS_PER_MINUTE="120"
@@ -44,9 +48,24 @@ HUB_PROJECT_WORKSPACE_ROOT="/var/lib/cph-hub/workspaces"
# startup unless XDG_STATE_HOME is set (then defaults to $XDG_STATE_HOME/skills).
HUB_SKILL_STORE_ROOT="/var/lib/cph-hub/state/skills"
# Optional tenant-local Typst package roots. When configured, the agent
# sandbox forwards these exact paths to Typst. The preinstalled package path is
# read-only; the cache path is the only additional write location.
# Keep the preinstalled package path outside the release tree and provision it
# with the namespace layout expected by Typst, for example:
# <root>/paradigm/paradigm-templates/0.2.20/
# Use a separate service-writable cache path when runtime dependencies may be
# downloaded; do not make the immutable preinstalled directory the cache.
# TYPST_PACKAGE_PATH="/srv/curriculum-project-hub/typst-packages/org-a"
# TYPST_PACKAGE_CACHE_PATH="/var/cache/cph-hub/org-a/typst"
# This process is pinned to exactly one Organization. Feishu credentials are
# resolved from that Organization's encrypted ACTIVE connection.
HUB_SILO_ORGANIZATION_ID=""
# Absolute path to the bot-only lark-cli binary used by Agent Feishu tools.
# The CLI is invoked by Hub with a disposable HOME and the ACTIVE Feishu
# Application Connection; the App Secret is never put in Agent argv/env.
HUB_FEISHU_CLI_BIN="/usr/local/bin/lark-cli"
HUB_SYSTEMD_UNIT="cph-hub-example.service"
# Absolute path to the `cph` binary (ADR-0016). Production preflight requires
+96 -7
View File
@@ -7,6 +7,9 @@
"": {
"name": "admin-web",
"version": "0.0.1",
"dependencies": {
"fflate": "^0.8.3"
},
"devDependencies": {
"@skeletonlabs/skeleton": "^4.15.2",
"@skeletonlabs/skeleton-svelte": "^4.15.2",
@@ -26,24 +29,38 @@
}
},
"node_modules/@emnapi/core": {
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz",
"integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==",
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz",
"integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.3",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/core/node_modules/@emnapi/wasi-threads": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
"integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
"integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -881,6 +898,72 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.2",
"resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz",
@@ -1768,6 +1851,12 @@
}
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmmirror.com/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
+3
View File
@@ -29,5 +29,8 @@
"tailwindcss": "^4.3.2",
"typescript": "^6.0.3",
"vite": "^8.0.16"
},
"dependencies": {
"fflate": "^0.8.3"
}
}
+39 -2
View File
@@ -317,6 +317,7 @@ export interface AgentRoleRow {
createdAt: string;
updatedAt: string;
skillNames: readonly string[];
folderId: string | null;
}
export interface AgentSkillRow {
@@ -329,6 +330,14 @@ export interface AgentSkillRow {
createdAt: string;
updatedAt: string;
boundRoleIds: readonly string[];
folderId: string | null;
}
/** ADR-0028 transparent folder node shared by agent roles and skills. */
export interface AgentConfigFolderRow {
id: string;
name: string;
parentId: string | null;
}
export interface SkillFileEntry {
@@ -401,7 +410,7 @@ export const api = {
archiveFolder: (slug: string, folderId: string) =>
post(`${orgBase(slug)}/folders/${folderId}/archive`) as Promise<{ archived: true; folderId: string }>,
createProject: (slug: string, body: { name: string; folderId?: string }) =>
post(`${orgBase(slug)}/projects`, body) as Promise<{ id: string; name: string }>,
post(`${orgBase(slug)}/projects`, body) as Promise<{ projectId: string; folderId: string | null; workspaceDir: string; name: string }>,
project: (slug: string, projectId: string) => get(`${orgBase(slug)}/projects/${projectId}`) as Promise<ProjectDetail>,
renameProject: (slug: string, projectId: string, name: string) =>
patch(`${orgBase(slug)}/projects/${projectId}`, { name }),
@@ -480,7 +489,18 @@ export const api = {
rotateCapabilityConnection: (
slug: 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>,
disableCapabilityConnection: (slug: string, capabilityId: string) =>
@@ -515,4 +535,21 @@ export const api = {
patchAgentSkill: (slug: string, name: string, body: { description?: string; disabled?: boolean }) =>
patch(`${orgBase(slug)}/agent-skills/${encodeURIComponent(name)}`, body) as Promise<{ disabled?: boolean; updated?: boolean }>,
agentModels: (slug: string) => get(`${orgBase(slug)}/agent-models`) as Promise<{ models: AgentModelRow[] }>,
agentConfigFolders: (slug: string) =>
get(`${orgBase(slug)}/agent-config-folders`) as Promise<{ folders: AgentConfigFolderRow[] }>,
createAgentConfigFolder: (slug: string, body: { name: string; parentId?: string }) =>
post(`${orgBase(slug)}/agent-config-folders`, body) as Promise<AgentConfigFolderRow>,
patchAgentConfigFolder: (slug: string, folderId: string, body: { name?: string; parentId?: string | null }) =>
patch(`${orgBase(slug)}/agent-config-folders/${encodeURIComponent(folderId)}`, body) as Promise<AgentConfigFolderRow>,
deleteAgentConfigFolder: (slug: string, folderId: string) =>
del(`${orgBase(slug)}/agent-config-folders/${encodeURIComponent(folderId)}`) as Promise<{ deleted: boolean }>,
setAgentRoleFolder: (slug: string, roleId: string, folderId: string | null) =>
patch(`${orgBase(slug)}/agent-roles/${encodeURIComponent(roleId)}/folder`, { folderId }) as Promise<{
folderId: string | null;
}>,
setAgentSkillFolder: (slug: string, name: string, folderId: string | null) =>
patch(`${orgBase(slug)}/agent-skills/${encodeURIComponent(name)}/folder`, { folderId }) as Promise<{
folderId: string | null;
}>,
};
@@ -0,0 +1,160 @@
<script lang="ts">
import type { AgentConfigFolderRow } from '$lib/api';
import Icon from './Icon.svelte';
/**
* ADR-0028 left folder-tree nav for the agent config pages (roles/skills).
* Transparent grouping only: selection filters the item list, it never
* affects role/skill identity or run resolution.
*/
let {
folders,
selected,
counts,
totalCount,
unfiledCount,
onselect,
oncreate,
onrename,
ondelete,
}: {
folders: AgentConfigFolderRow[];
/** 'all' | 'unfiled' | folder id */
selected: string;
/** item count per folder id (roles or skills, depending on the page) */
counts: Record<string, number>;
totalCount: number;
unfiledCount: number;
onselect: (id: string) => void;
oncreate: (parentId: string | null) => void;
onrename: (folder: AgentConfigFolderRow) => void;
ondelete: (folder: AgentConfigFolderRow) => void;
} = $props();
type Row = { folder: AgentConfigFolderRow; depth: number; hasChildren: boolean };
let collapsed = $state<ReadonlySet<string>>(new Set());
function flatten(list: AgentConfigFolderRow[], collapsedSet: ReadonlySet<string>): Row[] {
const rows: Row[] = [];
const walk = (parentId: string | null, depth: number) => {
const siblings = list
.filter((f) => f.parentId === parentId)
.sort((a, b) => a.name.localeCompare(b.name));
for (const folder of siblings) {
const hasChildren = list.some((f) => f.parentId === folder.id);
rows.push({ folder, depth, hasChildren });
if (hasChildren && !collapsedSet.has(folder.id)) walk(folder.id, depth + 1);
}
};
walk(null, 0);
return rows;
}
const rows = $derived(flatten(folders, collapsed));
function toggleCollapse(folderId: string) {
const next = new Set(collapsed);
if (next.has(folderId)) next.delete(folderId);
else next.add(folderId);
collapsed = next;
}
function childFolderCount(folderId: string): number {
return folders.filter((f) => f.parentId === folderId).length;
}
const rowClass = (active: boolean) =>
`group flex w-full items-center gap-1.5 px-2 py-1.5 text-left text-sm transition hover:bg-surface-100 ${
active ? 'bg-primary-100 font-medium text-primary-900' : 'text-surface-800'
}`;
</script>
<nav class="saas-card p-2" aria-label="文件夹导航">
<div class="flex items-center justify-between px-2 py-1.5">
<span class="text-xs font-semibold uppercase tracking-wide text-surface-600">文件夹</span>
<button
type="button"
class="text-xs text-primary-700 hover:text-primary-900"
onclick={() => oncreate(null)}
>
+ 新建
</button>
</div>
<button type="button" class={rowClass(selected === 'all')} onclick={() => onselect('all')}>
<span class="w-3.5"></span>
<span class="min-w-0 flex-1 truncate">全部</span>
<span class="saas-badge-neutral">{totalCount}</span>
</button>
<button type="button" class={rowClass(selected === 'unfiled')} onclick={() => onselect('unfiled')}>
<span class="w-3.5"></span>
<span class="min-w-0 flex-1 truncate">未分类</span>
<span class="saas-badge-neutral">{unfiledCount}</span>
</button>
{#each rows as row (row.folder.id)}
{@const itemCount = counts[row.folder.id] ?? 0}
{@const childCount = childFolderCount(row.folder.id)}
<div class={rowClass(selected === row.folder.id)} style:padding-left="{0.5 + row.depth * 1}rem">
{#if row.hasChildren}
<button
type="button"
class="w-3.5 shrink-0 text-center text-xs text-surface-600"
aria-label={collapsed.has(row.folder.id) ? '展开' : '折叠'}
onclick={(e) => {
e.stopPropagation();
toggleCollapse(row.folder.id);
}}
>
{collapsed.has(row.folder.id) ? '▸' : '▾'}
</button>
{:else}
<span class="w-3.5 shrink-0"></span>
{/if}
<button type="button" class="flex min-w-0 flex-1 items-center gap-1.5 text-left" onclick={() => onselect(row.folder.id)}>
<Icon name="folder" class="h-3.5 w-3.5 shrink-0 opacity-60" />
<span class="min-w-0 flex-1 truncate">{row.folder.name}</span>
{#if itemCount > 0}
<span class="saas-badge-neutral">{itemCount}</span>
{/if}
</button>
<span
class="flex shrink-0 items-center gap-0.5 opacity-0 transition group-hover:opacity-100 focus-within:opacity-100"
>
<button
type="button"
class="flex h-5 w-5 items-center justify-center text-surface-500 hover:text-primary-700"
title="在此新建子文件夹"
aria-label="在 {row.folder.name} 内新建子文件夹"
onclick={() => oncreate(row.folder.id)}
>
<Icon name="folder-plus" class="h-3.5 w-3.5" />
</button>
<button
type="button"
class="flex h-5 w-5 items-center justify-center text-surface-500 hover:text-primary-700"
title="重命名 / 移动"
aria-label="重命名或移动 {row.folder.name}"
onclick={() => onrename(row.folder)}
>
</button>
<button
type="button"
class="flex h-5 w-5 items-center justify-center text-surface-500 hover:text-error-700 disabled:cursor-not-allowed disabled:opacity-30"
title={itemCount > 0 || childCount > 0 ? '仅可删除空文件夹' : '删除文件夹'}
aria-label="删除 {row.folder.name}"
disabled={itemCount > 0 || childCount > 0}
onclick={() => ondelete(row.folder)}
>
×
</button>
</span>
</div>
{/each}
{#if folders.length === 0}
<p class="px-2 py-3 text-xs text-surface-600">还没有文件夹。新建一个来分组管理。</p>
{/if}
</nav>
+138 -19
View File
@@ -1,10 +1,11 @@
<script lang="ts">
import { Checkbox, Label } from 'bits-ui';
import type { AgentRoleRow, AgentModelRow, AgentSkillRow } from '$lib/api';
import type { AgentConfigFolderRow, AgentRoleRow, AgentModelRow, AgentSkillRow } from '$lib/api';
import { api } from '$lib/api';
import { fmtDate } from '$lib/format';
import { TOOL_OPTIONS } from '$lib/constants';
import SelectField from '$lib/components/SelectField.svelte';
import SearchableSelectField from '$lib/components/SearchableSelectField.svelte';
import CheckboxControl from '$lib/components/CheckboxControl.svelte';
import Icon from '$lib/components/Icon.svelte';
import { toastError, toastSuccess } from '$lib/toast';
@@ -14,15 +15,23 @@
models,
skills,
slug,
folders,
folderItems,
onupdated,
onskillschanged,
onfolderchanged,
}: {
r: AgentRoleRow;
models: AgentModelRow[];
skills: AgentSkillRow[];
slug: string;
/** ADR-0028 shared management tree — skill picker groups only; not binding identity */
folders: AgentConfigFolderRow[];
/** ADR-0028 folder choices ('' = 未分类); transparent grouping only */
folderItems: { value: string; label: string }[];
onupdated: (updated: AgentRoleRow) => void;
onskillschanged: (roleId: string, skillNames: string[]) => void;
onfolderchanged: (roleId: string, folderId: string | null) => void;
} = $props();
const initial = {
@@ -44,6 +53,8 @@
let isDefault = $state(initial.isDefault);
let selectedSkills = $state<string[]>([...initial.skillNames]);
let saving = $state(false);
let folderValue = $state(r.folderId ?? '');
let savingFolder = $state(false);
const groupedTools = TOOL_OPTIONS.reduce(
(acc, t) => {
@@ -53,12 +64,69 @@
{} as Record<string, typeof TOOL_OPTIONS>,
);
const modelItems = $derived([
{ value: '', label: '(使用平台默认模型)' },
...models.map((m) => ({ value: m.id, label: `${m.label}${m.id}` })),
]);
const modelItems = $derived.by(() => {
const fromCatalog = models.map((m) => ({ value: m.id, label: `${m.label}${m.id}` }));
const items = [{ value: '', label: '(使用平台默认模型)' }, ...fromCatalog];
// Keep a previously saved model selectable even if it left the live catalog.
if (defaultModel !== '' && !items.some((item) => item.value === defaultModel)) {
items.push({ value: defaultModel, label: `${defaultModel}(当前已存,不在目录中)` });
}
return items;
});
const skillItems = $derived(skills.map((s) => ({ value: s.name, label: s.name })));
function folderPathLabel(folderId: string): string {
const folder = folders.find((f) => f.id === folderId);
if (!folder) return '(未知文件夹)';
const parts: string[] = [folder.name];
let cur: AgentConfigFolderRow | undefined = folder;
while (cur?.parentId) {
const parent = folders.find((x) => x.id === cur!.parentId);
if (!parent) break;
parts.unshift(parent.name);
cur = parent;
}
return parts.join(' / ');
}
/** Group picker by management folder; bindings remain skill name (ADR-0028). */
const skillGroups = $derived.by(() => {
type Group = { key: string; label: string; skills: AgentSkillRow[] };
const byFolder = new Map<string | null, AgentSkillRow[]>();
for (const s of skills) {
const key = s.folderId;
const list = byFolder.get(key) ?? [];
list.push(s);
byFolder.set(key, list);
}
for (const list of byFolder.values()) {
list.sort((a, b) => a.name.localeCompare(b.name));
}
const filed = [...byFolder.entries()]
.filter((e): e is [string, AgentSkillRow[]] => e[0] !== null)
.map(([id, list]) => ({ key: id, label: folderPathLabel(id), skills: list }))
.sort((a, b) => a.label.localeCompare(b.label));
const unfiled = byFolder.get(null);
const groups: Group[] = [...filed];
if (unfiled && unfiled.length > 0) {
groups.push({ key: 'unfiled', label: '未分类', skills: unfiled });
}
return groups;
});
function groupSelectedCount(groupSkills: AgentSkillRow[]): number {
return groupSkills.filter((s) => selectedSkills.includes(s.name)).length;
}
function toggleGroup(groupSkills: AgentSkillRow[], checked: boolean) {
const names = new Set(groupSkills.map((s) => s.name));
if (checked) {
const next = new Set(selectedSkills);
for (const n of names) next.add(n);
selectedSkills = [...next];
} else {
selectedSkills = selectedSkills.filter((n) => !names.has(n));
}
}
function skillsDirty(): boolean {
const a = [...selectedSkills].sort();
@@ -105,6 +173,24 @@
function sortKeyDirty(): boolean {
return Number(sortOrder) !== r.sortOrder;
}
// ADR-0028: folder assignment is a label-class change — instant-apply, no
// session archival, independent of the configuration save button.
async function saveFolder(next: string) {
const folderId = next === '' ? null : next;
if (folderId === r.folderId) return;
savingFolder = true;
try {
await api.setAgentRoleFolder(slug, r.roleId, folderId);
onfolderchanged(r.roleId, folderId);
toastSuccess('已更新所属文件夹');
} catch (err) {
folderValue = r.folderId ?? '';
toastError(err instanceof Error ? err.message : String(err));
} finally {
savingFolder = false;
}
}
</script>
<div class="saas-card-pad">
@@ -114,6 +200,12 @@
{#if r.isDefault}
<span class="saas-badge-success">默认</span>
{/if}
<div class="ml-auto flex items-center gap-2">
<span class="text-xs text-surface-600">文件夹</span>
<div class="w-44">
<SelectField items={folderItems} bind:value={folderValue} disabled={savingFolder} onchange={saveFolder} />
</div>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
@@ -129,7 +221,13 @@
<div class="mt-4">
<p class="saas-label">默认模型</p>
<SelectField items={modelItems} bind:value={defaultModel} />
<SearchableSelectField
items={modelItems}
bind:value={defaultModel}
placeholder="选择模型…"
searchPlaceholder="搜索模型名称或 ID"
emptyText="无匹配模型"
/>
</div>
<div class="mt-4">
@@ -166,19 +264,40 @@
<div class="mt-4">
<span class="saas-label">技能绑定</span>
{#if skills.length === 0}
<p class="text-sm text-surface-600">组织内暂无已安装技能。技能通过 CLI / seed 安装ADR-0018)。</p>
<p class="text-sm text-surface-600">组织内暂无已安装技能。请在技能页上传 zip 或新建空白模板ADR-0018)。</p>
{:else}
<div class="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
{#each skillItems as s}
<label class="flex cursor-pointer items-center gap-2 px-2 py-1.5 text-sm hover:bg-surface-100">
<CheckboxControl
checked={selectedSkills.includes(s.value)}
onchange={(checked) => {
selectedSkills = checked ? [...selectedSkills, s.value] : selectedSkills.filter((x) => x !== s.value);
}}
/>
<span class="font-mono text-xs">{s.label}</span>
</label>
<div class="space-y-3">
{#each skillGroups as group (group.key)}
<div class="border border-surface-300">
<label class="flex cursor-pointer items-center gap-2 border-b border-surface-200 bg-surface-100 px-2 py-1.5 text-sm">
<CheckboxControl
checked={groupSelectedCount(group.skills) === group.skills.length}
onchange={(checked) => toggleGroup(group.skills, checked)}
/>
<span class="text-xs font-semibold text-surface-700">{group.label}</span>
<span class="text-[10px] text-surface-500">
{groupSelectedCount(group.skills)}/{group.skills.length}
</span>
</label>
<div class="grid grid-cols-1 gap-0.5 p-1 sm:grid-cols-2">
{#each group.skills as s (s.name)}
<label class="flex cursor-pointer items-center gap-2 px-2 py-1.5 text-sm hover:bg-surface-100">
<CheckboxControl
checked={selectedSkills.includes(s.name)}
onchange={(checked) => {
selectedSkills = checked
? [...selectedSkills, s.name]
: selectedSkills.filter((x) => x !== s.name);
}}
/>
<span class="font-mono text-xs">{s.name}</span>
{#if s.version}
<span class="text-[10px] text-surface-500">v{s.version}</span>
{/if}
</label>
{/each}
</div>
</div>
{/each}
</div>
{/if}
@@ -0,0 +1,102 @@
<script lang="ts">
import { Combobox } from 'bits-ui';
import Icon from './Icon.svelte';
import type { SelectItem } from './SelectField.svelte';
let {
items,
value = $bindable(''),
class: className = '',
disabled = false,
placeholder = '请选择…',
searchPlaceholder = '搜索…',
emptyText = '无匹配项',
onchange,
}: {
items: SelectItem[];
value?: string;
class?: string;
disabled?: boolean;
placeholder?: string;
searchPlaceholder?: string;
emptyText?: string;
onchange?: (value: string) => void;
} = $props();
let searchValue = $state('');
const filteredItems = $derived.by(() => {
const q = searchValue.trim().toLowerCase();
if (q === '') return items;
return items.filter(
(item) => item.label.toLowerCase().includes(q) || item.value.toLowerCase().includes(q),
);
});
</script>
<Combobox.Root
type="single"
{items}
{disabled}
{value}
allowDeselect={false}
onValueChange={(next) => {
value = next;
onchange?.(next);
}}
onOpenChangeComplete={(open) => {
if (!open) searchValue = '';
}}
>
<div class="relative {className}">
<Combobox.Input
class="saas-combobox-input"
{disabled}
{placeholder}
aria-label={searchPlaceholder}
oninput={(e) => {
searchValue = e.currentTarget.value;
}}
/>
<Combobox.Trigger
class="absolute inset-y-0 right-0 flex w-9 items-center justify-center text-surface-600 disabled:cursor-not-allowed disabled:opacity-55"
{disabled}
aria-label="展开选项"
>
<svg
class="h-4 w-4 shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 15l3.75 3.75L15.75 15" />
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 9l3.75-3.75L15.75 9" />
</svg>
</Combobox.Trigger>
</div>
<Combobox.Portal>
<Combobox.Content class="saas-select-content" sideOffset={6} collisionPadding={8}>
<Combobox.Viewport class="max-h-72 overflow-y-auto p-1">
{#each filteredItems as item (item.value)}
<Combobox.Item
class="saas-select-item"
value={item.value}
label={item.label}
disabled={item.disabled}
>
{#snippet children({ selected })}
<span class="min-w-0 flex-1 truncate">{item.label}</span>
{#if selected}
<Icon name="check" class="ml-2 h-4 w-4 shrink-0 text-primary-600" />
{/if}
{/snippet}
</Combobox.Item>
{:else}
<div class="px-2 py-2 text-sm text-surface-600">{emptyText}</div>
{/each}
</Combobox.Viewport>
</Combobox.Content>
</Combobox.Portal>
</Combobox.Root>
@@ -2,19 +2,26 @@
import type { AgentSkillRow, SkillFileEntry } from '$lib/api';
import { api } from '$lib/api';
import { fmtDate } from '$lib/format';
import { parseSkillZip } from '$lib/skillZip';
import Icon from '$lib/components/Icon.svelte';
import SelectField from '$lib/components/SelectField.svelte';
import { toastError, toastSuccess } from '$lib/toast';
let {
slug,
skill,
folderItems,
oninstalled,
ondisabled,
onfolderchanged,
}: {
slug: string;
skill: AgentSkillRow;
/** ADR-0028 folder choices ('' = 未分类); transparent grouping only */
folderItems: { value: string; label: string }[];
oninstalled: (result: { id: string; name: string; contentDigest: string }) => void;
ondisabled: (name: string) => void;
onfolderchanged: (name: string, folderId: string | null) => void;
} = $props();
type FileNode = { path: string; content: string };
@@ -28,6 +35,10 @@
let dirty = $state(false);
let newFilePath = $state('');
let showNewFile = $state(false);
let folderValue = $state(skill.folderId ?? '');
let savingFolder = $state(false);
let zipInputEl = $state<HTMLInputElement | null>(null);
let zipImporting = $state(false);
const selectedFile = $derived(files.find((f) => f.path === selectedPath) ?? null);
const hasManifest = $derived(files.some((f) => f.path === 'SKILL.md'));
@@ -152,6 +163,53 @@
dirty = true;
}
// ADR-0028: folder assignment is a label-class change — instant-apply, no
// session archival, independent of the content save button.
async function saveFolder(next: string) {
const folderId = next === '' ? null : next;
if (folderId === skill.folderId) return;
savingFolder = true;
try {
await api.setAgentSkillFolder(slug, skill.name, folderId);
onfolderchanged(skill.name, folderId);
toastSuccess('已更新所属文件夹');
} catch (err) {
folderValue = skill.folderId ?? '';
toastError(err instanceof Error ? err.message : String(err));
} finally {
savingFolder = false;
}
}
async function importZip(fileList: FileList | null) {
const file = fileList?.[0];
if (!file) return;
const trimmedVersion = version.trim();
if (trimmedVersion === '') {
toastError('请先填写版本号再导入 zip');
if (zipInputEl) zipInputEl.value = '';
return;
}
zipImporting = true;
try {
const buf = new Uint8Array(await file.arrayBuffer());
const parsed = parseSkillZip(buf);
if (parsed.name !== skill.name) {
throw new Error(`zip 内技能名 "${parsed.name}" 与当前技能 "${skill.name}" 不一致`);
}
files = parsed.files.map((f) => ({ path: f.path, content: f.content }));
selectedPath = files.find((f) => f.path === 'SKILL.md')?.path ?? files[0]?.path ?? null;
if (parsed.description !== null) description = parsed.description;
dirty = true;
toastSuccess(`已载入 zip${files.length} 个文件),请保存以写入`);
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
zipImporting = false;
if (zipInputEl) zipInputEl.value = '';
}
}
function updateFrontmatter(content: string, key: string, value: string): string {
const regex = new RegExp(`^(${key}:\\s*)(.*?)(\\s*)$`, 'm');
if (regex.test(content)) {
@@ -178,6 +236,12 @@
{#if skill.disabledAt}
<span class="saas-badge-error">已禁用</span>
{/if}
<div class="ml-auto flex items-center gap-2">
<span class="text-xs text-surface-600">文件夹</span>
<div class="w-44">
<SelectField items={folderItems} bind:value={folderValue} disabled={savingFolder} onchange={saveFolder} />
</div>
</div>
</div>
<div class="mb-4 grid grid-cols-1 gap-4 md:grid-cols-2">
@@ -292,9 +356,20 @@
</div>
<div class="mt-4 flex flex-wrap items-center gap-3 border-t border-surface-100 pt-4">
<button class="saas-btn-primary" onclick={save} disabled={saving || !dirty}>
<button class="saas-btn-primary" onclick={save} disabled={saving || !dirty || zipImporting}>
{saving ? '保存中…' : '保存'}
</button>
<label class="saas-btn-ghost cursor-pointer {zipImporting ? 'pointer-events-none opacity-50' : ''}">
{zipImporting ? '导入中…' : '从 zip 替换'}
<input
bind:this={zipInputEl}
type="file"
accept=".zip,application/zip,application/x-zip-compressed"
class="hidden"
disabled={zipImporting || saving}
onchange={(e) => importZip(e.currentTarget.files)}
/>
</label>
{#if !skill.disabledAt}
<button class="saas-btn-danger" onclick={disable} disabled={saving}>
禁用
+3
View File
@@ -12,12 +12,15 @@ export const TOOL_OPTIONS: ToolOption[] = [
{ id: 'bash', label: 'Bash 命令', group: 'Shell' },
{ id: 'web_fetch', label: 'WebFetch', group: '网络' },
{ id: 'web_search', label: 'WebSearch', group: '网络' },
{ id: 'todo', label: '任务清单 (todo_write)', group: '规划' },
{ id: 'cph_check', label: 'cph check', group: 'CPH' },
{ id: 'cph_build', label: 'cph build', group: 'CPH' },
{ id: 'send_file', label: '发送文件(飞书)', group: '飞书' },
{ id: 'feishu_read_context', label: '读飞书上下文', group: '飞书' },
{ id: 'feishu_download_resource', label: '下载飞书资源', group: '飞书' },
{ id: 'request_approval', label: '请求审批', group: '飞书' },
{ id: 'convert_pdf_to_md', label: 'PDF→Markdown', group: '能力' },
{ id: 'pbank', label: '题库 (PBank)', group: '能力' },
];
/** 组织成员角色(接口枚举保持英文,界面用 orgRoleLabel */
+97
View File
@@ -0,0 +1,97 @@
import { unzipSync } from 'fflate';
import type { SkillFileEntry } from '$lib/api';
/** Mirror hub/src/agent/skillStore.ts limits (ADR-0018). */
const MAX_SKILL_FILES = 512;
const MAX_SKILL_BYTES = 16 * 1024 * 1024;
const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
export interface ParsedSkillZip {
readonly name: string;
readonly description: string | null;
readonly files: readonly SkillFileEntry[];
}
/**
* Parse a skill package zip. Root of the archive IS the skill directory
* (must contain SKILL.md directly — no wrapping folder).
*/
export function parseSkillZip(data: Uint8Array): ParsedSkillZip {
let entries: Record<string, Uint8Array>;
try {
entries = unzipSync(data, {
filter: (file) => !file.name.endsWith('/'),
});
} catch {
throw new Error('无法解析 zip 文件');
}
const files: SkillFileEntry[] = [];
let totalBytes = 0;
for (const [rawPath, bytes] of Object.entries(entries)) {
const path = normalizeZipPath(rawPath);
if (path === null) continue;
totalBytes += bytes.byteLength;
if (totalBytes > MAX_SKILL_BYTES) {
throw new Error(`技能总大小超过 ${MAX_SKILL_BYTES / (1024 * 1024)} MiB 上限`);
}
if (files.length >= MAX_SKILL_FILES) {
throw new Error(`技能文件数超过 ${MAX_SKILL_FILES} 上限`);
}
// Text-only alpha path — API encodes content as UTF-8 strings.
try {
files.push({ path, content: decodeUtf8Strict(bytes) });
} catch {
throw new Error(`文件不是合法 UTF-8 文本:${path}`);
}
}
if (files.length === 0) {
throw new Error('zip 为空或不含可用文件');
}
const manifest = files.find((f) => f.path === 'SKILL.md');
if (!manifest) {
throw new Error('zip 根目录必须包含 SKILL.md(根即 skill 目录,不要多包一层文件夹)');
}
const name = parseFrontmatterField(manifest.content, 'name');
if (name === null || name === '') {
throw new Error('SKILL.md frontmatter 缺少 name');
}
if (!SKILL_NAME_PATTERN.test(name)) {
throw new Error('技能名称仅允许小写字母、数字和连字符,且以字母或数字开头');
}
const description = parseFrontmatterField(manifest.content, 'description');
files.sort((a, b) => a.path.localeCompare(b.path));
return { name, description, files };
}
function normalizeZipPath(raw: string): string | null {
let path = raw.replace(/\\/g, '/');
// Drop zip noise and absolute/parent escapes before other checks.
if (path.startsWith('__MACOSX/') || path.includes('/__MACOSX/')) return null;
const base = path.split('/').pop() ?? '';
if (base === '.DS_Store' || base.startsWith('._')) return null;
if (path.startsWith('/') || path.includes('\0')) {
throw new Error(`非法文件路径:${raw}`);
}
// Strip a single leading "./"
if (path.startsWith('./')) path = path.slice(2);
const parts = path.split('/').filter((p) => p !== '' && p !== '.');
if (parts.length === 0 || parts.some((p) => p === '..')) {
throw new Error(`非法文件路径:${raw}`);
}
return parts.join('/');
}
function parseFrontmatterField(manifest: string, key: string): string | null {
const match = new RegExp(`^${key}:\\s*['"]?([^'"\\r\\n]+)['"]?\\s*$`, 'm').exec(manifest);
return match ? match[1]!.trim() : null;
}
function decodeUtf8Strict(bytes: Uint8Array): string {
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
}
+3
View File
@@ -32,3 +32,6 @@ export function toastSuccess(message: string): void {
export function toastError(message: string): void {
pushToast(message, 'error', 5000);
}
export function toastInfo(message: string): void {
pushToast(message, 'info');
}
@@ -13,9 +13,26 @@
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
type CapKind = 'docmind' | 'pbank';
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;
let connections = $state<Map<string, CapabilityConnection>>(new Map());
@@ -23,9 +40,17 @@
let error = $state<string | null>(null);
let editingCap = $state<string | null>(null);
let editingKind = $state<CapKind>('docmind');
let accessKeyId = $state('');
let accessKeySecret = $state('');
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 disabling = $state<string | null>(null);
@@ -42,11 +67,19 @@
}
}
function startEdit(capId: string) {
function startEdit(capId: string, kind: CapKind) {
editingCap = capId;
editingKind = kind;
accessKeyId = '';
accessKeySecret = '';
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() {
@@ -54,17 +87,36 @@
}
async function save(capId: string) {
if (accessKeyId.trim() === '' || accessKeySecret.trim() === '' || endpoint.trim() === '') {
toastError('AccessKey ID、AccessKey Secret、Endpoint 均为必填');
return;
}
saving = true;
try {
const result = await api.rotateCapabilityConnection(slug, capId, {
accessKeyId: accessKeyId.trim(),
accessKeySecret: accessKeySecret.trim(),
endpoint: endpoint.trim(),
});
let result: CapabilityConnection;
if (editingKind === 'docmind') {
if (accessKeyId.trim() === '' || accessKeySecret.trim() === '' || 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 = new Map(connections);
editingCap = null;
@@ -110,7 +162,7 @@
<PageHeader
title="外部能力"
description="管理文档/媒体转换服务的组织级凭据(ADR-0027)。凭据按组织隔离、版本化信封存储,缺失或校验失败即 fail-closed。"
description="管理文档/媒体转换与题库等外部服务的组织级凭据(ADR-0027)。凭据按组织隔离、版本化信封存储,缺失或校验失败即 fail-closed。Agent 永不接收能力凭据。"
/>
{#if loading}
@@ -147,7 +199,7 @@
{/if}
<button
class="saas-btn-primary text-sm"
onclick={() => startEdit(cap.id)}
onclick={() => startEdit(cap.id, cap.kind)}
disabled={editingCap === cap.id}
>
{conn ? '轮换凭据' : '配置凭据'}
@@ -174,23 +226,77 @@
{#if editingCap === cap.id}
<div class="mt-4 border-t border-surface-100 pt-4">
<p class="saas-muted mb-3 text-sm">
阿里云 RAM 用户的 AccessKey。密钥仅写入新版本,旧版本归档。
</p>
<div class="grid gap-4">
<div>
<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} />
{#if cap.kind === 'docmind'}
<p class="saas-muted mb-3 text-sm">
阿里云 RAM 用户的 AccessKey。密钥仅写入新版本,旧版本归档。
</p>
<div class="grid gap-4">
<div>
<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>
<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} />
{:else}
<p class="saas-muted mb-3 text-sm">
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>
<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>
{/if}
<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-primary" onclick={() => save(cap.id)} disabled={saving}>
@@ -88,7 +88,7 @@
projectName = '';
projectFolder = '';
showProjectModal = false;
window.location.href = `/admin/projects/${res.id}`;
window.location.href = `/admin/projects/${res.projectId}`;
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
@@ -1,4 +1,5 @@
<script lang="ts">
import { tick } from 'svelte';
import { page } from '$app/state';
import { api, type ProviderConnectionRow } from '$lib/api';
import { session } from '$lib/session';
@@ -8,7 +9,12 @@
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import { toastError, toastSuccess } from '$lib/toast';
import { toastError, toastInfo, toastSuccess } from '$lib/toast';
type FormState =
| { kind: 'new'; error?: string }
| { kind: 'rotate'; providerId: string; error?: string }
| { kind: 'saving'; intent: 'new' | 'rotate'; providerId?: string };
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
@@ -17,11 +23,23 @@
let loading = $state(true);
let error = $state<string | null>(null);
let formState = $state<FormState>({ kind: 'new' });
let providerId = $state('');
let baseUrl = $state('');
let authToken = $state('');
let anthropicApiKey = $state('');
let saving = $state(false);
const rotationId = $derived(
formState.kind === 'rotate'
? formState.providerId
: formState.kind === 'saving' && formState.intent === 'rotate'
? (formState.providerId ?? null)
: null
);
const saving = $derived(formState.kind === 'saving');
const formError = $derived(
formState.kind === 'new' || formState.kind === 'rotate' ? (formState.error ?? null) : null
);
async function load() {
loading = true;
@@ -36,48 +54,77 @@
}
}
function startRotate(row: ProviderConnectionRow) {
async function startRotate(row: ProviderConnectionRow) {
formState = { kind: 'rotate', providerId: row.providerId };
providerId = row.providerId;
baseUrl = '';
authToken = '';
anthropicApiKey = '';
await tick();
const el = document.getElementById('base-url');
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
el?.focus();
toastInfo(`已开始轮换 ${row.providerId},请填写新接口地址与访问令牌`);
}
function resetForm() {
formState = { kind: 'new' };
providerId = '';
baseUrl = '';
authToken = '';
anthropicApiKey = '';
}
function cancelOrClear() {
const wasRotating = rotationId !== null;
resetForm();
if (wasRotating) toastInfo('已取消轮换');
}
function showFormError(message: string, targetProviderId: string | null) {
formState =
targetProviderId === null
? { kind: 'new', error: message }
: { kind: 'rotate', providerId: targetProviderId, error: message };
toastError(message);
}
async function save() {
const targetProviderId = formState.kind === 'rotate' ? formState.providerId : null;
const intent = targetProviderId === null ? 'new' : 'rotate';
const id = providerId.trim();
if (id === '') {
toastError('请填写供应方 ID');
showFormError('请填写供应方 ID', targetProviderId);
return;
}
const url = baseUrl.trim();
const token = authToken.trim();
if (url === '' || token === '') {
toastError('接口地址与访问令牌均为必填');
showFormError('接口地址与访问令牌均为必填', targetProviderId);
return;
}
saving = true;
const body: { baseUrl: string; authToken: string; anthropicApiKey?: string } = {
baseUrl: url,
authToken: token,
};
const key = anthropicApiKey.trim();
if (key !== '') body.anthropicApiKey = key;
formState =
intent === 'rotate'
? { kind: 'saving', intent, providerId: id }
: { kind: 'saving', intent };
try {
await api.rotateProviderConnection(slug, id, body);
toastSuccess('凭据已轮换');
const saved = await api.rotateProviderConnection(slug, id, body);
resetForm();
toastSuccess(saved.activeVersion === 1 ? '已创建 BYOK 连接' : '凭据已轮换');
await load();
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
saving = false;
const message = err instanceof Error ? err.message : String(err);
formState =
intent === 'rotate'
? { kind: 'rotate', providerId: id, error: message }
: { kind: 'new', error: message };
toastError(message);
}
}
@@ -125,7 +172,14 @@
<td class="text-surface-600">{fmtDate(row.updatedAt)}</td>
<td>
{#if row.mode === 'BYOK'}
<button class="saas-btn-ghost px-2! py-1! text-xs" onclick={() => startRotate(row)}>轮换</button>
<button
class="saas-btn-ghost px-2! py-1! text-xs"
onclick={() => startRotate(row)}
disabled={saving}
aria-label={`开始轮换供应方 ${row.providerId}`}
>
开始轮换
</button>
{:else}
<span class="text-xs text-surface-500">平台管理</span>
{/if}
@@ -139,33 +193,78 @@
</div>
<div class="saas-card-pad">
<h3 class="saas-section-title mb-1">轮换 BYOK 凭据</h3>
<p class="saas-muted mb-4">
密钥仅写入新版本,旧版本归档;保存时需重新填写接口地址与访问令牌。平台托管连接不在此处管理。
<h3 class="saas-section-title mb-1">
{rotationId ? `轮换凭据 · ${rotationId}` : '新建 BYOK 凭据'}
</h3>
<p class="saas-muted mb-4" role="status">
{#if saving}
正在验证新凭据;验证通过后才会切换版本,请勿重复提交。
{:else if rotationId}
已选择供应方 {rotationId}。点击“开始轮换”只打开此表单;填写新接口地址和访问令牌后,点击“验证并保存”才会生效。
{:else}
填写供应方 ID、接口地址和访问令牌,保存前会先验证凭据。平台托管连接不在此处管理。
{/if}
</p>
<div class="grid gap-5">
<div>
<Label.Root class="saas-label" for="provider-id">供应方 ID</Label.Root>
<input id="provider-id" class="saas-input font-mono text-sm" bind:value={providerId} placeholder="openrouter" />
<input
id="provider-id"
class="saas-input font-mono text-sm"
bind:value={providerId}
placeholder="openrouter"
readonly={rotationId !== null}
disabled={saving}
/>
{#if rotationId}
<p class="mt-1 text-xs text-surface-500">
轮换目标已锁定为 {rotationId}。如需新建其他供应方,请先取消轮换。
</p>
{/if}
</div>
<div>
<Label.Root class="saas-label" for="base-url">接口地址</Label.Root>
<input id="base-url" class="saas-input" placeholder="https://openrouter.ai/api" bind:value={baseUrl} />
<input
id="base-url"
class="saas-input"
placeholder="https://openrouter.ai/api"
bind:value={baseUrl}
disabled={saving}
/>
</div>
<div>
<Label.Root class="saas-label" for="auth-token">访问令牌</Label.Root>
<input id="auth-token" class="saas-input" type="password" bind:value={authToken} />
<input
id="auth-token"
class="saas-input"
type="password"
bind:value={authToken}
disabled={saving}
/>
</div>
<div>
<Label.Root class="saas-label" for="anthropic-key">Anthropic API Key(可选)</Label.Root>
<input id="anthropic-key" class="saas-input" type="password" bind:value={anthropicApiKey} />
<input
id="anthropic-key"
class="saas-input"
type="password"
bind:value={anthropicApiKey}
disabled={saving}
/>
</div>
</div>
{#if formError}
<div role="alert" class="border border-error-200 bg-error-50 px-4 py-3 text-sm text-error-700">
{formError}
</div>
{/if}
<div class="mt-6 flex items-center gap-3 border-t border-surface-100 pt-4">
<div class="flex-1"></div>
<button class="saas-btn-ghost" onclick={resetForm} disabled={saving}>清空</button>
<button class="saas-btn-ghost" onclick={cancelOrClear} disabled={saving}>
{rotationId ? '取消轮换' : '清空'}
</button>
<button class="saas-btn-primary" onclick={save} disabled={saving}>
{saving ? '保存中…' : '保存'}
{saving ? '验证并保存中…' : rotationId ? '验证并保存' : '验证并创建'}
</button>
</div>
</div>
+229 -33
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type AgentRoleRow, type AgentModelRow, type AgentSkillRow } from '$lib/api';
import { api, type AgentConfigFolderRow, type AgentModelRow, type AgentRoleRow, type AgentSkillRow } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -8,6 +8,9 @@
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import RoleCard from '$lib/components/RoleCard.svelte';
import AgentConfigFolderNav from '$lib/components/AgentConfigFolderNav.svelte';
import Modal from '$lib/components/Modal.svelte';
import SelectField from '$lib/components/SelectField.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const org = $derived(resolveOrg($session.me, page.url.search));
@@ -16,20 +19,32 @@
let roles = $state<AgentRoleRow[]>([]);
let models = $state<AgentModelRow[]>([]);
let skills = $state<AgentSkillRow[]>([]);
let folders = $state<AgentConfigFolderRow[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
/** 'all' | 'unfiled' | folder id (ADR-0028: transparent grouping filter) */
let selectedFolder = $state<string>('all');
let newRoleId = $state('');
let newLabel = $state('');
let adding = $state(false);
let showFolderModal = $state(false);
let folderModalMode = $state<'create' | 'rename'>('create');
let folderModalId = $state<string | null>(null);
let folderName = $state('');
let folderParent = $state('');
let savingFolder = $state(false);
async function load() {
loading = true;
error = null;
try {
const [r, s] = await Promise.all([api.agentRoles(slug), api.agentSkills(slug)]);
const [r, s, f] = await Promise.all([api.agentRoles(slug), api.agentSkills(slug), api.agentConfigFolders(slug)]);
roles = r.roles;
skills = s.skills;
folders = f.folders;
// Model fetch hits the provider API and may fail or be slow; load it
// independently so roles remain editable even without a model list.
models = [];
@@ -43,6 +58,66 @@
}
}
const counts = $derived.by(() => {
const map: Record<string, number> = {};
for (const r of roles) {
if (r.folderId) map[r.folderId] = (map[r.folderId] ?? 0) + 1;
}
return map;
});
const unfiledCount = $derived(roles.filter((r) => !r.folderId).length);
const visibleRoles = $derived(
selectedFolder === 'all'
? roles
: selectedFolder === 'unfiled'
? roles.filter((r) => !r.folderId)
: roles.filter((r) => r.folderId === selectedFolder),
);
function folderPath(f: AgentConfigFolderRow): string {
const parts: string[] = [f.name];
let cur: AgentConfigFolderRow | undefined = f;
while (cur?.parentId) {
const parent = folders.find((x) => x.id === cur!.parentId);
if (!parent) break;
parts.unshift(parent.name);
cur = parent;
}
return parts.join(' / ');
}
/** Self + descendant ids of a folder — excluded as move targets in the rename modal. */
function subtreeIds(folderId: string): Set<string> {
const ids = new Set<string>([folderId]);
let grew = true;
while (grew) {
grew = false;
for (const f of folders) {
if (f.parentId && ids.has(f.parentId) && !ids.has(f.id)) {
ids.add(f.id);
grew = true;
}
}
}
return ids;
}
const folderItems = $derived([
{ value: '', label: '(未分类)' },
...folders.map((f) => ({ value: f.id, label: folderPath(f) })),
]);
const moveTargetItems = $derived.by(() => {
if (folderModalMode !== 'rename' || folderModalId === null) {
return [{ value: '', label: '(根)' }, ...folders.map((f) => ({ value: f.id, label: folderPath(f) }))];
}
const excluded = subtreeIds(folderModalId);
return [
{ value: '', label: '(根)' },
...folders.filter((f) => !excluded.has(f.id)).map((f) => ({ value: f.id, label: folderPath(f) })),
];
});
async function add() {
const roleId = newRoleId.trim();
const label = newLabel.trim();
@@ -57,7 +132,12 @@
adding = true;
try {
const created = await api.upsertAgentRole(slug, roleId, { label });
roles = [...roles, created];
let folderId: string | null = null;
if (selectedFolder !== 'all' && selectedFolder !== 'unfiled') {
await api.setAgentRoleFolder(slug, roleId, selectedFolder);
folderId = selectedFolder;
}
roles = [...roles, { ...created, folderId }];
newRoleId = '';
newLabel = '';
toastSuccess('角色已创建');
@@ -79,6 +159,68 @@
roles = roles.map((x) => (x.roleId === roleId ? { ...x, skillNames } : x));
}
function onRoleFolderChanged(roleId: string, folderId: string | null) {
roles = roles.map((x) => (x.roleId === roleId ? { ...x, folderId } : x));
}
function openCreateFolder(parentId: string | null) {
folderModalMode = 'create';
folderModalId = null;
folderName = '';
folderParent = parentId ?? '';
showFolderModal = true;
}
function openRenameFolder(folder: AgentConfigFolderRow) {
folderModalMode = 'rename';
folderModalId = folder.id;
folderName = folder.name;
folderParent = folder.parentId ?? '';
showFolderModal = true;
}
async function submitFolderModal() {
const name = folderName.trim();
if (name === '') {
toastError('文件夹名称不能为空');
return;
}
savingFolder = true;
try {
if (folderModalMode === 'create') {
await api.createAgentConfigFolder(slug, {
name,
...(folderParent !== '' ? { parentId: folderParent } : {}),
});
toastSuccess('文件夹已创建');
} else if (folderModalId !== null) {
await api.patchAgentConfigFolder(slug, folderModalId, {
name,
parentId: folderParent === '' ? null : folderParent,
});
toastSuccess('文件夹已更新');
}
showFolderModal = false;
await load();
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
savingFolder = false;
}
}
async function deleteFolder(folder: AgentConfigFolderRow) {
if (!confirm(`删除文件夹「${folder.name}」? 仅空文件夹可删除。`)) return;
try {
await api.deleteAgentConfigFolder(slug, folder.id);
if (selectedFolder === folder.id) selectedFolder = 'all';
toastSuccess('文件夹已删除');
await load();
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
$effect(() => {
if (slug) load();
});
@@ -86,7 +228,7 @@
<PageHeader
title="角色"
description="角色是组织级数据:组合默认模型、系统提示词、工具白名单与已绑定技能。角色 ID 即飞书斜杠命令(如 /draft)。"
description="角色是组织级数据:组合默认模型、系统提示词、工具白名单与已绑定技能。文件夹仅作管理分组,不影响角色解析与默认角色约束。"
/>
{#if loading}
@@ -94,39 +236,93 @@
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else}
<div class="saas-card-pad mb-6">
<h2 class="saas-section-title mb-4">新建角色</h2>
<div class="grid gap-3 sm:grid-cols-[10rem_1fr_auto]">
<input
class="saas-input font-mono text-sm"
placeholder="角色 ID(如 draft"
bind:value={newRoleId}
onkeydown={(e) => {
if (e.key === 'Enter') add();
}}
<div class="grid gap-4 lg:grid-cols-[15rem_1fr]">
<div class="h-fit lg:sticky lg:top-4">
<AgentConfigFolderNav
{folders}
selected={selectedFolder}
{counts}
totalCount={roles.length}
{unfiledCount}
onselect={(id) => (selectedFolder = id)}
oncreate={openCreateFolder}
onrename={openRenameFolder}
ondelete={deleteFolder}
/>
<input
class="saas-input"
placeholder="显示名(如 草稿)"
bind:value={newLabel}
onkeydown={(e) => {
if (e.key === 'Enter') add();
}}
/>
<button class="saas-btn-primary" onclick={add} disabled={adding}>新建</button>
</div>
<p class="mt-2 text-xs text-surface-600">角色 ID 仅允许小写字母、数字、下划线与连字符,且以字母或数字开头。</p>
<div>
<div class="saas-card-pad mb-6">
<h2 class="saas-section-title mb-4">新建角色</h2>
<div class="grid gap-3 sm:grid-cols-[10rem_1fr_auto]">
<input
class="saas-input font-mono text-sm"
placeholder="角色 ID(如 draft"
bind:value={newRoleId}
onkeydown={(e) => {
if (e.key === 'Enter') add();
}}
/>
<input
class="saas-input"
placeholder="显示名(如 草稿)"
bind:value={newLabel}
onkeydown={(e) => {
if (e.key === 'Enter') add();
}}
/>
<button class="saas-btn-primary" onclick={add} disabled={adding}>新建</button>
</div>
<p class="mt-2 text-xs text-surface-600">角色 ID 仅允许小写字母、数字、下划线与连字符,且以字母或数字开头;当前选中文件夹时新角色会自动归入其中。</p>
</div>
{#if roles.length === 0}
<div class="saas-card">
<EmptyState title="暂无角色" description="组织必须且只能有一个启用中的默认角色;新建第一个角色将自动成为默认。" />
</div>
{:else if visibleRoles.length === 0}
<div class="saas-card">
<EmptyState title="此分类下暂无角色" description="在角色卡片上可将其移入当前文件夹。" />
</div>
{:else}
<div class="space-y-4">
{#each visibleRoles as r (r.roleId)}
<RoleCard
{r}
{models}
{skills}
{slug}
{folders}
{folderItems}
onupdated={onRoleUpdated}
onskillschanged={onRoleSkillsChanged}
onfolderchanged={onRoleFolderChanged}
/>
{/each}
</div>
{/if}
</div>
</div>
{#if roles.length === 0}
<div class="saas-card">
<EmptyState title="暂无角色" description="组织必须且只能有一个启用中的默认角色;新建第一个角色将自动成为默认。" />
<Modal bind:open={showFolderModal} title={folderModalMode === 'create' ? '新建文件夹' : '重命名 / 移动文件夹'}>
<label class="saas-label" for="agent-folder-name">名称</label>
<input
id="agent-folder-name"
class="saas-input mb-4"
bind:value={folderName}
onkeydown={(e) => {
if (e.key === 'Enter') submitFolderModal();
}}
/>
<p class="saas-label">父文件夹</p>
<div class="mb-4">
<SelectField items={moveTargetItems} bind:value={folderParent} />
</div>
{:else}
<div class="space-y-4">
{#each roles as r (r.roleId)}
<RoleCard {r} {models} {skills} {slug} onupdated={onRoleUpdated} onskillschanged={onRoleSkillsChanged} />
{/each}
<div class="flex justify-end gap-2">
<button class="saas-btn-ghost" onclick={() => (showFolderModal = false)}>取消</button>
<button class="saas-btn-primary" onclick={submitFolderModal} disabled={savingFolder}>
{savingFolder ? '保存中…' : '保存'}
</button>
</div>
{/if}
</Modal>
{/if}
@@ -1,34 +1,55 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type AgentSkillRow, type SkillFileEntry } from '$lib/api';
import { api, type AgentConfigFolderRow, type AgentSkillRow, type SkillFileEntry } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import { parseSkillZip } from '$lib/skillZip';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import SkillEditor from '$lib/components/SkillEditor.svelte';
import AgentConfigFolderNav from '$lib/components/AgentConfigFolderNav.svelte';
import Modal from '$lib/components/Modal.svelte';
import SelectField from '$lib/components/SelectField.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
let skills = $state<AgentSkillRow[]>([]);
let folders = $state<AgentConfigFolderRow[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
/** 'all' | 'unfiled' | folder id (ADR-0028: transparent grouping filter) */
let selectedFolder = $state<string>('all');
let showNewSkill = $state(false);
let newSkillName = $state('');
let newSkillVersion = $state('0.1.0');
let newSkillDescription = $state('');
let creating = $state(false);
let showZipUpload = $state(false);
let zipVersion = $state('0.1.0');
let zipUploading = $state(false);
let zipInputEl = $state<HTMLInputElement | null>(null);
let showFolderModal = $state(false);
let folderModalMode = $state<'create' | 'rename'>('create');
let folderModalId = $state<string | null>(null);
let folderName = $state('');
let folderParent = $state('');
let savingFolder = $state(false);
async function load() {
loading = true;
error = null;
try {
const res = await api.agentSkills(slug);
skills = res.skills;
const [s, f] = await Promise.all([api.agentSkills(slug), api.agentConfigFolders(slug)]);
skills = s.skills;
folders = f.folders;
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
@@ -36,6 +57,66 @@
}
}
const counts = $derived.by(() => {
const map: Record<string, number> = {};
for (const s of skills) {
if (s.folderId) map[s.folderId] = (map[s.folderId] ?? 0) + 1;
}
return map;
});
const unfiledCount = $derived(skills.filter((s) => !s.folderId).length);
const visibleSkills = $derived(
selectedFolder === 'all'
? skills
: selectedFolder === 'unfiled'
? skills.filter((s) => !s.folderId)
: skills.filter((s) => s.folderId === selectedFolder),
);
function folderPath(f: AgentConfigFolderRow): string {
const parts: string[] = [f.name];
let cur: AgentConfigFolderRow | undefined = f;
while (cur?.parentId) {
const parent = folders.find((x) => x.id === cur!.parentId);
if (!parent) break;
parts.unshift(parent.name);
cur = parent;
}
return parts.join(' / ');
}
/** Self + descendant ids of a folder — excluded as move targets in the rename modal. */
function subtreeIds(folderId: string): Set<string> {
const ids = new Set<string>([folderId]);
let grew = true;
while (grew) {
grew = false;
for (const f of folders) {
if (f.parentId && ids.has(f.parentId) && !ids.has(f.id)) {
ids.add(f.id);
grew = true;
}
}
}
return ids;
}
const folderItems = $derived([
{ value: '', label: '(未分类)' },
...folders.map((f) => ({ value: f.id, label: folderPath(f) })),
]);
const moveTargetItems = $derived.by(() => {
if (folderModalMode !== 'rename' || folderModalId === null) {
return [{ value: '', label: '(根)' }, ...folders.map((f) => ({ value: f.id, label: folderPath(f) }))];
}
const excluded = subtreeIds(folderModalId);
return [
{ value: '', label: '(根)' },
...folders.filter((f) => !excluded.has(f.id)).map((f) => ({ value: f.id, label: folderPath(f) })),
];
});
async function createSkill() {
const name = newSkillName.trim();
if (name === '') {
@@ -56,6 +137,9 @@
const manifest = buildManifest(name, newSkillDescription.trim());
const files: SkillFileEntry[] = [{ path: 'SKILL.md', content: manifest }];
const result = await api.installAgentSkill(slug, name, { version, files });
if (selectedFolder !== 'all' && selectedFolder !== 'unfiled') {
await api.setAgentSkillFolder(slug, result.name, selectedFolder);
}
toastSuccess(`技能 ${result.name} 已创建`);
newSkillName = '';
newSkillDescription = '';
@@ -73,6 +157,37 @@
return `---\nname: ${name}\ndescription: ${desc}\n---\n# ${name}\n\n`;
}
async function uploadZip(fileList: FileList | null) {
const file = fileList?.[0];
if (!file) return;
const version = zipVersion.trim();
if (version === '') {
toastError('版本号不能为空');
if (zipInputEl) zipInputEl.value = '';
return;
}
zipUploading = true;
try {
const buf = new Uint8Array(await file.arrayBuffer());
const parsed = parseSkillZip(buf);
const result = await api.installAgentSkill(slug, parsed.name, {
version,
files: parsed.files,
});
if (selectedFolder !== 'all' && selectedFolder !== 'unfiled') {
await api.setAgentSkillFolder(slug, result.name, selectedFolder);
}
toastSuccess(`技能 ${result.name} 已从 zip 安装(${parsed.files.length} 个文件)`);
showZipUpload = false;
await load();
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
zipUploading = false;
if (zipInputEl) zipInputEl.value = '';
}
}
function onInstalled(_result: { id: string; name: string; contentDigest: string }) {
load();
}
@@ -81,6 +196,68 @@
load();
}
function onSkillFolderChanged(name: string, folderId: string | null) {
skills = skills.map((s) => (s.name === name ? { ...s, folderId } : s));
}
function openCreateFolder(parentId: string | null) {
folderModalMode = 'create';
folderModalId = null;
folderName = '';
folderParent = parentId ?? '';
showFolderModal = true;
}
function openRenameFolder(folder: AgentConfigFolderRow) {
folderModalMode = 'rename';
folderModalId = folder.id;
folderName = folder.name;
folderParent = folder.parentId ?? '';
showFolderModal = true;
}
async function submitFolderModal() {
const name = folderName.trim();
if (name === '') {
toastError('文件夹名称不能为空');
return;
}
savingFolder = true;
try {
if (folderModalMode === 'create') {
await api.createAgentConfigFolder(slug, {
name,
...(folderParent !== '' ? { parentId: folderParent } : {}),
});
toastSuccess('文件夹已创建');
} else if (folderModalId !== null) {
await api.patchAgentConfigFolder(slug, folderModalId, {
name,
parentId: folderParent === '' ? null : folderParent,
});
toastSuccess('文件夹已更新');
}
showFolderModal = false;
await load();
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
savingFolder = false;
}
}
async function deleteFolder(folder: AgentConfigFolderRow) {
if (!confirm(`删除文件夹「${folder.name}」? 仅空文件夹可删除。`)) return;
try {
await api.deleteAgentConfigFolder(slug, folder.id);
if (selectedFolder === folder.id) selectedFolder = 'all';
toastSuccess('文件夹已删除');
await load();
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
$effect(() => {
if (slug) load();
});
@@ -88,7 +265,7 @@
<PageHeader
title="技能"
description="技能是组织级 Agent 能力包:一个包含 SKILL.md manifest 的目录。技能内容按 SHA-256 content-addressed 存储,变更后绑定角色的活跃会话自动归档。"
description="技能是组织级 Agent 能力包:一个包含 SKILL.md manifest 的目录。文件夹仅作管理分组,不影响技能解析与绑定。"
/>
{#if loading}
@@ -96,49 +273,136 @@
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else}
<div class="saas-card-pad mb-6">
<div class="flex items-center justify-between">
<h2 class="saas-section-title">新建技能</h2>
<button class="text-sm text-primary-700 hover:text-primary-900" onclick={() => (showNewSkill = !showNewSkill)}>
{showNewSkill ? '取消' : '+ 新建'}
</button>
<div class="grid gap-4 lg:grid-cols-[15rem_1fr]">
<div class="h-fit lg:sticky lg:top-4">
<AgentConfigFolderNav
{folders}
selected={selectedFolder}
{counts}
totalCount={skills.length}
{unfiledCount}
onselect={(id) => (selectedFolder = id)}
oncreate={openCreateFolder}
onrename={openRenameFolder}
ondelete={deleteFolder}
/>
</div>
{#if showNewSkill}
<div class="mt-4 grid gap-3 sm:grid-cols-[12rem_8rem_1fr_auto]">
<input
class="saas-input font-mono text-sm"
placeholder="技能名(如 typst-help"
bind:value={newSkillName}
/>
<input
class="saas-input text-sm"
placeholder="版本号"
bind:value={newSkillVersion}
/>
<input
class="saas-input text-sm"
placeholder="描述"
bind:value={newSkillDescription}
/>
<button class="saas-btn-primary" onclick={createSkill} disabled={creating}>
{creating ? '创建中…' : '创建'}
</button>
<div>
<div class="saas-card-pad mb-6 space-y-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="saas-section-title">新建技能</h2>
<div class="flex flex-wrap gap-3">
<button
type="button"
class="text-sm text-primary-700 hover:text-primary-900"
onclick={() => {
showZipUpload = !showZipUpload;
if (showZipUpload) showNewSkill = false;
}}
>
{showZipUpload ? '取消上传' : '上传 zip'}
</button>
<button
type="button"
class="text-sm text-primary-700 hover:text-primary-900"
onclick={() => {
showNewSkill = !showNewSkill;
if (showNewSkill) showZipUpload = false;
}}
>
{showNewSkill ? '取消' : '+ 空白模板'}
</button>
</div>
</div>
{#if showZipUpload}
<div class="grid gap-3 sm:grid-cols-[8rem_1fr_auto]">
<input class="saas-input text-sm" placeholder="版本号" bind:value={zipVersion} disabled={zipUploading} />
<input
bind:this={zipInputEl}
class="saas-input text-sm file:mr-3 file:border-0 file:bg-transparent file:text-sm file:font-medium"
type="file"
accept=".zip,application/zip,application/x-zip-compressed"
disabled={zipUploading}
onchange={(e) => uploadZip(e.currentTarget.files)}
/>
<span class="self-center text-xs text-surface-600">{zipUploading ? '安装中…' : '选择 .zip'}</span>
</div>
<p class="text-xs text-surface-600">
zip 根目录即为 skill(须直接含 SKILL.md)。名称取自 manifest;仅 UTF-8 文本;当前选中管理文件夹时会自动归入。覆盖同 name 会更新内容(可能使绑定角色会话不可安全恢复)。
</p>
{/if}
{#if showNewSkill}
<div class="grid gap-3 sm:grid-cols-[12rem_8rem_1fr_auto]">
<input
class="saas-input font-mono text-sm"
placeholder="技能名(如 typst-help"
bind:value={newSkillName}
/>
<input
class="saas-input text-sm"
placeholder="版本号"
bind:value={newSkillVersion}
/>
<input
class="saas-input text-sm"
placeholder="描述"
bind:value={newSkillDescription}
/>
<button class="saas-btn-primary" onclick={createSkill} disabled={creating}>
{creating ? '创建中…' : '创建'}
</button>
</div>
<p class="text-xs text-surface-600">
技能名称仅允许小写字母、数字和连字符,且以字母或数字开头。创建后会生成 SKILL.md 模板;当前选中文件夹时新技能会自动归入其中。
</p>
{/if}
</div>
<p class="mt-2 text-xs text-surface-600">
技能名称仅允许小写字母、数字和连字符,且以字母或数字开头。创建后会生成 SKILL.md 模板。
</p>
{/if}
{#if skills.length === 0}
<div class="saas-card">
<EmptyState title="暂无技能" description="新建一个技能,然后在角色管理中绑定到角色。" />
</div>
{:else if visibleSkills.length === 0}
<div class="saas-card">
<EmptyState title="此分类下暂无技能" description="在技能卡片上可将其移入当前文件夹。" />
</div>
{:else}
<div class="space-y-4">
{#each visibleSkills as skill (skill.id)}
<SkillEditor
{slug}
{skill}
{folderItems}
oninstalled={onInstalled}
ondisabled={onDisabled}
onfolderchanged={onSkillFolderChanged}
/>
{/each}
</div>
{/if}
</div>
</div>
{#if skills.length === 0}
<div class="saas-card">
<EmptyState title="暂无技能" description="新建一个技能,然后在角色管理中绑定到角色。" />
<Modal bind:open={showFolderModal} title={folderModalMode === 'create' ? '新建文件夹' : '重命名 / 移动文件夹'}>
<label class="saas-label" for="agent-folder-name">名称</label>
<input
id="agent-folder-name"
class="saas-input mb-4"
bind:value={folderName}
onkeydown={(e) => {
if (e.key === 'Enter') submitFolderModal();
}}
/>
<p class="saas-label">父文件夹</p>
<div class="mb-4">
<SelectField items={moveTargetItems} bind:value={folderParent} />
</div>
{:else}
<div class="space-y-4">
{#each skills as skill (skill.id)}
<SkillEditor {slug} {skill} oninstalled={onInstalled} ondisabled={onDisabled} />
{/each}
<div class="flex justify-end gap-2">
<button class="saas-btn-ghost" onclick={() => (showFolderModal = false)}>取消</button>
<button class="saas-btn-primary" onclick={submitFolderModal} disabled={savingFolder}>
{savingFolder ? '保存中…' : '保存'}
</button>
</div>
{/if}
</Modal>
{/if}
+24 -6
View File
@@ -406,7 +406,8 @@
resize: vertical;
}
.saas-select-trigger {
.saas-select-trigger,
.saas-combobox-input {
display: inline-flex;
width: 100%;
align-items: center;
@@ -425,14 +426,25 @@
text-align: left;
}
.saas-combobox-input {
cursor: text;
padding-right: 2.25rem;
}
.saas-combobox-input::placeholder {
color: var(--color-surface-500);
}
.saas-select-trigger:focus-visible,
.saas-select-trigger[data-state='open'] {
.saas-select-trigger[data-state='open'],
.saas-combobox-input:focus {
border-color: var(--color-primary-600);
box-shadow: inset 0 0 0 1px var(--color-primary-600);
}
.saas-select-trigger:disabled,
.saas-select-trigger[data-disabled] {
.saas-select-trigger[data-disabled],
.saas-combobox-input:disabled {
cursor: not-allowed;
opacity: 0.55;
}
@@ -443,9 +455,15 @@
.saas-select-content {
z-index: 70;
max-height: min(18rem, var(--bits-select-content-available-height, 18rem));
width: var(--bits-select-anchor-width);
min-width: var(--bits-select-anchor-width);
max-height: min(
18rem,
var(
--bits-combobox-content-available-height,
var(--bits-select-content-available-height, 18rem)
)
);
width: var(--bits-combobox-anchor-width, var(--bits-select-anchor-width));
min-width: var(--bits-combobox-anchor-width, var(--bits-select-anchor-width));
overflow: hidden;
border-radius: 0;
border: 1px solid var(--color-surface-400);
+7 -4
View File
@@ -86,7 +86,7 @@ REMOTE
-e "ssh ${SSH_OPTS[*]}" \
"$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/"
echo "[fleet] npm ci + build (tsc + admin-web & filelib-web SPAs)"
echo "[fleet] npm ci (including build-time dev deps) + build (tsc + admin-web & filelib-web SPAs)"
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" bash -s <<REMOTE
set -euo pipefail
flock /var/lock/cph-hub-release-publish bash -c '
@@ -96,9 +96,12 @@ flock /var/lock/cph-hub-release-publish bash -c '
exit 0
fi
cd "$HUB_DIR"
PUPPETEER_SKIP_DOWNLOAD=1 npm ci
npm ci --prefix admin-web
npm ci --prefix filelib-web
PUPPETEER_SKIP_DOWNLOAD=1 npm ci --include=dev
npm ci --include=dev --prefix admin-web
npm ci --include=dev --prefix filelib-web
test -x node_modules/.bin/tsc
test -x admin-web/node_modules/.bin/vite
test -x filelib-web/node_modules/.bin/vite
npm run audit:production
npm run build
test -f admin-web/build/index.html
+4 -2
View File
@@ -60,11 +60,13 @@ if [ "$release_ready" = false ]; then
-e "ssh ${SSH_OPTS[*]}" \
"$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/"
# 2. Install deps (hub + both SPAs), audit hub prod, build tsc + SPAs, mark complete.
# 2. Install deps (including build-time dev deps) for hub + both SPAs, audit
# hub prod, build tsc + SPAs, mark complete. NODE_ENV=production may be
# inherited by the remote shell, so --include=dev is intentional here.
# `npm run build` → tsc then admin:build + filelib:build → admin-web/build and
# filelib-web/build for registerStaticSpa / registerDatabaseSpa.
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" \
"cd '$HUB_DIR' && PUPPETEER_SKIP_DOWNLOAD=1 npm ci && npm ci --prefix admin-web && npm ci --prefix filelib-web && npm run audit:production && npm run build && touch '$RELEASE_DIR/.complete'"
"cd '$HUB_DIR' && PUPPETEER_SKIP_DOWNLOAD=1 npm ci --include=dev && npm ci --include=dev --prefix admin-web && npm ci --include=dev --prefix filelib-web && npm run audit:production && npm run build && touch '$RELEASE_DIR/.complete'"
fi
# 3. Ensure the service is installed (idempotent), then restart.
+3 -1
View File
@@ -164,17 +164,19 @@ DATABASE_URL=
HUB_SILO_ORGANIZATION_ID=
HUB_SYSTEMD_UNIT=$SERVICE_UNIT
CPH_BIN=$CPH_BIN_DEFAULT
HUB_FEISHU_CLI_BIN=/usr/local/bin/lark-cli
HOST=$HOST
PORT=$PORT
HUB_PROJECT_WORKSPACE_ROOT=$WORKSPACE_ROOT
HUB_PUBLIC_BASE_URL=
HUB_SESSION_SECRET=
HUB_AGENT_MAX_TURNS=25
HUB_AGENT_MAX_TURNS=150
HUB_AGENT_MAX_CONCURRENT_RUNS=
HUB_AGENT_MAX_RUN_SECONDS=
HUB_HTTP_BODY_LIMIT_BYTES=
HUB_MAX_FILES_PER_MESSAGE=
HUB_MAX_FILE_BYTES=
HUB_PDF_TO_MD_MAX_CONCURRENT=3
HUB_HTTP_REQUESTS_PER_MINUTE=
HUB_FEISHU_EVENTS_PER_MINUTE=
HUB_FEISHU_LISTENER_ENABLED=true
+3 -3
View File
@@ -367,11 +367,11 @@ seed_default PROVIDER_BASE_URL "https://openrouter.ai/api"
seed_default DEFAULT_MODEL "anthropic/claude-sonnet-5"
seed_default DEFAULT_ROLE_ID "draft"
seed_default DEFAULT_ROLE_LABEL "智能助手"
seed_default MAX_TURNS "25"
seed_default MAX_TURNS "150"
seed_default MAX_CONCURRENT_RUNS "4"
seed_default MAX_RUN_SECONDS "900"
seed_default MAX_RUN_SECONDS "1800"
seed_default HTTP_BODY_LIMIT_BYTES "1048576"
seed_default MAX_FILES_PER_MESSAGE "8"
seed_default MAX_FILES_PER_MESSAGE "20"
seed_default MAX_FILE_BYTES "26214400"
seed_default HTTP_REQUESTS_PER_MINUTE "120"
seed_default FEISHU_EVENTS_PER_MINUTE "120"
+95 -71
View File
@@ -1,12 +1,12 @@
{
"name": "@paradigm/hub",
"version": "0.0.36",
"version": "0.0.42",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@paradigm/hub",
"version": "0.0.36",
"version": "0.0.42",
"dependencies": {
"@alicloud/credentials": "^2.4.5",
"@alicloud/docmind-api20220711": "^1.4.15",
@@ -248,22 +248,22 @@
}
},
"node_modules/@anthropic-ai/claude-agent-sdk": {
"version": "0.3.202",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.202.tgz",
"integrity": "sha512-LnaLxDtsZP7J6g++xRSnnpTX7CHNe4v+cvBRIlD2ar+N+xi0aqY2YDaCsxPsl+haVUB9kqlUMd0zosmwsfTGjQ==",
"version": "0.3.217",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.217.tgz",
"integrity": "sha512-juszT3itL8R6OQ6nb/8IZE34UjKps8Jf7N8vjCXLx+vbJc+k3EojZOs93tJwT5iTRvfV1a0N53zJbKn/iJpKrQ==",
"license": "SEE LICENSE IN README.md",
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.202",
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.202",
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.202",
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.202",
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.202",
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.202",
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.202",
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.202"
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.217",
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.217",
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.217",
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.217",
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.217",
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.217",
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.217",
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.217"
},
"peerDependencies": {
"@anthropic-ai/sdk": ">=0.93.0",
@@ -272,9 +272,9 @@
}
},
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
"version": "0.3.202",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.202.tgz",
"integrity": "sha512-ujR3zDthDPkZs+AxW95iHpqLT5cuwGImsS3mVxLt1DlDij4qeTnihLX8+EpQTK+oNW9jjvFA86yKwa84fa1KYA==",
"version": "0.3.217",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.217.tgz",
"integrity": "sha512-dl119zmL1Ssyd8Fx0xfVMpss2scrGCZwf+rhZwl2lHa2dYuXVluLgqi4DUIWDj3rRYdrAvaMpjCAv6a5w07ddw==",
"cpu": [
"arm64"
],
@@ -285,9 +285,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
"version": "0.3.202",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.202.tgz",
"integrity": "sha512-s/RVSGgkVmIMfyt1ndR8braLLu82bARoijmt1kk8d4IptUZ0Sc+zNUWKoFXwR9XqDBu6rBbBF9RIzD02raT57w==",
"version": "0.3.217",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.217.tgz",
"integrity": "sha512-IeKL1HN8fEcRQ4uw5d02by1ThpjhRtOgfHcCTBQ2KS4JfEIHvc1VGWt6Exb2a7VHhT8uRcfjPk9urbmYayZmaw==",
"cpu": [
"x64"
],
@@ -298,12 +298,15 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
"version": "0.3.202",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.202.tgz",
"integrity": "sha512-a4YtRkgGYt3ogePJDW8Ts6bNW690jb9LHyZaiWXsi+zT53xCNqJB2zKPyRc7hXWOqzIk4nCfwJpjmhLzMu3WIg==",
"version": "0.3.217",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.217.tgz",
"integrity": "sha512-KtrnfEwUSCdq2cc4Pgysl+U66vqw3h7u04N5/OLHmYZ4AZYy8JcqdOaSJZ27iL2bgbAxyKwu5/9YmEk9A4IswA==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
@@ -311,12 +314,15 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
"version": "0.3.202",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.202.tgz",
"integrity": "sha512-abSb3Gah45kUNyOeKjmQ/dd1KZ4CaQz5JAr9YQxRDXoOwx8wJVx6huBIpDxjms9wyS9X5Rqxn0Lx7zFP+wV2zQ==",
"version": "0.3.217",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.217.tgz",
"integrity": "sha512-Bb4AJxqrVPouM4sYIdvX3/AO5womhe70u3Euv+6B5J2OoqcRaWarVvYevX3KRruC5TvlV2Josw14dsL5qVNL+A==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
@@ -324,12 +330,15 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
"version": "0.3.202",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.202.tgz",
"integrity": "sha512-XIvhdCWAAT4OdOA82fOJII+WH0Tf8pFckckEbJMMmOgQBKOnHT+609Pd3Ehw6zGcA9iFrhG5mY8Ncuckeo1aMw==",
"version": "0.3.217",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.217.tgz",
"integrity": "sha512-JsAQyfl4n0PR4LX0h1SxMo0raERGb8B8dvbaoNQRRSpb9A2vvcwPEjyKu0eRKHRhTvspvuD6TfNxzxrmnouX9A==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
@@ -337,12 +346,15 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
"version": "0.3.202",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.202.tgz",
"integrity": "sha512-fze5nAQL1ErcMCQNB10ILaWdM0QbJSaTQzBz8NVAy0FGW8ZL0t4Wf/VgFkfzXbfkaxmPuM1C27Dn5HiU7UDEHQ==",
"version": "0.3.217",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.217.tgz",
"integrity": "sha512-qhugNZd77vAoPMIGM8vFHlbwTltFyI1POmfyl0ZJSpc6v7RE9+5+nqL2aGbGSDsDQkEHrJasXURxIeTMn9ut2w==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "SEE LICENSE IN LICENSE.md",
"optional": true,
"os": [
@@ -350,9 +362,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
"version": "0.3.202",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.202.tgz",
"integrity": "sha512-N1J0HRvC+8a69bqNY7+ENIYQzR0i7s+rOIGH5XtuLxvLqOnZO8LHxWEZOe8ezabGq5eZqphSCgL6vQnQQpNh+A==",
"version": "0.3.217",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.217.tgz",
"integrity": "sha512-LuaQ+PXZvIToAR81JoiGa6Me9HDma2WH2oiYlAWh43IWaXHyOqgaI1aqSM0BjDhy2UiYWTvGzAopnqPnk+jSBw==",
"cpu": [
"arm64"
],
@@ -363,9 +375,9 @@
]
},
"node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
"version": "0.3.202",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.202.tgz",
"integrity": "sha512-ytLGEC1fjTSiVSoXukS+j9G+06Mi20NSzxxzlG6uE75SEB0+17tHdWUaHqd8PhH/6GPzcYx81czxWQl1MVbq4Q==",
"version": "0.3.217",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.217.tgz",
"integrity": "sha512-4r/T+ze/S/CLZ58tP4Mw52XPmsc/LOrCOd8jZOqM13FCPWdCMU2osWmszEIKGVMRG2cGsaLVDYcks5cWFqjCjw==",
"cpu": [
"x64"
],
@@ -426,22 +438,34 @@
}
},
"node_modules/@emnapi/core": {
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz",
"integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==",
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz",
"integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.3",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/core/node_modules/@emnapi/wasi-threads": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
"integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
"integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -1128,13 +1152,13 @@
"license": "MIT"
},
"node_modules/@hono/node-server": {
"version": "1.19.14",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.0.tgz",
"integrity": "sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.14.1"
"node": ">=20"
},
"peerDependencies": {
"hono": "^4"
@@ -1172,13 +1196,13 @@
}
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.29.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
"integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==",
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz",
"integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@hono/node-server": "^1.19.9",
"@hono/node-server": "^1.19.9 || ^2.0.5",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"content-type": "^1.0.5",
@@ -2807,9 +2831,9 @@
"peer": true
},
"node_modules/fast-uri": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
"integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"funding": [
{
"type": "github",
@@ -2921,9 +2945,9 @@
}
},
"node_modules/find-my-way": {
"version": "9.6.0",
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz",
"integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==",
"version": "9.7.0",
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.7.0.tgz",
"integrity": "sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@@ -3138,9 +3162,9 @@
}
},
"node_modules/hono": {
"version": "4.12.28",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.28.tgz",
"integrity": "sha512-YwUvVpSF7m1yOblFPrU3Hbo8XhPheBoiyfGuII6z19LnOr6JpDnyyp7LFNrfV56wS8tpvtBFGRISHN02pDdLOA==",
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.0.tgz",
"integrity": "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==",
"license": "MIT",
"peer": true,
"engines": {
@@ -3243,9 +3267,9 @@
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz",
"integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==",
"license": "MIT",
"engines": {
"node": ">= 12"
@@ -3819,9 +3843,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
"version": "3.3.17",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz",
"integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==",
"dev": true,
"funding": [
{
@@ -4095,9 +4119,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.16",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
"version": "8.5.25",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
"integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
"dev": true,
"funding": [
{
@@ -4115,7 +4139,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"nanoid": "^3.3.16",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@paradigm/hub",
"version": "0.0.36",
"version": "0.0.42",
"private": true,
"type": "module",
"engines": {
@@ -0,0 +1,33 @@
-- ADR-0028 agent configuration folder tree. One org-scoped transparent folder
-- tree shared by Agent roles and skills (management-surface grouping only);
-- skill name / roleId uniqueness, role→skill bindings, run-time skill loading
-- and Feishu slash commands never reference folders. A folder deletes only
-- when empty (service-enforced); item `folderId` references are SetNull as
-- backstop.
-- CreateTable
CREATE TABLE "OrganizationAgentConfigFolder" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"parentId" TEXT,
"name" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OrganizationAgentConfigFolder_pkey" PRIMARY KEY ("id")
);
-- AlterTable
ALTER TABLE "OrganizationAgentSkill" ADD COLUMN "folderId" TEXT;
ALTER TABLE "OrganizationAgentRole" ADD COLUMN "folderId" TEXT;
-- CreateIndex
CREATE INDEX "OrganizationAgentConfigFolder_organizationId_parentId_idx" ON "OrganizationAgentConfigFolder"("organizationId", "parentId");
CREATE INDEX "OrganizationAgentSkill_organizationId_folderId_idx" ON "OrganizationAgentSkill"("organizationId", "folderId");
CREATE INDEX "OrganizationAgentRole_organizationId_folderId_idx" ON "OrganizationAgentRole"("organizationId", "folderId");
-- AddForeignKey
ALTER TABLE "OrganizationAgentConfigFolder" ADD CONSTRAINT "OrganizationAgentConfigFolder_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "OrganizationAgentConfigFolder" ADD CONSTRAINT "OrganizationAgentConfigFolder_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "OrganizationAgentConfigFolder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "OrganizationAgentSkill" ADD CONSTRAINT "OrganizationAgentSkill_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "OrganizationAgentConfigFolder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "OrganizationAgentRole" ADD CONSTRAINT "OrganizationAgentRole_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "OrganizationAgentConfigFolder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,5 @@
-- Allow Organization wipe/cascade to clear nested agent-config folders.
-- Service layer still refuses non-empty folder deletes (ADR-0028); this only
-- unblocks parent-row removal during org teardown and test resetDb.
ALTER TABLE "OrganizationAgentConfigFolder" DROP CONSTRAINT "OrganizationAgentConfigFolder_parentId_fkey";
ALTER TABLE "OrganizationAgentConfigFolder" ADD CONSTRAINT "OrganizationAgentConfigFolder_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "OrganizationAgentConfigFolder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+32 -2
View File
@@ -47,6 +47,7 @@ model Organization {
capabilityConnections OrganizationCapabilityConnection[]
agentSkills OrganizationAgentSkill[]
agentRoles OrganizationAgentRole[]
agentConfigFolders OrganizationAgentConfigFolder[]
projectGroupBindings ProjectGroupBinding[]
auditEntries AuditEntry[] @relation("organizationAudit")
projectSearchDocuments ProjectSearchDocument[]
@@ -96,16 +97,19 @@ model OrganizationAgentSkill {
version String
description String?
contentDigest String
folderId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
disabledAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
folder OrganizationAgentConfigFolder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
roleBindings OrganizationAgentRoleSkill[]
@@unique([organizationId, name])
@@unique([organizationId, id])
@@index([organizationId, disabledAt])
@@index([organizationId, folderId])
@@index([contentDigest])
}
@@ -122,17 +126,20 @@ model OrganizationAgentRole {
tools Json?
sortOrder Int @default(0)
isDefault Boolean @default(false)
folderId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
disabledAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
folder OrganizationAgentConfigFolder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
skillBindings OrganizationAgentRoleSkill[]
selectedByBindings ProjectGroupBinding[] @relation("selectedAgentRole")
@@unique([organizationId, roleId])
@@unique([organizationId, id])
@@index([organizationId, disabledAt, sortOrder])
@@index([organizationId, folderId])
}
/// Same-Organization join enforced by both composite foreign keys. `sortOrder`
@@ -152,6 +159,29 @@ model OrganizationAgentRoleSkill {
@@index([organizationId, agentSkillId])
}
/// ADR-0028: org-scoped transparent folder tree shared by agent roles and
/// skills. Management-surface navigation/grouping only — not a permission
/// resource, and never referenced by role→skill bindings, run-time skill
/// loading, or Feishu slash commands. Skill name and roleId stay unique per
/// organization regardless of folder membership. A folder is deleted only
/// when empty (service-enforced); item references are SetNull as backstop.
model OrganizationAgentConfigFolder {
id String @id @default(cuid())
organizationId String
parentId String?
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
parent OrganizationAgentConfigFolder? @relation("agentConfigFolderTree", fields: [parentId], references: [id], onDelete: Cascade)
children OrganizationAgentConfigFolder[] @relation("agentConfigFolderTree")
skills OrganizationAgentSkill[]
roles OrganizationAgentRole[]
@@index([organizationId, parentId])
}
/// ADR-0021: org-level project onboarding policy. Ordinary Feishu users can
/// create projects from unbound chats only when membersCanCreateProjects=true.
model OrganizationProjectSettings {
+5 -1
View File
@@ -26,7 +26,11 @@ const client = new DocmindClient.default({
} as never);
const fileStream = createReadStream(pdfPath);
const runtime = new RuntimeOptions({});
const runtime = new RuntimeOptions({
connectTimeout: 15_000,
// httpx defaults to 3000ms; OSS upload of multi-MB PDFs needs far more.
readTimeout: 5 * 60_000,
});
console.log("Submitting job...");
const submitResp = await client.submitDocParserJobAdvance(
+60
View File
@@ -0,0 +1,60 @@
---
name: pbank-problem-report
description: >
Use when a teacher wants Paradigm PBank (题库) problems as lesson examples,
asks to search PBank by outline, map candidates to outline parts, inspect
downloaded PBank source zips including images, or produce a Markdown
selection/adaptation report.
---
# PBank Problem Report
Use the `cph_hub` MCP tools `pbank_search_problems`, `pbank_get_problem`, and
`pbank_get_many_problems` to support problem selection from Paradigm PBank.
The normal output is a Markdown report for the teacher to review before any
lesson source is changed.
## Workflow
1. Understand the current lesson before searching.
- Read the lesson outline, plan, or main source files in the current workspace.
- Identify the teaching parts, target concepts, required difficulty, and what kind of examples are needed.
2. Search PBank with narrow queries.
- Use `pbank_search_problems` with Chinese keywords from the outline.
- Prefer several focused searches over one broad search.
- Keep the initial result set small, then fetch only plausible candidates.
3. Fetch source projects for candidates.
- Use `pbank_get_problem` or `pbank_get_many_problems`.
- Keep `includeProjects: true`, `materializeProjects: true`, and `includeAssetImages: true` unless the user explicitly asks otherwise.
- Read the returned `rights` object first. Treat it as the authoritative rights signal for all content returned by these tools.
- Read returned `files`, `assets`, `zipPath`, and `extractDir`. Images usually live under `fig/` in the extracted source tree.
- When image or PDF assets matter, cite their workspace-relative paths under `.pbank-sources/...`.
4. Produce a Markdown report instead of editing the lesson by default.
- Save it in the current project workspace as `pbank-candidate-report.md` unless the user gives another path.
- Do not copy problems into the lesson, rewrite the lesson, or write back to PBank unless the user explicitly asks.
5. When the teacher explicitly asks for adaptation, follow the rights signal.
- If `rights.derivativeUseAllowed` is `true`, you may quote, rewrite, adapt, and reorganize the returned PBank content into the current teaching-material project.
- Do not refuse on generic copyright grounds when the tools say derivative use is allowed.
- If `rights.derivativeUseAllowed` is `false`, stay read-only and ask the teacher/admin to confirm rights first.
## Report Shape
Use concise Chinese. Include:
- Lesson outline parts and the example need for each part.
- A candidate table with PBank title/id/link, matched outline part, fit score, directly usable material, required modifications, assets, and recommendation.
- Per-problem notes explaining the mathematical/physical idea, why it matches, and what needs to be changed for this lesson.
- A short final recommendation: which problems to use directly, which to adapt, and which to discard.
## Rules
- Do not invent problem statements, diagrams, or answers. Base every claim on PBank metadata or extracted source files.
- Treat `.pbank-sources/` as a local read cache. It is safe to read from and reference in reports.
- Keep PBank itself read-only. The teacher's workflow is selection and adaptation inside the current lesson project, not modifying the bank.
- Treat rights metadata as authoritative. Do not override `rights.derivativeUseAllowed: true` with a generic copyright refusal.
- If tools fail because no ACTIVE `pbank` capability connection exists, tell the user an org admin must configure 题库 credentials under 外部能力.
- If no candidate fits, say that directly and record the searches tried.
+52 -19
View File
@@ -2,62 +2,95 @@
name: pdf-to-md
description: >
Convert PDF documents to Markdown bundles using the convert_pdf_to_md tool.
Handles PDFs from Feishu messages, local workspace files, and produces
high-quality Markdown with LaTeX formulas and extracted images.
Handles single or multiple PDFs (concurrent batch), Feishu attachments, and
local workspace files. Produces high-quality Markdown with LaTeX formulas
and extracted images.
---
# PDF to Markdown Conversion
## When to use
Use this skill when the user asks to convert a PDF to Markdown, extract text
from a PDF, or turn a PDF document into an editable format.
Use this skill when the user asks to convert a PDF (or several PDFs) to
Markdown, extract text from a PDF, or turn PDF documents into an editable
format.
## How it works
The `convert_pdf_to_md` tool (provided by the `cph_hub` MCP server) calls
Alibaba Cloud Document Mind to parse the PDF. It:
The `convert_pdf_to_md` tool (provided by the in-process `cph_hub` MCP server)
calls Alibaba Cloud Document Mind to parse each PDF. It:
- Extracts text in reading order (handles multi-column, scanned, and
multi-language documents)
- Converts mathematical formulas to **LaTeX** (`$...$` inline, `$$...$$` block)
- Extracts tables as Markdown tables
- Downloads embedded images into the output directory
- Writes a single `document.md` file plus image files
- Writes a single `document.md` file plus image files **per** `output_dir`
There is **no** workspace `.mcp.json` source file. MCP tools are injected by
Hub at run start. Do not look for MCP or skill source under workspace
`.claude/` — those paths are sandbox stubs (often character devices) and are
not readable constitution.
## Where this skill text lives
Prefer the Skill tool when the runtime offers it. If you need to re-read these
instructions with Read:
- Workspace copy (always under cwd): `.cph/runtime-skills/pdf-to-md/SKILL.md`
- Absolute path env: `$CPH_RUNTIME_SKILLS_DIR/pdf-to-md/SKILL.md`
## Workflow
### PDF from a Feishu message
### One PDF from a Feishu message
1. Use `feishu_read_context` to find the `file_key` of the PDF attachment.
2. Use `feishu_download_resource` to download it into the workspace.
3. Use `convert_pdf_to_md` with the downloaded file path and an output directory.
3. Use `convert_pdf_to_md` with `input_path` + `output_dir`.
### PDF already in the workspace
1. Use `convert_pdf_to_md` directly with the file path and an output directory.
1. Use `convert_pdf_to_md` with `input_path` and `output_dir`.
### Multiple PDFs (concurrent)
1. Download or locate every PDF in the workspace first.
2. Call **`convert_pdf_to_md` once** with:
```json
{
"items": [
{ "input_path": "sources/a.pdf", "output_dir": "md/a" },
{ "input_path": "sources/b.pdf", "output_dir": "md/b" }
]
}
```
3. Hub submits Docmind jobs with bounded concurrency (default 3, max 8;
optional `concurrency` argument). Prefer this over N sequential tool calls.
4. **Each item must use a distinct `output_dir`** — the tool always writes
`document.md` inside that directory; shared dirs overwrite each other.
5. Partial failure returns per-file OK/FAIL lines; re-run only failed items.
## Important rules
- **Always** use `convert_pdf_to_md` for PDF→Markdown. Do NOT attempt to parse
PDFs yourself with Read, Bash, Python, or any other method. The tool provides
accurate formula, table, and image extraction that manual methods cannot
match.
PDFs yourself with Read, Bash, Python, or any other method.
- If `convert_pdf_to_md` fails because no capability connection is configured,
tell the user to ask their organization admin to configure the Aliyun
docmind credential in the admin web UI (组织后台 → 能力).
- The output directory will be created if it does not exist.
- After conversion, use `send_file` to send the generated markdown back to the
user if they requested it.
- After conversion, use `send_file` to send generated markdown (or a zip you
assemble) back to the user if they requested delivery.
## Output
The tool returns a list of generated files:
Per `output_dir`:
- `document.md` — the main markdown file
- `*.jpg` / `*.png` — extracted images, referenced from the markdown
## Cost
The conversion is billed per page (0.04 CNY/page ≈ $0.0056/page for the
enhanced formula mode). The cost is automatically recorded on the run's
usage ledger.
Billed per page (0.04 CNY/page ≈ $0.0056/page for enhanced formula mode).
Each successful file records its own usage fact on the run ledger.
+121
View File
@@ -15,6 +15,10 @@
* `commitSkillContent`, so the web path and CLI path share one ingestion
* pipeline and one set of safety checks (SKILL.md manifest required, 512-file
* / 16-byte limits, symlink rejection).
*
* Folder tree (ADR-0028): one org-scoped transparent folder tree shared by
* roles and skills for management-surface grouping. Folder endpoints never
* touch session state — assignment is a label-class change (ADR-0017).
*/
import type { PrismaClient } from "@prisma/client";
import type { FastifyInstance } from "fastify";
@@ -243,4 +247,121 @@ export async function registerAgentConfigRoutes(
return handleRouteError(reply, err);
}
});
// --- ADR-0028 shared agent-config folder tree (transparent grouping) ---
app.get("/api/org/:orgSlug/agent-config-folders", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const folders = await agentConfig.listFolders({ organizationId: auth.organization.id });
return { folders };
} catch (err) {
return handleRouteError(reply, err);
}
});
app.post("/api/org/:orgSlug/agent-config-folders", async (request, reply) => {
try {
const { orgSlug } = request.params as { orgSlug: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as { name?: unknown; parentId?: unknown };
if (typeof body.name !== "string") {
return reply.status(400).send({
error: { code: "bad_request", message: "name is required" },
});
}
const folder = await agentConfig.createFolder({
organizationId: auth.organization.id,
name: body.name,
...(typeof body.parentId === "string" ? { parentId: body.parentId } : {}),
});
return reply.status(201).send(folder);
} catch (err) {
return handleRouteError(reply, err);
}
});
app.patch("/api/org/:orgSlug/agent-config-folders/:folderId", async (request, reply) => {
try {
const { orgSlug, folderId } = request.params as { orgSlug: string; folderId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as { name?: unknown; parentId?: unknown };
const folder = await agentConfig.updateFolder({
organizationId: auth.organization.id,
folderId,
...(typeof body.name === "string" ? { name: body.name } : {}),
...(body.parentId === null || typeof body.parentId === "string"
? { parentId: body.parentId as string | null }
: {}),
});
return folder;
} catch (err) {
return handleRouteError(reply, err);
}
});
app.delete("/api/org/:orgSlug/agent-config-folders/:folderId", async (request, reply) => {
try {
const { orgSlug, folderId } = request.params as { orgSlug: string; folderId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
await agentConfig.deleteFolder({
organizationId: auth.organization.id,
folderId,
});
return { deleted: true };
} catch (err) {
return handleRouteError(reply, err);
}
});
// Folder assignment is a label-class change (ADR-0017): these endpoints
// never archive Agent sessions (ADR-0028).
app.patch("/api/org/:orgSlug/agent-roles/:roleId/folder", async (request, reply) => {
try {
const { orgSlug, roleId } = request.params as { orgSlug: string; roleId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as { folderId?: unknown };
if (body.folderId !== null && typeof body.folderId !== "string") {
return reply.status(400).send({
error: { code: "bad_request", message: "folderId must be a string or null" },
});
}
await agentConfig.setRoleFolder({
organizationId: auth.organization.id,
roleId,
folderId: body.folderId as string | null,
});
return { folderId: body.folderId as string | null };
} catch (err) {
return handleRouteError(reply, err);
}
});
app.patch("/api/org/:orgSlug/agent-skills/:name/folder", async (request, reply) => {
try {
const { orgSlug, name } = request.params as { orgSlug: string; name: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = request.body as { folderId?: unknown };
if (body.folderId !== null && typeof body.folderId !== "string") {
return reply.status(400).send({
error: { code: "bad_request", message: "folderId must be a string or null" },
});
}
await agentConfig.setSkillFolder({
organizationId: auth.organization.id,
name,
folderId: body.folderId as string | null,
});
return { folderId: body.folderId as string | null };
} catch (err) {
return handleRouteError(reply, err);
}
});
}
@@ -7,8 +7,12 @@
*/
import type { PrismaClient } from "@prisma/client";
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 { secretKindForCapability } from "../../capability/types.js";
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
import { requireOrgRole, type GuardDeps } from "../auth/guards.js";
import { handleRouteError } from "../errors.js";
@@ -25,11 +29,10 @@ export async function registerCapabilityConnectionRoutes(
config: CapabilityConnectionRouteConfig,
): Promise<void> {
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
const connections = new CapabilityConnectionService(
config.prisma,
config.secretEnvelope,
config.readinessProbe,
);
const connections =
config.readinessProbe === undefined
? new CapabilityConnectionService(config.prisma, config.secretEnvelope)
: new CapabilityConnectionService(config.prisma, config.secretEnvelope, config.readinessProbe);
app.get("/api/org/:orgSlug/capability-connections", async (request, reply) => {
try {
@@ -60,12 +63,12 @@ export async function registerCapabilityConnectionRoutes(
const { orgSlug, capabilityId } = request.params as { orgSlug: string; capabilityId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return;
const body = parseBody(request.body);
const credential = parseCredentialBody(capabilityId, request.body);
const result = await connections.rotate({
organizationId: auth.organization.id,
capabilityId,
actorUserId: auth.user.id,
...body,
credential,
});
request.log.info({
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)) {
throw new Error("invalid capability credential body");
}
const body = value as Record<string, unknown>;
for (const name of ["accessKeyId", "accessKeySecret", "endpoint"] as const) {
if (typeof body[name] !== "string" || (body[name] as string).trim() === "") {
throw new Error(`${name} is required`);
}
const expectedKind = secretKindForCapability(capabilityId);
const kind =
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 {
accessKeyId: body["accessKeyId"] as string,
accessKeySecret: body["accessKeySecret"] as string,
endpoint: body["endpoint"] as string,
kind: "pbank",
baseUrl: requireStringField(body, "baseUrl"),
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;
}
+6 -1
View File
@@ -155,7 +155,12 @@ export async function registerExplorerRoutes(
workspaceRoot: config.projectWorkspaceRoot,
...(typeof body.folderId === "string" ? { folderId: body.folderId } : {}),
});
return reply.status(201).send({ id: result.projectId, name: body.name });
return reply.status(201).send({
projectId: result.projectId,
folderId: result.folderId,
workspaceDir: result.workspaceDir,
name: body.name,
});
} catch (err) {
return handleRouteError(reply, err);
}
+256 -11
View File
@@ -18,6 +18,7 @@ export interface AgentRoleRow {
readonly createdAt: string;
readonly updatedAt: string;
readonly skillNames: readonly string[];
readonly folderId: string | null;
}
export interface AgentSkillRow {
@@ -30,6 +31,17 @@ export interface AgentSkillRow {
readonly createdAt: string;
readonly updatedAt: string;
readonly boundRoleIds: readonly string[];
readonly folderId: string | null;
}
/**
* ADR-0028 transparent folder node of the org's shared agent-config folder
* tree. Grouping only: never part of skill/role identity or run resolution.
*/
export interface AgentConfigFolderRow {
readonly id: string;
readonly name: string;
readonly parentId: string | null;
}
/**
@@ -80,9 +92,213 @@ export class OrganizationAgentConfiguration {
createdAt: skill.createdAt.toISOString(),
updatedAt: skill.updatedAt.toISOString(),
boundRoleIds: skill.roleBindings.map((binding) => binding.role.roleId),
folderId: skill.folderId,
}));
}
async listFolders(input: { readonly organizationId: string }): Promise<readonly AgentConfigFolderRow[]> {
await this.requireActiveOrganization(input.organizationId);
const folders = await this.prisma.organizationAgentConfigFolder.findMany({
where: { organizationId: input.organizationId },
orderBy: [{ name: "asc" }, { id: "asc" }],
select: { id: true, name: true, parentId: true },
});
return folders;
}
async createFolder(input: {
readonly organizationId: string;
readonly name: string;
readonly parentId?: string | undefined;
}): Promise<AgentConfigFolderRow> {
await this.requireActiveOrganization(input.organizationId);
const name = nonEmpty(input.name, "folder name");
return this.prisma.$transaction(async (tx) => {
if (input.parentId !== undefined) {
await requireFolder(tx, input.organizationId, input.parentId);
}
const folder = await tx.organizationAgentConfigFolder.create({
data: {
organizationId: input.organizationId,
name,
...(input.parentId !== undefined ? { parentId: input.parentId } : {}),
},
select: { id: true, name: true, parentId: true },
});
await tx.auditEntry.create({
data: {
organizationId: input.organizationId,
action: "agent_config_folder.created",
metadata: { folderId: folder.id, name: folder.name, parentId: folder.parentId },
},
});
return folder;
});
}
/**
* Rename and/or move a folder inside the same Organization tree. Moving is
* rejected when the target parent is the folder itself or one of its
* descendants (would create a cycle).
*/
async updateFolder(input: {
readonly organizationId: string;
readonly folderId: string;
readonly name?: string | undefined;
readonly parentId?: string | null | undefined;
}): Promise<AgentConfigFolderRow> {
await this.requireActiveOrganization(input.organizationId);
return this.prisma.$transaction(async (tx) => {
const folder = await requireFolder(tx, input.organizationId, input.folderId);
if (input.parentId !== undefined && input.parentId !== null) {
if (input.parentId === folder.id) {
throw new Error("folder cannot be its own parent");
}
await requireFolder(tx, input.organizationId, input.parentId);
const descendant = await tx.$queryRaw<Array<{ found: boolean }>>(Prisma.sql`
WITH RECURSIVE descendants AS (
SELECT "id" FROM "OrganizationAgentConfigFolder" WHERE "parentId" = ${folder.id}
UNION ALL
SELECT child."id" FROM "OrganizationAgentConfigFolder" child
JOIN descendants parent ON child."parentId" = parent."id"
)
SELECT EXISTS(SELECT 1 FROM descendants WHERE "id" = ${input.parentId}) AS found
`);
if (descendant[0]?.found === true) throw new Error("folder cannot be moved below its descendant");
}
const name = input.name !== undefined ? nonEmpty(input.name, "folder name") : undefined;
const updated = await tx.organizationAgentConfigFolder.update({
where: { id: folder.id },
data: {
...(name !== undefined ? { name } : {}),
...(input.parentId !== undefined ? { parentId: input.parentId } : {}),
},
select: { id: true, name: true, parentId: true },
});
await tx.auditEntry.create({
data: {
organizationId: input.organizationId,
action: "agent_config_folder.updated",
metadata: {
folderId: folder.id,
...(name !== undefined ? { name } : {}),
...(input.parentId !== undefined ? { parentId: input.parentId } : {}),
},
},
});
return updated;
});
}
/**
* Delete a folder. Refused while the folder still has child folders, roles
* or skills (ADR-0028: items are relocated explicitly, so no orphan-placement
* rule is needed).
*/
async deleteFolder(input: {
readonly organizationId: string;
readonly folderId: string;
}): Promise<void> {
await this.requireActiveOrganization(input.organizationId);
await this.prisma.$transaction(async (tx) => {
const folder = await requireFolder(tx, input.organizationId, input.folderId);
const childFolders = await tx.organizationAgentConfigFolder.count({
where: { parentId: folder.id },
});
if (childFolders > 0) {
throw new Error(`cannot delete folder: still has ${childFolders} child folder(s)`);
}
const skills = await tx.organizationAgentSkill.count({
where: { organizationId: input.organizationId, folderId: folder.id },
});
const roles = await tx.organizationAgentRole.count({
where: { organizationId: input.organizationId, folderId: folder.id },
});
if (skills > 0 || roles > 0) {
throw new Error(`cannot delete folder: still has ${roles} role(s) and ${skills} skill(s)`);
}
await tx.organizationAgentConfigFolder.delete({ where: { id: folder.id } });
await tx.auditEntry.create({
data: {
organizationId: input.organizationId,
action: "agent_config_folder.deleted",
metadata: { folderId: folder.id, name: folder.name },
},
});
});
}
/**
* Assign a skill to a folder (or unfile it with `folderId: null`). This is
* a label-class change in the ADR-0017 sense — the execution surface is
* untouched, so no session archival (ADR-0028).
*/
async setSkillFolder(input: {
readonly organizationId: string;
readonly name: string;
readonly folderId: string | null;
}): Promise<void> {
await this.requireActiveOrganization(input.organizationId);
await this.prisma.$transaction(async (tx) => {
const skill = await tx.organizationAgentSkill.findUnique({
where: { organizationId_name: { organizationId: input.organizationId, name: input.name } },
select: { id: true, disabledAt: true },
});
if (skill === null || skill.disabledAt !== null) {
throw new Error(`active skill not found in organization: ${input.name}`);
}
if (input.folderId !== null) {
await requireFolder(tx, input.organizationId, input.folderId);
}
await tx.organizationAgentSkill.update({
where: { id: skill.id },
data: { folderId: input.folderId },
});
await tx.auditEntry.create({
data: {
organizationId: input.organizationId,
action: "agent_skill.folder_set",
metadata: { name: input.name, folderId: input.folderId },
},
});
});
}
/**
* Assign a role to a folder (or unfile it with `folderId: null`). Same
* label-class semantics as `setSkillFolder`: no session archival (ADR-0028).
*/
async setRoleFolder(input: {
readonly organizationId: string;
readonly roleId: string;
readonly folderId: string | null;
}): Promise<void> {
await this.requireActiveOrganization(input.organizationId);
await this.prisma.$transaction(async (tx) => {
const role = await tx.organizationAgentRole.findUnique({
where: { organizationId_roleId: { organizationId: input.organizationId, roleId: input.roleId } },
select: { id: true, disabledAt: true },
});
if (role === null || role.disabledAt !== null) {
throw new Error(`active role not found in organization: ${input.roleId}`);
}
if (input.folderId !== null) {
await requireFolder(tx, input.organizationId, input.folderId);
}
await tx.organizationAgentRole.update({
where: { id: role.id },
data: { folderId: input.folderId },
});
await tx.auditEntry.create({
data: {
organizationId: input.organizationId,
action: "agent_role.folder_set",
metadata: { roleId: input.roleId, folderId: input.folderId },
},
});
});
}
async installSkill(input: {
readonly organizationId: string;
readonly sourceDir: string;
@@ -173,7 +389,7 @@ export class OrganizationAgentConfiguration {
where: { id: skill.id },
data: { disabledAt: new Date() },
});
await archiveRoleSessions(
await invalidateRoleSessionClaudeIds(
tx,
input.organizationId,
skill.roleBindings.map((binding) => binding.role.roleId),
@@ -271,7 +487,7 @@ export class OrganizationAgentConfiguration {
},
});
if (previous !== null && previous.contentDigest !== skill.contentDigest) {
await archiveRoleSessions(
await invalidateRoleSessionClaudeIds(
tx,
input.organizationId,
skill.roleBindings.map((binding) => binding.role.roleId),
@@ -379,7 +595,7 @@ export class OrganizationAgentConfiguration {
if (activeDefaultCount !== 1) {
throw new Error(`organization ${input.organizationId} must have exactly one active default role`);
}
if (executionSurfaceChanged) await archiveRoleSessions(tx, input.organizationId, [input.roleId]);
if (executionSurfaceChanged) await invalidateRoleSessionClaudeIds(tx, input.organizationId, [input.roleId]);
await tx.auditEntry.create({
data: {
organizationId: input.organizationId,
@@ -449,7 +665,7 @@ export class OrganizationAgentConfiguration {
})),
});
}
await archiveRoleSessions(tx, input.organizationId, [input.roleId]);
await invalidateRoleSessionClaudeIds(tx, input.organizationId, [input.roleId]);
await tx.auditEntry.create({
data: {
organizationId: input.organizationId,
@@ -472,7 +688,22 @@ export class OrganizationAgentConfiguration {
}
}
async function archiveRoleSessions(
/**
* Invalidate the provider session cursor (e.g. `claudeSessionId`) for every
* active session of the given roles, WITHOUT archiving the session.
*
* Execution-surface changes (role model/systemPrompt/tools, skill content or
* binding changes) make a stale provider session cursor unsafe to resume: the
* prior turns were produced under a different config. But the conversation
* history itself (AgentMessage rows) is still valuable and the logical Hub
* session should stay continuous — the next run re-seeds context from the
* transcript instead of resuming the old provider session. So we drop only
* the cursor, not the session.
*
* `userResumable` is cleared because the session is no longer backed by a
* live provider cursor the user can drop back into.
*/
async function invalidateRoleSessionClaudeIds(
tx: Prisma.TransactionClient,
organizationId: string,
roleIds: readonly string[],
@@ -482,24 +713,36 @@ async function archiveRoleSessions(
where: {
roleId: { in: [...new Set(roleIds)] },
project: { organizationId },
archivedAt: null,
},
select: { id: true, archivedAt: true, metadata: true },
select: { id: true, metadata: true },
});
const archivedAt = new Date();
for (const session of sessions) {
const metadata = typeof session.metadata === "object" && session.metadata !== null && !Array.isArray(session.metadata)
? session.metadata as Prisma.JsonObject
: {};
const { claudeSessionId: _drop, ...rest } = metadata;
await tx.agentSession.update({
where: { id: session.id },
data: {
...(session.archivedAt === null ? { archivedAt } : {}),
metadata: { ...metadata, userResumable: false },
},
data: { metadata: { ...rest, userResumable: false } },
});
}
}
async function requireFolder(
tx: Prisma.TransactionClient,
organizationId: string,
folderId: string,
): Promise<{ readonly id: string; readonly name: string; readonly parentId: string | null }> {
const folder = await tx.organizationAgentConfigFolder.findFirst({
where: { id: folderId, organizationId },
select: { id: true, name: true, parentId: true },
});
if (folder === null) throw new Error(`folder not found in organization: ${folderId}`);
return folder;
}
function nonEmpty(value: string, label: string): string {
const normalized = value.trim();
if (normalized === "") throw new Error(`${label} is required`);
@@ -530,6 +773,7 @@ function toRoleRow(role: {
readonly disabledAt: Date | null;
readonly createdAt: Date;
readonly updatedAt: Date;
readonly folderId: string | null;
readonly skillBindings: ReadonlyArray<{
readonly skill: { readonly name: string; readonly disabledAt: Date | null };
}>;
@@ -549,5 +793,6 @@ function toRoleRow(role: {
skillNames: role.skillBindings
.filter((binding) => binding.skill.disabledAt === null)
.map((binding) => binding.skill.name),
folderId: role.folderId,
};
}
+80 -45
View File
@@ -1,11 +1,13 @@
export const DEFAULT_CLAUDE_BUILT_IN_TOOLS = [
"Read",
"Write",
"Edit",
"Bash",
"Glob",
"Grep",
"WebFetch",
"WebSearch",
"TodoWrite",
] as const;
export const CPH_HUB_MCP_SERVER_NAME = "cph_hub";
@@ -15,6 +17,10 @@ export const CPH_HUB_MCP_TOOL_IDS = [
"feishu_download_resource",
"request_approval",
"convert_pdf_to_md",
"pbank_search_problems",
"pbank_get_problem",
"pbank_get_many_problems",
"todo_write",
] as const;
export type CphHubMcpToolId = (typeof CPH_HUB_MCP_TOOL_IDS)[number];
@@ -24,48 +30,65 @@ export interface ClaudeSdkToolConfig {
readonly allowedTools: readonly string[];
}
const ROLE_TOOL_TO_CLAUDE_BUILT_INS = new Map<string, readonly string[]>([
["read_file", ["Read"]],
["write_file", ["Write"]],
["list_files", ["Glob"]],
["search_files", ["Grep"]],
["bash", ["Bash"]],
const ROLE_TOOL_TO_CLAUDE_BUILT_INS: Readonly<Record<string, readonly string[]>> = {
read_file: ["Read"],
write_file: ["Write", "Edit"],
list_files: ["Glob"],
search_files: ["Grep"],
bash: ["Bash"],
// ADR-0017 replaced cph custom tools with Bash commands. Granting either
// cph role tool therefore exposes the SDK Bash tool; cph-only Bash narrowing
// would need a separate command-policy layer.
["cph_check", ["Bash"]],
["cph_build", ["Bash"]],
["web_fetch", ["WebFetch"]],
["web_search", ["WebSearch"]],
["Read", ["Read"]],
["Write", ["Write"]],
["Bash", ["Bash"]],
["Glob", ["Glob"]],
["Grep", ["Grep"]],
["WebFetch", ["WebFetch"]],
["WebSearch", ["WebSearch"]],
]);
cph_check: ["Bash"],
cph_build: ["Bash"],
web_fetch: ["WebFetch"],
web_search: ["WebSearch"],
todo: ["TodoWrite"],
TodoWrite: ["TodoWrite"],
Read: ["Read"],
Write: ["Write"],
Edit: ["Edit"],
Bash: ["Bash"],
Glob: ["Glob"],
Grep: ["Grep"],
WebFetch: ["WebFetch"],
WebSearch: ["WebSearch"],
};
const ROLE_TOOL_TO_CPH_HUB_MCP_TOOL = new Map<string, CphHubMcpToolId>([
["send_file", "send_file"],
["feishu_read_context", "feishu_read_context"],
["feishu_download_resource", "feishu_download_resource"],
["request_approval", "request_approval"],
["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"],
]);
const ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS: Readonly<Record<string, readonly CphHubMcpToolId[]>> = {
send_file: ["send_file"],
feishu_read_context: ["feishu_read_context"],
feishu_download_resource: ["feishu_download_resource"],
request_approval: ["request_approval"],
convert_pdf_to_md: ["convert_pdf_to_md"],
pbank: ["pbank_search_problems", "pbank_get_problem", "pbank_get_many_problems"],
pbank_search_problems: ["pbank_search_problems"],
pbank_get_problem: ["pbank_get_problem"],
pbank_get_many_problems: ["pbank_get_many_problems"],
todo: ["todo_write"],
TodoWrite: ["todo_write"],
todo_write: ["todo_write"],
"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"],
"mcp__cph_hub__todo_write": ["todo_write"],
};
const SUPPORTED_ROLE_TOOLS = new Set([
...ROLE_TOOL_TO_CLAUDE_BUILT_INS.keys(),
...ROLE_TOOL_TO_CPH_HUB_MCP_TOOL.keys(),
...Object.keys(ROLE_TOOL_TO_CLAUDE_BUILT_INS),
...Object.keys(ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS),
]);
export function claudeSdkToolConfigForRole(roleTools: readonly string[] | undefined): ClaudeSdkToolConfig {
if (roleTools === undefined) {
export function claudeSdkToolConfigForRole(
roleTools: readonly string[] | null | undefined,
): ClaudeSdkToolConfig {
// DB/runtime "unrestricted" is JSON null; treat the same as undefined.
if (roleTools === undefined || roleTools === null) {
const mcpTools = CPH_HUB_MCP_TOOL_IDS.map(claudeMcpToolName);
return {
tools: [...DEFAULT_CLAUDE_BUILT_IN_TOOLS],
@@ -77,13 +100,12 @@ export function claudeSdkToolConfigForRole(roleTools: readonly string[] | undefi
const allowedTools: string[] = [];
for (const roleTool of roleTools) {
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(allowedTools, tool);
}
const mcpTool = ROLE_TOOL_TO_CPH_HUB_MCP_TOOL.get(roleTool);
if (mcpTool !== undefined) {
for (const mcpTool of ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS[roleTool] ?? []) {
pushUnique(allowedTools, claudeMcpToolName(mcpTool));
}
}
@@ -91,24 +113,37 @@ export function claudeSdkToolConfigForRole(roleTools: readonly string[] | undefi
return { tools: builtIns, allowedTools };
}
export function cphHubMcpToolsForRole(roleTools: readonly string[] | undefined): readonly CphHubMcpToolId[] {
if (roleTools === undefined) return [...CPH_HUB_MCP_TOOL_IDS];
export function cphHubMcpToolsForRole(
roleTools: readonly string[] | null | undefined,
): readonly CphHubMcpToolId[] {
// Always expose hub-side todo_write so progress cards work even when the
// native Claude TodoWrite tool is not registered in headless agent mode.
if (roleTools === undefined || roleTools === null) {
return [...CPH_HUB_MCP_TOOL_IDS];
}
const tools: CphHubMcpToolId[] = [];
const tools: CphHubMcpToolId[] = ["todo_write"];
for (const roleTool of roleTools) {
assertSupportedRoleTool(roleTool);
const mcpTool = ROLE_TOOL_TO_CPH_HUB_MCP_TOOL.get(roleTool);
if (mcpTool !== undefined) pushUnique(tools, mcpTool);
for (const mcpTool of ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS[roleTool] ?? []) {
pushUnique(tools, mcpTool);
}
}
return tools;
}
export function roleToolsAllow(roleTools: readonly string[] | undefined, roleTool: string): boolean {
if (roleTools === undefined) return true;
export function roleToolsAllow(
roleTools: readonly string[] | null | undefined,
roleTool: string,
): boolean {
if (roleTools === undefined || roleTools === null) return true;
for (const configured of roleTools) {
assertSupportedRoleTool(configured);
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;
}
+110 -11
View File
@@ -47,7 +47,7 @@ export type StreamEvent =
| { readonly type: "thinking-delta"; readonly text: string }
| { readonly type: "tool-start"; readonly toolName: string; readonly toolUseId: string }
| { readonly type: "tool-end"; readonly toolName: string; readonly toolUseId: string; readonly input: unknown; readonly durationMs?: number }
| { readonly type: "tool-result"; readonly toolUseId: string; readonly toolName: string; readonly result: string; readonly isError: boolean; readonly durationMs?: number }
| { readonly type: "tool-result"; readonly toolUseId: string; readonly toolName: string; readonly result: string; readonly isError: boolean; readonly input?: unknown; readonly durationMs?: number }
| { readonly type: "finish" };
export type StreamCallback = (event: StreamEvent) => void;
@@ -140,7 +140,9 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
let cleanupSecurity = async (): Promise<void> => {};
try {
await persistAgentMessage(req, "user", req.prompt);
const toolConfig = claudeSdkToolConfigForRole(req.tools);
// Role tools JSON null means the default single-agent tool set, not "deny all".
const roleToolIds = req.tools === null ? undefined : req.tools;
const toolConfig = claudeSdkToolConfigForRole(roleToolIds);
const workspaceRoot = req.project.workspaceRoot?.trim();
if (workspaceRoot === undefined || workspaceRoot === "") {
throw new Error("Agent run requires the configured workspace root");
@@ -154,14 +156,42 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
});
cleanupSecurity = security.cleanup;
const hasSkills = security.skillIds.length > 0;
type QueryOptions = NonNullable<Parameters<typeof query>[0]["options"]>;
// Always use an explicit tool list — never the claude_code preset.
// The preset registers Agent/SendMessage/Task multi-agent machinery.
// Concurrent background agents abort with reason "background", and the
// Claude Agent SDK maps that to toolDenialKind "cancelled" with:
// "The user doesn't want to take this action right now..."
// which freezes Bash mid-run while Read/Glob continue to work.
// Hub "unrestricted" means the default single-agent built-ins + MCP, not
// the full interactive Claude product surface.
const skillExtras = hasSkills ? (["Skill"] as const) : ([] as const);
const toolsOption: QueryOptions["tools"] = uniqueTools([
...toolConfig.tools,
"TodoWrite",
...skillExtras,
]);
const allowedToolsOption = uniqueTools([
...toolConfig.allowedTools,
"TodoWrite",
"mcp__cph_hub__todo_write",
...skillExtras,
]);
// Hard deny multi-agent orchestration even if a future preset/skills path
// reintroduces them — bypassPermissions would otherwise auto-allow them.
const disallowedToolsOption = [
"Agent",
"SendMessage",
"TeamCreate",
"Task",
"ScheduleWakeup",
] as const;
const options: QueryOptions = {
cwd: security.cwd,
// `skills` controls discovery/allowlisting, but an explicit `tools`
// list still has to expose the Skill dispatcher itself.
tools: [...toolConfig.tools, ...(hasSkills ? ["Skill"] : [])],
allowedTools: [...toolConfig.allowedTools],
tools: toolsOption,
allowedTools: allowedToolsOption,
disallowedTools: [...disallowedToolsOption],
maxTurns: cap,
includePartialMessages: true,
// ADR-0018: bypass interactive prompts (headless server); the sandbox
@@ -178,7 +208,16 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
// The project workspace is untrusted input. Do not load user/project
// settings that could widen tools, hooks, MCP servers, or sandbox paths.
settingSources: [],
settings: { disableBundledSkills: true },
settings: {
disableBundledSkills: true,
todoFeatureEnabled: true,
// Sessions are resumed across runs (ADR-0017). Without auto-compact
// the SDK jsonl grows unboundedly — a long-lived project session hit
// 31 MB / 1995 lines, making every API call resend the entire history
// and inflating a "change a title" task to 22 minutes. Let the SDK
// compact automatically when the context window fills.
autoCompactEnabled: true,
},
...(hasSkills && security.skillPluginRoot !== undefined
? { plugins: [{ type: "local" as const, path: security.skillPluginRoot, skipMcpDiscovery: true }] }
: {}),
@@ -198,13 +237,25 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
if (req.abortController !== undefined) options.abortController = req.abortController;
if (req.onSdkStderr !== undefined) options.stderr = req.onSdkStderr;
// When there is no provider session cursor to resume (first run, or after a
// role/skill/model config change invalidated claudeSessionId), re-seed the
// conversation from this Hub session's prior AgentMessage rows. This keeps
// the logical session continuous across config changes and restarts — the
// agent still "remembers" the earlier turns even though the SDK starts a
// fresh provider session. The resume path above is preferred when available
// (it carries tool calls/results natively and avoids re-sending tokens).
const promptForAgent = req.resumeSessionId === undefined
? await withSessionHistory(req, req.prompt)
: req.prompt;
const conversation = query({
prompt: req.prompt,
prompt: promptForAgent,
options,
});
// Track tool start timestamps for duration calculation
// Track tool start timestamps and names/inputs for duration + tool-result attribution.
const toolStartTimestamps = new Map<string, number>();
const toolMetaByUseId = new Map<string, { readonly name: string; readonly input: unknown }>();
for await (const message of conversation) {
switch (message.type) {
@@ -225,6 +276,10 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
if (evt.type === "content_block_start" && evt.content_block.type === "tool_use") {
const toolUseId = evt.content_block.id;
toolStartTimestamps.set(toolUseId, Date.now());
toolMetaByUseId.set(toolUseId, {
name: evt.content_block.name,
input: undefined,
});
onStream?.({ type: "tool-start", toolName: evt.content_block.name, toolUseId });
}
if (evt.type === "content_block_stop") {
@@ -246,6 +301,7 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
const durationMs = toolStartTimestamps.has(block.id)
? Date.now() - (toolStartTimestamps.get(block.id) ?? 0)
: undefined;
toolMetaByUseId.set(block.id, { name: block.name, input: block.input });
onStream?.({
type: "tool-end",
toolName: block.name,
@@ -279,12 +335,14 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
const isError = block.is_error === true;
const resultText = extractToolResultText(block.content);
const durationMs = toolStartTimestamps.get(toolUseId);
const meta = toolMetaByUseId.get(toolUseId);
onStream?.({
type: "tool-result",
toolUseId,
toolName: toolUseId,
toolName: meta?.name ?? toolUseId,
result: resultText,
isError,
...(meta?.input !== undefined ? { input: meta.input } : {}),
...(durationMs !== undefined ? { durationMs: Date.now() - durationMs } : {}),
});
}
@@ -333,6 +391,39 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
}
}
/**
* Re-seed a run's prompt with this Hub session's prior conversation when the
* SDK cannot resume a provider session (no `resumeSessionId`). Pulls prior
* `AgentMessage` rows for this session — excluding the current run's own user
* message, which was already persisted before `runAgent` called this — and
* frames them as `<session_history>` so the model treats them as prior turns,
* not new instructions. The current run's prompt follows as the live request.
*
* Best-effort: if the history query fails, the run proceeds with the bare
* prompt rather than aborting. A cap (`MAX_HISTORY_TURNS`) bounds token cost;
* older turns beyond the cap are dropped, preserving the most recent context.
*/
async function withSessionHistory(req: RunRequest, prompt: string): Promise<string> {
const MAX_HISTORY_TURNS = 40;
try {
const messages = await req.prisma.agentMessage.findMany({
where: { sessionId: req.sessionId, runId: { not: req.runId } },
orderBy: { createdAt: "asc" },
select: { role: true, content: true },
take: MAX_HISTORY_TURNS * 2, // user+assistant per turn
});
if (messages.length === 0) return prompt;
const turns: string[] = [];
for (const message of messages) {
const label = message.role === "assistant" ? "Assistant" : "User";
turns.push(`${label}: ${message.content}`);
}
return `<session_history>\nThis is the prior conversation in this session, replayed because the provider session could not be resumed. Treat these as earlier turns you produced or received.\n\n${turns.join("\n\n")}\n</session_history>\n\n${prompt}`;
} catch {
return prompt;
}
}
async function persistAgentMessage(req: RunRequest, role: string, content: string): Promise<void> {
if (content === "") return;
try {
@@ -361,3 +452,11 @@ function extractToolResultText(content: unknown): string {
}
return parts.join("\n");
}
function uniqueTools(tools: readonly string[]): string[] {
const out: string[] = [];
for (const tool of tools) {
if (!out.includes(tool)) out.push(tool);
}
return out;
}
+57 -2
View File
@@ -1,4 +1,4 @@
import { chmod, lstat, mkdir, realpath } from "node:fs/promises";
import { chmod, cp, lstat, mkdir, readdir, realpath, rm } from "node:fs/promises";
import { homedir } from "node:os";
import { isAbsolute, join, relative, resolve } from "node:path";
import type { RoleSkillEntry } from "./models.js";
@@ -21,6 +21,20 @@ const SAFE_HOST_ENV_KEYS = [
"LOGNAME",
"SHELL",
"CPH_BIN",
// Host egress is often only reachable via a local forward proxy. Without
// these, sandboxed Bash/curl times out on public HTTPS (ADR-0018: network
// open ≠ direct routing). Values come from the trusted service environment.
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"NO_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
"no_proxy",
"NODE_USE_ENV_PROXY",
"TYPST_PACKAGE_PATH",
"TYPST_PACKAGE_CACHE_PATH",
] as const;
const SANDBOX_HIDDEN_ENV_KEYS = [
@@ -122,6 +136,7 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
const sensitiveReadPaths = hostSensitiveReadPaths(hostEnv);
const runtimeReadPaths = hostRuntimeReadPaths(hostEnv);
const typstCacheWritePaths = hostTypstCacheWritePaths(hostEnv);
const selectedSkills = input.skills ?? [];
const skillPlugin = selectedSkills.length === 0
? null
@@ -130,6 +145,25 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
runId: input.runId,
skills: selectedSkills,
});
// Mirror selected skills under the workspace so the agent can Read SKILL.md
// without guessing the opaque host plugin UUID path. Workspace `.claude/` and
// `.mcp.json` are Claude sandbox stubs — not skill/MCP source of truth.
const runtimeSkillsRel = join(".cph", "runtime-skills");
const runtimeSkillsAbs = join(workspaceDir, runtimeSkillsRel);
await rm(runtimeSkillsAbs, { recursive: true, force: true });
await mkdir(runtimeSkillsAbs, { recursive: true, mode: 0o700 });
if (skillPlugin !== null) {
const pluginSkillsRoot = join(skillPlugin.root, "skills");
const skillNames = await readdir(pluginSkillsRoot);
for (const skillName of skillNames) {
await cp(join(pluginSkillsRoot, skillName), join(runtimeSkillsAbs, skillName), {
recursive: true,
force: true,
});
}
env.CPH_RUNTIME_SKILLS_DIR = runtimeSkillsAbs;
env.CPH_RUNTIME_SKILLS_REL = runtimeSkillsRel;
}
return {
cwd: workspaceDir,
workspaceRoot,
@@ -143,7 +177,7 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
autoAllowBashIfSandboxed: true,
allowUnsandboxedCommands: false,
filesystem: {
allowWrite: [workspaceDir],
allowWrite: [...new Set([workspaceDir, ...typstCacheWritePaths])],
// Reject every write path by default, then re-open only the canonical
// workspace. This prevents bubblewrap's ordinary temp exceptions from
// turning an unauthorized path into a successful ephemeral write.
@@ -213,9 +247,30 @@ function hostRuntimeReadPaths(env: Readonly<Record<string, string | undefined>>)
if (!isAbsolute(cphBin)) throw new Error("CPH_BIN must be absolute for the Agent subprocess");
platformPaths.push(resolve(cphBin));
}
platformPaths.push(...configuredTypstPackagePaths(env, ["TYPST_PACKAGE_PATH", "TYPST_PACKAGE_CACHE_PATH"]));
return [...new Set(platformPaths.map((path) => resolve(path)))];
}
function hostTypstCacheWritePaths(env: Readonly<Record<string, string | undefined>>): string[] {
return configuredTypstPackagePaths(env, ["TYPST_PACKAGE_CACHE_PATH"]);
}
function configuredTypstPackagePaths(
env: Readonly<Record<string, string | undefined>>,
names: readonly ("TYPST_PACKAGE_PATH" | "TYPST_PACKAGE_CACHE_PATH")[],
): string[] {
const paths: string[] = [];
for (const name of names) {
const packagePath = env[name]?.trim();
if (packagePath === undefined || packagePath === "") continue;
if (!isAbsolute(packagePath)) throw new Error(`${name} must be absolute for the Agent subprocess`);
const canonical = resolve(packagePath);
if (canonical === "/") throw new Error(`${name} must not be the filesystem root`);
paths.push(canonical);
}
return paths;
}
function hostSensitiveReadPaths(env: Readonly<Record<string, string | undefined>>): string[] {
const home = homedir();
const paths = [
+266
View File
@@ -0,0 +1,266 @@
/**
* Parse agent checklist tools into a stable model for Feishu card progress.
*
* Supports:
* - Claude TodoWrite (and hub mcp todo_write): full-list replace
* - Claude TaskCreate / TaskUpdate tools used in headless agent mode
*/
export type AgentTodoStatus = "pending" | "in_progress" | "completed";
export interface AgentTodoItem {
/** Present for Task* tools; optional for whole-list TodoWrite payloads. */
readonly id: string | undefined;
readonly content: string;
readonly status: AgentTodoStatus;
/** Present-tense label while the item is active, when the model supplies it. */
readonly activeForm: string | undefined;
}
const STATUSES = new Set<AgentTodoStatus>(["pending", "in_progress", "completed"]);
/** True when the tool name is SDK TodoWrite or hub mcp todo_write. */
export function isTodoWriteTool(toolName: string): boolean {
const lower = toolName.toLowerCase();
return (
toolName === "TodoWrite" ||
toolName.endsWith("__TodoWrite") ||
lower === "todo_write" ||
lower.endsWith("__todo_write")
);
}
export function isTaskChecklistTool(toolName: string): boolean {
const base = stripToolSuffix(toolName);
return (
base === "TaskCreate" ||
base === "TaskUpdate" ||
base === "TaskList" ||
base === "TaskGet" ||
base === "TaskStop" ||
base === "TaskOutput"
);
}
export function isChecklistProgressTool(toolName: string): boolean {
return isTodoWriteTool(toolName) || isTaskChecklistTool(toolName);
}
/**
* Extract the full todo list from a TodoWrite / todo_write tool_use input.
* Returns null when the payload is not a usable body.
*/
export function parseTodoWriteInput(input: unknown): readonly AgentTodoItem[] | null {
if (typeof input !== "object" || input === null || Array.isArray(input)) return null;
if (!("todos" in input)) return null;
const rawTodos = (input as { todos?: unknown }).todos;
if (!Array.isArray(rawTodos) || rawTodos.length === 0) return null;
const todos: AgentTodoItem[] = [];
for (const raw of rawTodos) {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
const record = raw as Record<string, unknown>;
const content = typeof record.content === "string" ? record.content.trim() : "";
if (content === "") continue;
const statusRaw = typeof record.status === "string" ? record.status : "pending";
const status: AgentTodoStatus = STATUSES.has(statusRaw as AgentTodoStatus)
? (statusRaw as AgentTodoStatus)
: "pending";
const activeForm =
typeof record.activeForm === "string" && record.activeForm.trim() !== ""
? record.activeForm.trim()
: undefined;
const id =
typeof record.id === "string" && record.id.trim() !== "" ? record.id.trim() : undefined;
todos.push({ id, content, status, activeForm });
}
return todos.length === 0 ? null : todos;
}
/**
* Fold a Task* / TodoWrite tool event into the running checklist.
* Returns null when the event does not change checklist state.
*/
export function applyChecklistToolEvent(
current: readonly AgentTodoItem[],
params: {
readonly toolName: string;
readonly input: unknown;
readonly result: unknown;
readonly toolUseId?: string | undefined;
},
): readonly AgentTodoItem[] | null {
if (isTodoWriteTool(params.toolName)) {
return parseTodoWriteInput(params.input);
}
const baseName = stripToolSuffix(params.toolName);
if (baseName === "TaskCreate") {
return applyTaskCreate(current, params.input, params.result, params.toolUseId);
}
if (baseName === "TaskUpdate") {
return applyTaskUpdate(current, params.input);
}
return null;
}
export function todoProgressSummary(todos: readonly AgentTodoItem[]): {
readonly completed: number;
readonly total: number;
readonly inProgress: number;
} {
let completed = 0;
let inProgress = 0;
for (const todo of todos) {
if (todo.status === "completed") completed += 1;
else if (todo.status === "in_progress") inProgress += 1;
}
return { completed, total: todos.length, inProgress };
}
function stripToolSuffix(toolName: string): string {
// SDK sometimes emits TaskCreate_0 sequential copies in the card title path.
const bare = toolName.includes("__") ? (toolName.split("__").pop() ?? toolName) : toolName;
return bare.replace(/_\d+$/, "");
}
function applyTaskCreate(
current: readonly AgentTodoItem[],
input: unknown,
result: unknown,
toolUseId: string | undefined,
): readonly AgentTodoItem[] | null {
if (typeof input !== "object" || input === null || Array.isArray(input)) return null;
const record = input as Record<string, unknown>;
const subject =
typeof record.subject === "string"
? record.subject.trim()
: typeof record.description === "string"
? record.description.trim()
: "";
if (subject === "") return null;
const activeForm =
typeof record.activeForm === "string" && record.activeForm.trim() !== ""
? record.activeForm.trim()
: undefined;
const idFromResult = extractTaskIdFromResult(result);
const provisionalId = toolUseId ?? `task-${current.length + 1}`;
const id = idFromResult ?? provisionalId;
// Replace any provisional row for this tool use or same pending subject.
const without = current.filter(
(t) =>
t.id !== provisionalId &&
t.id !== toolUseId &&
!(t.content === subject && t.status === "pending" && t.id !== id),
);
const existing = without.find((t) => taskIdsMatch(t.id, id));
if (existing !== undefined) {
return without.map((t) =>
taskIdsMatch(t.id, id)
? {
id,
content: subject,
status: existing.status,
activeForm: activeForm ?? existing.activeForm,
}
: t,
);
}
return [
...without,
{
id,
content: subject,
status: "pending",
activeForm,
},
];
}
function applyTaskUpdate(
current: readonly AgentTodoItem[],
input: unknown,
): readonly AgentTodoItem[] | null {
if (typeof input !== "object" || input === null || Array.isArray(input)) return null;
const record = input as Record<string, unknown>;
const taskId =
typeof record.taskId === "string"
? record.taskId.trim()
: typeof record.id === "string"
? record.id.trim()
: "";
if (taskId === "") return null;
const statusRaw = typeof record.status === "string" ? record.status : undefined;
if (statusRaw === "deleted") {
const next = current.filter((t) => !taskIdsMatch(t.id, taskId));
return next.length === current.length ? null : next;
}
const status: AgentTodoStatus | undefined =
statusRaw !== undefined && STATUSES.has(statusRaw as AgentTodoStatus)
? (statusRaw as AgentTodoStatus)
: undefined;
const subject =
typeof record.subject === "string" && record.subject.trim() !== ""
? record.subject.trim()
: undefined;
const activeForm =
typeof record.activeForm === "string" && record.activeForm.trim() !== ""
? record.activeForm.trim()
: undefined;
if (status === undefined && subject === undefined && activeForm === undefined) return null;
let found = false;
const next = current.map((todo) => {
if (!taskIdsMatch(todo.id, taskId)) return todo;
found = true;
return {
id: todo.id ?? taskId,
content: subject ?? todo.content,
status: status ?? todo.status,
activeForm: activeForm ?? todo.activeForm,
};
});
if (found) return next;
// Update arrived before create (or id mismatch): synthesize a row so the
// teacher still sees lifecycle updates.
if (subject === undefined && status === undefined) return null;
return [
...current,
{
id: taskId,
content: subject ?? `任务 ${taskId}`,
status: status ?? "pending",
activeForm,
},
];
}
function extractTaskIdFromResult(result: unknown): string | undefined {
const text =
typeof result === "string"
? result
: result !== null && typeof result === "object" && "content" in result
? String((result as { content: unknown }).content)
: "";
if (text === "") return undefined;
const hash = text.match(/Task\s*#\s*([0-9A-Za-z_-]+)/i);
if (hash?.[1]) return hash[1];
const bare = text.match(/\bid\s*[:=]\s*["']?([0-9A-Za-z_-]+)/i);
if (bare?.[1]) return bare[1];
return undefined;
}
function taskIdsMatch(a: string | undefined, b: string): boolean {
if (a === undefined) return false;
return normalizeTaskId(a) === normalizeTaskId(b);
}
function normalizeTaskId(id: string | undefined): string {
if (id === undefined) return "";
return id.trim().replace(/^#/, "");
}
+1 -1
View File
@@ -107,7 +107,7 @@ export function feishuContextTool(
inputSchema: z.object({
chat_id: z.string().describe("The Feishu chat id to read from."),
anchor: z.enum(["trigger_message", "status_card", "reply", "thread"]).describe("Which kind of anchor to read."),
id: z.string().describe("The anchor id (message id or run id)."),
id: z.string().describe("The anchor id: a message_id for trigger_message/status_card/reply, or a thread_id for thread."),
}),
execute: async (args): Promise<string> => {
if (args.chat_id !== ctx.boundChatId) {
@@ -10,22 +10,41 @@ import { randomUUID } from "node:crypto";
import type { Prisma, PrismaClient } from "@prisma/client";
import { lockActiveOrganization } from "../org/status.js";
import { LocalSecretEnvelope } from "../security/secretEnvelope.js";
import { probeDocmindCredential, type CapabilityReadinessProbe } from "./capabilityReadiness.js";
import type { CapabilitySecretPayload } from "./types.js";
import { probeCapabilityCredential, type CapabilityReadinessProbe } from "./capabilityReadiness.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 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 {
readonly accessKeyId: string;
readonly accessKeySecret: string;
readonly endpoint: string;
}
export type CapabilityCredentialInput =
| {
readonly kind: "docmind";
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 capabilityId: string;
readonly actorUserId: string;
readonly credential: CapabilityCredentialInput;
}
export interface CapabilityConnectionMetadata {
@@ -45,25 +64,24 @@ export interface CapabilityConnectionWriteResult extends CapabilityConnectionMet
export type CapabilitySecretPayloadV1 = CapabilitySecretPayload;
export class CapabilityConnectionService {
private readonly readinessProbe: CapabilityReadinessProbe;
constructor(
private readonly prisma: PrismaClient,
private readonly secrets: LocalSecretEnvelope,
private readonly readinessProbe: CapabilityReadinessProbe = probeDocmindCredential,
) {}
readinessProbe: CapabilityReadinessProbe = probeCapabilityCredential,
) {
this.readinessProbe = readinessProbe;
}
async rotate(input: RotateCapabilityInput): Promise<CapabilityConnectionWriteResult> {
if (!CAPABILITY_ID_PATTERN.test(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 requireCapabilityAdmin(tx, input);
});
await this.readinessProbe({
endpoint: payload.endpoint,
accessKeyId: payload.accessKeyId,
accessKeySecret: payload.accessKeySecret,
});
await this.readinessProbe(payload);
return this.prisma.$transaction(async (tx) => {
await requireCapabilityAdmin(tx, input);
@@ -142,6 +160,7 @@ export class CapabilityConnectionService {
status: "ACTIVE",
secretVersion: version,
keyId: envelope.keyId,
secretKind: payload.kind,
},
},
});
@@ -209,14 +228,47 @@ export class CapabilityConnectionService {
}
}
function validateCredential(input: RotateCapabilityInput): CapabilitySecretPayloadV1 {
if (!KNOWN_CAPABILITY_IDS.has(input.capabilityId)) {
throw new Error(`unsupported capabilityId: ${input.capabilityId}`);
function validateCredential(
capabilityId: string,
input: CapabilityCredentialInput,
): CapabilitySecretPayload {
if (!KNOWN_CAPABILITY_IDS.has(capabilityId)) {
throw new Error(`unsupported capabilityId: ${capabilityId}`);
}
const accessKeyId = nonEmpty(input.accessKeyId, "accessKeyId");
const accessKeySecret = nonEmpty(input.accessKeySecret, "accessKeySecret");
const endpoint = nonEmpty(input.endpoint, "endpoint");
return { schemaVersion: 1, accessKeyId, accessKeySecret, endpoint };
const expectedKind = secretKindForCapability(capabilityId);
if (input.kind !== expectedKind) {
throw new Error(`capability ${capabilityId} requires kind=${expectedKind}, got ${input.kind}`);
}
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(
@@ -264,3 +316,13 @@ function nonEmpty(value: string, label: string): string {
if (trimmed === "") throw new Error(`${label} must not be empty`);
return trimmed;
}
function optional(value: string | undefined): string | undefined {
if (value === undefined) return undefined;
const trimmed = value.trim();
return trimmed === "" ? undefined : trimmed;
}
function normalizeBaseUrl(value: string): string {
return value.replace(/\/+$/, "");
}
+13 -9
View File
@@ -12,17 +12,19 @@ import type { PrismaClient } from "@prisma/client";
import { LocalSecretEnvelope, type SecretEnvelopeV1 } from "../security/secretEnvelope.js";
import {
CapabilityConnectionUnavailable,
normalizeCapabilitySecretPayload,
secretKindForCapability,
type CapabilitySecretPayload,
type CapabilityId,
} from "./types.js";
const CAPABILITY_PURPOSE = "capability";
export interface ResolvedCapabilityCredential extends CapabilitySecretPayload {
export type ResolvedCapabilityCredential = CapabilitySecretPayload & {
readonly connectionId: string;
readonly organizationId: string;
readonly capabilityId: string;
}
};
/**
* Resolve the active capability credential for an organization. Throws
@@ -54,17 +56,19 @@ export async function resolveCapabilityCredential(
connectionId: connection.id,
secretVersionId: version.id,
};
const payload = secrets.decryptJson<CapabilitySecretPayload>(binding, version.envelope as unknown as SecretEnvelopeV1);
if (payload.schemaVersion !== 1) {
throw new Error(`unsupported capability secret schemaVersion: ${payload.schemaVersion}`);
const payload = normalizeCapabilitySecretPayload(
secrets.decryptJson<unknown>(binding, version.envelope as unknown as SecretEnvelopeV1),
);
const expectedKind = secretKindForCapability(input.capabilityId);
if (payload.kind !== expectedKind) {
throw new Error(
`capability ${input.capabilityId} secret kind mismatch: expected ${expectedKind}, got ${payload.kind}`,
);
}
return {
connectionId: connection.id,
organizationId: connection.organizationId,
capabilityId: connection.capabilityId,
schemaVersion: 1,
accessKeyId: payload.accessKeyId,
accessKeySecret: payload.accessKeySecret,
endpoint: payload.endpoint,
...payload,
};
}
+74 -12
View File
@@ -1,18 +1,11 @@
/**
* ADR-0027: Capability readiness probe. Validates the Alibaba Cloud docmind
* credential by calling QueryDocParserStatus with a dummy id — a 400 (bad
* request) means the credential is valid (the API accepted auth but rejected
* the id); a 401/403 means the credential is bad.
* ADR-0027: Capability readiness probes. Validate credentials before
* activation. Docmind uses QueryDocParserStatus; PBank uses /login.
*/
import { classifyNetworkFailure, type NetworkFailureCategory } from "../connections/networkFailure.js";
import type { CapabilitySecretPayload, DocmindCapabilitySecretPayload, PbankCapabilitySecretPayload } from "./types.js";
export interface CapabilityReadinessInput {
readonly endpoint: string;
readonly accessKeyId: string;
readonly accessKeySecret: string;
}
export type CapabilityReadinessProbe = (input: CapabilityReadinessInput) => Promise<void>;
export type CapabilityReadinessProbe = (payload: CapabilitySecretPayload) => Promise<void>;
export class CapabilityReadinessError extends Error {
constructor(
@@ -33,7 +26,7 @@ export class CapabilityReadinessError extends Error {
* - 401/403 (InvalidAccessKey/Forbidden) → credential invalid → probe fails
* - 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 authHeader = makeBasicAuth(input.accessKeyId, input.accessKeySecret);
@@ -61,6 +54,75 @@ export const probeDocmindCredential: CapabilityReadinessProbe = async (input) =>
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 {
+57 -10
View File
@@ -19,9 +19,10 @@ import $DocmindClient, {
QueryDocParserStatusRequest,
} from "@alicloud/docmind-api20220711";
import { RuntimeOptions } from "@alicloud/tea-util";
import { createReadStream } from "node:fs";
import { createReadStream, type ReadStream } from "node:fs";
import { once } from "node:events";
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. */
export interface DocmindExtractedImage {
@@ -43,7 +44,7 @@ export interface DocmindParseOptions {
}
export interface CapabilityProviderClient {
parse(credential: CapabilitySecretPayload, options: DocmindParseOptions): Promise<DocmindParseResult>;
parse(credential: DocmindCapabilitySecretPayload, options: DocmindParseOptions): Promise<DocmindParseResult>;
}
export class DocmindClientError extends Error {
@@ -61,11 +62,33 @@ export class DocmindClientError extends Error {
const COST_PER_PAGE_USD = 0.0056;
const POLL_INTERVAL_MS = 10_000;
const POLL_TIMEOUT_MS = 5 * 60_000;
/**
* httpx (tea transport) defaults read/connect timeout to 3000ms when unset.
* SubmitDocParserJobAdvance uploads the PDF to OSS; multi-MB files routinely
* exceed 3s on the silo host (production: ReadTimeout(3000) on
* docmind-api-cn-hangzhou.oss-cn-hangzhou.aliyuncs.com).
*/
export const DOCMIND_CONNECT_TIMEOUT_MS = 15_000;
/** Allow slow / large PDF OSS uploads up to the same bound as job polling. */
export const DOCMIND_READ_TIMEOUT_MS = POLL_TIMEOUT_MS;
/** RuntimeOptions for Docmind SDK calls that may upload or wait on the wire. */
export function createDocmindRuntimeOptions(): RuntimeOptions {
return new RuntimeOptions({
connectTimeout: DOCMIND_CONNECT_TIMEOUT_MS,
readTimeout: DOCMIND_READ_TIMEOUT_MS,
});
}
type DocmindConfig = ConstructorParameters<typeof $DocmindClient.default>[0];
export class AliyunDocmindClient implements CapabilityProviderClient {
async parse(credential: CapabilitySecretPayload, options: DocmindParseOptions): Promise<DocmindParseResult> {
async parse(credential: DocmindCapabilitySecretPayload, options: DocmindParseOptions): Promise<DocmindParseResult> {
// Open first so missing local inputs fail closed before touching the SDK.
// Unhandled createReadStream('error') previously crashed the Hub process.
const fileName = basename(options.inputFilePath);
const fileStream = await openLocalFileStream(options.inputFilePath);
const config: DocmindConfig = {
endpoint: credential.endpoint,
accessKeyId: credential.accessKeyId,
@@ -75,22 +98,21 @@ export class AliyunDocmindClient implements CapabilityProviderClient {
} as DocmindConfig;
const client = new $DocmindClient.default(config);
// 1. Submit job with local file as a ReadStream (not a Buffer — the SDK
// serializes Buffers as JSON {type:"Buffer",data:[...]} which the API
// can't read; a Stream is uploaded as multipart form data).
const fileName = basename(options.inputFilePath);
const fileStream = createReadStream(options.inputFilePath);
// Submit job with local file as a ReadStream (not a Buffer — the SDK
// serializes Buffers as JSON {type:"Buffer",data:[...]} which the API
// can't read; a Stream is uploaded as multipart form data).
const advanceRequest = new SubmitDocParserJobAdvanceRequest({
fileUrlObject: fileStream,
fileName,
outputFormat: ["markdown"],
formulaEnhancement: true,
});
const runtime = new RuntimeOptions({});
const runtime = createDocmindRuntimeOptions();
let submitResponse;
try {
submitResponse = await client.submitDocParserJobAdvance(advanceRequest, runtime);
} catch (e) {
fileStream.destroy();
throw new DocmindClientError(
e instanceof Error ? e.message : String(e),
"docmind_unreachable",
@@ -229,3 +251,28 @@ function extractFilename(altText: string, url: string, index: number): string {
if (base !== "" && base !== "/") return base;
return `image_${index + 1}.png`;
}
/**
* Open a local file as a ReadStream only after the fd is successfully open.
* createReadStream() emits asynchronous 'error' for missing paths; without a
* listener that becomes an unhandled EventEmitter error and exits Node.
*/
async function openLocalFileStream(path: string): Promise<ReadStream> {
const stream = createReadStream(path);
try {
await once(stream, "open");
} catch (error) {
stream.destroy();
const err = error instanceof Error ? error : new Error(String(error));
const code = (err as NodeJS.ErrnoException).code;
if (code === "ENOENT") {
throw new DocmindClientError(`input file not found: ${path}`, "docmind_rejected");
}
throw new DocmindClientError(err.message, "docmind_unreachable");
}
// After open, residual stream errors must not become unhandled and crash Hub.
stream.on("error", () => {
// The Aliyun SDK / destroy path owns consumption failures after open.
});
return stream;
}
+664
View File
@@ -0,0 +1,664 @@
/**
* ADR-0027: pbank capability — Paradigm 题库 search/fetch tools for Agent runs.
*
* Invariants:
* 1. Credential isolation — org ACTIVE connection is resolved in Hub; credentials
* never reach the Agent process (ADR-0024/0027).
* 2. Workspace containment — materialize path is always under the run workspace
* (ADR-0018 AgentSurface).
* 3. Mandatory fact — each successful tool call writes ≥1 UsageFact with
* kind=external_capability and unit=requests (cost unknown unless reported).
*/
import { inflateRawSync } from "node:zlib";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { dirname, join, relative, resolve } from "node:path";
import type { PrismaClient } from "@prisma/client";
import { LocalSecretEnvelope } from "../security/secretEnvelope.js";
import { resolveCapabilityCredential } from "./capabilityConnections.js";
import {
extractProblemId,
HttpPbankClient,
pbankRightsFromCredential,
type PbankClient,
} from "./pbankClient.js";
import {
CAPABILITIES,
asPbankSecret,
type PbankCapabilitySecretPayload,
} from "./types.js";
export const PBANK_CAPABILITY_ID = "pbank" as const;
const PROVIDER_ID = "paradigm_pbank";
const MAX_BATCH_SIZE = 20;
const MAX_PROJECT_BYTES = 80 * 1024 * 1024;
const MAX_PROJECT_TEXT_BYTES = 120 * 1024;
const MAX_EXTRACTED_PROJECT_BYTES = 80 * 1024 * 1024;
const MAX_INLINE_ASSET_BYTES = 1024 * 1024;
const MAX_INLINE_ASSETS = 4;
const CACHE_DIR_NAME = ".pbank-sources";
export class CapabilityPathEscape extends Error {
constructor(readonly requested: string, readonly workspaceDir: string) {
super(`capability path escapes workspace: ${requested} (root ${workspaceDir})`);
this.name = "CapabilityPathEscape";
}
}
export interface PbankServiceDeps {
readonly prisma: PrismaClient;
readonly secrets: LocalSecretEnvelope;
readonly client?: PbankClient;
}
export interface PbankToolContext {
readonly organizationId: string;
readonly runId: string;
readonly workspaceDir: string;
}
export interface PbankSearchArgs {
readonly q?: string | undefined;
readonly keywords?: readonly string[] | undefined;
readonly pageNum?: number | undefined;
readonly pageSize?: number | undefined;
}
export interface PbankGetProblemArgs {
readonly urlOrId: string;
readonly includeProjects?: boolean | undefined;
readonly materializeProjects?: boolean | undefined;
readonly includeAssetImages?: boolean | undefined;
readonly includeOccurrences?: boolean | undefined;
}
export interface PbankGetManyArgs {
readonly urlsOrIds: readonly string[];
readonly includeProjects?: boolean | undefined;
readonly materializeProjects?: boolean | undefined;
readonly includeAssetImages?: boolean | undefined;
readonly includeOccurrences?: boolean | undefined;
}
export interface PbankToolResult {
readonly data: unknown;
readonly inlineImages: readonly { readonly data: string; readonly mimeType: string }[];
}
interface TokenCacheEntry {
readonly token: string;
readonly expiresAt: number;
readonly password: string;
readonly username: string;
readonly baseUrl: string;
}
export interface PbankService {
searchProblems(ctx: PbankToolContext, args: PbankSearchArgs): Promise<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>> {
// Pure Node unzip (store/deflate). Do not shell out to host `unzip` —
// silo service PATH/tooling must not gate 题库 materialize.
const entries = listZipEntries(buffer);
const cacheRoot = confineToWorkspace(CACHE_DIR_NAME, options.workspaceDir);
const cacheDir = join(cacheRoot, safePathSegment(options.id), safePathSegment(options.target));
const extractDir = options.materialize ? join(cacheDir, "source") : null;
const zipPath = options.materialize ? join(cacheDir, `${options.target}.zip`) : null;
if (options.materialize) {
await mkdir(cacheDir, { recursive: true });
if (extractDir !== null) {
await rm(extractDir, { recursive: true, force: true });
await mkdir(extractDir, { recursive: true });
}
if (zipPath !== null) await writeFile(zipPath, buffer);
}
const files: Array<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 entries) {
if (!safeZipPath(entry.name)) {
omitted.push({ path: entry.name, reason: "unsafe path" });
continue;
}
if (entry.name.endsWith("/") || entry.isDirectory) continue;
if (usedExtractedBytes >= MAX_EXTRACTED_PROJECT_BYTES) {
omitted.push({ path: entry.name, reason: "extracted byte limit reached" });
continue;
}
const maxEntryBytes = Math.max(
1,
Math.min(MAX_PROJECT_BYTES, MAX_EXTRACTED_PROJECT_BYTES - usedExtractedBytes),
);
try {
const entryBuffer = inflateZipEntry(buffer, entry, maxEntryBytes);
usedExtractedBytes += entryBuffer.length;
let localPath: string | null = null;
if (extractDir !== null) {
localPath = join(extractDir, ...entry.name.split(/[\\/]+/));
await mkdir(dirname(localPath), { recursive: true });
await writeFile(localPath, entryBuffer);
extractedFiles.push({
path: entry.name,
localPath: toWorkspaceRelative(options.workspaceDir, localPath),
bytes: entryBuffer.length,
});
}
const mimeType = assetMimeType(entry.name);
if (mimeType !== null) {
const asset: {
path: string;
localPath: string | null;
bytes: number;
mimeType: string;
inlineData?: string;
} = {
path: entry.name,
localPath: localPath === null ? null : toWorkspaceRelative(options.workspaceDir, localPath),
bytes: entryBuffer.length,
mimeType,
};
if (
options.includeAssetImages &&
isInlineImageMime(mimeType) &&
entryBuffer.length <= MAX_INLINE_ASSET_BYTES &&
inlineAssetCount < MAX_INLINE_ASSETS
) {
asset.inlineData = entryBuffer.toString("base64");
inlineAssetCount += 1;
}
assets.push(asset);
}
if (isTextLikePath(entry.name)) {
if (usedTextBytes >= MAX_PROJECT_TEXT_BYTES) {
omitted.push({ path: entry.name, reason: "text byte limit reached" });
continue;
}
const content = decodeUtf8IfText(entryBuffer);
if (content === null) {
omitted.push({ path: entry.name, reason: "text decode failed" });
continue;
}
usedTextBytes += Buffer.byteLength(content, "utf8");
files.push({
path: entry.name,
localPath: localPath === null ? null : toWorkspaceRelative(options.workspaceDir, localPath),
content,
});
}
} catch (error) {
omitted.push({
path: entry.name,
reason: error instanceof Error ? error.message : String(error),
});
}
}
return {
target: options.target,
status: "downloaded",
bytes: buffer.length,
zipPath: zipPath === null ? null : toWorkspaceRelative(options.workspaceDir, zipPath),
extractDir: extractDir === null ? null : toWorkspaceRelative(options.workspaceDir, extractDir),
extractedFiles,
files,
assets: assets.map(({ inlineData: _inlineData, ...asset }) => asset),
inlineAssets: assets
.filter((asset) => asset.inlineData !== undefined)
.map((asset) => ({
path: asset.path,
localPath: asset.localPath,
bytes: asset.bytes,
mimeType: asset.mimeType,
data: asset.inlineData,
})),
omitted,
};
}
interface ZipEntryMeta {
readonly name: string;
readonly method: number;
readonly compressedSize: number;
readonly uncompressedSize: number;
readonly localHeaderOffset: number;
readonly isDirectory: boolean;
}
/** Minimal ZIP central-directory reader (store + deflate). No external unzip binary. */
function listZipEntries(buffer: Buffer): ZipEntryMeta[] {
let eocd = -1;
const minEocd = Math.max(0, buffer.length - (22 + 0xffff));
for (let i = buffer.length - 22; i >= minEocd; i -= 1) {
if (buffer.readUInt32LE(i) === 0x06054b50) {
eocd = i;
break;
}
}
if (eocd < 0) throw new Error("invalid zip: missing end of central directory");
const totalEntries = buffer.readUInt16LE(eocd + 10);
const centralSize = buffer.readUInt32LE(eocd + 12);
const centralOffset = buffer.readUInt32LE(eocd + 16);
if (centralOffset + centralSize > buffer.length) {
throw new Error("invalid zip: central directory out of range");
}
const entries: ZipEntryMeta[] = [];
let offset = centralOffset;
for (let i = 0; i < totalEntries; i += 1) {
if (offset + 46 > buffer.length || buffer.readUInt32LE(offset) !== 0x02014b50) {
throw new Error("invalid zip: bad central directory entry");
}
const method = buffer.readUInt16LE(offset + 10);
const compressedSize = buffer.readUInt32LE(offset + 20);
const uncompressedSize = buffer.readUInt32LE(offset + 24);
const nameLen = buffer.readUInt16LE(offset + 28);
const extraLen = buffer.readUInt16LE(offset + 30);
const commentLen = buffer.readUInt16LE(offset + 32);
const localHeaderOffset = buffer.readUInt32LE(offset + 42);
const nameStart = offset + 46;
const name = buffer.subarray(nameStart, nameStart + nameLen).toString("utf8");
entries.push({
name,
method,
compressedSize,
uncompressedSize,
localHeaderOffset,
isDirectory: name.endsWith("/"),
});
offset = nameStart + nameLen + extraLen + commentLen;
}
return entries;
}
function inflateZipEntry(buffer: Buffer, entry: ZipEntryMeta, maxBytes: number): Buffer {
if (entry.uncompressedSize > maxBytes) {
throw new Error(`zip entry exceeds ${maxBytes} bytes`);
}
const local = entry.localHeaderOffset;
if (local + 30 > buffer.length || buffer.readUInt32LE(local) !== 0x04034b50) {
throw new Error("invalid zip: bad local header");
}
const nameLen = buffer.readUInt16LE(local + 26);
const extraLen = buffer.readUInt16LE(local + 28);
const dataStart = local + 30 + nameLen + extraLen;
const dataEnd = dataStart + entry.compressedSize;
if (dataEnd > buffer.length) throw new Error("invalid zip: compressed data out of range");
const compressed = buffer.subarray(dataStart, dataEnd);
if (entry.method === 0) {
if (compressed.length > maxBytes) throw new Error(`zip entry exceeds ${maxBytes} bytes`);
return Buffer.from(compressed);
}
if (entry.method === 8) {
return Buffer.from(inflateRawSync(compressed, { maxOutputLength: maxBytes }));
}
throw new Error(`unsupported zip compression method ${entry.method}`);
}
async function writeUsageFact(
prisma: PrismaClient,
runId: string,
correlationId: string,
quantity: number,
): Promise<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;
}
function decodeUtf8IfText(buffer: Buffer): string | null {
const text = buffer.toString("utf8");
const replacementRatio = (text.match(/\uFFFD/g) ?? []).length / Math.max(text.length, 1);
if (replacementRatio > 0.02) return null;
return text;
}
function isZipBuffer(buffer: Buffer, contentType: string): boolean {
if (contentType.includes("zip")) return true;
return buffer.length >= 4 && buffer[0] === 0x50 && buffer[1] === 0x4b;
}
function isTextLikePath(filePath: string): boolean {
const lower = filePath.toLowerCase();
return [".typ", ".md", ".txt", ".tex", ".json", ".yaml", ".yml", ".toml", ".csv"].some((ext) =>
lower.endsWith(ext),
);
}
function assetMimeType(filePath: string): string | null {
const lower = filePath.toLowerCase();
if (lower.endsWith(".png")) return "image/png";
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
if (lower.endsWith(".gif")) return "image/gif";
if (lower.endsWith(".webp")) return "image/webp";
if (lower.endsWith(".pdf")) return "application/pdf";
return null;
}
function isInlineImageMime(mimeType: string): boolean {
return mimeType.startsWith("image/") && mimeType !== "image/svg+xml";
}
function safeZipPath(entry: string): boolean {
if (entry.includes("\0")) return false;
const normalized = entry.replace(/\\/g, "/");
if (normalized.startsWith("/") || normalized.includes("://")) return false;
for (const part of normalized.split("/")) {
if (part === ".." || part === "") return false;
}
return true;
}
function safePathSegment(value: string): string {
const cleaned = value.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "");
if (cleaned === "" || cleaned === "." || cleaned === "..") return "item";
return cleaned.slice(0, 80);
}
function clampInt(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min;
const n = Math.trunc(value);
if (n < min) return min;
if (n > max) return max;
return n;
}
+279
View File
@@ -0,0 +1,279 @@
/**
* Paradigm PBank (题库) HTTP client.
*
* Credentials are injected per call from the org capability connection
* (ADR-0024/0027) — never read from process env and never passed to the Agent.
*/
import type { PbankCapabilitySecretPayload } from "./types.js";
const DEFAULT_BASE_URL = "https://pbank.paradigm-edu.net/api";
const POSITIVE_RIGHTS_STATUSES = new Set(["owned", "exclusive_license", "licensed_adapt"]);
export interface PbankRights {
readonly status: string;
readonly holder: string;
readonly scope: string;
readonly note: string;
readonly derivativeUseAllowed: boolean;
readonly source: "operator_confirmed";
}
export interface PbankLoginResult {
readonly token: string;
readonly expiresAt: number;
}
export interface PbankSearchInput {
readonly q?: string | undefined;
readonly keywords?: readonly string[] | undefined;
readonly pageNum: number;
readonly pageSize: number;
}
export interface PbankProjectDownload {
readonly buffer: Buffer;
readonly contentType: string;
}
export class PbankClientError extends Error {
constructor(
message: string,
readonly code: "pbank_unreachable" | "pbank_rejected" | "pbank_invalid_response",
readonly upstreamStatus?: number,
) {
super(message);
this.name = "PbankClientError";
}
}
export interface PbankClient {
login(credential: PbankCapabilitySecretPayload): Promise<PbankLoginResult>;
searchProblems(
credential: PbankCapabilitySecretPayload,
token: string,
input: PbankSearchInput,
): Promise<unknown>;
getProblem(
credential: PbankCapabilitySecretPayload,
token: string,
id: string,
): Promise<unknown>;
getOccurrences(
credential: PbankCapabilitySecretPayload,
token: string,
id: string,
): Promise<unknown>;
downloadProject(
credential: PbankCapabilitySecretPayload,
token: string,
id: string,
target: "problem" | "answer",
): Promise<PbankProjectDownload>;
}
export function pbankRightsFromCredential(credential: PbankCapabilitySecretPayload): PbankRights {
const status = normalizeRightsStatus(credential.rightsStatus ?? "unknown");
const holder = (credential.rightsHolder ?? "").trim() || "Paradigm Education";
const scope = (credential.rightsScope ?? "").trim() || "internal teaching-material production";
const note =
(credential.rightsNote ?? "").trim() ||
"All content returned by this capability is operator-confirmed as owned by Paradigm Education or sufficiently licensed for excerpting, rewriting, and adaptation within current teaching-material projects.";
return {
status,
holder,
scope,
note,
derivativeUseAllowed: POSITIVE_RIGHTS_STATUSES.has(status),
source: "operator_confirmed",
};
}
export function normalizePbankBaseUrl(value: string | undefined): string {
const raw = (value ?? "").trim();
if (raw === "") return DEFAULT_BASE_URL;
return raw.replace(/\/+$/, "");
}
export function extractProblemId(value: string): string {
const input = value.trim();
if (input === "") throw new PbankClientError("urlOrId is required", "pbank_invalid_response");
const uuidPattern = /[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/i;
const direct = input.match(uuidPattern);
if (direct !== null) return direct[0]!;
try {
const url = new URL(input);
for (const key of ["id", "problemId", "problem_id"] as const) {
const fromQuery = url.searchParams.get(key);
const match = fromQuery?.match(uuidPattern);
if (match !== null && match !== undefined) return match[0]!;
}
} catch {
// Not a URL.
}
throw new PbankClientError(`Could not find a problem UUID in: ${input}`, "pbank_invalid_response");
}
export class HttpPbankClient implements PbankClient {
async login(credential: PbankCapabilitySecretPayload): Promise<PbankLoginResult> {
const data = await this.requestJson(credential, "/login", {
method: "POST",
body: { username: credential.username, password: credential.password },
timeoutMs: 30_000,
});
if (typeof data !== "object" || data === null || Array.isArray(data)) {
throw new PbankClientError("PBank login returned non-object", "pbank_invalid_response");
}
const record = data as Record<string, unknown>;
if (typeof record.token !== "string" || record.token.trim() === "") {
throw new PbankClientError("PBank login did not return a token", "pbank_invalid_response");
}
const expiresAt =
typeof record.expiresAt === "number" && Number.isFinite(record.expiresAt)
? record.expiresAt
: Date.now() + 30 * 60_000;
return { token: record.token, expiresAt };
}
async searchProblems(
credential: PbankCapabilitySecretPayload,
token: string,
input: PbankSearchInput,
): Promise<unknown> {
return this.requestJson(credential, "/problem/query", {
token,
query: {
q: input.q,
keywords: input.keywords !== undefined && input.keywords.length > 0 ? input.keywords.join(",") : undefined,
pageNum: input.pageNum,
pageSize: input.pageSize,
},
});
}
async getProblem(
credential: PbankCapabilitySecretPayload,
token: string,
id: string,
): Promise<unknown> {
return this.requestJson(credential, `/problem/${encodeURIComponent(id)}`, { token });
}
async getOccurrences(
credential: PbankCapabilitySecretPayload,
token: string,
id: string,
): Promise<unknown> {
return this.requestJson(credential, `/problem/${encodeURIComponent(id)}/occurrence`, {
token,
query: { pageNum: 1, pageSize: 20 },
});
}
async downloadProject(
credential: PbankCapabilitySecretPayload,
token: string,
id: string,
target: "problem" | "answer",
): Promise<PbankProjectDownload> {
const url = buildUrl(credential.baseUrl, `/problem/${encodeURIComponent(id)}/project/${target}`);
let response: Response;
try {
response = await fetch(url, {
headers: { authorization: `Bearer ${token}`, accept: "*/*" },
signal: AbortSignal.timeout(120_000),
});
} catch (error) {
throw new PbankClientError(
error instanceof Error ? error.message : String(error),
"pbank_unreachable",
);
}
if (!response.ok) {
throw new PbankClientError(
`PBank project download failed: ${response.status}`,
response.status === 401 || response.status === 403 ? "pbank_rejected" : "pbank_invalid_response",
response.status,
);
}
const contentType = response.headers.get("content-type") ?? "";
const arrayBuffer = await response.arrayBuffer();
return { buffer: Buffer.from(arrayBuffer), contentType };
}
private async requestJson(
credential: PbankCapabilitySecretPayload,
route: string,
options: {
readonly method?: string;
readonly token?: string;
readonly body?: unknown;
readonly query?: Record<string, string | number | undefined>;
readonly timeoutMs?: number;
},
): Promise<unknown> {
const url = buildUrl(credential.baseUrl, route, options.query);
const headers: Record<string, string> = { accept: "application/json" };
if (options.token !== undefined) headers.authorization = `Bearer ${options.token}`;
if (options.body !== undefined) headers["content-type"] = "application/json";
const init: RequestInit = {
method: options.method ?? "GET",
headers,
signal: AbortSignal.timeout(options.timeoutMs ?? 30_000),
};
if (options.body !== undefined) init.body = JSON.stringify(options.body);
let response: Response;
try {
response = await fetch(url, init);
} catch (error) {
throw new PbankClientError(
error instanceof Error ? error.message : String(error),
"pbank_unreachable",
);
}
const data = await response.json().catch(() => null);
if (!response.ok) {
throw new PbankClientError(
extractErrorMessage(data, `PBank API request failed: ${response.status}`),
response.status === 401 || response.status === 403 ? "pbank_rejected" : "pbank_invalid_response",
response.status,
);
}
return data;
}
}
function buildUrl(
baseUrl: string,
route: string,
query?: Record<string, string | number | undefined>,
): string {
const url = new URL(route.replace(/^\/+/, ""), `${normalizePbankBaseUrl(baseUrl)}/`);
if (query !== undefined) {
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === "") continue;
url.searchParams.set(key, String(value));
}
}
return url.toString();
}
function extractErrorMessage(data: unknown, fallback: string): string {
if (typeof data === "object" && data !== null) {
const record = data as Record<string, unknown>;
if (typeof record.message === "string" && record.message !== "") return record.message;
if (typeof record.error === "string" && record.error !== "") return record.error;
}
if (typeof data === "string" && data !== "") return data;
return fallback;
}
function normalizeRightsStatus(value: string): string {
const normalized = value.trim().toLowerCase().replace(/[\s-]+/g, "_");
if (normalized === "") return "unknown";
return normalized;
}
+131 -1
View File
@@ -23,6 +23,7 @@ import { resolveCapabilityCredential } from "./capabilityConnections.js";
import { DocmindClientError, type CapabilityProviderClient } from "./docmindClient.js";
import {
CAPABILITIES,
asDocmindSecret,
type CapabilityAdapter,
type CapabilityInvocationInput,
type CapabilityInvocationResult,
@@ -63,6 +64,135 @@ export interface PdfToMdBundleDeps {
readonly prisma: PrismaClient;
}
/** Default max concurrent Docmind jobs for one convert_pdf_to_md batch call. */
export const DEFAULT_PDF_TO_MD_CONCURRENCY = 3;
/** Hard ceiling for agent-requested concurrency (also clamps env). */
export const MAX_PDF_TO_MD_CONCURRENCY = 8;
/** Max PDFs accepted in one batch tool call. */
export const MAX_PDF_TO_MD_BATCH_ITEMS = 32;
export function clampPdfToMdConcurrency(value: number): number {
if (!Number.isFinite(value)) return DEFAULT_PDF_TO_MD_CONCURRENCY;
const n = Math.trunc(value);
if (n < 1) return 1;
if (n > MAX_PDF_TO_MD_CONCURRENCY) return MAX_PDF_TO_MD_CONCURRENCY;
return n;
}
/** Read HUB_PDF_TO_MD_MAX_CONCURRENT (default 3, max 8). */
export function readPdfToMdConcurrency(
env: Readonly<Record<string, string | undefined>> = process.env,
): number {
const raw = env["HUB_PDF_TO_MD_MAX_CONCURRENT"]?.trim();
if (raw === undefined || raw === "") return DEFAULT_PDF_TO_MD_CONCURRENCY;
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed < 1) {
throw new Error(`HUB_PDF_TO_MD_MAX_CONCURRENT must be a positive integer, got ${raw}`);
}
return clampPdfToMdConcurrency(parsed);
}
export interface PdfToMdBatchItem {
readonly inputPath: string;
readonly outputDir: string;
}
export type PdfToMdBatchItemResult =
| {
readonly ok: true;
readonly inputPath: string;
readonly outputDir: string;
readonly result: CapabilityInvocationResult;
}
| {
readonly ok: false;
readonly inputPath: string;
readonly outputDir: string;
readonly error: string;
};
/**
* Run worker over items with bounded parallelism. Order of results matches
* input order. Rejects in worker are not swallowed — caller should catch.
*/
export async function mapPool<T, R>(
items: readonly T[],
concurrency: number,
worker: (item: T, index: number) => Promise<R>,
): Promise<R[]> {
if (items.length === 0) return [];
const limit = Math.max(1, Math.min(Math.trunc(concurrency), items.length));
const results = new Array<R>(items.length);
let next = 0;
async function runWorker(): Promise<void> {
for (;;) {
const index = next;
next += 1;
if (index >= items.length) return;
results[index] = await worker(items[index]!, index);
}
}
await Promise.all(Array.from({ length: limit }, () => runWorker()));
return results;
}
/**
* Convert multiple PDFs with bounded concurrency. Each item is attribute-
* independent (own paths + own UsageFact). Failures are per-item and do not
* cancel siblings; order matches `items`.
*/
export async function invokePdfToMdBatch(
adapter: CapabilityAdapter,
base: Omit<CapabilityInvocationInput, "inputPath" | "outputDir">,
items: readonly PdfToMdBatchItem[],
concurrency: number = DEFAULT_PDF_TO_MD_CONCURRENCY,
): Promise<PdfToMdBatchItemResult[]> {
if (items.length === 0) {
throw new Error("pdf_to_md batch requires at least one item");
}
if (items.length > MAX_PDF_TO_MD_BATCH_ITEMS) {
throw new Error(
`pdf_to_md batch supports at most ${MAX_PDF_TO_MD_BATCH_ITEMS} items per call (got ${items.length})`,
);
}
const seenOutputDirs = new Set<string>();
for (const item of items) {
if (item.inputPath.trim() === "" || item.outputDir.trim() === "") {
throw new Error("pdf_to_md batch items require non-empty inputPath and outputDir");
}
const key = item.outputDir.replace(/\\/g, "/").replace(/\/+$/, "");
if (seenOutputDirs.has(key)) {
throw new Error(
`pdf_to_md batch items must use distinct output_dir values; duplicate: ${item.outputDir}`,
);
}
seenOutputDirs.add(key);
}
const limit = clampPdfToMdConcurrency(concurrency);
return mapPool(items, limit, async (item) => {
try {
const result = await adapter.invoke({
...base,
inputPath: item.inputPath,
outputDir: item.outputDir,
});
return {
ok: true as const,
inputPath: item.inputPath,
outputDir: item.outputDir,
result,
};
} catch (error) {
return {
ok: false as const,
inputPath: item.inputPath,
outputDir: item.outputDir,
error: error instanceof Error ? error.message : String(error),
};
}
});
}
/** Build the pdf_to_md_bundle adapter. The client is injectable for testing. */
export function createPdfToMdBundleAdapter(deps: PdfToMdBundleDeps): CapabilityAdapter {
return {
@@ -83,7 +213,7 @@ export function createPdfToMdBundleAdapter(deps: PdfToMdBundleDeps): CapabilityA
// 3. Call the backing service.
let result;
try {
result = await deps.client.parse(credential, { inputFilePath: absoluteInput });
result = await deps.client.parse(asDocmindSecret(credential), { inputFilePath: absoluteInput });
} catch (e) {
if (e instanceof DocmindClientError) throw e;
throw new DocmindClientError(
+123 -7
View File
@@ -1,11 +1,11 @@
/**
* 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
* active capability connection, calls the backing service via an injectable
* client, writes output into the run's workspace (AgentSurface, ADR-0018),
* and records consumption on a UsageFact (ADR-0026).
* client, writes output into the run's workspace when applicable (AgentSurface,
* ADR-0018), and records consumption on a UsageFact (ADR-0026).
*/
import type { PrismaClient, Prisma } from "@prisma/client";
@@ -13,6 +13,7 @@ import type { PrismaClient, Prisma } from "@prisma/client";
export const CAPABILITY_IDS = [
"pdf_to_md_bundle",
"audio_video_to_text",
"pbank",
] as const;
export type CapabilityId = (typeof CAPABILITY_IDS)[number];
@@ -27,6 +28,7 @@ export interface CapabilityDescriptor {
export const CAPABILITIES: Readonly<Record<CapabilityId, CapabilityDescriptor>> = {
pdf_to_md_bundle: { id: "pdf_to_md_bundle", meteringUnit: "pages" },
audio_video_to_text: { id: "audio_video_to_text", meteringUnit: "audio_seconds" },
pbank: { id: "pbank", meteringUnit: "requests" },
};
/** Input passed to a capability adapter invocation. */
@@ -59,7 +61,7 @@ export interface CapabilityConsumption {
readonly model: string | null;
readonly inputTokens: 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 unit: string;
/** USD cost if the service reported one; null = unknown (ADR-0022). */
@@ -80,15 +82,39 @@ export interface CapabilityAdapter {
invoke(input: CapabilityInvocationInput): Promise<CapabilityInvocationResult>;
}
/** Decrypted capability credential (CapabilitySecretPayloadV1, ADR-0027).
* Alibaba Cloud Document Mind (docmind) uses AccessKey ID + Secret + endpoint. */
export interface CapabilitySecretPayload {
/** Alibaba Cloud Document Mind (docmind) AccessKey + endpoint. */
export interface DocmindCapabilitySecretPayload {
readonly schemaVersion: 1;
readonly kind: "docmind";
readonly accessKeyId: string;
readonly accessKeySecret: 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). */
export class CapabilityConnectionUnavailable extends Error {
constructor(readonly capabilityId: string, readonly organizationId: string) {
@@ -99,3 +125,93 @@ export class CapabilityConnectionUnavailable extends Error {
/** Prisma transaction client type alias (for resolver signatures). */
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;
}
+227
View File
@@ -0,0 +1,227 @@
import { chmod, mkdir, mkdtemp, rm, stat } from "node:fs/promises";
import { createReadStream } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawn } from "node:child_process";
import type { PrismaClient } from "@prisma/client";
import {
resolveActiveFeishuApplication,
type ResolvedFeishuApplication,
} from "../connections/feishuApplicationConnections.js";
import type { LocalSecretEnvelope } from "../security/secretEnvelope.js";
import {
WorkspaceFileBoundaryError,
writeNewWorkspaceFileNoFollow,
} from "../security/workspaceFiles.js";
const DEFAULT_TIMEOUT_MS = 180_000;
const MAX_COMMAND_OUTPUT_BYTES = 1024 * 1024;
const DEFAULT_CLI_PATH = "/usr/local/bin:/usr/bin:/bin";
const SAFE_CLI_ENV_KEYS = [
"PATH",
"LANG",
"LC_ALL",
"LC_CTYPE",
"TZ",
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"NO_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
"no_proxy",
"NODE_USE_ENV_PROXY",
] as const;
export interface FeishuBotCliDownloadRequest {
readonly messageId: string;
readonly fileKey: string;
readonly resourceType: "image" | "file";
readonly workspaceRoot: string;
readonly workspaceDir: string;
readonly workspaceRelativePath: string;
readonly maxBytes?: number | undefined;
}
export interface FeishuBotCli {
downloadResource(request: FeishuBotCliDownloadRequest): Promise<string>;
}
export interface FeishuBotCliOptions {
readonly organizationId: string;
readonly prisma: PrismaClient;
readonly secretEnvelope: LocalSecretEnvelope;
readonly binary?: string | undefined;
readonly timeoutMs?: number | undefined;
readonly resolveCredential?: (() => Promise<ResolvedFeishuApplication>) | undefined;
}
interface CommandResult {
readonly stdout: string;
readonly stderr: string;
}
/**
* Runs the real lark-cli as a Hub-owned bot operation.
*
* The CLI receives the App Secret over stdin and gets a disposable HOME. No
* Feishu credential is placed in Agent environment, project files, argv, or
* the process-global CLI configuration. The caller still owns project/chat
* authorization; this adapter only performs bot-identity transport.
*/
export function createFeishuBotCli(options: FeishuBotCliOptions): FeishuBotCli {
const resolveCredential = options.resolveCredential ?? (() => resolveActiveFeishuApplication(
options.prisma,
options.secretEnvelope,
{ organizationId: options.organizationId },
));
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
return {
async downloadResource(request): Promise<string> {
const credential = await resolveCredential();
const root = await mkdtemp(join(tmpdir(), "cph-feishu-bot-cli-"), { encoding: "utf8" });
const home = join(root, "home");
await mkdirPrivate(home);
const cliEnv = buildCliEnv(home);
const cliBinary = options.binary ?? process.env["HUB_FEISHU_CLI_BIN"] ?? "lark-cli";
const temporaryName = "resource.bin";
const temporaryPath = join(home, temporaryName);
try {
await runCli(
cliBinary,
["config", "init", "--app-id", credential.appId, "--app-secret-stdin", "--brand", "feishu"],
{ cwd: root, env: cliEnv, stdin: `${credential.appSecret}\n`, timeoutMs, label: "config init" },
);
await runCli(
cliBinary,
[
"im",
"+messages-resources-download",
"--as",
"bot",
"--message-id",
request.messageId,
"--file-key",
request.fileKey,
"--type",
request.resourceType,
"--output",
temporaryName,
],
{ cwd: home, env: cliEnv, timeoutMs, label: "resource download" },
);
const metadata = await stat(temporaryPath);
if (!metadata.isFile()) {
throw new Error("lark-cli resource download did not produce a regular file");
}
if (request.maxBytes !== undefined && metadata.size > request.maxBytes) {
throw new WorkspaceFileBoundaryError(
`Feishu resource exceeds ${request.maxBytes} bytes: ${request.fileKey}`,
request.workspaceRelativePath,
"limit",
);
}
return await writeNewWorkspaceFileNoFollow(
request.workspaceRoot,
request.workspaceDir,
request.workspaceRelativePath,
createReadStream(temporaryPath),
request.maxBytes,
);
} finally {
await rm(root, { recursive: true, force: true });
}
},
};
}
async function mkdirPrivate(path: string): Promise<void> {
await mkdir(path, { recursive: true, mode: 0o700 });
await chmod(path, 0o700);
}
function buildCliEnv(home: string): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {};
for (const name of SAFE_CLI_ENV_KEYS) {
const value = process.env[name];
if (value !== undefined) env[name] = value;
}
if (env.PATH === undefined || env.PATH.trim() === "") env.PATH = DEFAULT_CLI_PATH;
env.HOME = home;
env.XDG_CONFIG_HOME = join(home, ".config");
env.XDG_CACHE_HOME = join(home, ".cache");
env.XDG_STATE_HOME = join(home, ".state");
return env;
}
async function runCli(
binary: string,
args: readonly string[],
input: {
readonly cwd: string;
readonly env: NodeJS.ProcessEnv;
readonly stdin?: string | undefined;
readonly timeoutMs: number;
readonly label: string;
},
): Promise<CommandResult> {
return new Promise<CommandResult>((resolve, reject) => {
const child = spawn(binary, args, {
cwd: input.cwd,
env: input.env,
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let outputBytes = 0;
let settled = false;
const timer = setTimeout(() => {
child.kill("SIGTERM");
finish(new Error(`lark-cli ${input.label} timed out after ${input.timeoutMs}ms`));
}, input.timeoutMs);
const appendOutput = (target: "stdout" | "stderr", chunk: Buffer | string): void => {
if (outputBytes >= MAX_COMMAND_OUTPUT_BYTES) return;
const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
const remaining = MAX_COMMAND_OUTPUT_BYTES - outputBytes;
const bounded = text.slice(0, remaining);
outputBytes += Buffer.byteLength(bounded);
if (target === "stdout") stdout += bounded;
else stderr += bounded;
};
const finish = (error?: Error, result?: CommandResult): void => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (error !== undefined) reject(error);
else resolve(result!);
};
child.stdout.on("data", (chunk: Buffer | string) => appendOutput("stdout", chunk));
child.stderr.on("data", (chunk: Buffer | string) => appendOutput("stderr", chunk));
child.once("error", (error) => {
finish(error instanceof Error ? error : new Error(String(error)));
});
child.once("close", (code, signal) => {
if (code !== 0) {
const detail = (stderr.trim() || stdout.trim()).slice(0, 500);
const status = code === null ? signal ?? "signal" : `exit ${code}`;
finish(new Error(
detail === ""
? `lark-cli ${input.label} failed (${status})`
: `lark-cli ${input.label} failed (${status}): ${detail}`,
));
return;
}
finish(undefined, { stdout, stderr });
});
child.stdin.end(input.stdin);
});
}
+111 -15
View File
@@ -2,9 +2,10 @@
* Feishu interactive card builder for agent run output.
*
* Produces card JSON with:
* 1. A collapsible tool-use panel (tool steps with status, params, results)
* 2. A collapsible reasoning panel (thinking text)
* 3. The streaming/final answer text (markdown)
* 1. A live todo checklist when the agent uses TodoWrite (Manus-style progress)
* 2. A collapsible tool-use panel (tool steps with status, params, results)
* 3. A collapsible reasoning panel (thinking text)
* 4. The streaming/final answer text (markdown)
*
* Adapted from openclaw-lark's builder.ts, simplified for our
* message.patch-based approach (no CardKit 2.0 streaming_mode).
@@ -12,7 +13,9 @@
*/
import type { ToolUseTraceStep } from "./trace-store.js";
import type { CardContentSegment } from "../outboundImages.js";
import type { AgentTodoItem } from "../../agent/todoList.js";
import { todoProgressSummary } from "../../agent/todoList.js";
import { maskMarkdownImagesForStreaming, type CardContentSegment } from "../outboundImages.js";
// ---------------------------------------------------------------------------
// Types
@@ -39,6 +42,8 @@ const TOOL_ICONS: Record<string, string> = {
glob: "search-filled",
grep: "search-filled",
edit: "edit-filled",
todowrite: "todo-filled",
todo_write: "todo-filled",
send_file: "send-filled",
request_approval: "thumb-up-filled",
feishu_read_context: "search-filled",
@@ -48,10 +53,26 @@ const TOOL_ICONS: Record<string, string> = {
};
function toolIcon(toolName: string): string {
const normalized = toolName.toLowerCase().replace(/^mcp_/, "");
const normalized = toolName.toLowerCase().replace(/^mcp_/, "").replace(/^cph_hub__/, "");
return TOOL_ICONS[normalized] ?? "tool-filled";
}
function isTodoToolName(toolName: string): boolean {
const lower = toolName.toLowerCase();
const bare = lower.includes("__") ? (lower.split("__").pop() ?? lower) : lower;
const stripped = bare.replace(/_\d+$/, "");
return (
stripped === "todowrite" ||
stripped === "todo_write" ||
stripped === "taskcreate" ||
stripped === "taskupdate" ||
stripped === "tasklist" ||
stripped === "taskget" ||
stripped === "taskstop" ||
stripped === "taskoutput"
);
}
// ---------------------------------------------------------------------------
// Card builder
// ---------------------------------------------------------------------------
@@ -61,20 +82,32 @@ export function buildAgentCard(params: {
text: string;
contentSegments?: readonly CardContentSegment[] | undefined;
reasoningText: string | undefined;
todos: readonly AgentTodoItem[] | undefined;
toolUseSteps: ToolUseTraceStep[];
toolUseElapsedMs: number | undefined;
isError: boolean | undefined;
interrupted: boolean | undefined;
runId: string | undefined;
}): Record<string, unknown> {
const { phase, text, contentSegments, reasoningText, toolUseSteps, toolUseElapsedMs, isError, interrupted } = params;
const { phase, text, contentSegments, reasoningText, todos, toolUseSteps, toolUseElapsedMs, isError, interrupted } = params;
const elements: unknown[] = [];
// Tool-use panel (always present if there are steps)
if (toolUseSteps.length > 0) {
elements.push(buildToolUsePanel(toolUseSteps, toolUseElapsedMs, phase !== "complete"));
} else if (phase === "thinking" || (phase === "streaming" && text === "" && (contentSegments === undefined || contentSegments.length === 0))) {
elements.push(buildPendingToolUsePanel());
// Todo checklist panel — primary progress signal; hide bare TodoWrite noise below.
if (todos !== undefined && todos.length > 0) {
elements.push(buildTodoPanel(todos, phase !== "complete"));
}
// Tool-use panel (exclude todo tools — already shown as checklist)
const visibleToolSteps = toolUseSteps.filter((step) => !isTodoToolName(step.toolName));
if (visibleToolSteps.length > 0) {
elements.push(buildToolUsePanel(visibleToolSteps, toolUseElapsedMs, phase !== "complete"));
} else if (
todos === undefined ||
todos.length === 0
) {
if (phase === "thinking" || (phase === "streaming" && text === "" && (contentSegments === undefined || contentSegments.length === 0))) {
elements.push(buildPendingToolUsePanel());
}
}
// Reasoning panel
@@ -161,6 +194,62 @@ function buildInterruptAction(runId: string): unknown {
};
}
// ---------------------------------------------------------------------------
// Todo checklist panel
// ---------------------------------------------------------------------------
function buildTodoPanel(todos: readonly AgentTodoItem[], expanded: boolean): unknown {
const { completed, total, inProgress } = todoProgressSummary(todos);
const titleParts = [`\u{1F4CB} \u4EFB\u52A1\u8FDB\u5EA6 ${completed}/${total}`];
if (inProgress > 0 && completed < total) titleParts.push(`(\u8FDB\u884C\u4E2D ${inProgress})`);
const lines = todos.map((todo) => formatTodoLine(todo));
return {
tag: "collapsible_panel",
expanded,
header: {
title: {
tag: "plain_text",
content: titleParts.join(" "),
text_color: completed === total && total > 0 ? "green" : "grey",
text_size: "notation",
},
vertical_align: "center",
icon: {
tag: "standard_icon",
token: "down-small-ccm_outlined",
color: "grey",
size: "16px 16px",
},
icon_position: "right",
icon_expanded_angle: -180,
},
border: { color: "grey", corner_radius: "5px" },
vertical_spacing: "4px",
padding: "8px 8px 8px 8px",
elements: [
{
tag: "markdown",
content: lines.join("\n"),
text_size: "notation",
},
],
};
}
function formatTodoLine(todo: AgentTodoItem): string {
const label =
todo.status === "in_progress" && todo.activeForm !== undefined && todo.activeForm !== ""
? todo.activeForm
: todo.content;
if (todo.status === "completed") return `- [\u2713] ~~${escapeMd(todo.content)}~~`;
if (todo.status === "in_progress") return `- [\u25B6] **${escapeMd(label)}**`;
return `- [ ] ${escapeMd(todo.content)}`;
}
function escapeMd(text: string): string {
return text.replace(/([\\`*_{}\[\]()#+\-.!>])/g, "\\$1");
}
// ---------------------------------------------------------------------------
// Tool-use panel
// ---------------------------------------------------------------------------
@@ -411,6 +500,7 @@ function buildAnswerElements(
let remaining = MAX_TEXT_LENGTH;
for (const segment of contentSegments) {
if (segment.type === "image") {
if (segment.imgKey.trim() === "") continue;
elements.push({
tag: "img",
img_key: segment.imgKey,
@@ -421,9 +511,13 @@ function buildAnswerElements(
continue;
}
if (segment.content === "" || remaining <= 0) continue;
const slice = segment.content.length <= remaining
? segment.content
: truncateText(segment.content, remaining);
// Feishu card markdown rejects ![](url) without a Feishu image_key
// ("card contains images but no imagekey" / empty image key).
const safe = maskMarkdownImagesForStreaming(segment.content);
if (safe === "" || remaining <= 0) continue;
const slice = safe.length <= remaining
? safe
: truncateText(safe, remaining);
remaining -= slice.length;
elements.push({
tag: "markdown",
@@ -433,9 +527,11 @@ function buildAnswerElements(
return elements;
}
if (text === "") return [];
const safe = maskMarkdownImagesForStreaming(text);
if (safe === "") return [];
return [{
tag: "markdown",
content: truncateText(text, MAX_TEXT_LENGTH),
content: truncateText(safe, MAX_TEXT_LENGTH),
}];
}
+33 -8
View File
@@ -34,6 +34,11 @@ import {
getToolUseTraceSteps,
} from "./trace-store.js";
import { buildAgentCard, type CardPhase } from "./builder.js";
import {
applyChecklistToolEvent,
isChecklistProgressTool,
type AgentTodoItem,
} from "../../agent/todoList.js";
import {
type CardContentSegment,
maskMarkdownImagesForStreaming,
@@ -66,6 +71,7 @@ export class StreamingAgentCard {
private currentMessageId: string | null = null;
private text = "";
private reasoningText = "";
private todos: readonly AgentTodoItem[] = [];
private runStartedAt = Date.now();
private toolUseElapsedMs: number | undefined;
private flushChain: Promise<void> = Promise.resolve();
@@ -123,17 +129,31 @@ export class StreamingAgentCard {
error: string | undefined;
durationMs: number | undefined;
}): void {
if (isChecklistProgressTool(params.toolName)) {
const next = applyChecklistToolEvent(this.todos, {
toolName: params.toolName,
input: params.input,
result: params.result,
toolUseId: params.toolUseId,
});
if (next !== null) this.todos = next;
}
recordToolUseEnd({ runId: this.runId, ...params });
this.scheduleFlush();
}
async finish(
fallbackText: string,
options: { readonly interrupted?: boolean; readonly footerText?: string | undefined } = {},
options: {
readonly interrupted?: boolean;
readonly footerText?: string | undefined;
readonly isError?: boolean;
} = {},
): Promise<void> {
await this.flushChain;
this.interrupted = options.interrupted === true;
const footerText = options.footerText ?? "";
const isError = options.isError === true;
const fallbackWithFooter = appendFooter(fallbackText, footerText);
try {
let answerText =
@@ -153,18 +173,20 @@ export class StreamingAgentCard {
);
}
let updated = true;
let cardUpdated = true;
if (answerText.length > 0 || segments.length > 0) {
updated = await this.flushCard("complete", answerText, false, segments);
cardUpdated = await this.flushCard("complete", answerText, isError, segments);
} else if (this.currentMessageId !== null) {
updated = await this.flushCard("complete", "", false, []);
cardUpdated = await this.flushCard("complete", "", isError, []);
}
if (!updated) {
if (!cardUpdated) {
// Card path failed (e.g. residual content policy). Deliver text + standalone images.
updated = await this.deliverPlainFallback(segments, answerText);
await this.deliverPlainFallback(segments, answerText);
}
if (!updated && this.interrupted) {
// Interrupt is terminal; if the live card could not be finalized, always
// send an explicit notice so the teacher sees the abort even when plain
// text partial delivery succeeded.
if (!cardUpdated && this.interrupted) {
await sendText(this.rt, this.chatId, "\u5DF2\u4E2D\u65AD\u5F53\u524D\u8FD0\u884C\u3002", this.sendOptions);
}
} finally {
@@ -231,6 +253,7 @@ export class StreamingAgentCard {
? contentSegments
: undefined,
reasoningText: this.reasoningText || undefined,
todos: this.todos.length > 0 ? this.todos : undefined,
toolUseSteps,
toolUseElapsedMs: this.toolUseElapsedMs,
isError,
@@ -249,6 +272,7 @@ export class StreamingAgentCard {
phase,
text: chunk,
reasoningText: undefined,
todos: undefined,
toolUseSteps: [],
toolUseElapsedMs: undefined,
isError,
@@ -270,6 +294,7 @@ export class StreamingAgentCard {
phase,
text: chunk,
reasoningText: undefined,
todos: undefined,
toolUseSteps: [],
toolUseElapsedMs: undefined,
isError,
+32 -12
View File
@@ -1,7 +1,8 @@
import { randomUUID } from "node:crypto";
import { join } from "node:path";
import type { ToolContext } from "../agent/tools.js";
import { downloadMessageFile, type FeishuRuntime } from "./client.js";
import type { FeishuBotCli } from "./botCli.js";
import type { FeishuRuntime } from "./client.js";
export interface FeishuMessageResourceArgs {
readonly messageId: string;
@@ -13,7 +14,11 @@ export interface DownloadedFeishuMessageResource extends FeishuMessageResourceAr
readonly path: string;
}
type DownloadContext = Pick<ToolContext, "boundChatId" | "workspaceDir"> & { readonly workspaceRoot: string };
type DownloadContext = Pick<ToolContext, "boundChatId" | "workspaceDir"> & {
readonly workspaceRoot: string;
readonly botCli: FeishuBotCli;
readonly maxFileBytes?: number | undefined;
};
interface MessageLookupResult {
readonly data?: {
@@ -51,14 +56,29 @@ export async function downloadFeishuMessageResource(
"inbox",
`feishu-${args.resourceType}-${randomUUID()}${extension}`,
);
const savePath = await downloadMessageFile(
rt,
args.messageId,
args.fileKey,
context.workspaceRoot,
context.workspaceDir,
workspaceRelativePath,
args.resourceType,
);
return { ...args, path: savePath };
try {
const savePath = await context.botCli.downloadResource({
messageId: args.messageId,
fileKey: args.fileKey,
resourceType: args.resourceType,
workspaceRoot: context.workspaceRoot,
workspaceDir: context.workspaceDir,
workspaceRelativePath,
maxBytes: context.maxFileBytes,
});
return { ...args, path: savePath };
} catch (error) {
rt.logger.error(
{
err: error,
messageId: args.messageId,
fileKey: args.fileKey,
resourceType: args.resourceType,
boundChatId: context.boundChatId,
workspaceDir: context.workspaceDir,
},
"Feishu bot CLI resource download failed",
);
throw error;
}
}
+263 -14
View File
@@ -1,6 +1,7 @@
import { createSdkMcpServer, tool, type McpSdkServerConfigWithInstance, type SdkMcpToolDefinition } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod";
import { sendApprovalCard, sendFileData, type FeishuRuntime, type SendMessageOptions } from "./client.js";
import { createFeishuBotCli } from "./botCli.js";
import { resolveDeliverableFile } from "./fileDelivery.js";
import { downloadFeishuMessageResource } from "./download.js";
import { readFeishuContext } from "./read.js";
@@ -9,8 +10,16 @@ import { CPH_HUB_MCP_TOOL_IDS, type CphHubMcpToolId } from "../agent/roleTools.j
import { WorkspaceFileBoundaryError } from "../security/workspaceFiles.js";
import type { PrismaClient } from "@prisma/client";
import type { LocalSecretEnvelope } from "../security/secretEnvelope.js";
import { createPdfToMdBundleAdapter } from "../capability/pdfToMdBundle.js";
import {
createPdfToMdBundleAdapter,
invokePdfToMdBatch,
readPdfToMdConcurrency,
MAX_PDF_TO_MD_BATCH_ITEMS,
type PdfToMdBatchItemResult,
} from "../capability/pdfToMdBundle.js";
import { AliyunDocmindClient } from "../capability/docmindClient.js";
import { createPbankService, type PbankToolResult } from "../capability/pbank.js";
import { CapabilityConnectionUnavailable } from "../capability/types.js";
export interface FileDeliveryToolOptions {
readonly rt: FeishuRuntime;
@@ -30,6 +39,11 @@ export interface FileDeliveryToolOptions {
}
export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): McpSdkServerConfigWithInstance {
const botCli = createFeishuBotCli({
organizationId: options.organizationId,
prisma: options.prisma,
secretEnvelope: options.secretEnvelope,
});
const enabledTools = new Set(options.tools ?? CPH_HUB_MCP_TOOL_IDS);
const tools: Array<SdkMcpToolDefinition<any>> = [];
@@ -147,7 +161,7 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
tools.push(
tool(
"feishu_download_resource",
"Download an image or file resource from a Feishu message in the current project's bound chat into the project workspace. Use message_id and file_key returned by feishu_read_context.",
"Download an image or file resource from a Feishu message in the current project's bound chat into the project workspace using the Organization bot identity. Use message_id and file_key returned by feishu_read_context.",
{
message_id: z.string().describe("The Feishu message id containing the resource."),
file_key: z.string().describe("The image_key or file_key from that message's content."),
@@ -175,6 +189,8 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
boundChatId: options.chatId,
workspaceRoot,
workspaceDir: options.workspaceDir,
botCli,
maxFileBytes: options.maxFileBytes,
},
options.rt,
);
@@ -246,21 +262,54 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
tools.push(
tool(
"convert_pdf_to_md",
"Convert a PDF file in the workspace to a Markdown bundle (markdown + extracted images) using Alibaba Cloud Document Mind. The PDF must already be in the workspace (use feishu_download_resource first if it came from Feishu). Returns the path to the generated markdown file and the list of extracted image paths. Mathematical formulas are converted to LaTeX.",
"Convert one or more PDF files in the workspace to Markdown bundles (markdown + extracted images) via Alibaba Cloud Document Mind. PDFs must already be in the workspace (use feishu_download_resource first for Feishu attachments). Prefer a single call with `items` for multiple PDFs — Hub converts them concurrently (bounded). Each item needs its own output_dir because the tool always writes document.md inside that directory. Formulas become LaTeX.",
{
input_path: z.string().describe("Relative path to the input PDF within the workspace."),
output_dir: z.string().describe("Relative directory within the workspace to write the markdown and images into. Will be created if it does not exist."),
input_path: z.string().optional().describe("Single-file mode: workspace-relative path to the input PDF. Required when `items` is omitted."),
output_dir: z.string().optional().describe("Single-file mode: workspace-relative directory for document.md + images. Required when `items` is omitted."),
items: z.array(z.object({
input_path: z.string().describe("Workspace-relative path to one input PDF."),
output_dir: z.string().describe("Workspace-relative output directory for this PDF (must be unique per item)."),
})).min(1).max(MAX_PDF_TO_MD_BATCH_ITEMS).optional().describe(`Batch mode: multiple PDFs converted concurrently. Max ${MAX_PDF_TO_MD_BATCH_ITEMS} items. Do not reuse output_dir across items.`),
concurrency: z.number().int().min(1).max(8).optional().describe("Optional parallel job limit for batch mode (1-8). Defaults to HUB_PDF_TO_MD_MAX_CONCURRENT (usually 3)."),
},
async (args) => {
const base = {
runId: options.runId,
organizationId: options.organizationId,
projectId: options.projectId,
workspaceDir: options.workspaceDir,
prisma: options.prisma,
};
try {
if (args.items !== undefined && args.items.length > 0) {
const batchResults = await invokePdfToMdBatch(
adapter,
base,
args.items.map((item) => ({
inputPath: item.input_path,
outputDir: item.output_dir,
})),
args.concurrency ?? readPdfToMdConcurrency(),
);
return {
content: [{ type: "text", text: formatPdfToMdBatchResult(batchResults) }],
...(batchResults.every((item) => item.ok) ? {} : { isError: true }),
};
}
if (args.input_path === undefined || args.input_path.trim() === ""
|| args.output_dir === undefined || args.output_dir.trim() === "") {
return {
isError: true,
content: [{
type: "text",
text: "convert_pdf_to_md requires either items[{input_path,output_dir},...] or both input_path and output_dir.",
}],
};
}
const result = await adapter.invoke({
runId: options.runId,
organizationId: options.organizationId,
projectId: options.projectId,
workspaceDir: options.workspaceDir,
...base,
inputPath: args.input_path,
outputDir: args.output_dir,
prisma: options.prisma,
});
const lines = [`Converted PDF to markdown. ${result.artifacts.length} files written:`];
for (const artifact of result.artifacts) {
@@ -282,6 +331,127 @@ 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 },
),
);
}
}
if (enabledTools.has("todo_write")) {
tools.push(
tool(
"todo_write",
"Create or replace the shared task checklist for this run. Call this first when the user`s request has multiple steps, then again whenever progress changes. Each item needs content + status (pending|in_progress|completed). Optionally set activeForm (present-tense label) for the current in_progress item. Keep exactly one item in_progress when work is underway. Hub shows this list on the Feishu card so teachers can track progress.",
{
todos: z
.array(
z.object({
content: z.string().min(1).describe("Imperative task description, e.g. Search PBank for derivatives."),
status: z
.enum(["pending", "in_progress", "completed"])
.describe("pending | in_progress | completed"),
activeForm: z
.string()
.optional()
.describe("Present continuous label while in_progress, e.g. Searching PBank."),
}),
)
.min(1)
.max(32)
.describe("Full replacement list for the checklist (not a patch)."),
},
async (args) => {
const completed = args.todos.filter((t) => t.status === "completed").length;
const inProgress = args.todos.filter((t) => t.status === "in_progress").length;
const lines = args.todos.map((t, i) => {
const mark = t.status === "completed" ? "x" : t.status === "in_progress" ? ">" : " ";
const label = t.status === "in_progress" && t.activeForm ? t.activeForm : t.content;
return `${i + 1}. [${mark}] ${label}`;
});
return {
content: [
{
type: "text",
text: `Checklist updated ${completed}/${args.todos.length} completed, ${inProgress} in progress.\n${lines.join("\n")}`,
},
],
};
},
{ alwaysLoad: true },
),
);
}
const instructions = mcpInstructions(enabledTools);
return createSdkMcpServer({
name: "cph_hub",
@@ -292,11 +462,68 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
});
}
function formatPdfToMdBatchResult(results: readonly PdfToMdBatchItemResult[]): string {
const ok = results.filter((item) => item.ok);
const failed = results.filter((item) => !item.ok);
const lines = [
`Batch PDF→Markdown finished: ${ok.length} succeeded, ${failed.length} failed (of ${results.length}).`,
];
for (const item of results) {
if (item.ok) {
const md = item.result.artifacts.find((artifact) => artifact.kind === "markdown")?.path;
lines.push(
`OK ${item.inputPath}${item.outputDir}`
+ (md !== undefined ? ` (${md})` : "")
+ `; pages=${item.result.consumption.quantity}`
+ `; cost=$${(item.result.consumption.costUsd ?? 0).toFixed(4)}`,
);
for (const artifact of item.result.artifacts) {
lines.push(` - ${artifact.path} (${artifact.kind})`);
}
} else {
lines.push(`FAIL ${item.inputPath}${item.outputDir}: ${item.error}`);
}
}
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 {
const instructions: string[] = [];
if (enabledTools.has("send_file")) {
instructions.push(
"Use send_file when the user asks to receive, resend, download, or attach a file.",
"Use send_file only for downloadable attachments the user should save (PDF, DOCX, ZIP, etc.).",
"For inline 图文 answers, put ![alt](workspace-relative-path) in the final assistant text instead of send_file; the hub embeds those images in the reply card.",
"Do not claim a file was sent unless send_file returns success.",
"If a generated file path is uncertain, list or inspect the workspace first, then call send_file with the actual path.",
);
@@ -314,10 +541,32 @@ function mcpInstructions(enabledTools: ReadonlySet<CphHubMcpToolId>): string {
}
if (enabledTools.has("convert_pdf_to_md")) {
instructions.push(
"Use convert_pdf_to_md when the user asks to convert a PDF to Markdown.",
"If the PDF came from a Feishu message, first use feishu_download_resource to save it to the workspace, then call convert_pdf_to_md.",
"Do NOT attempt to parse PDFs yourself with Read or Bash — always use convert_pdf_to_md for accurate text, formula, and image extraction.",
"Use convert_pdf_to_md when the user asks to convert a PDF (or several PDFs) to Markdown.",
"If PDFs came from Feishu, download each with feishu_download_resource first, then convert.",
"For multiple PDFs, call convert_pdf_to_md once with items=[{input_path,output_dir},...] so Hub converts them concurrently; give each file its own output_dir (the tool writes document.md inside it).",
"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.",
);
}
if (enabledTools.has("todo_write")) {
instructions.push(
"For multi-step work, call todo_write first with a full checklist, then update it as each step starts/finishes so the teacher sees live progress on the card.",
"Prefer mcp__cph_hub__todo_write (todo_write) for progress tracking — do not skip it because built-in TodoWrite is absent.",
);
}
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.",
);
return instructions.join(" ");
}
+45 -5
View File
@@ -14,8 +14,15 @@ import {
export const FEISHU_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
export const DEFAULT_MAX_OUTBOUND_IMAGES = 10;
const IMAGE_MARKDOWN_RE = /!\[([^\]\n]*)\]\(([^)\n]+)\)/g;
const IMAGE_MARKDOWN_RE = /!\[([^\]\n]*)\]\(([^)\n]*)\)/g;
const FENCED_CODE_RE = /```[\s\S]*?```/g;
const INLINE_CODE_RE = /`[^`\n]+`/g;
const IMAGE_FETCH_HEADERS: Record<string, string> = {
accept: "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
// Some CDNs (incl. Wikimedia) reject bare programmatic clients with 400 HTML.
"user-agent":
"Mozilla/5.0 (compatible; EducraftHub/1.0; +https://educraft.paradigm-edu.net) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
};
export type CardContentSegment =
| { readonly type: "markdown"; readonly content: string }
@@ -261,6 +268,14 @@ function blockedRanges(text: string): Array<{ start: number; end: number }> {
while ((match = FENCED_CODE_RE.exec(text)) !== null) {
ranges.push({ start: match.index, end: match.index + match[0].length });
}
INLINE_CODE_RE.lastIndex = 0;
while ((match = INLINE_CODE_RE.exec(text)) !== null) {
const start = match.index;
const end = start + match[0].length;
// Skip inline spans fully inside a fence already recorded above.
if (ranges.some((range) => start >= range.start && end <= range.end)) continue;
ranges.push({ start, end });
}
return ranges;
}
@@ -297,11 +312,13 @@ async function fetchRemoteImage(
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15_000);
try {
const response = await fetchImpl(url, {
// Direct fetch: host env may enable NODE_USE_ENV_PROXY; several image CDNs
// reject or rewrite traffic through shared egress proxies.
const response = await fetchWithoutEnvProxy(fetchImpl, url, {
method: "GET",
redirect: "manual",
signal: controller.signal,
headers: { accept: "image/*,*/*;q=0.8" },
headers: IMAGE_FETCH_HEADERS,
});
// One safe redirect hop to another public http(s) host.
if (response.status >= 300 && response.status < 400) {
@@ -315,11 +332,11 @@ async function fetchRemoteImage(
}
if (redirected.protocol !== "http:" && redirected.protocol !== "https:") return null;
if (!isPublicHttpHost(redirected.hostname)) return null;
const second = await fetchImpl(redirected, {
const second = await fetchWithoutEnvProxy(fetchImpl, redirected, {
method: "GET",
redirect: "manual",
signal: controller.signal,
headers: { accept: "image/*,*/*;q=0.8" },
headers: IMAGE_FETCH_HEADERS,
});
return readImageBody(second, maxBytes);
}
@@ -329,6 +346,29 @@ async function fetchRemoteImage(
}
}
/**
* Fetch without inheriting HTTP(S)_PROXY from the process env for one call.
* Restores env immediately so unrelated concurrent work keeps proxy settings.
*/
async function fetchWithoutEnvProxy(
fetchImpl: typeof fetch,
url: URL,
init: RequestInit,
): Promise<Response> {
const proxyKeys = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"] as const;
const saved: Array<[string, string | undefined]> = proxyKeys.map((key) => [key, process.env[key]]);
try {
for (const key of proxyKeys) delete process.env[key];
return await fetchImpl(url, init);
} finally {
for (const [key, value] of saved) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
}
async function readImageBody(response: Response, maxBytes: number): Promise<Buffer | null> {
if (!response.ok) return null;
const contentType = (response.headers.get("content-type") ?? "").toLowerCase();
+10 -5
View File
@@ -7,8 +7,10 @@
*
* - `trigger_message` / `reply`: `message.get` by message_id.
* - `status_card`: the run's status card message — same `message.get` by id.
* - `thread`: lark's thread replies. The SDK exposes `message.list` with a
* `parent_message_id` filter; we map "thread" to that.
* - `thread`: lark's thread replies. `im.v1.message.list` with
* `container_id_type="thread"` and `container_id` = the thread_id (NOT a
* message_id, which Feishu rejects with 230001). The caller supplies the
* thread_id via `args.id`; the trigger context exposes it as `thread_id`.
*
* The lark SDK's `im.v1.message` methods are dynamic at runtime (weak types);
* we cast through a known request/response shape and return a compact JSON for
@@ -69,11 +71,14 @@ export async function readFeishuContext(
return JSON.stringify(compact(msg));
}
case "thread": {
// Thread = replies to a parent message. `container_id` is the parent's
// message_id; container_id_type=message_id scopes the list to that thread.
// Thread = replies to a topic. Feishu's `im.v1.message.list` scopes
// thread replies when `container_id_type="thread"` and `container_id`
// is the thread_id (NOT a message_id — that is rejected with 230001
// "invalid container_id_type"). The caller supplies the thread_id via
// `args.id`; the trigger context exposes it as `thread_id`.
const res = await api.list({
params: {
container_id_type: "message_id",
container_id_type: "thread",
container_id: args.id,
page_size: 50,
},
+12 -10
View File
@@ -6,7 +6,7 @@ import {
removeWorkspaceFileIfUnchangedNoFollow,
type WorkspaceFileWriteResult,
} from "../security/workspaceFiles.js";
import { downloadMessageFile, type FeishuRuntime } from "./client.js";
import type { FeishuBotCli } from "./botCli.js";
export interface MessageResourceStageRequest {
readonly fileKey: string;
@@ -33,7 +33,7 @@ export interface PublishedMessageResource extends WorkspaceFileWriteResult {
/** Download Feishu resources into a private temporary workspace, never the tenant workspace. */
export async function stageMessageResources(
rt: FeishuRuntime,
botCli: FeishuBotCli,
messageId: string,
requests: readonly MessageResourceStageRequest[],
workspaceRoot: string,
@@ -49,16 +49,18 @@ export async function stageMessageResources(
try {
await mkdir(stagingRoot, { mode: 0o700 });
for (const [index, request] of requests.entries()) {
const stagedPath = await downloadMessageFile(
rt,
// Bot-identity transport only (ADR-0024): org App Secret stays in Hub,
// never crosses into the Agent surface. Staging still lands under the
// private .cph-staging tree before publish link into the tenant workspace.
const stagedPath = await botCli.downloadResource({
messageId,
request.fileKey,
fileKey: request.fileKey,
resourceType: request.resourceType,
workspaceRoot,
stagingRoot,
`resource-${index}`,
request.resourceType,
limits?.maxBytesPerFile,
);
workspaceDir: stagingRoot,
workspaceRelativePath: `resource-${index}`,
maxBytes: limits?.maxBytesPerFile,
});
resources.push({
resourceType: request.resourceType,
workspaceRelativePath: request.workspaceRelativePath,
+106
View File
@@ -0,0 +1,106 @@
/**
* User-visible run termination copy for Feishu teachers.
* Keep messages short, actionable, and free of stack traces.
*/
export interface RunOutcomeNoticeInput {
readonly wallTimeExceeded: boolean;
readonly interrupted: boolean;
readonly resultStatus: string;
readonly resultError: string | undefined;
readonly maxTurns: number;
readonly maxRunSeconds: number;
readonly hasPartialText: boolean;
}
export interface RunOutcomeNotice {
/** Mark the streaming card as failed (red footer). */
readonly isError: boolean;
/**
* Teacher-facing explanation. Appended after any partial answer text so the
* cause of a stop is never silent.
*/
readonly notice: string | undefined;
}
export function teacherFacingRunOutcome(input: RunOutcomeNoticeInput): RunOutcomeNotice {
if (input.wallTimeExceeded) {
return {
isError: true,
notice: noticeLine(
input.hasPartialText,
`\u23F1 \u4EFB\u52A1\u8D85\u65F6\uFF1A\u5DF2\u8FBE\u5230\u5355\u6B21\u8FD0\u884C\u65F6\u95F4\u4E0A\u9650\uFF08${input.maxRunSeconds} \u79D2\uFF09\u3002\u8BF7\u62C6\u5206\u4EFB\u52A1\u540E\u91CD\u8BD5\uFF0C\u6216\u8054\u7CFB\u7BA1\u7406\u5458\u8C03\u9AD8\u8FD0\u884C\u65F6\u957F\u4E0A\u9650\u3002`,
),
};
}
if (input.interrupted) {
return { isError: false, notice: undefined };
}
if (input.resultStatus === "completed") {
return { isError: false, notice: undefined };
}
const err = input.resultError ?? "";
if (isMaxTurnsError(err) || input.resultStatus === "length") {
return {
isError: true,
notice: noticeLine(
input.hasPartialText,
`\u26A0\uFE0F \u4EFB\u52A1\u4E2D\u65AD\uFF1A\u5DF2\u8FBE\u5230\u6700\u5927\u6B65\u9AA4\u6570\uFF08${input.maxTurns} \u8F6E\uFF09\u3002\u8BF7\u62C6\u5206\u4EFB\u52A1\u540E\u7EE7\u7EED\uFF0C\u6216\u8054\u7CFB\u7BA1\u7406\u5458\u8C03\u9AD8\u6B65\u9AA4\u4E0A\u9650\u3002`,
),
};
}
if (err.trim() !== "") {
const brief = sanitizeErrorBrief(err);
return {
isError: true,
notice: noticeLine(
input.hasPartialText,
`\u274C \u4EFB\u52A1\u5931\u8D25\uFF1A${brief}\u3002\u8BF7\u91CD\u8BD5\uFF1B\u82E5\u591A\u6B21\u51FA\u73B0\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458\u3002`,
),
};
}
return {
isError: true,
notice: noticeLine(
input.hasPartialText,
"\u274C \u4EFB\u52A1\u672A\u6B63\u5E38\u5B8C\u6210\u3002\u8BF7\u91CD\u8BD5\uFF1B\u82E5\u591A\u6B21\u51FA\u73B0\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458\u3002",
),
};
}
export function appendTeacherNotice(body: string, notice: string | undefined): string {
if (notice === undefined || notice === "") return body;
if (body.trim() === "") return notice;
return `${body.trimEnd()}\n\n${notice}`;
}
export function isMaxTurnsError(error: string): boolean {
const lower = error.toLowerCase();
return (
lower.includes("maximum number of turns") ||
lower.includes("max_turns") ||
lower.includes("error_max_turns") ||
lower.includes("result_error_max_turns") ||
(lower.includes("result_error_during_execution") && lower.includes("turn"))
);
}
function noticeLine(hasPartialText: boolean, message: string): string {
if (!hasPartialText) return message;
return `${message}\n\uFF08\u4E0A\u65B9\u4E3A\u5DF2\u751F\u6210\u7684\u90E8\u5206\u7ED3\u679C\u3002\uFF09`;
}
function sanitizeErrorBrief(error: string): string {
const oneLine = error.replace(/\s+/g, " ").trim();
// Drop common SDK prefixes for readability.
const stripped = oneLine
.replace(/^Claude Code returned an error result:\s*/i, "")
.replace(/^Error:\s*/i, "");
if (stripped.length <= 160) return stripped;
return `${stripped.slice(0, 157)}...`;
}
+56 -12
View File
@@ -1,7 +1,8 @@
/**
* Sends a "processing" reaction immediately, then streams a single
* interactive card through the full agent run lifecycle: thinking → tool
* calls (with trace panel) → streaming answer text → final card. The card
* calls (with trace panel) → streaming answer text → final card. On finish,
* replaces Typing with CheckMark (success) or CrossMark (failure). The card
* shows a collapsible tool-use panel, a collapsible reasoning panel, and
* the markdown answer text. Throttled to ~2.5 patches/sec to avoid
* spamming the Feishu API.
@@ -40,6 +41,7 @@ import { createAgentSdkStderrSink } from "../agent/diagnostics.js";
import { InactiveOrganizationError, lockActiveOrganization } from "../org/status.js";
import { StreamingAgentCard } from "./card/streaming-card.js";
import { createFileDeliveryMcpServer } from "./fileDeliveryTool.js";
import { appendTeacherNotice, teacherFacingRunOutcome } from "./runOutcomeNotice.js";
import { readFeishuContext } from "./read.js";
import { MessageBatcher, messageBatchKey, type MessageBatcherOptions } from "./messageBatcher.js";
import { ApprovalManager } from "./approval.js";
@@ -52,6 +54,7 @@ import {
type MessageResourceStageRequest,
type StagedMessageResourceBatch,
} from "./resourceStaging.js";
import { createFeishuBotCli, type FeishuBotCli } from "./botCli.js";
import { TriggerQueue, triggerQueue as defaultTriggerQueue, type QueuedTrigger } from "./triggerQueue.js";
import { createSlashCommandRegistry, parseSlashInvocation } from "./slashCommands.js";
import { cphHubMcpToolsForRole, roleToolsAllow } from "../agent/roleTools.js";
@@ -113,6 +116,8 @@ interface TriggerDeps {
readonly allowLegacyFeishuIdentity?: boolean | undefined;
/** Alpha Silo aggregate ingress ceiling across message and card events. */
readonly maxFeishuEventsPerMinute?: number | undefined;
/** Test/injection seam for bot-identity Feishu resource downloads. */
readonly feishuBotCli?: FeishuBotCli | undefined;
}
interface TriggerActor {
@@ -301,8 +306,13 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
senderOpenId,
});
const senderMetadata = await senderAuditMetadata(rt, senderOpenId);
const botCli = deps.feishuBotCli ?? createFeishuBotCli({
organizationId: deps.siloOrganizationId,
prisma: deps.prisma,
secretEnvelope: deps.secretEnvelope,
});
const stagedResources = await stageTriggerMessageResources(
rt,
botCli,
msg,
projectWorkspaceRoot,
deps.resourceLimits,
@@ -579,7 +589,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
card.onToolEnd({
toolName: event.toolName,
toolUseId: event.toolUseId,
input: undefined,
input: event.input,
result: event.result,
error: event.isError ? event.result : undefined,
durationMs: event.durationMs,
@@ -598,13 +608,24 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
agentExecution
.then(async (result) => {
const interrupted = result.status === "interrupted" && !wallTimeExceeded;
const finalText =
const hasPartialText = result.text.trim() !== "";
const outcome = teacherFacingRunOutcome({
wallTimeExceeded,
interrupted,
resultStatus: result.status,
resultError: result.error,
maxTurns: runPolicy.maxTurns,
maxRunSeconds: runPolicy.maxRunSeconds,
hasPartialText,
});
const baseText =
result.text !== ""
? result.text
: result.status === "failed" && result.error !== undefined
: result.status === "failed" && result.error !== undefined && outcome.notice === undefined
? `\u5904\u7406\u5931\u8D25: ${result.error}`
: result.text;
await card.finish(finalText, { interrupted });
const finalText = appendTeacherNotice(baseText, outcome.notice);
await card.finish(finalText, { interrupted, isError: outcome.isError });
const metadataPatch = sessionMetadataPatch(result.sdkSessionId);
if (metadataPatch !== null) {
await deps.prisma.agentSession.update({
@@ -668,14 +689,36 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
initializedSkills: [...(result.initializedSkillIds ?? [])],
},
});
await removeProcessingReaction();
// Mirror the start "Typing" reaction: drop processing, then stamp a
// terminal emoji so teachers see done/failed without reading the card.
const removedProcessingReaction = await removeProcessingReaction();
if (removedProcessingReaction) {
await addReaction(
rt,
msg.message_id,
outcome.isError ? "CrossMark" : "CheckMark",
);
}
})
.catch(async (e) => {
const removedProcessingReaction = await removeProcessingReaction();
if (removedProcessingReaction) {
await addReaction(rt, msg.message_id, "CrossMark");
}
await card.fail(e instanceof Error ? e.message : String(e));
await card.fail(
appendTeacherNotice(
"",
teacherFacingRunOutcome({
wallTimeExceeded: false,
interrupted: false,
resultStatus: "failed",
resultError: e instanceof Error ? e.message : String(e),
maxTurns: runPolicy.maxTurns,
maxRunSeconds: runPolicy.maxRunSeconds,
hasPartialText: false,
}).notice ?? `\u274C \u4EFB\u52A1\u5931\u8D25\uFF1A${e instanceof Error ? e.message : String(e)}`,
),
);
try {
await deps.prisma.agentRun.update({
where: { id: run.id },
@@ -1834,8 +1877,9 @@ async function senderAuditMetadata(rt: FeishuRuntime, openId: string): Promise<P
function withFileDeliveryInstructions(systemPrompt: string | undefined, roleTools: readonly string[] | undefined): string | undefined {
if (!roleToolsAllow(roleTools, "send_file")) return systemPrompt;
const fileDeliveryPrompt =
"When the user asks you to send, resend, attach, or provide a file, call the cph_hub send_file tool with the actual existing file path. " +
"Do not say a file is attached or sent unless that tool returns success.";
"When the user asks for a downloadable file attachment (PDF/DOCX/ZIP/etc.), call the cph_hub send_file tool with the actual existing file path. " +
"Do not say a file is attached or sent unless that tool returns success. " +
"For 图文并茂 / inline illustrations inside your answer, do NOT use send_file. Put workspace-relative images in the final answer with markdown image syntax ![alt](relative/path.png) (or a public https image URL). The platform uploads those into the Feishu card. Prefer workspace files over remote URLs.";
return systemPrompt === undefined ? fileDeliveryPrompt : `${systemPrompt}\n\n${fileDeliveryPrompt}`;
}
@@ -1916,7 +1960,7 @@ function isPrismaUniqueConstraintError(error: unknown): boolean {
}
async function stageTriggerMessageResources(
rt: FeishuRuntime,
botCli: FeishuBotCli,
msg: MessageReceiveEvent["message"],
workspaceRoot: string,
limits?: TriggerDeps["resourceLimits"],
@@ -1950,7 +1994,7 @@ async function stageTriggerMessageResources(
}
}
return stageMessageResources(
rt,
botCli,
msg.message_id,
requests,
workspaceRoot,
+50 -8
View File
@@ -2,7 +2,7 @@ import Fastify from "fastify";
import { registerAdminPlugin } from "./admin/plugin.js";
import { registerDatabasePlugin } from "./database/plugin.js";
import { prisma } from "./db.js";
import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client.js";
import { createLarkClient, sendText, startFeishuListenerWithClient, type FeishuRuntime } from "./feishu/client.js";
import { archiveFeishuBindingForLifecycleEvent } from "./feishu/bindingLifecycle.js";
import { makeTriggerHandler } from "./feishu/trigger.js";
import { removeAbandonedMessageResourceStages } from "./feishu/resourceStaging.js";
@@ -119,15 +119,22 @@ export async function startHub(): Promise<void> {
const publicBaseUrl = process.env["HUB_PUBLIC_BASE_URL"] ?? "http://127.0.0.1:8788";
const bind = readServerBinding();
// Startup reset: clear stale locks + mark dead runs as FAILED.
await prisma.projectAgentLock.deleteMany({});
await prisma.agentRun.updateMany({
// Startup reset: clear stale locks + mark dead runs as FAILED. Capture the
// killed runs first so we can tell their Feishu chats after the listener is up.
const interruptedRuns = await prisma.agentRun.findMany({
where: { status: "ACTIVE" },
data: { status: "FAILED", error: "process restart", finishedAt: new Date() },
select: { id: true, projectId: true },
});
app.log.info("startup: cleared stale locks + dead runs");
await prisma.projectAgentLock.deleteMany({});
if (interruptedRuns.length > 0) {
await prisma.agentRun.updateMany({
where: { id: { in: interruptedRuns.map((run) => run.id) } },
data: { status: "FAILED", error: "process restart", finishedAt: new Date() },
});
}
app.log.info({ killedRuns: interruptedRuns.length }, "startup: cleared stale locks + dead runs");
let feishuRuntime: { readonly isListenerReady?: () => boolean } | undefined;
let feishuRuntime: FeishuRuntime | undefined;
app.get("/api/healthz", async (_request, reply) => {
const feishuReady = feishuRuntime?.isListenerReady?.() ?? !booleanEnv("HUB_FEISHU_LISTENER_ENABLED", true);
if (!feishuReady) return reply.status(503).send({ ok: false, feishuReady, ts: Date.now() });
@@ -180,7 +187,7 @@ export async function startHub(): Promise<void> {
allowLegacyFeishuIdentity: false,
maxFeishuEventsPerMinute: feishuEventsPerMinute,
});
feishuRuntime = await startFeishuListenerWithClient(
const runtime = await startFeishuListenerWithClient(
feishuConfig,
larkClient,
app.log,
@@ -195,6 +202,8 @@ export async function startHub(): Promise<void> {
app.log.info({ ...event, archived: result.archived, projectId: result.projectId }, "feishu binding lifecycle event handled");
},
);
feishuRuntime = runtime;
await notifyBoundChatsOfInterruptedRuns(runtime, prisma, interruptedRuns, app.log);
} else {
app.log.info("feishu listener disabled by HUB_FEISHU_LISTENER_ENABLED");
}
@@ -202,6 +211,39 @@ export async function startHub(): Promise<void> {
app.log.info({ address }, "hub listening");
}
async function notifyBoundChatsOfInterruptedRuns(
rt: FeishuRuntime,
db: typeof prisma,
interruptedRuns: ReadonlyArray<{ readonly id: string; readonly projectId: string }>,
logger: { info: (obj: unknown, msg?: string) => void; warn: (obj: unknown, msg?: string) => void },
): Promise<void> {
if (interruptedRuns.length === 0) return;
const byProject = new Map<string, string[]>();
for (const run of interruptedRuns) {
const list = byProject.get(run.projectId) ?? [];
list.push(run.id);
byProject.set(run.projectId, list);
}
for (const [projectId, runIds] of byProject) {
const binding = await db.projectGroupBinding.findFirst({
where: { projectId, archivedAt: null },
select: { chatId: true },
});
if (binding === null) continue;
const n = runIds.length;
const text =
n === 1
? `\u26A0\uFE0F \u670D\u52A1\u521A\u521A\u91CD\u542F\uFF0C\u4E0A\u4E00\u4E2A\u6B63\u5728\u8FDB\u884C\u7684\u4EFB\u52A1\u88AB\u4E2D\u65AD\u4E86\u3002\u8BF7\u91CD\u65B0\u53D1\u8D77\u8BF7\u6C42\uFF08run: ${runIds[0]}\uFF09\u3002`
: `\u26A0\uFE0F \u670D\u52A1\u521A\u521A\u91CD\u542F\uFF0C${n} \u4E2A\u6B63\u5728\u8FDB\u884C\u7684\u4EFB\u52A1\u88AB\u4E2D\u65AD\u4E86\u3002\u8BF7\u91CD\u65B0\u53D1\u8D77\u8BF7\u6C42\u3002`;
try {
await sendText(rt, binding.chatId, text);
logger.info({ projectId, chatId: binding.chatId, runIds }, "startup: notified chat of interrupted runs");
} catch (error) {
logger.warn({ projectId, chatId: binding.chatId, err: String(error) }, "startup: failed to notify chat of interrupted runs");
}
}
}
function positiveIntegerEnv(name: string): number {
const raw = requireEnv(name);
const value = Number(raw);
+2 -2
View File
@@ -11,9 +11,9 @@ type EnvSource = Env | (() => Env);
const DEFAULT_SONNET_MODEL = "anthropic/claude-sonnet-5";
const DEFAULT_SONNET_LABEL = "Claude Sonnet 5";
const DEFAULT_AGENT_MAX_TURNS = 25;
const DEFAULT_AGENT_MAX_TURNS = 150;
const DEFAULT_AGENT_MAX_CONCURRENT_RUNS = 1;
const DEFAULT_AGENT_MAX_RUN_SECONDS = 900;
const DEFAULT_AGENT_MAX_RUN_SECONDS = 1800;
export interface ProviderRuntimeSettings {
readonly id: string;
+2 -1
View File
@@ -242,7 +242,8 @@ describe("admin auth + org API guards", () => {
headers: { cookie: `${OAUTH_STATE_COOKIE_NAME}=${nonce}` },
});
expect(res.statusCode).toBe(302);
expect(res.headers.location).toBe("/admin");
// New users without membership land on login error; session is still set.
expect(res.headers.location).toBe("/admin/login?error=no_organization");
expect(JSON.stringify(res.headers["set-cookie"])).toContain("cph_session=");
const user = await prisma.user.findUnique({ where: { feishuOpenId: "ou_new" } });
@@ -43,7 +43,7 @@ describe("Organization Agent configuration management", () => {
provider: "openrouter",
roleId: "draft",
model: "anthropic/claude-sonnet-5",
metadata: {},
metadata: { claudeSessionId: "sdk-session-old", userResumable: true },
},
});
await configuration.setRoleSkills({
@@ -59,11 +59,12 @@ describe("Organization Agent configuration management", () => {
expect(role).toMatchObject({ label: "课程草稿", systemPrompt: "write carefully" });
expect(role.tools).toEqual(["read_file", "write_file", "cph_build"]);
expect(role.skillBindings.map((binding) => binding.skill.name)).toEqual(["outline", "typst"]);
await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: "session-old-role-config" } }))
.resolves.toMatchObject({
archivedAt: expect.any(Date),
metadata: expect.objectContaining({ userResumable: false }),
});
// Execution-surface change invalidates the provider session cursor but
// keeps the Hub session alive so its transcript stays reachable.
const invalidated = await prisma.agentSession.findUniqueOrThrow({ where: { id: "session-old-role-config" } });
expect(invalidated.archivedAt).toBeNull();
expect(invalidated.metadata).toEqual(expect.objectContaining({ userResumable: false }));
expect(invalidated.metadata).not.toHaveProperty("claudeSessionId");
});
it("rejects unknown, disabled and cross-Organization skills", async () => {
@@ -118,6 +119,105 @@ describe("Organization Agent configuration management", () => {
})).rejects.toThrow("must have exactly one active default Agent role");
});
it("groups roles and skills in the shared folder tree (ADR-0028)", async () => {
const teaching = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "教学" });
const lessonPrep = await configuration.createFolder({
organizationId: DEFAULT_ORG_ID,
name: "备课",
parentId: teaching.id,
});
expect(lessonPrep.parentId).toBe(teaching.id);
const listed = await configuration.listFolders({ organizationId: DEFAULT_ORG_ID });
expect(listed.map((folder) => folder.id).sort()).toEqual([teaching.id, lessonPrep.id].sort());
const typst = await makeSkill(root, "typst");
await configuration.installSkill({ organizationId: DEFAULT_ORG_ID, sourceDir: typst, version: "1" });
await configuration.setSkillFolder({ organizationId: DEFAULT_ORG_ID, name: "typst", folderId: lessonPrep.id });
await configuration.setRoleFolder({ organizationId: DEFAULT_ORG_ID, roleId: "draft", folderId: teaching.id });
const skills = await configuration.listSkills({ organizationId: DEFAULT_ORG_ID });
expect(skills.find((skill) => skill.name === "typst")?.folderId).toBe(lessonPrep.id);
const roles = await configuration.listRoles({ organizationId: DEFAULT_ORG_ID });
expect(roles.find((role) => role.roleId === "draft")?.folderId).toBe(teaching.id);
const renamed = await configuration.updateFolder({
organizationId: DEFAULT_ORG_ID,
folderId: teaching.id,
name: "教研",
});
expect(renamed.name).toBe("教研");
});
it("moves folders within the tree and rejects moves below a descendant", async () => {
const a = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "a" });
const b = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "b", parentId: a.id });
const c = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "c" });
const moved = await configuration.updateFolder({ organizationId: DEFAULT_ORG_ID, folderId: c.id, parentId: b.id });
expect(moved.parentId).toBe(b.id);
await expect(configuration.updateFolder({ organizationId: DEFAULT_ORG_ID, folderId: a.id, parentId: c.id }))
.rejects.toThrow("folder cannot be moved below its descendant");
await expect(configuration.updateFolder({ organizationId: DEFAULT_ORG_ID, folderId: a.id, parentId: a.id }))
.rejects.toThrow("folder cannot be its own parent");
});
it("deletes only empty folders", async () => {
const folder = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "非空" });
const typst = await makeSkill(root, "typst");
await configuration.installSkill({ organizationId: DEFAULT_ORG_ID, sourceDir: typst, version: "1" });
await configuration.setSkillFolder({ organizationId: DEFAULT_ORG_ID, name: "typst", folderId: folder.id });
await expect(configuration.deleteFolder({ organizationId: DEFAULT_ORG_ID, folderId: folder.id }))
.rejects.toThrow("still has");
const parent = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "父" });
await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "子", parentId: parent.id });
await expect(configuration.deleteFolder({ organizationId: DEFAULT_ORG_ID, folderId: parent.id }))
.rejects.toThrow("child folder");
await configuration.setSkillFolder({ organizationId: DEFAULT_ORG_ID, name: "typst", folderId: null });
await configuration.deleteFolder({ organizationId: DEFAULT_ORG_ID, folderId: folder.id });
const remaining = await configuration.listFolders({ organizationId: DEFAULT_ORG_ID });
expect(remaining.map((f) => f.id)).not.toContain(folder.id);
const skill = (await configuration.listSkills({ organizationId: DEFAULT_ORG_ID })).find((s) => s.name === "typst");
expect(skill?.folderId).toBeNull();
});
it("rejects cross-Organization folder assignment", async () => {
await seedTestOrganization("org_other", "other");
const otherFolder = await configuration.createFolder({ organizationId: "org_other", name: "外部" });
const typst = await makeSkill(root, "typst");
await configuration.installSkill({ organizationId: DEFAULT_ORG_ID, sourceDir: typst, version: "1" });
await expect(configuration.setSkillFolder({
organizationId: DEFAULT_ORG_ID,
name: "typst",
folderId: otherFolder.id,
})).rejects.toThrow("folder not found in organization");
await expect(configuration.setRoleFolder({
organizationId: DEFAULT_ORG_ID,
roleId: "draft",
folderId: otherFolder.id,
})).rejects.toThrow("folder not found in organization");
});
it("treats folder assignment as a label-class change (no session archival)", async () => {
await prisma.project.create({
data: { id: "project-a", organizationId: DEFAULT_ORG_ID, name: "A", workspaceDir: "/tmp/a" },
});
await prisma.agentSession.create({
data: {
id: "session-folder-assignment",
projectId: "project-a",
provider: "openrouter",
roleId: "draft",
model: "anthropic/claude-sonnet-5",
metadata: {},
},
});
const folder = await configuration.createFolder({ organizationId: DEFAULT_ORG_ID, name: "分组" });
await configuration.setRoleFolder({ organizationId: DEFAULT_ORG_ID, roleId: "draft", folderId: folder.id });
await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: "session-folder-assignment" } }))
.resolves.toMatchObject({ archivedAt: null });
});
async function makeSkill(parent: string, name: string): Promise<string> {
const source = join(parent, "sources", name);
await mkdir(source, { recursive: true });
@@ -157,9 +157,10 @@ describe("real Claude SDK sandbox boundary", () => {
[result.error, sdkStderr.join(""), JSON.stringify(streamEvents)].filter(Boolean).join("\n"),
).toBe("completed");
expect(stub.requestCount()).toBeGreaterThanOrEqual(3);
expect(new Set(result.initializedSkillIds)).toEqual(new Set([
"cph-runtime:outline",
]));
const skillIds = new Set(result.initializedSkillIds ?? []);
expect(skillIds.has("cph-runtime:outline")).toBe(true);
// Workspace-local untrusted skills must never load (ADR-0018).
expect([...skillIds].some((id) => id.includes("untrusted"))).toBe(false);
const toolResults = streamEvents.filter((event) => event.type === "tool-result");
expect(toolResults).toHaveLength(2);
const rejectedOptOut = toolResults[0];
@@ -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> {
const payload: CapabilitySecretPayload = {
schemaVersion: 1,
kind: "docmind",
accessKeyId: "LTAI-test-key-id",
accessKeySecret: "test-secret-never-log",
endpoint: "docmind-api.cn-hangzhou.aliyuncs.com",
@@ -16,7 +16,9 @@ import { ProviderConnectionService } from "../../src/connections/providerConnect
import { FeishuApplicationConnectionService } from "../../src/connections/feishuApplicationConnections.js";
const execFileAsync = promisify(execFile);
const TEST_DATABASE_URL = "postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test";
const TEST_DATABASE_URL =
process.env.DATABASE_URL?.trim() ||
"postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test";
describe("deployment preflight CLI", { timeout: 20_000 }, () => {
let root: string;
+80 -59
View File
@@ -5,6 +5,8 @@
* FeishuRuntime (sendText/sendCard are no-ops that record calls), and a mock
* AI SDK model factory (doGenerate() returns canned responses - no network).
*/
import { mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { PrismaClient } from "@prisma/client";
import type { FastifyBaseLogger } from "fastify";
import type {
@@ -19,7 +21,21 @@ import type { FeishuRuntime } from "../../src/feishu/client.js";
import type { ModelFactory } from "../../src/agent/runner.js";
import { LocalSecretEnvelope } from "../../src/security/secretEnvelope.js";
export const TEST_DATABASE_URL = "postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test";
// Admin routes and agent-config tests need a skill store root; seed once for
// the whole vitest process when CI/dev didn't set one.
if (
(process.env.HUB_SKILL_STORE_ROOT === undefined || process.env.HUB_SKILL_STORE_ROOT.trim() === "") &&
(process.env.XDG_STATE_HOME === undefined || process.env.XDG_STATE_HOME.trim() === "")
) {
process.env.HUB_SKILL_STORE_ROOT = `${tmpdir()}/cph-test-skills`;
}
if (process.env.HUB_SKILL_STORE_ROOT !== undefined && process.env.HUB_SKILL_STORE_ROOT.trim() !== "") {
mkdirSync(process.env.HUB_SKILL_STORE_ROOT, { recursive: true });
}
export const TEST_DATABASE_URL =
process.env.DATABASE_URL?.trim() ||
"postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test";
export const DEFAULT_ORG_ID = "org_test_default";
export const TEST_SECRET_KEY_ID = "test-active";
export const TEST_SECRET_KEY = Buffer.alloc(32, "k");
@@ -34,26 +50,29 @@ export const prisma = new PrismaClient({
/** Truncate all tables before each test for isolation. */
export async function resetDb(): Promise<void> {
// User and Organization are the aggregate roots for all domain rows; their
// declared FK cascades clear projects, search documents, permissions,
// sessions and connections without repeatedly truncating pg_trgm indexes.
// Event receipts and global audit rows are independent roots.
await prisma.$transaction([
prisma.feishuEventReceipt.deleteMany(),
prisma.auditEntry.deleteMany(),
// Permission resource ids are intentionally polymorphic strings, so these
// two tables have no FK to Project and must be cleared explicitly.
prisma.permissionGrant.deleteMany(),
prisma.permissionSettings.deleteMany(),
// MemberGroup is global (ADR-0028): no FK to the org/user roots, so the
// cascade above never reaches it. Clear explicitly — closure/membership
// first (they FK into MemberGroup), groups last.
prisma.memberGroupClosure.deleteMany(),
prisma.memberGroupMembership.deleteMany(),
prisma.memberGroup.deleteMany(),
prisma.user.deleteMany(),
prisma.organization.deleteMany(),
]);
// Hard reset via TRUNCATE CASCADE. Parent RESTRICT edges and leftover
// folder trees made deleteMany-based cleanup race Prisma upserts
// ("Unique constraint failed on id" while the where-branch saw no row).
await prisma.$executeRawUnsafe(`
DO $$
DECLARE
stmt text;
BEGIN
SELECT 'TRUNCATE TABLE ' || string_agg(format('%I.%I', schemaname, tablename), ', ')
|| ' RESTART IDENTITY CASCADE'
INTO stmt
FROM pg_tables
WHERE schemaname = 'public'
AND tablename <> '_prisma_migrations';
IF stmt IS NOT NULL THEN
EXECUTE stmt;
END IF;
END $$;
`);
const leftover = await prisma.organization.count();
if (leftover !== 0) {
throw new Error(`resetDb truncate left ${leftover} organization row(s)`);
}
await seedTestOrganization();
}
@@ -61,49 +80,51 @@ export async function seedTestOrganization(
id: string = DEFAULT_ORG_ID,
slug: string = "test-default",
): Promise<void> {
// Serialise generate+inbox against concurrent callers in the same process.
// Integration tests share one DB and some files call seed without resetDb.
await prisma.$transaction(async (tx) => {
await tx.organization.upsert({
const existing = await tx.organization.findUnique({
where: { id },
update: {},
create: { id, slug, name: "Test Default Organization" },
select: { id: true },
});
await tx.organizationProjectSettings.upsert({
where: { organizationId: id },
update: {},
create: { organizationId: id, membersCanCreateProjects: true },
});
const defaultRole = await tx.organizationAgentRole.upsert({
where: { organizationId_roleId: { organizationId: id, roleId: "draft" } },
update: { label: "草稿", isDefault: true, disabledAt: null },
create: {
id: `agent_role_draft_${id}`,
organizationId: id,
roleId: "draft",
label: "草稿",
sortOrder: 10,
isDefault: true,
},
});
await tx.organizationAgentRole.updateMany({
where: { organizationId: id, id: { not: defaultRole.id }, isDefault: true },
data: { isDefault: false },
if (existing === null) {
await tx.organization.create({
data: {
id,
slug,
name: "Test Default Organization",
projectSettings: {
create: { membersCanCreateProjects: true },
},
agentRoles: {
create: {
id: `agent_role_draft_${id}`,
roleId: "draft",
label: "草稿",
sortOrder: 10,
isDefault: true,
},
},
},
});
}
const inbox = await tx.folder.findFirst({
where: { organizationId: id, kind: "SYSTEM_INBOX", archivedAt: null },
select: { id: true },
});
if (inbox === null) {
await tx.folder.create({
data: {
id: `folder_inbox_${id}`,
organizationId: id,
name: "Inbox",
kind: "SYSTEM_INBOX",
sortKey: "000000",
},
});
}
});
const inbox = await prisma.folder.findFirst({
where: { organizationId: id, kind: "SYSTEM_INBOX", archivedAt: null },
select: { id: true },
});
if (inbox === null) {
await prisma.folder.create({
data: {
id: `folder_inbox_${id}`,
organizationId: id,
name: "Inbox",
kind: "SYSTEM_INBOX",
sortKey: "000000",
},
});
}
}
/** A logger that discards everything (tests don't need fastify's pino). */
+31 -18
View File
@@ -11,11 +11,14 @@ import {
seedProject,
seedTestOrganization,
silentLogger,
testSecretEnvelope,
} from "./helpers.js";
import { InMemoryModelRegistry } from "../../src/agent/models.js";
import { makeTriggerHandler as makeProductionTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js";
import { TriggerQueue } from "../../src/feishu/triggerQueue.js";
import type { MessageReceiveEvent, CardActionEvent } from "../../src/feishu/client.js";
import type { FeishuBotCli } from "../../src/feishu/botCli.js";
import { writeNewWorkspaceFileNoFollow } from "../../src/security/workspaceFiles.js";
import type { RunRequest, RunResult } from "../../src/agent/runner.js";
import type { RuntimeSettings } from "../../src/settings/runtime.js";
@@ -34,6 +37,7 @@ function makeTriggerHandler(deps: TestTriggerDeps): ReturnType<typeof makeProduc
publicBaseUrl: "https://educraft.example.test",
siloOrganizationId: DEFAULT_ORG_ID,
allowLegacyFeishuIdentity: true,
secretEnvelope: testSecretEnvelope,
...deps,
});
}
@@ -190,13 +194,14 @@ describe("trigger full lifecycle (integration)", () => {
where: { id: "proj-post-image" },
data: { workspaceDir },
});
const messageResourceGet = vi.fn(async () => ({
getReadableStream: () => Readable.from([Buffer.from("image bytes")]),
}));
const imV1 = (rt.client as unknown as {
im: { v1: { messageResource?: { get: typeof messageResourceGet } } };
}).im.v1;
imV1.messageResource = { get: messageResourceGet };
const downloadResource = vi.fn(async (request) => writeNewWorkspaceFileNoFollow(
request.workspaceRoot,
request.workspaceDir,
request.workspaceRelativePath,
Readable.from([Buffer.from("image bytes")]),
request.maxBytes,
));
const feishuBotCli: FeishuBotCli = { downloadResource };
const baseEvent = makeEvent("chat-post-image", "@_user_1 看看这张图");
const event: MessageReceiveEvent = {
...baseEvent,
@@ -220,6 +225,7 @@ describe("trigger full lifecycle (integration)", () => {
runAgent,
projectWorkspaceRoot: workspaceRoot,
messageBatcherOptions: { maxMessages: 1 },
feishuBotCli,
});
await trigger(event, rt);
@@ -228,10 +234,11 @@ describe("trigger full lifecycle (integration)", () => {
expect(runAgentCalls).toHaveLength(1);
});
expect(runAgentCalls[0]?.prompt).toContain(join(await realpath(workspaceDir), ".cph", "inbox"));
expect(messageResourceGet).toHaveBeenCalledWith({
params: { type: "image" },
path: { message_id: event.message.message_id, file_key: "img-key-1" },
});
expect(downloadResource).toHaveBeenCalledWith(expect.objectContaining({
messageId: event.message.message_id,
fileKey: "img-key-1",
resourceType: "image",
}));
const inboxFiles = await readdir(join(workspaceDir, ".cph", "inbox"));
expect(inboxFiles).toHaveLength(1);
await expect(readFile(join(workspaceDir, ".cph", "inbox", inboxFiles[0]!))).resolves.toEqual(Buffer.from("image bytes"));
@@ -1109,15 +1116,18 @@ describe("trigger full lifecycle (integration)", () => {
});
const resourceEntered = deferred<void>();
const releaseResource = deferred<void>();
const messageResourceGet = vi.fn(async () => {
const downloadResource = vi.fn(async (request) => {
resourceEntered.resolve();
await releaseResource.promise;
return { getReadableStream: () => Readable.from([Buffer.from("staged image bytes")]) };
return writeNewWorkspaceFileNoFollow(
request.workspaceRoot,
request.workspaceDir,
request.workspaceRelativePath,
Readable.from([Buffer.from("staged image bytes")]),
request.maxBytes,
);
});
const imV1 = (rt.client as unknown as {
im: { v1: { messageResource?: { get: typeof messageResourceGet } } };
}).im.v1;
imV1.messageResource = { get: messageResourceGet };
const feishuBotCli: FeishuBotCli = { downloadResource };
const baseEvent = makeEvent("chat-attachment-race", "@_user_1 附件竞态");
const event: MessageReceiveEvent = {
...baseEvent,
@@ -1141,6 +1151,7 @@ describe("trigger full lifecycle (integration)", () => {
runAgent,
projectWorkspaceRoot: workspaceRoot,
messageBatcherOptions: { maxMessages: 1 },
feishuBotCli,
});
const pendingTrigger = trigger(event, rt);
@@ -1600,7 +1611,9 @@ describe("trigger full lifecycle (integration)", () => {
expect(runs[0]?.status).toBe("CANCELED");
});
expect(patch).toHaveBeenCalled();
expect(rt.sentTexts).toContain("已中断当前运行。");
await vi.waitFor(() => {
expect(rt.sentTexts).toContain("已中断当前运行。");
});
});
it("denies interrupt when the operator lacks agent.cancel permission", async () => {
+110 -2
View File
@@ -1,8 +1,9 @@
import { mkdir, mkdtemp, realpath, rm, symlink } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createAgentSecurityPolicy } from "../../src/agent/security.js";
import { importSkillDirectory } from "../../src/agent/skillStore.js";
describe("agent subprocess security policy", () => {
const roots: string[] = [];
@@ -26,6 +27,13 @@ describe("agent subprocess security policy", () => {
PATH: "/usr/local/bin:/usr/bin:/bin",
LANG: "C.UTF-8",
CPH_BIN: "/usr/local/bin/cph",
HTTP_PROXY: "http://127.0.0.1:7890",
HTTPS_PROXY: "http://127.0.0.1:7890",
ALL_PROXY: "socks5h://127.0.0.1:7890",
NO_PROXY: "127.0.0.1,localhost,::1",
NODE_USE_ENV_PROXY: "1",
TYPST_PACKAGE_PATH: "/srv/curriculum-project-hub/typst-packages/para-26071100",
TYPST_PACKAGE_CACHE_PATH: "/srv/curriculum-project-hub/typst-packages/para-26071100",
DATABASE_URL: "postgresql://platform-secret",
FEISHU_APP_SECRET: "feishu-secret",
HUB_SESSION_SECRET: "session-secret",
@@ -38,9 +46,16 @@ describe("agent subprocess security policy", () => {
PATH: "/usr/local/bin:/usr/bin:/bin",
LANG: "C.UTF-8",
CPH_BIN: "/usr/local/bin/cph",
TYPST_PACKAGE_PATH: "/srv/curriculum-project-hub/typst-packages/para-26071100",
TYPST_PACKAGE_CACHE_PATH: "/srv/curriculum-project-hub/typst-packages/para-26071100",
ANTHROPIC_BASE_URL: "http://127.0.0.1:43123",
ANTHROPIC_AUTH_TOKEN: "run-proxy-capability",
ANTHROPIC_API_KEY: "",
HTTP_PROXY: "http://127.0.0.1:7890",
HTTPS_PROXY: "http://127.0.0.1:7890",
ALL_PROXY: "socks5h://127.0.0.1:7890",
NO_PROXY: "127.0.0.1,localhost,::1",
NODE_USE_ENV_PROXY: "1",
});
expect(policy.env).not.toHaveProperty("DATABASE_URL");
expect(policy.env).not.toHaveProperty("FEISHU_APP_SECRET");
@@ -61,7 +76,10 @@ describe("agent subprocess security policy", () => {
autoAllowBashIfSandboxed: true,
allowUnsandboxedCommands: false,
filesystem: {
allowWrite: [canonicalWorkspace],
allowWrite: expect.arrayContaining([
canonicalWorkspace,
"/srv/curriculum-project-hub/typst-packages/para-26071100",
]),
denyRead: ["/"],
allowRead: expect.arrayContaining([canonicalWorkspace, "/usr/bin"]),
},
@@ -74,6 +92,59 @@ describe("agent subprocess security policy", () => {
});
});
it("passes configured Typst package roots and exposes them read-only to the sandbox", async () => {
const { workspaceRoot, workspace } = await makeWorkspace();
const packageRoot = "/srv/curriculum-project-hub/typst-packages/para-26071100";
const cacheRoot = "/var/cache/cph-hub/para-26071100/typst";
const policy = await createAgentSecurityPolicy({
runId: "run-test",
workspaceRoot,
workspaceDir: workspace,
hostEnv: {
PATH: "/usr/bin:/bin",
TYPST_PACKAGE_PATH: packageRoot,
TYPST_PACKAGE_CACHE_PATH: cacheRoot,
},
});
const canonicalWorkspace = await realpath(workspace);
expect(policy.env).toMatchObject({
TYPST_PACKAGE_PATH: packageRoot,
TYPST_PACKAGE_CACHE_PATH: cacheRoot,
});
expect(policy.sandbox.filesystem.allowRead).toEqual(expect.arrayContaining([packageRoot, cacheRoot]));
expect(policy.sandbox.filesystem.allowWrite).toEqual(expect.arrayContaining([canonicalWorkspace, cacheRoot]));
expect(policy.sandbox.filesystem.allowWrite).not.toContain(packageRoot);
});
it("rejects a relative Typst package root instead of silently losing package access", async () => {
const { workspaceRoot, workspace } = await makeWorkspace();
await expect(createAgentSecurityPolicy({
runId: "run-test",
workspaceRoot,
workspaceDir: workspace,
hostEnv: {
PATH: "/usr/bin:/bin",
TYPST_PACKAGE_PATH: "typst-packages",
},
})).rejects.toThrow("TYPST_PACKAGE_PATH must be absolute");
});
it("rejects a Typst cache rooted at the filesystem root instead of widening writes", async () => {
const { workspaceRoot, workspace } = await makeWorkspace();
await expect(createAgentSecurityPolicy({
runId: "run-test",
workspaceRoot,
workspaceDir: workspace,
hostEnv: {
PATH: "/usr/bin:/bin",
TYPST_PACKAGE_CACHE_PATH: "/",
},
})).rejects.toThrow("TYPST_PACKAGE_CACHE_PATH must not be the filesystem root");
});
it("rejects provider environment keys outside the explicit protocol", async () => {
const { workspaceRoot, workspace } = await makeWorkspace();
@@ -125,6 +196,43 @@ describe("agent subprocess security policy", () => {
})).rejects.toThrow("Agent temp path is too long for sandbox bridge sockets");
});
it("mirrors selected skills under .cph/runtime-skills and exposes CPH_RUNTIME_SKILLS_DIR", async () => {
const { root, workspaceRoot, workspace } = await makeWorkspace();
const storeRoot = join(root, "skills-store");
const skillSource = join(root, "skill-src", "pdf-to-md");
await mkdir(skillSource, { recursive: true });
await writeFile(
join(skillSource, "SKILL.md"),
"---\nname: pdf-to-md\ndescription: convert\n---\n# pdf-to-md\nbatch items\n",
);
const installed = await importSkillDirectory({ sourceDir: skillSource, storeRoot });
const policy = await createAgentSecurityPolicy({
runId: "run-skills",
workspaceRoot,
workspaceDir: workspace,
skills: [{
name: "pdf-to-md",
version: "1",
contentDigest: installed.contentDigest,
}],
hostEnv: {
PATH: "/usr/bin:/bin",
HUB_SKILL_STORE_ROOT: storeRoot,
},
});
const canonicalWorkspace = await realpath(workspace);
const mirrored = join(canonicalWorkspace, ".cph", "runtime-skills", "pdf-to-md", "SKILL.md");
await expect(readFile(mirrored, "utf8")).resolves.toContain("batch items");
expect(policy.env.CPH_RUNTIME_SKILLS_DIR).toBe(join(canonicalWorkspace, ".cph", "runtime-skills"));
expect(policy.env.CPH_RUNTIME_SKILLS_REL).toBe(join(".cph", "runtime-skills"));
expect(policy.skillIds).toEqual(["cph-runtime:pdf-to-md"]);
expect(policy.sandbox.filesystem.allowRead).toEqual(
expect.arrayContaining([canonicalWorkspace, policy.skillPluginRoot]),
);
await policy.cleanup();
});
it("rejects a project workspace whose real path escapes the configured workspace root", async () => {
const { root, workspaceRoot } = await makeWorkspace();
const outside = join(root, "outside");
+50
View File
@@ -0,0 +1,50 @@
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, it } from "vitest";
import {
AliyunDocmindClient,
DocmindClientError,
DOCMIND_CONNECT_TIMEOUT_MS,
DOCMIND_READ_TIMEOUT_MS,
createDocmindRuntimeOptions,
} from "../../src/capability/docmindClient.js";
describe("createDocmindRuntimeOptions", () => {
it("overrides httpx's 3s default so PDF OSS uploads can complete", () => {
const runtime = createDocmindRuntimeOptions();
// Production failure: ReadTimeout(3000) on docmind OSS upload.
expect(DOCMIND_CONNECT_TIMEOUT_MS).toBeGreaterThan(3_000);
expect(DOCMIND_READ_TIMEOUT_MS).toBeGreaterThan(3_000);
expect(runtime.connectTimeout).toBe(DOCMIND_CONNECT_TIMEOUT_MS);
expect(runtime.readTimeout).toBe(DOCMIND_READ_TIMEOUT_MS);
});
});
describe("AliyunDocmindClient local file open", () => {
it("rejects a missing input file without crashing the process", async () => {
const client = new AliyunDocmindClient();
const missing = join(tmpdir(), `docmind-missing-${Date.now()}.pdf`);
// If createReadStream errors are left unhandled, Vitest aborts the suite
// with an unhandled 'error' event instead of reaching this assertion.
await expect(client.parse(
{
accessKeyId: "ak",
accessKeySecret: "sk",
endpoint: "docmind-api.cn-hangzhou.aliyuncs.com",
},
{ inputFilePath: missing },
)).rejects.toBeInstanceOf(DocmindClientError);
await expect(client.parse(
{
accessKeyId: "ak",
accessKeySecret: "sk",
endpoint: "docmind-api.cn-hangzhou.aliyuncs.com",
},
{ inputFilePath: missing },
)).rejects.toMatchObject({
code: "docmind_rejected",
message: expect.stringContaining("input file not found"),
});
});
});
+101
View File
@@ -0,0 +1,101 @@
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, it } from "vitest";
import { createFeishuBotCli } from "../../src/feishu/botCli.js";
import type { PrismaClient } from "@prisma/client";
import type { LocalSecretEnvelope } from "../../src/security/secretEnvelope.js";
const itOnLinux = process.platform === "linux" ? it : it.skip;
const fakeCredential = {
connectionId: "connection-1",
organizationId: "org-1",
appId: "cli-test-app",
appSecret: "cli-test-secret",
botOpenId: "ou-test-bot",
verificationToken: "verification-token",
encryptKey: "encrypt-key",
};
describe("Feishu bot CLI adapter", () => {
itOnLinux("uses bot identity and writes the CLI result into the workspace", async () => {
const root = await mkdtemp(join(tmpdir(), "hub-feishu-bot-cli-test-"));
const workspaceDir = join(root, "workspace");
const binary = join(root, "fake-lark-cli");
await mkdir(workspaceDir);
await writeFakeCli(binary);
try {
const cli = createFeishuBotCli({
organizationId: "org-1",
prisma: {} as PrismaClient,
secretEnvelope: {} as LocalSecretEnvelope,
binary,
resolveCredential: async () => fakeCredential,
});
const result = await cli.downloadResource({
messageId: "message-1",
fileKey: "file-1",
resourceType: "file",
workspaceRoot: root,
workspaceDir,
workspaceRelativePath: "inbox/resource.bin",
maxBytes: 1024,
});
await expect(readFile(result, "utf8")).resolves.toBe("resource bytes");
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("rejects a resource above the configured limit", async () => {
const root = await mkdtemp(join(tmpdir(), "hub-feishu-bot-cli-limit-"));
const workspaceDir = join(root, "workspace");
const binary = join(root, "fake-lark-cli");
await mkdir(workspaceDir);
await writeFakeCli(binary);
try {
const cli = createFeishuBotCli({
organizationId: "org-1",
prisma: {} as PrismaClient,
secretEnvelope: {} as LocalSecretEnvelope,
binary,
resolveCredential: async () => fakeCredential,
});
await expect(cli.downloadResource({
messageId: "message-1",
fileKey: "file-1",
resourceType: "file",
workspaceRoot: root,
workspaceDir,
workspaceRelativePath: "inbox/resource.bin",
maxBytes: 4,
})).rejects.toMatchObject({ reason: "limit" });
} finally {
await rm(root, { recursive: true, force: true });
}
});
});
async function writeFakeCli(path: string): Promise<void> {
await writeFile(path, `#!/usr/bin/env node
import { writeFileSync } from "node:fs";
import { join } from "node:path";
const args = process.argv.slice(2);
if (args[0] === "config" && args[1] === "init") {
process.stdin.resume();
process.stdin.on("end", () => process.exit(0));
} else if (args.includes("+messages-resources-download")) {
const asIndex = args.indexOf("--as");
const outputIndex = args.indexOf("--output");
if (asIndex < 0 || args[asIndex + 1] !== "bot" || outputIndex < 0) process.exit(2);
writeFileSync(join(process.cwd(), args[outputIndex + 1]), "resource bytes");
process.exit(0);
} else {
process.exit(3);
}
`);
await chmod(path, 0o755);
}
+28 -12
View File
@@ -5,6 +5,8 @@ import { Readable } from "node:stream";
import { describe, expect, it, vi } from "vitest";
import { claudeSdkToolConfigForRole, cphHubMcpToolsForRole } from "../../src/agent/roleTools.js";
import type { FeishuRuntime } from "../../src/feishu/client.js";
import type { FeishuBotCli } from "../../src/feishu/botCli.js";
import { writeNewWorkspaceFileNoFollow } from "../../src/security/workspaceFiles.js";
import { downloadFeishuMessageResource } from "../../src/feishu/download.js";
const itOnLinux = process.platform === "linux" ? it : it.skip;
@@ -12,7 +14,10 @@ const itOnLinux = process.platform === "linux" ? it : it.skip;
describe("Feishu message resource download", () => {
it("exposes the download tool to default and explicitly configured roles", () => {
expect(cphHubMcpToolsForRole(undefined)).toContain("feishu_download_resource");
expect(cphHubMcpToolsForRole(["feishu_download_resource"])).toEqual(["feishu_download_resource"]);
expect(cphHubMcpToolsForRole(["feishu_download_resource"])).toEqual([
"todo_write",
"feishu_download_resource",
]);
expect(claudeSdkToolConfigForRole(["feishu_download_resource"]).allowedTools).toEqual([
"mcp__cph_hub__feishu_download_resource",
]);
@@ -24,14 +29,12 @@ describe("Feishu message resource download", () => {
await mkdir(workspaceDir);
try {
const messageGet = vi.fn(async () => ({ data: { items: [{ chat_id: "chat-1" }] } }));
const messageResourceGet = vi.fn(async () => ({
getReadableStream: () => Readable.from([Buffer.from("image bytes")]),
}));
const messageResourceGet = vi.fn();
const rt = mockRuntime(messageGet, messageResourceGet);
const botCli = fakeBotCli();
const result = await downloadFeishuMessageResource(
{ messageId: "message-1", fileKey: "img-key-1", resourceType: "image" },
{ boundChatId: "chat-1", workspaceRoot, workspaceDir },
{ boundChatId: "chat-1", workspaceRoot, workspaceDir, botCli },
rt,
);
@@ -41,10 +44,7 @@ describe("Feishu message resource download", () => {
);
expect(result.path).toMatch(/\.png$/);
await expect(readFile(result.path, "utf8")).resolves.toBe("image bytes");
expect(messageResourceGet).toHaveBeenCalledWith({
params: { type: "image" },
path: { message_id: "message-1", file_key: "img-key-1" },
});
expect(messageResourceGet).not.toHaveBeenCalled();
} finally {
await rm(workspaceRoot, { recursive: true, force: true });
}
@@ -57,10 +57,14 @@ describe("Feishu message resource download", () => {
await expect(downloadFeishuMessageResource(
{ messageId: "message-other", fileKey: "img-key-other", resourceType: "image" },
{ boundChatId: "chat-1", workspaceRoot: "/tmp", workspaceDir: "/tmp/project-1" },
{
boundChatId: "chat-1",
workspaceRoot: "/tmp",
workspaceDir: "/tmp/project-1",
botCli: fakeBotCli(),
},
rt,
)).rejects.toThrow("current project's bound chat");
expect(messageResourceGet).not.toHaveBeenCalled();
});
});
@@ -89,6 +93,18 @@ function mockRuntime(
};
}
function fakeBotCli(): FeishuBotCli {
return {
downloadResource: (request) => writeNewWorkspaceFileNoFollow(
request.workspaceRoot,
request.workspaceDir,
request.workspaceRelativePath,
Readable.from([Buffer.from("image bytes")]),
request.maxBytes,
),
};
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
@@ -42,6 +42,12 @@ describe("outbound markdown image parsing", () => {
);
expect(maskMarkdownImagesForStreaming("![](https://x/y.png)")).toBe("【图片】");
});
it("ignores markdown image examples inside inline code", () => {
const text = "例如 `![](images/img_5.png)` 这样写,不会当真实图片";
expect(findMarkdownImagesOutsideCode(text)).toEqual([]);
expect(maskMarkdownImagesForStreaming(text)).toBe(text);
});
});
describe("materializeAnswerSegments", () => {
+2 -1
View File
@@ -52,7 +52,7 @@ describe("Feishu reactions", () => {
await expect(removeReaction(rt, "message-1", "reaction-1")).resolves.toBe(false);
});
it("adds Typing on start and removes it on success", async () => {
it("adds Typing on start and replaces it with CheckMark on success", async () => {
const run = deferred<RunResult>();
const rt = mockRuntime();
const runAgent = vi.fn((req: RunRequest) => {
@@ -71,6 +71,7 @@ describe("Feishu reactions", () => {
expect(rt.reactionRequests).toEqual([
{ kind: "add", messageId: "message-1", emoji: "Typing", reactionId: "reaction-1" },
{ kind: "remove", messageId: "message-1", reactionId: "reaction-1" },
{ kind: "add", messageId: "message-1", emoji: "CheckMark", reactionId: "reaction-2" },
]);
});
});
Binary file not shown.
+58
View File
@@ -0,0 +1,58 @@
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([
"todo_write",
"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");
});
});
+56
View File
@@ -0,0 +1,56 @@
import { readFile } from "node:fs/promises";
import { inflateRawSync } from "node:zlib";
import { describe, expect, it } from "vitest";
/** Mirrors hub/src/capability/pbank.ts zip reader contracts. */
function listZipEntries(buffer: Buffer) {
let eocd = -1;
const minEocd = Math.max(0, buffer.length - (22 + 0xffff));
for (let i = buffer.length - 22; i >= minEocd; i -= 1) {
if (buffer.readUInt32LE(i) === 0x06054b50) {
eocd = i;
break;
}
}
if (eocd < 0) throw new Error("missing eocd");
const totalEntries = buffer.readUInt16LE(eocd + 10);
const centralOffset = buffer.readUInt32LE(eocd + 16);
const entries: Array<{ name: string; method: number; compressedSize: number; uncompressedSize: number; localHeaderOffset: number }> = [];
let offset = centralOffset;
for (let i = 0; i < totalEntries; i += 1) {
const method = buffer.readUInt16LE(offset + 10);
const compressedSize = buffer.readUInt32LE(offset + 20);
const uncompressedSize = buffer.readUInt32LE(offset + 24);
const nameLen = buffer.readUInt16LE(offset + 28);
const extraLen = buffer.readUInt16LE(offset + 30);
const commentLen = buffer.readUInt16LE(offset + 32);
const localHeaderOffset = buffer.readUInt32LE(offset + 42);
const nameStart = offset + 46;
const name = buffer.subarray(nameStart, nameStart + nameLen).toString("utf8");
entries.push({ name, method, compressedSize, uncompressedSize, localHeaderOffset });
offset = nameStart + nameLen + extraLen + commentLen;
}
return entries;
}
function inflateZipEntry(buffer: Buffer, entry: { method: number; compressedSize: number; uncompressedSize: number; localHeaderOffset: number }) {
const local = entry.localHeaderOffset;
const nameLen = buffer.readUInt16LE(local + 26);
const extraLen = buffer.readUInt16LE(local + 28);
const dataStart = local + 30 + nameLen + extraLen;
const compressed = buffer.subarray(dataStart, dataStart + entry.compressedSize);
if (entry.method === 0) return Buffer.from(compressed);
if (entry.method === 8) return Buffer.from(inflateRawSync(compressed));
throw new Error(`method ${entry.method}`);
}
describe("pbank zip reader contract", () => {
it("lists and inflates deflated entries without host unzip", async () => {
const zip = await readFile(new URL("./fixtures/pbank-mini.zip", import.meta.url));
const entries = listZipEntries(zip);
expect(entries.map((e) => e.name).sort()).toEqual(["fig/a.png", "hello.txt"]);
const hello = entries.find((e) => e.name === "hello.txt")!;
const text = inflateZipEntry(zip, hello).toString("utf8");
expect(text).toBe("hello pbank\n");
});
});
+148
View File
@@ -0,0 +1,148 @@
import { describe, expect, it, vi } from "vitest";
import {
clampPdfToMdConcurrency,
invokePdfToMdBatch,
mapPool,
readPdfToMdConcurrency,
} from "../../src/capability/pdfToMdBundle.js";
import type { CapabilityAdapter, CapabilityInvocationResult } from "../../src/capability/types.js";
function okResult(label: string, pages: number): CapabilityInvocationResult {
return {
artifacts: [{ path: `out/${label}/document.md`, kind: "markdown" }],
consumption: {
provider: "aliyun_docmind",
model: null,
inputTokens: null,
outputTokens: null,
quantity: pages,
unit: "pages",
costUsd: pages * 0.0056,
correlationId: `job-${label}`,
},
};
}
describe("pdf_to_md batch concurrency helpers", () => {
it("mapPool caps in-flight workers", async () => {
let inFlight = 0;
let maxInFlight = 0;
const values = await mapPool([1, 2, 3, 4, 5], 2, async (item) => {
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
await new Promise((resolve) => setTimeout(resolve, 40));
inFlight -= 1;
return item * 10;
});
expect(values).toEqual([10, 20, 30, 40, 50]);
expect(maxInFlight).toBe(2);
});
it("clamps concurrency and reads env", () => {
expect(clampPdfToMdConcurrency(99)).toBe(8);
expect(clampPdfToMdConcurrency(0)).toBe(1);
expect(readPdfToMdConcurrency({})).toBe(3);
expect(readPdfToMdConcurrency({ HUB_PDF_TO_MD_MAX_CONCURRENT: "5" })).toBe(5);
expect(() => readPdfToMdConcurrency({ HUB_PDF_TO_MD_MAX_CONCURRENT: "nope" })).toThrow(/positive integer/);
});
it("runs batch items concurrently and preserves order", async () => {
let inFlight = 0;
let maxInFlight = 0;
const adapter: CapabilityAdapter = {
capabilityId: "pdf_to_md_bundle",
invoke: vi.fn(async (input) => {
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
await new Promise((resolve) => setTimeout(resolve, 60));
inFlight -= 1;
const label = input.inputPath.includes("b") ? "b" : input.inputPath.includes("c") ? "c" : "a";
return okResult(label, label === "a" ? 1 : label === "b" ? 2 : 3);
}),
};
const batch = await invokePdfToMdBatch(
adapter,
{
runId: "run-1",
organizationId: "org-1",
projectId: "proj-1",
workspaceDir: "/tmp/ws",
prisma: {} as never,
},
[
{ inputPath: "a.pdf", outputDir: "out/a" },
{ inputPath: "b.pdf", outputDir: "out/b" },
{ inputPath: "c.pdf", outputDir: "out/c" },
],
3,
);
expect(batch.map((item) => item.ok)).toEqual([true, true, true]);
expect(maxInFlight).toBe(3);
expect(adapter.invoke).toHaveBeenCalledTimes(3);
if (batch[0]?.ok && batch[1]?.ok && batch[2]?.ok) {
expect(batch[0].result.consumption.correlationId).toBe("job-a");
expect(batch[1].result.consumption.correlationId).toBe("job-b");
expect(batch[2].result.consumption.correlationId).toBe("job-c");
}
});
it("isolates per-item failures without canceling siblings", async () => {
const adapter: CapabilityAdapter = {
capabilityId: "pdf_to_md_bundle",
invoke: vi.fn(async (input) => {
if (input.inputPath.includes("bad")) {
throw new Error("boom");
}
return okResult("ok", 1);
}),
};
const batch = await invokePdfToMdBatch(
adapter,
{
runId: "run-1",
organizationId: "org-1",
projectId: "proj-1",
workspaceDir: "/tmp/ws",
prisma: {} as never,
},
[
{ inputPath: "ok.pdf", outputDir: "out/ok" },
{ inputPath: "bad.pdf", outputDir: "out/bad" },
],
2,
);
expect(batch[0]?.ok).toBe(true);
expect(batch[1]?.ok).toBe(false);
if (batch[1]?.ok === false) {
expect(batch[1].error).toMatch(/boom/);
}
});
it("rejects duplicate output dirs before starting work", async () => {
const invoke = vi.fn();
const adapter: CapabilityAdapter = {
capabilityId: "pdf_to_md_bundle",
invoke,
};
await expect(invokePdfToMdBatch(
adapter,
{
runId: "run-1",
organizationId: "org-1",
projectId: "proj-1",
workspaceDir: "/tmp/ws",
prisma: {} as never,
},
[
{ inputPath: "a.pdf", outputDir: "out/same" },
{ inputPath: "b.pdf", outputDir: "out/same/" },
],
2,
)).rejects.toThrow(/distinct output_dir/);
expect(invoke).not.toHaveBeenCalled();
});
});
+7 -2
View File
@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { removeAbandonedMessageResourceStages, stageMessageResources } from "../../src/feishu/resourceStaging.js";
import type { FeishuRuntime } from "../../src/feishu/client.js";
import type { FeishuBotCli } from "../../src/feishu/botCli.js";
const roots: string[] = [];
@@ -34,8 +34,13 @@ describe("Feishu resource staging recovery", () => {
it("rejects too many resources before contacting Feishu", async () => {
const root = await tempRoot();
const botCli: FeishuBotCli = {
downloadResource: async () => {
throw new Error("should not contact Feishu when over limit");
},
};
await expect(stageMessageResources(
{} as FeishuRuntime,
botCli,
"message-1",
[
{ fileKey: "a", resourceType: "file", workspaceRelativePath: "a" },
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import {
appendTeacherNotice,
isMaxTurnsError,
teacherFacingRunOutcome,
} from "../../src/feishu/runOutcomeNotice.js";
describe("teacherFacingRunOutcome", () => {
it("explains max-turn stops with the configured ceiling", () => {
const outcome = teacherFacingRunOutcome({
wallTimeExceeded: false,
interrupted: false,
resultStatus: "failed",
resultError: "Claude Code returned an error result: Reached maximum number of turns (25)",
maxTurns: 150,
maxRunSeconds: 1800,
hasPartialText: true,
});
expect(outcome.isError).toBe(true);
expect(outcome.notice).toContain("150");
expect(outcome.notice).toContain("最大步骤数");
expect(outcome.notice).toContain("部分结果");
});
it("explains wall-clock timeouts", () => {
const outcome = teacherFacingRunOutcome({
wallTimeExceeded: true,
interrupted: false,
resultStatus: "interrupted",
resultError: undefined,
maxTurns: 150,
maxRunSeconds: 1800,
hasPartialText: false,
});
expect(outcome.isError).toBe(true);
expect(outcome.notice).toContain("1800");
expect(outcome.notice).toContain("超时");
});
it("is quiet on successful completion", () => {
expect(
teacherFacingRunOutcome({
wallTimeExceeded: false,
interrupted: false,
resultStatus: "completed",
resultError: undefined,
maxTurns: 150,
maxRunSeconds: 1800,
hasPartialText: true,
}),
).toEqual({ isError: false, notice: undefined });
});
it("appendTeacherNotice joins body and notice", () => {
expect(appendTeacherNotice("hello", "bye")).toBe("hello\n\nbye");
expect(appendTeacherNotice("", "only")).toBe("only");
});
it("detects max-turn SDK wording", () => {
expect(isMaxTurnsError("Reached maximum number of turns (25)")).toBe(true);
expect(isMaxTurnsError("result_error_max_turns")).toBe(true);
expect(isMaxTurnsError("network glitch")).toBe(false);
});
});
+54 -8
View File
@@ -113,9 +113,10 @@ describe("runAgent", () => {
permissionMode: "bypassPermissions",
allowDangerouslySkipPermissions: true,
settingSources: [],
settings: { disableBundledSkills: true },
skills: [],
strictMcpConfig: true,
settings: { disableBundledSkills: true, todoFeatureEnabled: true, autoCompactEnabled: true },
tools: expect.arrayContaining(["Read", "Write", "Edit", "Bash", "Glob", "Grep", "TodoWrite"]),
allowedTools: expect.arrayContaining(["TodoWrite", "mcp__cph_hub__todo_write"]),
disallowedTools: expect.arrayContaining(["Agent", "SendMessage", "Task", "TeamCreate", "ScheduleWakeup"]),
sandbox: expect.objectContaining({
enabled: true,
failIfUnavailable: true,
@@ -130,6 +131,39 @@ describe("runAgent", () => {
});
});
it("never exposes multi-agent orchestration tools on unrestricted roles", async () => {
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));
await runAgent({
prompt: "继续",
model: undefined,
project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
systemPrompt: undefined,
tools: null,
runId: "run-1",
sessionId: "hub-session-1",
prisma: stubPrisma,
});
const call = queryMock.mock.calls[0]?.[0] as {
options?: { tools?: unknown; disallowedTools?: string[] };
} | undefined;
expect(call?.options?.tools).toEqual([
"Read",
"Write",
"Edit",
"Bash",
"Glob",
"Grep",
"WebFetch",
"WebSearch",
"TodoWrite",
]);
for (const blocked of ["Agent", "SendMessage", "Task", "TeamCreate", "ScheduleWakeup"]) {
expect(call?.options?.disallowedTools).toContain(blocked);
}
});
it("does not send resume for a fresh Hub session", async () => {
queryMock.mockReturnValue(messages(assistantMessage("fresh"), resultMessage("sdk-session-1")));
@@ -183,8 +217,16 @@ describe("runAgent", () => {
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
options: {
tools: ["Read", "Bash"],
allowedTools: ["Read", "Bash", "mcp__cph_hub__send_file"],
tools: ["Read", "Bash", "TodoWrite"],
allowedTools: [
"Read",
"Bash",
"mcp__cph_hub__send_file",
"TodoWrite",
"mcp__cph_hub__todo_write",
],
settings: expect.objectContaining({ todoFeatureEnabled: true }),
disallowedTools: expect.arrayContaining(["Agent", "SendMessage"]),
},
});
});
@@ -205,8 +247,10 @@ describe("runAgent", () => {
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
options: {
tools: [],
allowedTools: [],
tools: ["TodoWrite"],
allowedTools: ["TodoWrite", "mcp__cph_hub__todo_write"],
settings: expect.objectContaining({ todoFeatureEnabled: true }),
disallowedTools: expect.arrayContaining(["Agent", "SendMessage"]),
},
});
});
@@ -234,9 +278,11 @@ describe("runAgent", () => {
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
options: {
tools: ["Skill"],
tools: ["TodoWrite", "Skill"],
allowedTools: expect.arrayContaining(["TodoWrite", "mcp__cph_hub__todo_write", "Skill"]),
plugins: [expect.objectContaining({ type: "local", skipMcpDiscovery: true })],
skills: ["cph-runtime:typst"],
settings: expect.objectContaining({ todoFeatureEnabled: true }),
},
});
});
+111
View File
@@ -0,0 +1,111 @@
import { describe, expect, it } from "vitest";
import {
applyChecklistToolEvent,
isTodoWriteTool,
parseTodoWriteInput,
todoProgressSummary,
} from "../../src/agent/todoList.js";
import { buildAgentCard } from "../../src/feishu/card/builder.js";
describe("todo list parse", () => {
it("accepts TodoWrite payloads", () => {
const todos = parseTodoWriteInput({
todos: [
{ content: "搜题", status: "completed", activeForm: "正在搜题" },
{ content: "写报告", status: "in_progress", activeForm: "正在写报告" },
{ content: "发卡片", status: "pending" },
],
});
expect(todos).toEqual([
{ id: undefined, content: "搜题", status: "completed", activeForm: "正在搜题" },
{ id: undefined, content: "写报告", status: "in_progress", activeForm: "正在写报告" },
{ id: undefined, content: "发卡片", status: "pending", activeForm: undefined },
]);
expect(todoProgressSummary(todos!)).toEqual({ completed: 1, total: 3, inProgress: 1 });
});
it("folds TaskCreate + TaskUpdate into a checklist", () => {
let todos = applyChecklistToolEvent([], {
toolName: "TaskCreate",
input: { subject: "说你好", description: "greet" },
result: "Task #1 created successfully: 说你好",
});
expect(todos).toEqual([
{ id: "1", content: "说你好", status: "pending", activeForm: undefined },
]);
todos = applyChecklistToolEvent(todos!, {
toolName: "TaskCreate_1",
input: { subject: "算 1+1", description: "math" },
result: "Task #2 created successfully: 算 1+1",
});
todos = applyChecklistToolEvent(todos!, {
toolName: "TaskUpdate",
input: { taskId: "1", status: "in_progress", activeForm: "正在问好" },
result: "Updated task #1 status",
});
todos = applyChecklistToolEvent(todos!, {
toolName: "TaskUpdate_4",
input: { taskId: "1", status: "completed" },
result: "Updated task #1 status",
});
todos = applyChecklistToolEvent(todos!, {
toolName: "TaskUpdate",
input: { taskId: "2", status: "completed" },
result: "Updated task #2 status",
});
expect(todos).toEqual([
{ id: "1", content: "说你好", status: "completed", activeForm: "正在问好" },
{ id: "2", content: "算 1+1", status: "completed", activeForm: undefined },
]);
expect(todoProgressSummary(todos!)).toEqual({ completed: 2, total: 2, inProgress: 0 });
});
it("recognizes TodoWrite tool names", () => {
expect(isTodoWriteTool("TodoWrite")).toBe(true);
expect(isTodoWriteTool("mcp__cph_hub__todo_write")).toBe(true);
expect(isTodoWriteTool("Bash")).toBe(false);
});
});
describe("agent card todo panel", () => {
it("renders a progress checklist when todos are present", () => {
const card = buildAgentCard({
phase: "streaming",
text: "",
reasoningText: undefined,
todos: [
{ id: "1", content: "A", status: "completed", activeForm: undefined },
{ id: "2", content: "B", status: "in_progress", activeForm: "Doing B" },
{ id: "3", content: "C", status: "pending", activeForm: undefined },
],
toolUseSteps: [
{
id: "1",
seq: 1,
toolName: "TaskCreate",
toolUseId: "t1",
input: {},
result: undefined,
error: undefined,
status: "success",
startedAt: 0,
finishedAt: 1,
durationMs: 1,
},
],
toolUseElapsedMs: 10,
isError: undefined,
interrupted: undefined,
runId: "run-1",
});
const json = JSON.stringify(card);
expect(json).toContain("任务进度 1/3");
expect(json).toContain("Doing B");
expect(json).toContain("collapsible_panel");
// TaskCreate noise filtered when checklist present (no separate tool panel if only Task*)
expect(json).not.toContain("TaskCreate");
});
});
+4 -3
View File
@@ -3,9 +3,10 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["test/**/*.test.ts"],
// Integration tests share one DB; run files sequentially to avoid
// concurrent truncate/insert races. Unit tests are fast either way.
pool: "forks",
// Integration tests share one DB. Single worker + sequential files so
// TRUNCATE + seed cannot race across files.
pool: "threads",
maxWorkers: 1,
fileParallelism: false,
env: { NODE_ENV: "test" },
},