Compare commits

..

1 Commits

Author SHA1 Message Date
hongjr03 4e7b158ff9 feat(hub): drop redundant /admin/org/:slug path + release v0.0.35
Silo hostname already carries tenancy. Admin SPA routes become /admin/...,
legacy /admin/org/:slug/* bookmarks redirect, login lands on /admin.
2026-07-18 17:36:08 +00:00
151 changed files with 2827 additions and 7452 deletions
+5 -5
View File
@@ -1,12 +1,12 @@
name: checker check name: checker check
# Builds and lints the Rust implementation crates under crates/ (the rule-based # Builds and lints the Rust implementation crates under crates/ (the rule-based
# lesson checker). # checker that "stands in Lean's position" at product runtime).
# #
# This is an INTERNAL gate on the implementation's own health # Like spec-check, this is an INTERNAL gate on the implementation's own health
# (does it build, pass its tests, satisfy clippy + rustfmt?). There is no # (does it build, pass its tests, satisfy clippy + rustfmt?). It is NOT a
# decision-to-implementation conformance gate — implementations align to the # spec-to-implementation conformance gate — implementations align to the Lean
# ADRs by human review, not by CI. See the repo README. # contract by human review, not by CI. See the repo README.
on: on:
push: push:
+15 -62
View File
@@ -1,18 +1,15 @@
name: hub check name: hub check
# Builds, type-checks, and tests the Hub TS package under hub/. # Builds, type-checks, and tests the Hub TS package under hub/.
# The Hub is the Feishu-group collaboration + agent runtime half. # The Hub is the Feishu-group collaboration + agent runtime half
# This is an INTERNAL gate on the Hub's own # (spec/System implementation). This is an INTERNAL gate on the Hub's own
# health, like checker-check is for the Rust half. # health, like checker-check is for the Rust half.
on: on:
push: push:
pull_request:
workflow_dispatch: workflow_dispatch:
concurrency:
group: hub-check-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
hub-check: hub-check:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -23,9 +20,8 @@ jobs:
POSTGRES_USER: paradigm POSTGRES_USER: paradigm
POSTGRES_PASSWORD: paradigm POSTGRES_PASSWORD: paradigm
POSTGRES_DB: cph_hub_test POSTGRES_DB: cph_hub_test
# Avoid host-port binds: concurrent hub-check jobs on the shared ports:
# runner raced on published 5432/15432 ("port is already allocated"). - 5432:5432
# Reach the service by Docker DNS name from the job container instead.
options: >- options: >-
--health-cmd "pg_isready -U paradigm -d cph_hub_test" --health-cmd "pg_isready -U paradigm -d cph_hub_test"
--health-interval 5s --health-interval 5s
@@ -37,33 +33,15 @@ jobs:
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
- name: Install Rust toolchain (for cph binary)
uses: dtolnay/rust-toolchain@1.92.0
- name: Cache cargo registry + build
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: cargo-hub-check-${{ runner.os }}-${{ hashFiles('Cargo.lock') }}
restore-keys: |
cargo-hub-check-${{ runner.os }}-
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: "24" node-version: "24"
cache: npm cache: npm
cache-dependency-path: | cache-dependency-path: hub/package-lock.json
hub/package-lock.json
hub/admin-web/package-lock.json
- name: Install dependencies - name: Install dependencies
run: | run: npm ci
npm ci
npm ci --prefix admin-web
- name: Audit production Node dependencies - name: Audit production Node dependencies
run: npm run audit:production run: npm run audit:production
@@ -76,10 +54,8 @@ jobs:
node <<'NODE' node <<'NODE'
const net = require("node:net"); const net = require("node:net");
const deadline = Date.now() + 60000; const deadline = Date.now() + 60000;
const host = process.env.HUB_CHECK_PG_HOST || "postgres";
const port = Number(process.env.HUB_CHECK_PG_PORT || "5432");
function tryConnect() { function tryConnect() {
const socket = net.createConnection({ host, port }); const socket = net.createConnection({ host: "127.0.0.1", port: 5432 });
socket.once("connect", () => { socket.once("connect", () => {
socket.end(); socket.end();
process.exit(0); process.exit(0);
@@ -87,7 +63,7 @@ jobs:
socket.once("error", () => { socket.once("error", () => {
socket.destroy(); socket.destroy();
if (Date.now() > deadline) { if (Date.now() > deadline) {
console.error(`Postgres did not become reachable at ${host}:${port}`); console.error("Postgres did not become reachable at 127.0.0.1:5432");
process.exit(1); process.exit(1);
} }
setTimeout(tryConnect, 1000); setTimeout(tryConnect, 1000);
@@ -114,41 +90,19 @@ jobs:
run: | run: |
cd .. cd ..
cargo install --path crates/cph-cli --locked cargo install --path crates/cph-cli --locked
# Make cph available to the unprivileged sandbox user below.
sudo install -m 0755 "$HOME/.cargo/bin/cph" /usr/local/bin/cph
- name: Prove real Claude SDK Bash sandbox boundary - name: Prove real Claude SDK Bash sandbox boundary
run: | run: |
set -euo pipefail sudo install -d -o "$(id -u)" -g "$(id -g)" -m 0700 /w/t
# Nested act/docker runners often disallow unprivileged user CPH_SANDBOX_TEST_ROOT=/w/t \
# namespaces, which bwrap requires once CapEff is cleared. Skip the /usr/bin/setpriv --no-new-privs \
# live proof there; unit + non-sandbox integration still gate. npx vitest run test/integration/agent-sandbox-linux.test.ts
sysctl -w kernel.unprivileged_userns_clone=1 2>/dev/null || true
sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 2>/dev/null || true
if ! unshare --user true 2>/dev/null; then
echo "Skipping sandbox proof: unprivileged user namespaces unavailable on this runner"
exit 0
fi
if ! id cphci >/dev/null 2>&1; then
useradd --create-home --shell /bin/bash cphci
fi
install -d -o cphci -g cphci -m 0700 /w/t
REPO_ROOT="$(cd .. && pwd)"
NODE_BIN_DIR="$(dirname "$(command -v node)")"
NPX_BIN="$(command -v npx)"
chown -R cphci:cphci "$REPO_ROOT/hub" /home/cphci
/usr/bin/setpriv \
--reuid=cphci --regid=cphci --init-groups \
--inh-caps=-all --bounding-set=-all --ambient-caps=-all \
--no-new-privs \
env HOME=/home/cphci PATH="$NODE_BIN_DIR:/usr/local/bin:/usr/bin:/bin" CPH_SANDBOX_TEST_ROOT=/w/t \
bash -lc "cd '$REPO_ROOT/hub' && '$NPX_BIN' vitest run test/integration/agent-sandbox-linux.test.ts"
- name: Run unit tests - name: Run unit tests
run: npx vitest run test/unit run: npx vitest run test/unit
# Integration tests need PostgreSQL + cph. cph is installed above. # Integration tests need PostgreSQL + cph. cph is installed above.
# PostgreSQL is the job service container reachable as `postgres`. # PostgreSQL is set up as a service container below.
- name: Run integration tests (mock provider, real prisma + cph) - name: Run integration tests (mock provider, real prisma + cph)
run: | run: |
npx prisma migrate deploy --schema prisma/schema.prisma npx prisma migrate deploy --schema prisma/schema.prisma
@@ -156,8 +110,7 @@ jobs:
--exclude test/integration/real-model.test.ts \ --exclude test/integration/real-model.test.ts \
--exclude test/integration/agent-sandbox-linux.test.ts --exclude test/integration/agent-sandbox-linux.test.ts
env: env:
DATABASE_URL: postgresql://paradigm:paradigm@postgres:5432/cph_hub_test DATABASE_URL: postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test
HUB_SKILL_STORE_ROOT: /tmp/cph-hub-check-skills
# Real-model tests are opt-in: set RUN_REAL_MODEL_TESTS=true and provide # Real-model tests are opt-in: set RUN_REAL_MODEL_TESTS=true and provide
# OPENROUTER_API_KEY when a branch should hit live OpenRouter. # OPENROUTER_API_KEY when a branch should hit live OpenRouter.
+20
View File
@@ -0,0 +1,20 @@
name: spec check
# Builds the Lean semantic master spec under spec/.
# This is an INTERNAL well-formedness gate (does the contract type-check?),
# NOT a spec-to-implementation conformance gate — implementations align to the
# contract by human review, not by CI. See repo README.
on:
push:
pull_request:
workflow_dispatch:
jobs:
spec-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: leanprover/lean-action@v1
with:
lake-package-directory: spec
+4 -4
View File
@@ -1,3 +1,7 @@
# Lean / Lake build artifacts (spec/ has its own .gitignore too)
.lake/
**/.lake/
# Rust / Cargo build artifacts (repo-wide cargo workspace at root) # Rust / Cargo build artifacts (repo-wide cargo workspace at root)
/target /target
**/*.pdf **/*.pdf
@@ -15,7 +19,3 @@ node_modules/
# OS / editor # OS / editor
.DS_Store .DS_Store
# Local operator notes / specs (not product source)
/spec/
/需求整理-*.md
@@ -29,7 +29,7 @@ workload brakes.
The full current-state inventory, accepted behavior, and release evidence are The full current-state inventory, accepted behavior, and release evidence are
recorded in [Initial abuse and capacity controls](../assets/initial-abuse-capacity-controls.md), recorded in [Initial abuse and capacity controls](../assets/initial-abuse-capacity-controls.md),
with the durable decision in ADR-0022. Numerical with the durable decision in ADR-0022 and `Spec.System.Capacity`. Numerical
ceilings remain open until production-like calibration. ceilings remain open until production-like calibration.
The implementation frontier is: The implementation frontier is:
@@ -7,6 +7,6 @@ Blocked by: 01, 02, 03, 04, 05, 06, 07, 09, 10, 11, 12, 13, 14, 15, 16, 17, 18,
## Question ## Question
After the readiness investigations and resulting fixes are resolved, can one After the readiness investigations and resulting fixes are resolved, can one
repeatable release procedure prove build/test health, deploy a clean repeatable release procedure prove build/test/spec health, deploy a clean
production-like environment, exercise critical tenant and agent journeys, production-like environment, exercise critical tenant and agent journeys,
verify observability and recovery, and either roll forward or roll back safely? verify observability and recovery, and either roll forward or roll back safely?
@@ -8,7 +8,7 @@ Blocked by: 04
Separate or unify run-bound audit entries, pre-run security/permission events, Separate or unify run-bound audit entries, pre-run security/permission events,
structured messages, and operational recovery events without weakening structured messages, and operational recovery events without weakening
the pinned AuditEntry-to-run relation. Decide durability, `Spec.System.Audit`'s pinned AuditEntry-to-run relation. Decide durability,
failure, retention, and query semantics; then enforce referential integrity and failure, retention, and query semantics; then enforce referential integrity and
observable/recoverable writes instead of silently swallowing lost evidence. observable/recoverable writes instead of silently swallowing lost evidence.
Do not merge these customer Project/Run records with ADR-0023's already-decided Do not merge these customer Project/Run records with ADR-0023's already-decided
@@ -40,7 +40,9 @@ an off-host recovery key, an incident and reason, and issues only an expiring
Emergency Platform Grant. Emergency Platform Grant.
The complete accepted decision and implementation divergences are in The complete accepted decision and implementation divergences are in
[ADR-0023](../../../docs/adr/0023-platform-administrator-identity-and-audit.md), [ADR-0023](../../../docs/adr/0023-platform-administrator-identity-and-audit.md).
The pinned semantic invariants are in
[`Spec.System.PlatformAdministration`](../../../spec/Spec/System/PlatformAdministration.lean),
and the canonical terms are in [`CONTEXT.md`](../../../CONTEXT.md). and the canonical terms are in [`CONTEXT.md`](../../../CONTEXT.md).
Exact numeric session/invitation/step-up limits and browser mechanics remain Exact numeric session/invitation/step-up limits and browser mechanics remain
+16 -16
View File
@@ -1,25 +1,29 @@
# AGENTS.md —— agent 操作手册(全 repo) # AGENTS.md —— agent 操作手册(全 repo)
本 repo 是 monorepo。先读根 `README.md` 的"宪法"4 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。 本 repo 是 monorepo。先读根 `README.md` 的"宪法"5 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
## 这个 repo 是什么 ## 这个 repo 是什么
- `docs/adr/` 是系统级决策的唯一权威来源;`CONTEXT.md` 是平台语言词汇表;代码注释把关键不变量锚到 ADR 编号,可 grep - `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照
- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。
- `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team` - `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team`
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020)。 必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020 / `Spec.System.Organization`)。
- org 后台 project explorer 里 `Folder` 是透明组织节点,不是权限资源;project 仍是权限边界。 - org 后台 project explorer 里 `Folder` 是透明组织节点,不是权限资源;project 仍是权限边界。
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021)。 普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021 /
`Spec.System.ProjectWorkspace`)。
- 每个 org 自选 BYOK 或平台托管 model provider connection;平台托管也必须是该 org - 每个 org 自选 BYOK 或平台托管 model provider connection;平台托管也必须是该 org
独享的 key/base URL,不得让无关 org 共用 process-global provider key(见 ADR-0021)。 独享的 key/base URL,不得让无关 org 共用 process-global provider key(见 ADR-0021 /
`Spec.System.Organization`)。
- Feishu/provider secret 使用本地版本化 master-key keyring 的信封加密;生产由 systemd - Feishu/provider secret 使用本地版本化 master-key keyring 的信封加密;生产由 systemd
credential 注入,运行时只允许显式 org/project scope 的 fail-closed resolver,不得回退 credential 注入,运行时只允许显式 org/project scope 的 fail-closed resolver,不得回退
process-global credential;Agent child 只接收 run-scoped loopback proxy capability, process-global credential;Agent child 只接收 run-scoped loopback proxy capability,
不接收 org provider credential(见 ADR-0024)。 不接收 org provider credential(见 ADR-0024 / `Spec.System.Organization`)。
- 生产容量按不可突破的 platform ceiling 与 org 可下调 policy 分层;有效限制取两者较低值。 - 生产容量按不可突破的 platform ceiling 与 org 可下调 policy 分层;有效限制取两者较低值。
Agent admission 必须持久、有界、跨 org 公平且显式背压(见 ADR-0022)。 Agent admission 必须持久、有界、跨 org 公平且显式背压(见 ADR-0022 /
`Spec.System.Capacity`)。
- 平台管理员只通过独立的 platform-owned 飞书应用与可撤销 Platform Session 认证,不复用 - 平台管理员只通过独立的 platform-owned 飞书应用与可撤销 Platform Session 认证,不复用
客户 `User`/org membership;平台写操作与 append-only audit 同事务,break-glass 只走 客户 `User`/org membership;平台写操作与 append-only audit 同事务,break-glass 只走
双因子的离线恢复流程(见 ADR-0023)。 双因子的离线恢复流程(见 ADR-0023 / `Spec.System.PlatformAdministration`)。
- 受控 alpha 暂采用一 Organization 一具名 systemd Silo:独立 database role/database、 - 受控 alpha 暂采用一 Organization 一具名 systemd Silo:独立 database role/database、
service identity、workspace、keyring 与 Feishu/provider connection;进程必须由 service identity、workspace、keyring 与 Feishu/provider connection;进程必须由
`HUB_SILO_ORGANIZATION_ID` fail-closed 绑定唯一 org,平台后台不开放。共享 SaaS `HUB_SILO_ORGANIZATION_ID` fail-closed 绑定唯一 org,平台后台不开放。共享 SaaS
@@ -32,10 +36,6 @@
`/compact` 只能由卡片动作以未经包装的精确 prompt 转发。 `/compact` 只能由卡片动作以未经包装的精确 prompt 转发。
`settingSources: []` 继续禁用项目/用户配置加载,不得把任意 workspace `.claude` 配置变成 `settingSources: []` 继续禁用项目/用户配置加载,不得把任意 workspace `.claude` 配置变成
运行时能力(见 ADR-0018)。 运行时能力(见 ADR-0018)。
skill/role 的管理面分组由一棵 org 内共用、可嵌套的 folder 树承载:透明组织节点,
不进入身份、解析与授权——name/roleId 仍 org 内唯一,role→skill 绑定、run 加载与
slash 命令均不引用 folder;folder 归属变更是 label 类变更,不归档会话;仅空 folder
可删(见 ADR-0028 / `Spec.System.AgentRole`)。
- 项目发现由 `ProjectDiscovery` 模块统一承载:PostgreSQL `pg_trgm` 搜索派生文档、项目编号 - 项目发现由 `ProjectDiscovery` 模块统一承载:PostgreSQL `pg_trgm` 搜索派生文档、项目编号
归一化、完整 Folder breadcrumb、MANAGE 授权过滤与分页都在该模块内;飞书卡片只是 adapter。 归一化、完整 Folder breadcrumb、MANAGE 授权过滤与分页都在该模块内;飞书卡片只是 adapter。
`Project`/`Folder` 仍是事实来源,搜索文档必须可重建且由数据库触发器同步,禁止调用方双写。 `Project`/`Folder` 仍是事实来源,搜索文档必须可重建且由数据库触发器同步,禁止调用方双写。
@@ -44,12 +44,12 @@
## 纪律 ## 纪律
1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。ADR 与 `CONTEXT.md` 是语义的唯一权威来源;没写的,就是没定的。 1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。契约里 prose doc 注释是语义的唯一权威来源;契约没写的,就是没定的。
2. **凡 ADR 未写明者,不得假设。** 遇到没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。 2. **凡契约未写明者,不得假设。** 遇到标了 `OPEN` 的地方,或契约根本没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。
3. **新语义决策进 ADR。** 跨部件的语义分歧点按编号顺延新增 `docs/adr/NNNN-*.md`;代码里的关键不变量用注释锚到 ADR 编号,保持可 grep。已有 ADR 正文不改写历史——推翻旧决策就写新 ADR 标记 supersede 3. **改 `spec/` 必须保持其 `lake build` 通过。**`spec/` 目录下跑 `lake build`。新增声明必须带 `/-- … -/` doc 注释和恰当标签(`PINNED` / `OPEN` / `ADR-NNNN`)。规范见 `spec/README.md`。不准用 `sorry` 把 build 糊绿
4. **实现向 ADR 对齐;偏离必须 surface。** 没有 CI gate 替你把关 ADR↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与决策不一致时,报告它,不要默默让其中一边将就另一边。 4. **实现向契约对齐;偏离必须 surface。** 没有 CI gate 替你把关 spec↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与契约不一致时,报告它,不要默默让其中一边将就另一边。
5. **写操作谨慎。** 线上操作、git 写操作前与开发者确认(这是开发者的全局偏好)。 5. **写操作谨慎。** 线上操作、git 写操作前与开发者确认(这是开发者的全局偏好)。
+10 -8
View File
@@ -1,23 +1,25 @@
# CLAUDE.md —— agent 操作手册(全 repo) # CLAUDE.md —— agent 操作手册(全 repo)
本 repo 是 monorepo。先读根 `README.md` 的"宪法"4 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。 本 repo 是 monorepo。先读根 `README.md` 的"宪法"5 条,那是一切工作的前提。本文件是给在这里干活的 coding agent 的纪律。
## 这个 repo 是什么 ## 这个 repo 是什么
- `docs/adr/` 是系统级决策的唯一权威来源;`CONTEXT.md` 是平台语言词汇表;代码注释把关键不变量锚到 ADR 编号,可 grep - `spec/` 是一份**人机共识的契约**(Lean 语义母本),是产品语义的上游参照
- 其余部件(将来的 `spec/` 外文件夹)是**向 `spec/` 对齐的实现**。
- `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team` - `hub/` 的平台层按 SaaS 形态演进:`Organization` 是 tenant root;`Project`/`Team`
必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020)。 必须归属 org,TEAM→PROJECT 授权不得跨 org(见 ADR-0020 / `Spec.System.Organization`)。
- org 后台 project explorer 里 `Folder` 是透明组织节点,不是权限资源;project 仍是权限边界。 - org 后台 project explorer 里 `Folder` 是透明组织节点,不是权限资源;project 仍是权限边界。
普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021)。 普通老师可在飞书群自助建 project 但受 org policy 控制(见 ADR-0021 /
`Spec.System.ProjectWorkspace`)。
## 纪律 ## 纪律
1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。ADR 与 `CONTEXT.md` 是语义的唯一权威来源;没写的,就是没定的。 1. **不得用预训练先验脑补本领域。** 这个领域很新,你没有相关先验。契约里 prose doc 注释是语义的唯一权威来源;契约没写的,就是没定的。
2. **凡 ADR 未写明者,不得假设。** 遇到没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。 2. **凡契约未写明者,不得假设。** 遇到标了 `OPEN` 的地方,或契约根本没覆盖的地方,**显式 surface 出来**让开发者决定,绝不擅自替它选一个解。
3. **新语义决策进 ADR。** 跨部件的语义分歧点按编号顺延新增 `docs/adr/NNNN-*.md`;代码里的关键不变量用注释锚到 ADR 编号,保持可 grep。已有 ADR 正文不改写历史——推翻旧决策就写新 ADR 标记 supersede 3. **改 `spec/` 必须保持其 `lake build` 通过。**`spec/` 目录下跑 `lake build`。新增声明必须带 `/-- … -/` doc 注释和恰当标签(`PINNED` / `OPEN` / `ADR-NNNN`)。规范见 `spec/README.md`。不准用 `sorry` 把 build 糊绿
4. **实现向 ADR 对齐;偏离必须 surface。** 没有 CI gate 替你把关 ADR↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与决策不一致时,报告它,不要默默让其中一边将就另一边。 4. **实现向契约对齐;偏离必须 surface。** 没有 CI gate 替你把关 spec↔实现的一致性(见宪法第 2 条)——这道对齐靠 review 和你巡逻 diff。发现实现与契约不一致时,报告它,不要默默让其中一边将就另一边。
5. **写操作谨慎。** 线上操作、git 写操作前与开发者确认(这是开发者的全局偏好)。 5. **写操作谨慎。** 线上操作、git 写操作前与开发者确认(这是开发者的全局偏好)。
+23 -16
View File
@@ -2,7 +2,7 @@
教研生产的数字化解决方案。核心思路:课程像 DAW / 剪辑软件那样有一个**结构化的工程文件**;coding agent 协助编辑它;一个 rule-based checker(类编译器)校验其合法性并给出 helpful fix hint。目标是把教研从一次性的文档,沉淀成**可累积、可校验、可复用的资产**。 教研生产的数字化解决方案。核心思路:课程像 DAW / 剪辑软件那样有一个**结构化的工程文件**;coding agent 协助编辑它;一个 rule-based checker(类编译器)校验其合法性并给出 helpful fix hint。目标是把教研从一次性的文档,沉淀成**可累积、可校验、可复用的资产**。
这是一个 **monorepo**。它的组织方式本身就表达了一条原则:**`docs/adr/` 是系统级决策的唯一权威来源,代码注释把关键不变量锚到 ADR 编号,可 grep。** 这是一个 **monorepo**。它的组织方式本身就表达了一条原则:**`spec/` 是上游的语义母本,其余部件是向它对齐的实现。**
## 安装 `cph` 命令行 ## 安装 `cph` 命令行
@@ -33,40 +33,47 @@ cph completions zsh > ~/.zfunc/_cph # 或 bash/fish/powershell/elvish
``` ```
README.md ← 本文件:总览 + 宪法(下面 5 条) README.md ← 本文件:总览 + 宪法(下面 5 条)
CLAUDE.md ← 全局 agent 操作手册(管整个 repo) CLAUDE.md ← 全局 agent 操作手册(管整个 repo)
docs/adr/ ← 系统级架构决策记录(跨部件,决策的唯一权威来源) docs/adr/ ← 系统级架构决策记录(跨部件,被 spec 契约引用)
CONTEXT.md平台语言词汇表(术语与禁用说法) spec/ Lean 语义母本(自包含的 Lean 工程)。见 spec/README.md
Cargo.toml ← 仓库级 cargo workspace(实现部件共用,便于跨部件复用 crate) Cargo.toml ← 仓库级 cargo workspace(实现部件共用,便于跨部件复用 crate)
crates/ ← 实现:rule-based checker(语义由 ADR 锚定)。见 crates/README.md crates/ ← 实现:rule-based checker(向 spec 对齐)。见 crates/README.md
cph-diag / cph-model / cph-schema / cph-typst ← 可复用基础(模型/校验/typst 引擎) cph-diag / cph-model / cph-schema / cph-typst ← 可复用基础(模型/校验/typst 引擎)
cph-check / cph-cli ← checker 本体 + `cph` 命令行 cph-check / cph-cli ← checker 本体 + `cph` 命令行
render/ ← typst 渲染包 cph-render(checker 的渲染后端,ADR-0005) render/ ← typst 渲染包 cph-render(母本的渲染后端之一,ADR-0005)
examples/ ← 样例工程文件(如 TH-141),流水线的真实输入 examples/ ← 样例工程文件(如 TH-141),流水线的真实输入
hub/ ← SaaS Hub:飞书协作、org 管理、agent runtime 与生产部署 hub/ ← SaaS Hub:飞书协作、org 管理、agent runtime 与生产部署
(exporter/ …) ← 将来的其他部件,平级于 crates/ (exporter/ …) ← 将来的其他部件,平级于 spec/
``` ```
`spec/` 与实现部件**物理分离、平级共存**:谁是上游、谁向谁对齐,一眼可见。
实现部件共用一个仓库根的 cargo workspace,使基础 crate(模型、typst 引擎)能被 实现部件共用一个仓库根的 cargo workspace,使基础 crate(模型、typst 引擎)能被
未来部件(如 exporter)复用,而非各自重造。 未来部件(如 exporter)复用,而非各自重造。
## 宪法 ## 宪法
4 条是本仓库的协作约定,是一切工作的前提。 5 条是 `spec/` 这份语义母本的定位与约束,是本仓库一切工作的前提。
1. **角色 —— ADR 是决策真相** 1. **角色 —— Lean 是研发侧的上游参照**
跨部件的语义决策只记录在 `docs/adr/`,一份决策一份 ADR,编号顺延、正文不改写历史。代码里的关键不变量用注释锚到 ADR 编号,保持可 grep。没有第二份权威文档 `spec/` 用 Lean 编写,是开发者(领域专家)与 coding agent **共用**的 spec 工具,用来沉淀产品各部件的**语义**。它**不进入产品运行时**——产品里"站在 Lean 这个位置"的那个 checker 用什么技术实现,尚未决定;但那个东西的语义,先在 `spec/` 里固定下来
2. **对齐机制 —— 人肉承载,无机器兜底** 2. **对齐机制 —— Lean 只做上游参照**
CI 只验各部件自身良构(build / test / clippy),**没有**决策↔实现的一致性 gate。实现对齐 ADR,由"开发者 review + agent 巡逻 diff"这个人肉环节承载。发现漂移,报告它,不要默默让其中一边将就另一边。 不做 extract / codegen,不派生 conformance test,CI 里**没有** spec→实现的 gate。实现对齐 spec,由"开发者 review + agent 巡逻 diff"这个人肉环节承载。
(CI 里的 `spec check` 只验 spec **自身**能否 type-check,即契约内部良构,不是 spec↔实现的对齐检查。)
3. **形态 —— 自包含** 3. **资产性 —— 由 review 纪律承载,无机器兜底**
凡 ADR 未明文规定的,开发者与 agent 双方都不该假设;遇到没覆盖的地方,**显式 surface** 出来让开发者决定 这份仓库给你的是"精确、自洽、机器验内部良构的语义共识",**不是**"实现正确性保证"。spec 与实现之间那道缝,是我们自愿用人来守的——清醒地守,它就是资产;放任实现漂移而不回头同步,它就退化成最贵的过期文档
4. **深度判据 —— 只收录分歧点** 4. **形态 —— 它是人机共识的契约**
一条语义该不该写进 ADR,取决于一句话:**"不写明,开发者与 agent 会不会各自做出不同假设?"** 会 → 进 ADR;显然的东西 / 纯 plumbing / 普通 CRUD 字段 → 不进(写进去只稀释信噪比、增加维护面) 契约必须**自包含**:凡契约未明文规定的,开发者与 agent 双方都不该假设。这比"文档"严格——type checker 会逼这份契约在结构上无洞
深度上限是**你愿意在每次实现变更时手动回头同步的量**——写得比你能维护的更深,多出来的部分会率先过期、反过来误导实现。
5. **深度判据 —— 只收录分歧点。**
一条语义该不该写进 Lean,取决于一句话:**"不写明,开发者与 agent 会不会各自做出不同假设?"** 会 → 进契约;显然的东西 / 纯 plumbing / 普通 CRUD 字段 → 不进(写进去只稀释信噪比、增加维护面)。
深度上限不是 Lean 的表达力,而是**你愿意在每次实现变更时手动回头同步的量**——写得比你能维护的更深,多出来的部分会率先过期、反过来误导实现。
## CI ## CI
`.gitea/workflows/spec-check.yml` 在每次 push / PR 时于 `spec/` 下跑 `lake build`,确保契约始终 type-check 通过(从第一天起就是"绿"的)。这是良构 gate,见宪法第 2 条。
Rust checker 的本地与 CI 工具链由根 `rust-toolchain.toml` 固定;`.gitea/workflows/checker-check.yml` Rust checker 的本地与 CI 工具链由根 `rust-toolchain.toml` 固定;`.gitea/workflows/checker-check.yml`
必须安装同一精确版本并执行 `cargo fmt --all --check`、Clippy `-D warnings` 与 workspace 必须安装同一精确版本并执行 `cargo fmt --all --check`、Clippy `-D warnings` 与 workspace
全测试。升级 Rust 时这两处必须在同一提交更新并通过完整 checker gate。 全测试。升级 Rust 时这两处必须在同一提交更新并通过完整 checker gate。
+2 -3
View File
@@ -1,8 +1,7 @@
# crates/ # crates/
These crates implement the rule-based lesson checker whose semantics are These crates implement the rule-based lesson checker that aligns to the
pinned by the ADRs in `docs/adr/`: it reads an engineering-file (one lesson, semantic master in `spec/`: it reads an engineering-file (one lesson, ADR-0005)
ADR-0005)
laid out per ADR-0008 (declarative `manifest.toml` + per-element laid out per ADR-0008 (declarative `manifest.toml` + per-element
`element.toml`), validates structure and content, and emits diagnostics. `element.toml`), validates structure and content, and emits diagnostics.
`cph-diag` (the shared diagnostic vocabulary), `cph-model` (the ADR-0008 loader), `cph-diag` (the shared diagnostic vocabulary), `cph-model` (the ADR-0008 loader),
+11 -9
View File
@@ -19,11 +19,13 @@ const DEFAULT_TARGET: &str = "student";
/// Severity of the render-coverage ("element ignored under a target") diagnostic. /// Severity of the render-coverage ("element ignored under a target") diagnostic.
/// ///
/// **PINNED to `warning` by ADR-0005:** when a /// **PINNED to `warning` by the contract.** Mirrors the Lean master's
/// `Spec.Courseware.renderIgnoredSeverity : Severity := .warning`
/// (`spec/Spec/Courseware/Check/Diagnostic.lean`), itself citing ADR-0005: when a
/// `(kind, target)` pair has no render rule the checker reports that the element /// `(kind, target)` pair has no render rule the checker reports that the element
/// is ignored under that target and **does not block the export**. Naming the /// is ignored under that target and **does not block the export**. Naming the
/// severity as a const makes "it is a warning, not an error" a greppable /// severity as a const makes "it is a warning, not an error" a greppable,
/// fact rather than an inline literal. /// alignable fact rather than an inline literal.
const RENDER_IGNORED_SEVERITY: Severity = Severity::Warning; const RENDER_IGNORED_SEVERITY: Severity = Severity::Warning;
/// The result of running [`check`] (or the check phases of [`build`]). /// The result of running [`check`] (or the check phases of [`build`]).
@@ -55,12 +57,12 @@ impl CheckReport {
/// Whether any collected diagnostic is `Error`-severity. /// Whether any collected diagnostic is `Error`-severity.
/// ///
/// **Legality decision (ADR-0010).** `!has_errors()` decides lesson /// **Legality decision (spec alignment).** `!has_errors()` is the
/// legality: a lesson is *legal* iff its diagnostics contain no error-level /// implementation of `Spec.Courseware.Legal` (`spec/Spec/Courseware/Check/Diagnostic.lean`):
/// diagnostic (warnings are non-blocking — see `Severity`). There is no CI /// a lesson is *legal* iff its diagnostics contain no error-level diagnostic
/// gate enforcing ADR↔implementation alignment (repo constitution); it is /// (warnings are non-blocking — see `Severity` / ADR-0010). There is no CI
/// kept greppable here so a reviewer can tie the orchestrator's gate to /// gate enforcing this alignment (repo constitution); it is kept greppable
/// the ADR. /// here so a reviewer can tie the orchestrator's gate to the Lean master.
pub fn has_errors(&self) -> bool { pub fn has_errors(&self) -> bool {
self.diagnostics self.diagnostics
.iter() .iter()
+15 -10
View File
@@ -2,7 +2,7 @@
//! //!
//! Every other crate in the workspace depends on these types to report //! Every other crate in the workspace depends on these types to report
//! problems. The vocabulary is intentionally small and stable: a [`Severity`] //! problems. The vocabulary is intentionally small and stable: a [`Severity`]
//! (two-valued, ADR-0010), a closed set of machine-stable [`DiagCode`]s, an //! (mirroring the Lean master), a closed set of machine-stable [`DiagCode`]s, an
//! optional [`SourceSpan`] pointing back at the offending source, and a //! optional [`SourceSpan`] pointing back at the offending source, and a
//! [`Diagnostic`] tying them together with a human message and a fix hint. //! [`Diagnostic`] tying them together with a human message and a fix hint.
//! //!
@@ -17,27 +17,32 @@ use serde::Serialize;
/// Severity of a diagnostic. /// Severity of a diagnostic.
/// ///
/// **Pinned by ADR-0005 / ADR-0010: exactly two values.** /// **Mirrors `Spec.Courseware.Diagnostic.Severity`** in the Lean semantic
/// master (`spec/Spec/Courseware/Check/Diagnostic.lean`), whose definition is
/// exactly:
/// ///
/// ```text /// ```text
/// warning | error /// inductive Severity where
/// | warning
/// | error
/// ``` /// ```
/// ///
/// This two-valued shape is a **contract decision**, not an accident: the /// This two-valued shape is a **contract decision**, not an accident: the Lean
/// finer levels (`info` / `hint` / `note`) are deliberately undecided, so we /// module pins `Severity` to exactly `warning | error` and states the finer
/// levels (`info` / `hint` / `note`) are deliberately undecided. We therefore
/// do **not** add an info/note level here. `error` blocks (the artifact is /// do **not** add an info/note level here. `error` blocks (the artifact is
/// invalid); `warning` does not block (the artifact still exports, but with /// invalid); `warning` does not block (the artifact still exports, but with
/// loss / an ignored element — e.g. ADR-0005's "missing render ⇒ warning"). /// loss / an ignored element — e.g. ADR-0005's "missing render ⇒ warning").
/// ///
/// There is no CI gate enforcing ADR↔implementation alignment (see the repo /// There is no CI gate enforcing this alignment (see the repo constitution);
/// constitution); it is maintained by review, which is why the decision is /// it is maintained by review, which is why this correspondence is documented
/// documented here rather than only in the ADR. /// here rather than only in the spec.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum Severity { pub enum Severity {
/// Non-blocking: the artifact still exports, but is lossy / has an ignored /// Non-blocking: the artifact still exports, but is lossy / has an ignored
/// element. ADR-0010 `warning`. /// element. Mirrors Lean `Severity.warning`.
Warning, Warning,
/// Blocking: the artifact is invalid. ADR-0010 `error`. /// Blocking: the artifact is invalid. Mirrors Lean `Severity.error`.
Error, Error,
} }
+38 -25
View File
@@ -3,7 +3,9 @@
//! This crate is the **loader**, not the full checker. It reads //! This crate is the **loader**, not the full checker. It reads
//! `<root>/manifest.toml` (project / info / ordered `[[parts]]` / declared //! `<root>/manifest.toml` (project / info / ordered `[[parts]]` / declared
//! `[targets.*]`) and each part's `<root>/<path>/element.toml`, and produces an //! `[targets.*]`) and each part's `<root>/<path>/element.toml`, and produces an
//! ordered [`Lesson`], where the order of `parts` carries teaching semantics. //! ordered [`Lesson`] — mirroring the Lean master's `Lesson = List (Element P)`
//! (`spec/Spec/Courseware/Model/Lesson.lean`), where the order of `parts` carries
//! teaching semantics.
//! //!
//! Scope boundaries (deliberately staying in lane): //! Scope boundaries (deliberately staying in lane):
//! - It validates **structure** only: manifest shape, element.toml shape, and //! - It validates **structure** only: manifest shape, element.toml shape, and
@@ -21,7 +23,7 @@ use serde::{Deserialize, Serialize};
/// An ordered, in-memory lesson loaded from an engineering file. /// An ordered, in-memory lesson loaded from an engineering file.
/// ///
/// `parts` is an ordered /// Mirrors the Lean master's `Lesson = List (Element P)`: `parts` is an ordered
/// `Vec`, and that order is the lesson's order (ADR-0008 §"the lesson manifest /// `Vec`, and that order is the lesson's order (ADR-0008 §"the lesson manifest
/// is declarative" — the `[[parts]]` array order is the single source of truth). /// is declarative" — the `[[parts]]` array order is the single source of truth).
#[derive(Debug, Clone, PartialEq, Serialize)] #[derive(Debug, Clone, PartialEq, Serialize)]
@@ -84,8 +86,9 @@ pub struct TargetConfig {
/// with template `exports/<name>.typ` when no `[[steps]]` are given. /// with template `exports/<name>.typ` when no `[[steps]]` are given.
pub steps: Vec<Step>, pub steps: Vec<Step>,
/// The **render-coverage declaration**: which element kinds this target /// The **render-coverage declaration**: which element kinds this target
/// renders. Realizes ADR-0011's "render /// renders. Realizes `Spec.Courseware.TargetSpec.covers : KindId → Prop`
/// coverage is a declaration, not a payload": the declaration keeps *which /// (`spec/Spec/Courseware/Export/Render.lean`) and ADR-0011's "render
/// coverage is a declaration, not a payload": the contract keeps *which
/// kinds a target renders* (used by the `renderIgnored` seed diagnostic), /// kinds a target renders* (used by the `renderIgnored` seed diagnostic),
/// while the rendering "how" lives in the template/steps. /// while the rendering "how" lives in the template/steps.
/// ///
@@ -99,10 +102,13 @@ pub struct TargetConfig {
/// The artifact an export target produces (ADR-0009/0011). /// The artifact an export target produces (ADR-0009/0011).
/// ///
/// **Pinned by ADR-0011** as an ADT with fields: /// **Mirrors `Spec.Courseware.Artifact`** in the Lean semantic master
/// (`spec/Spec/Courseware/Export/Artifact.lean`), whose definition is exactly:
/// ///
/// ```text /// ```text
/// Artifact = singleFile (filepath) | fileTree (root, outputs) /// inductive Artifact where
/// | singleFile (filepath : String)
/// | fileTree (root : String) (outputs : String)
/// ``` /// ```
/// ///
/// ADR-0011 pinned the artifact as an ADT **with fields**: "what the product /// ADR-0011 pinned the artifact as an ADT **with fields**: "what the product
@@ -114,20 +120,20 @@ pub struct TargetConfig {
/// `"single-file"` → [`Artifact::SingleFile`], `"file-tree"` → /// `"single-file"` → [`Artifact::SingleFile`], `"file-tree"` →
/// [`Artifact::FileTree`]. /// [`Artifact::FileTree`].
/// ///
/// As with `cph-diag`'s `Severity`, there is no CI gate enforcing ADR↔ /// As with `cph-diag`'s `Severity`, there is no CI gate enforcing this
/// implementation alignment (see the repo constitution) — it is maintained by /// alignment (see the repo constitution) — it is maintained by review, which is
/// review, which is why the decision is documented here. /// why the correspondence is documented here.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum Artifact { pub enum Artifact {
/// One bundled document landing at `filepath` (relative to the engineering /// One bundled document landing at `filepath` (relative to the engineering
/// root). ADR-0011 `singleFile`. The default artifact shape. /// root). Mirrors Lean `Artifact.singleFile`. The default artifact shape.
SingleFile { SingleFile {
/// Where the single product is written (relative to the engineering /// Where the single product is written (relative to the engineering
/// root), e.g. `build/student.pdf`. /// root), e.g. `build/student.pdf`.
filepath: PathBuf, filepath: PathBuf,
}, },
/// A set of files under `root` matching the `outputs` glob. ADR-0011 /// A set of files under `root` matching the `outputs` glob. Mirrors Lean
/// `fileTree`. /// `Artifact.fileTree`.
FileTree { FileTree {
/// The output directory (relative to the engineering root). /// The output directory (relative to the engineering root).
root: PathBuf, root: PathBuf,
@@ -150,10 +156,14 @@ impl Artifact {
/// One typed build step (ADR-0011). /// One typed build step (ADR-0011).
/// ///
/// **Pinned by ADR-0011** as an ADT: /// **Mirrors `Spec.Courseware.Step`** in the Lean semantic master
/// (`spec/Spec/Courseware/Export/Render.lean`), whose definition is exactly:
/// ///
/// ```text /// ```text
/// Step = typstCompile (template) | shell (run) | assembleMarkdown (field) /// inductive Step where
/// | typstCompile (template : String)
/// | shell (run : String)
/// | assembleMarkdown (field : String)
/// ``` /// ```
/// ///
/// A step is a *typed* operation (extensible): `TypstCompile` compiles a /// A step is a *typed* operation (extensible): `TypstCompile` compiles a
@@ -173,20 +183,20 @@ impl Artifact {
#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum Step { pub enum Step {
/// Compile a template file (relative to the engineering root) into the /// Compile a template file (relative to the engineering root) into the
/// artifact; the framework injects the manifest. ADR-0011 /// artifact; the framework injects the manifest. Mirrors Lean
/// `typstCompile`. /// `Step.typstCompile`.
TypstCompile { TypstCompile {
/// The template file to compile as main, e.g. `exports/student.typ`. /// The template file to compile as main, e.g. `exports/student.typ`.
template: PathBuf, template: PathBuf,
}, },
/// Run a shell command — the escape hatch. ADR-0011 `shell`. /// Run a shell command — the escape hatch. Mirrors Lean `Step.shell`.
Shell { Shell {
/// The command line to run. /// The command line to run.
run: String, run: String,
}, },
/// Assemble a single-file markdown deliverable by concatenating each /// Assemble a single-file markdown deliverable by concatenating each
/// element's `field` markdown content file in `[[parts]]` order. ADR-0011 /// element's `field` markdown content file in `[[parts]]` order. Mirrors
/// `assembleMarkdown` (ADR-0015). Not a typst build — the /// Lean `Step.assembleMarkdown` (ADR-0015). Not a typst build — the
/// framework owns the read/concatenate/write itself. /// framework owns the read/concatenate/write itself.
AssembleMarkdown { AssembleMarkdown {
/// The per-element markdown content field to assemble (e.g. `slides`, /// The per-element markdown content field to assemble (e.g. `slides`,
@@ -216,11 +226,12 @@ pub struct Project {
/// `[info]` table (passed through to render targets verbatim). /// `[info]` table (passed through to render targets verbatim).
/// ///
/// The *canonical* model whose /// **Mirrors `Spec.Courseware.Info`** in the Lean semantic master
/// (`spec/Spec/Courseware/Model/Info.lean`): the *canonical* model whose
/// `authors` is always a list. The authoring-surface form (string-or-array /// `authors` is always a list. The authoring-surface form (string-or-array
/// `author`) is the separate [`RawInfo`] / [`RawAuthor`], normalized into this /// `author`) is the separate [`RawInfo`] / [`RawAuthor`], normalized into this
/// at the load boundary. No /// at the load boundary — mirroring the Lean `RawInfo` / `RawAuthor` split. No
/// CI gate enforces ADR↔implementation alignment (repo constitution); it is kept greppable. /// CI gate enforces this alignment (repo constitution); it is kept greppable.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Info { pub struct Info {
/// Lesson title. /// Lesson title.
@@ -229,6 +240,7 @@ pub struct Info {
/// so this is a list, not a single name. Empty when `[info]` declares no /// so this is a list, not a single name. Empty when `[info]` declares no
/// `author`. The on-disk `author` accepts either a bare string (one author) /// `author`. The on-disk `author` accepts either a bare string (one author)
/// or an array of strings (see [`RawAuthor`]); both load into this `Vec`. /// or an array of strings (see [`RawAuthor`]); both load into this `Vec`.
/// Mirrors Lean `Info.authors : List String`.
pub authors: Vec<String>, pub authors: Vec<String>,
} }
@@ -278,7 +290,8 @@ struct RawProject {
name: String, name: String,
} }
/// The authoring-surface `[info]`: the raw form that exists for /// The authoring-surface `[info]` (mirrors Lean `RawInfo` in
/// `spec/Spec/Courseware/Model/Info.lean`): the raw form that exists for
/// fill-in convenience, normalized into the canonical [`Info`] at the load /// fill-in convenience, normalized into the canonical [`Info`] at the load
/// boundary. Not the form the rest of the model traffics in. /// boundary. Not the form the rest of the model traffics in.
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -288,7 +301,7 @@ struct RawInfo {
} }
/// On-disk `author`: either a single name (`author = "…"`) or a list /// On-disk `author`: either a single name (`author = "…"`) or a list
/// (`author = ["…", "…"]`). A fill-in convenience whose /// (`author = ["…", "…"]`). Mirrors Lean `RawAuthor`: a fill-in convenience whose
/// string-or-array union lives **only** at the load boundary — [`RawAuthor::into_vec`] /// string-or-array union lives **only** at the load boundary — [`RawAuthor::into_vec`]
/// folds it into the canonical [`Info::authors`] `Vec`, after which it never appears. /// folds it into the canonical [`Info::authors`] `Vec`, after which it never appears.
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -299,7 +312,7 @@ enum RawAuthor {
} }
impl RawAuthor { impl RawAuthor {
/// Flatten to the ordered author list: a single /// Flatten to the ordered author list (Lean `RawAuthor.normalize`): a single
/// name becomes a one-element list; a list passes through verbatim. /// name becomes a one-element list; a list passes through verbatim.
fn into_vec(self) -> Vec<String> { fn into_vec(self) -> Vec<String> {
match self { match self {
-81
View File
@@ -1,81 +0,0 @@
# ADR 0028: Agent Configuration Folder Tree
## Status
Accepted.
## Context
ADR-0017/0018 made Agent roles and skills Organization-scoped dynamic runtime
configuration, managed without process restarts. The org admin surfaces for
them (`/admin/roles` and `/admin/skills`) render every
role/skill as one large editor card in a single flat list. As an Organization
accumulates roles and skills, the management pages degrade into an endless
scroll with no grouping affordance.
The project explorer already solves the analogous problem for projects with
transparent folders (ADR-0021): org-scoped navigation nodes that are not
permission resources. Roles and skills need the same affordance, but their
semantics differ from projects in one crucial way: skill names and role IDs
are referenced by role→skill bindings, run-time skill snapshot loading, and
Feishu slash commands. Any grouping mechanism must not leak into those
resolution paths.
## Decision
Introduce an Organization-scoped folder tree shared by Agent roles and Agent
skills:
- One folder tree per Organization is shared by both roles and skills (e.g. a
"高三化学组" folder groups that team's roles and its skills together). It is
a distinct entity from the project explorer `Folder` of ADR-0021 — the two
trees are managed independently and never reference each other.
- Folders are **transparent organization nodes**, following the ADR-0021
project-folder precedent: they exist for management-surface navigation and
grouping only, are not permission resources, and hold no grants.
- Folder membership is **not part of role/skill identity or resolution**:
- skill `name` and role `roleId` remain unique per Organization regardless
of folder membership;
- role→skill bindings, run admission's frozen role snapshot, run-scoped
skill loading, and Feishu slash commands never reference folders.
- Each role/skill sits in at most one folder; membership is optional
(unfiled items remain first-class). Folders nest arbitrarily.
- Folder assignment is a label-class change in the ADR-0017 sense: it never
archives Agent sessions, because the execution surface (model, prompt,
tools, skill content) is untouched.
- A folder can be deleted only when empty — no child folders, no roles, no
skills. Relocating items out of a folder is an explicit user action, so no
orphan-placement rule is needed yet.
- Admin web renders both pages as a left folder tree plus the item list of
the selected folder ("all" and "unfiled" included). The host-console CLI is
unchanged: folder management lives in the web surface, and CLI
`upsert-role`/`install-skill` never touch folder assignment.
## Consequences
- New DB entity `OrganizationAgentConfigFolder` (org-scoped, self-nesting via
`parentId`, delete restricted while referenced) plus nullable `folderId` on
`OrganizationAgentRole` and `OrganizationAgentSkill` (SetNull on folder
delete, though the service refuses to delete non-empty folders).
- The spec pins the transparency and single-membership semantics in
`Spec.System.AgentRole` (`AgentConfigFolder`), so future implementors do
not re-derive them differently (e.g. path-style names or per-folder
uniqueness).
- Org admin APIs gain folder CRUD plus role/skill folder-assignment endpoints
that skip session archival by construction.
- Moving a role/skill between folders changes nothing about authorization,
run resolution, or audit-visible configuration lineage beyond the folder
assignment event itself.
## Open Questions / Deferred
- Drag-and-drop assignment and bulk moves are deferred; assignment is a
per-item select for now.
- Folder-level usage aggregation for roles/skills is deferred (project
folders already aggregate usage under ADR-0021; agent configuration has no
usage dimension yet).
- CLI flags for folder assignment are deferred until a console workflow asks
for them.
- Folder-scoped default-role policies (e.g. per-folder defaults) are rejected
for now: the Organization keeps exactly one active default role
(ADR-0017/0018 invariant) regardless of folder structure.
+1 -4
View File
@@ -32,7 +32,7 @@ App ID 通常以 `cli_` 开头,可以写入交付单。App Secret 必须通过
| 接收群聊中 @ 机器人的消息 | `im:message.group_at_msg:readonly` | | 接收群聊中 @ 机器人的消息 | `im:message.group_at_msg:readonly` |
| 以应用身份发送消息 | `im:message:send_as_bot` | | 以应用身份发送消息 | `im:message:send_as_bot` |
| 读取触发消息和线程上下文 | `im:message:readonly` | | 读取触发消息和线程上下文 | `im:message:readonly` |
| 获取消息中的图片/文件,并向飞书上传图片或文件(含 Agent 回答中的图片发送) | `im:resource` | | 获取与上传图片或文件 | `im:resource` |
| 添加、删除消息表情回复 | `im:message.reactions:write_only` | | 添加、删除消息表情回复 | `im:message.reactions:write_only` |
| 获取用户基本信息 | `contact:user.base:readonly` | | 获取用户基本信息 | `contact:user.base:readonly` |
| 获取用户基本资料 | `contact:user.basic_profile:readonly` | | 获取用户基本资料 | `contact:user.basic_profile:readonly` |
@@ -66,9 +66,6 @@ Educraft 机器人以应用身份调用上述 API,因此这些 scope 全部放
如果 API 调试台提示缺少更细粒度权限,请把错误提示和发生时间截图给部署人员。不要自行开通通讯录全量读取等超出本表的权限。 如果 API 调试台提示缺少更细粒度权限,请把错误提示和发生时间截图给部署人员。不要自行开通通讯录全量读取等超出本表的权限。
说明:`im:resource` 既用于下载用户发来的图片/文件,也用于 Agent 回复时把本地或远程图片上传为飞书 `image_key` 后嵌入消息卡片。缺少该权限时,带图回答会发送失败或降级为无图文本。已开通该 scope 的存量应用一般无需新增权限,但若权限尚未随最新版本发布,请创建新版本并审核发布。
## 4. 配置事件与卡片回调 ## 4. 配置事件与卡片回调
进入“事件与回调”。 进入“事件与回调”。
+3 -20
View File
@@ -20,14 +20,12 @@ 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 # 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. # mandatory in production and should be calibrated on the target host.
HUB_AGENT_MAX_TURNS="150" # HUB_AGENT_MAX_TURNS=25
HUB_AGENT_MAX_CONCURRENT_RUNS="1" HUB_AGENT_MAX_CONCURRENT_RUNS="1"
HUB_AGENT_MAX_RUN_SECONDS="1800" HUB_AGENT_MAX_RUN_SECONDS="900"
HUB_HTTP_BODY_LIMIT_BYTES="1048576" HUB_HTTP_BODY_LIMIT_BYTES="1048576"
HUB_MAX_FILES_PER_MESSAGE="20" HUB_MAX_FILES_PER_MESSAGE="8"
HUB_MAX_FILE_BYTES="26214400" 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_HTTP_REQUESTS_PER_MINUTE="120"
HUB_FEISHU_EVENTS_PER_MINUTE="120" HUB_FEISHU_EVENTS_PER_MINUTE="120"
@@ -40,24 +38,9 @@ HUB_PROJECT_WORKSPACE_ROOT="/var/lib/cph-hub/workspaces"
# startup unless XDG_STATE_HOME is set (then defaults to $XDG_STATE_HOME/skills). # startup unless XDG_STATE_HOME is set (then defaults to $XDG_STATE_HOME/skills).
HUB_SKILL_STORE_ROOT="/var/lib/cph-hub/state/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 # This process is pinned to exactly one Organization. Feishu credentials are
# resolved from that Organization's encrypted ACTIVE connection. # resolved from that Organization's encrypted ACTIVE connection.
HUB_SILO_ORGANIZATION_ID="" 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" HUB_SYSTEMD_UNIT="cph-hub-example.service"
# Absolute path to the `cph` binary (ADR-0016). Production preflight requires # Absolute path to the `cph` binary (ADR-0016). Production preflight requires
+7 -96
View File
@@ -7,9 +7,6 @@
"": { "": {
"name": "admin-web", "name": "admin-web",
"version": "0.0.1", "version": "0.0.1",
"dependencies": {
"fflate": "^0.8.3"
},
"devDependencies": { "devDependencies": {
"@skeletonlabs/skeleton": "^4.15.2", "@skeletonlabs/skeleton": "^4.15.2",
"@skeletonlabs/skeleton-svelte": "^4.15.2", "@skeletonlabs/skeleton-svelte": "^4.15.2",
@@ -29,38 +26,24 @@
} }
}, },
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.11.3", "version": "1.11.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz",
"integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "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": { "dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@emnapi/runtime": { "node_modules/@emnapi/runtime": {
"version": "1.11.3", "version": "1.11.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
@@ -898,72 +881,6 @@
"node": ">=14.0.0" "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": { "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.2", "version": "4.3.2",
"resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz",
@@ -1851,12 +1768,6 @@
} }
} }
}, },
"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": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
-3
View File
@@ -29,8 +29,5 @@
"tailwindcss": "^4.3.2", "tailwindcss": "^4.3.2",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vite": "^8.0.16" "vite": "^8.0.16"
},
"dependencies": {
"fflate": "^0.8.3"
} }
} }
+2 -39
View File
@@ -317,7 +317,6 @@ export interface AgentRoleRow {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
skillNames: readonly string[]; skillNames: readonly string[];
folderId: string | null;
} }
export interface AgentSkillRow { export interface AgentSkillRow {
@@ -330,14 +329,6 @@ export interface AgentSkillRow {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
boundRoleIds: readonly 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 { export interface SkillFileEntry {
@@ -410,7 +401,7 @@ export const api = {
archiveFolder: (slug: string, folderId: string) => archiveFolder: (slug: string, folderId: string) =>
post(`${orgBase(slug)}/folders/${folderId}/archive`) as Promise<{ archived: true; folderId: string }>, post(`${orgBase(slug)}/folders/${folderId}/archive`) as Promise<{ archived: true; folderId: string }>,
createProject: (slug: string, body: { name: string; folderId?: string }) => createProject: (slug: string, body: { name: string; folderId?: string }) =>
post(`${orgBase(slug)}/projects`, body) as Promise<{ projectId: string; folderId: string | null; workspaceDir: string; name: string }>, post(`${orgBase(slug)}/projects`, body) as Promise<{ id: string; name: string }>,
project: (slug: string, projectId: string) => get(`${orgBase(slug)}/projects/${projectId}`) as Promise<ProjectDetail>, project: (slug: string, projectId: string) => get(`${orgBase(slug)}/projects/${projectId}`) as Promise<ProjectDetail>,
renameProject: (slug: string, projectId: string, name: string) => renameProject: (slug: string, projectId: string, name: string) =>
patch(`${orgBase(slug)}/projects/${projectId}`, { name }), patch(`${orgBase(slug)}/projects/${projectId}`, { name }),
@@ -489,18 +480,7 @@ export const api = {
rotateCapabilityConnection: ( rotateCapabilityConnection: (
slug: string, slug: string,
capabilityId: string, capabilityId: string,
body: body: { accessKeyId: string; accessKeySecret: string; endpoint: string },
| { kind?: 'docmind'; accessKeyId: string; accessKeySecret: string; endpoint: string }
| {
kind: 'pbank';
baseUrl: string;
username: string;
password: string;
rightsStatus?: string;
rightsHolder?: string;
rightsScope?: string;
rightsNote?: string;
},
) => ) =>
put(`${orgBase(slug)}/capability-connections/${encodeURIComponent(capabilityId)}`, body) as Promise<CapabilityConnection>, put(`${orgBase(slug)}/capability-connections/${encodeURIComponent(capabilityId)}`, body) as Promise<CapabilityConnection>,
disableCapabilityConnection: (slug: string, capabilityId: string) => disableCapabilityConnection: (slug: string, capabilityId: string) =>
@@ -535,21 +515,4 @@ export const api = {
patchAgentSkill: (slug: string, name: string, body: { description?: string; disabled?: boolean }) => patchAgentSkill: (slug: string, name: string, body: { description?: string; disabled?: boolean }) =>
patch(`${orgBase(slug)}/agent-skills/${encodeURIComponent(name)}`, body) as Promise<{ disabled?: boolean; updated?: 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[] }>, 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;
}>,
}; };
@@ -1,160 +0,0 @@
<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>
+13 -132
View File
@@ -1,11 +1,10 @@
<script lang="ts"> <script lang="ts">
import { Checkbox, Label } from 'bits-ui'; import { Checkbox, Label } from 'bits-ui';
import type { AgentConfigFolderRow, AgentRoleRow, AgentModelRow, AgentSkillRow } from '$lib/api'; import type { AgentRoleRow, AgentModelRow, AgentSkillRow } from '$lib/api';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { fmtDate } from '$lib/format'; import { fmtDate } from '$lib/format';
import { TOOL_OPTIONS } from '$lib/constants'; import { TOOL_OPTIONS } from '$lib/constants';
import SelectField from '$lib/components/SelectField.svelte'; import SelectField from '$lib/components/SelectField.svelte';
import SearchableSelectField from '$lib/components/SearchableSelectField.svelte';
import CheckboxControl from '$lib/components/CheckboxControl.svelte'; import CheckboxControl from '$lib/components/CheckboxControl.svelte';
import Icon from '$lib/components/Icon.svelte'; import Icon from '$lib/components/Icon.svelte';
import { toastError, toastSuccess } from '$lib/toast'; import { toastError, toastSuccess } from '$lib/toast';
@@ -15,23 +14,15 @@
models, models,
skills, skills,
slug, slug,
folders,
folderItems,
onupdated, onupdated,
onskillschanged, onskillschanged,
onfolderchanged,
}: { }: {
r: AgentRoleRow; r: AgentRoleRow;
models: AgentModelRow[]; models: AgentModelRow[];
skills: AgentSkillRow[]; skills: AgentSkillRow[];
slug: string; 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; onupdated: (updated: AgentRoleRow) => void;
onskillschanged: (roleId: string, skillNames: string[]) => void; onskillschanged: (roleId: string, skillNames: string[]) => void;
onfolderchanged: (roleId: string, folderId: string | null) => void;
} = $props(); } = $props();
const initial = { const initial = {
@@ -53,8 +44,6 @@
let isDefault = $state(initial.isDefault); let isDefault = $state(initial.isDefault);
let selectedSkills = $state<string[]>([...initial.skillNames]); let selectedSkills = $state<string[]>([...initial.skillNames]);
let saving = $state(false); let saving = $state(false);
let folderValue = $state(r.folderId ?? '');
let savingFolder = $state(false);
const groupedTools = TOOL_OPTIONS.reduce( const groupedTools = TOOL_OPTIONS.reduce(
(acc, t) => { (acc, t) => {
@@ -64,69 +53,12 @@
{} as Record<string, typeof TOOL_OPTIONS>, {} as Record<string, typeof TOOL_OPTIONS>,
); );
const modelItems = $derived.by(() => { const modelItems = $derived([
const fromCatalog = models.map((m) => ({ value: m.id, label: `${m.label}${m.id}` })); { value: '', label: '(使用平台默认模型)' },
const items = [{ value: '', label: '(使用平台默认模型)' }, ...fromCatalog]; ...models.map((m) => ({ value: m.id, label: `${m.label}${m.id}` })),
// 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;
});
function folderPathLabel(folderId: string): string { const skillItems = $derived(skills.map((s) => ({ value: s.name, label: s.name })));
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 { function skillsDirty(): boolean {
const a = [...selectedSkills].sort(); const a = [...selectedSkills].sort();
@@ -173,24 +105,6 @@
function sortKeyDirty(): boolean { function sortKeyDirty(): boolean {
return Number(sortOrder) !== r.sortOrder; 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> </script>
<div class="saas-card-pad"> <div class="saas-card-pad">
@@ -200,12 +114,6 @@
{#if r.isDefault} {#if r.isDefault}
<span class="saas-badge-success">默认</span> <span class="saas-badge-success">默认</span>
{/if} {/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>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
@@ -221,13 +129,7 @@
<div class="mt-4"> <div class="mt-4">
<p class="saas-label">默认模型</p> <p class="saas-label">默认模型</p>
<SearchableSelectField <SelectField items={modelItems} bind:value={defaultModel} />
items={modelItems}
bind:value={defaultModel}
placeholder="选择模型…"
searchPlaceholder="搜索模型名称或 ID"
emptyText="无匹配模型"
/>
</div> </div>
<div class="mt-4"> <div class="mt-4">
@@ -264,42 +166,21 @@
<div class="mt-4"> <div class="mt-4">
<span class="saas-label">技能绑定</span> <span class="saas-label">技能绑定</span>
{#if skills.length === 0} {#if skills.length === 0}
<p class="text-sm text-surface-600">组织内暂无已安装技能。请在技能页上传 zip 或新建空白模板ADR-0018)。</p> <p class="text-sm text-surface-600">组织内暂无已安装技能。技能通过 CLI / seed 安装ADR-0018)。</p>
{:else} {:else}
<div class="space-y-3"> <div class="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
{#each skillGroups as group (group.key)} {#each skillItems as s}
<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"> <label class="flex cursor-pointer items-center gap-2 px-2 py-1.5 text-sm hover:bg-surface-100">
<CheckboxControl <CheckboxControl
checked={selectedSkills.includes(s.name)} checked={selectedSkills.includes(s.value)}
onchange={(checked) => { onchange={(checked) => {
selectedSkills = checked selectedSkills = checked ? [...selectedSkills, s.value] : selectedSkills.filter((x) => x !== s.value);
? [...selectedSkills, s.name]
: selectedSkills.filter((x) => x !== s.name);
}} }}
/> />
<span class="font-mono text-xs">{s.name}</span> <span class="font-mono text-xs">{s.label}</span>
{#if s.version}
<span class="text-[10px] text-surface-500">v{s.version}</span>
{/if}
</label> </label>
{/each} {/each}
</div> </div>
</div>
{/each}
</div>
{/if} {/if}
</div> </div>
@@ -1,102 +0,0 @@
<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,26 +2,19 @@
import type { AgentSkillRow, SkillFileEntry } from '$lib/api'; import type { AgentSkillRow, SkillFileEntry } from '$lib/api';
import { api } from '$lib/api'; import { api } from '$lib/api';
import { fmtDate } from '$lib/format'; import { fmtDate } from '$lib/format';
import { parseSkillZip } from '$lib/skillZip';
import Icon from '$lib/components/Icon.svelte'; import Icon from '$lib/components/Icon.svelte';
import SelectField from '$lib/components/SelectField.svelte';
import { toastError, toastSuccess } from '$lib/toast'; import { toastError, toastSuccess } from '$lib/toast';
let { let {
slug, slug,
skill, skill,
folderItems,
oninstalled, oninstalled,
ondisabled, ondisabled,
onfolderchanged,
}: { }: {
slug: string; slug: string;
skill: AgentSkillRow; skill: AgentSkillRow;
/** ADR-0028 folder choices ('' = 未分类); transparent grouping only */
folderItems: { value: string; label: string }[];
oninstalled: (result: { id: string; name: string; contentDigest: string }) => void; oninstalled: (result: { id: string; name: string; contentDigest: string }) => void;
ondisabled: (name: string) => void; ondisabled: (name: string) => void;
onfolderchanged: (name: string, folderId: string | null) => void;
} = $props(); } = $props();
type FileNode = { path: string; content: string }; type FileNode = { path: string; content: string };
@@ -35,10 +28,6 @@
let dirty = $state(false); let dirty = $state(false);
let newFilePath = $state(''); let newFilePath = $state('');
let showNewFile = $state(false); 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 selectedFile = $derived(files.find((f) => f.path === selectedPath) ?? null);
const hasManifest = $derived(files.some((f) => f.path === 'SKILL.md')); const hasManifest = $derived(files.some((f) => f.path === 'SKILL.md'));
@@ -163,53 +152,6 @@
dirty = true; 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 { function updateFrontmatter(content: string, key: string, value: string): string {
const regex = new RegExp(`^(${key}:\\s*)(.*?)(\\s*)$`, 'm'); const regex = new RegExp(`^(${key}:\\s*)(.*?)(\\s*)$`, 'm');
if (regex.test(content)) { if (regex.test(content)) {
@@ -236,12 +178,6 @@
{#if skill.disabledAt} {#if skill.disabledAt}
<span class="saas-badge-error">已禁用</span> <span class="saas-badge-error">已禁用</span>
{/if} {/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>
<div class="mb-4 grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="mb-4 grid grid-cols-1 gap-4 md:grid-cols-2">
@@ -356,20 +292,9 @@
</div> </div>
<div class="mt-4 flex flex-wrap items-center gap-3 border-t border-surface-100 pt-4"> <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 || zipImporting}> <button class="saas-btn-primary" onclick={save} disabled={saving || !dirty}>
{saving ? '保存中…' : '保存'} {saving ? '保存中…' : '保存'}
</button> </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} {#if !skill.disabledAt}
<button class="saas-btn-danger" onclick={disable} disabled={saving}> <button class="saas-btn-danger" onclick={disable} disabled={saving}>
禁用 禁用
-3
View File
@@ -12,15 +12,12 @@ export const TOOL_OPTIONS: ToolOption[] = [
{ id: 'bash', label: 'Bash 命令', group: 'Shell' }, { id: 'bash', label: 'Bash 命令', group: 'Shell' },
{ id: 'web_fetch', label: 'WebFetch', group: '网络' }, { id: 'web_fetch', label: 'WebFetch', group: '网络' },
{ id: 'web_search', label: 'WebSearch', group: '网络' }, { id: 'web_search', label: 'WebSearch', group: '网络' },
{ id: 'todo', label: '任务清单 (todo_write)', group: '规划' },
{ id: 'cph_check', label: 'cph check', group: 'CPH' }, { id: 'cph_check', label: 'cph check', group: 'CPH' },
{ id: 'cph_build', label: 'cph build', group: 'CPH' }, { id: 'cph_build', label: 'cph build', group: 'CPH' },
{ id: 'send_file', label: '发送文件(飞书)', group: '飞书' }, { id: 'send_file', label: '发送文件(飞书)', group: '飞书' },
{ id: 'feishu_read_context', label: '读飞书上下文', group: '飞书' }, { id: 'feishu_read_context', label: '读飞书上下文', group: '飞书' },
{ id: 'feishu_download_resource', label: '下载飞书资源', group: '飞书' }, { id: 'feishu_download_resource', label: '下载飞书资源', group: '飞书' },
{ id: 'request_approval', label: '请求审批', group: '飞书' }, { id: 'request_approval', label: '请求审批', group: '飞书' },
{ id: 'convert_pdf_to_md', label: 'PDF→Markdown', group: '能力' },
{ id: 'pbank', label: '题库 (PBank)', group: '能力' },
]; ];
/** 组织成员角色(接口枚举保持英文,界面用 orgRoleLabel */ /** 组织成员角色(接口枚举保持英文,界面用 orgRoleLabel */
-97
View File
@@ -1,97 +0,0 @@
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,6 +32,3 @@ export function toastSuccess(message: string): void {
export function toastError(message: string): void { export function toastError(message: string): void {
pushToast(message, 'error', 5000); pushToast(message, 'error', 5000);
} }
export function toastInfo(message: string): void {
pushToast(message, 'info');
}
@@ -13,26 +13,9 @@
const org = $derived(resolveOrg($session.me, page.url.search)); const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? ''); const slug = $derived(org?.slug ?? '');
type CapKind = 'docmind' | 'pbank';
const KNOWN_CAPABILITIES = [ const KNOWN_CAPABILITIES = [
{ { id: 'pdf_to_md_bundle', label: 'PDF → Markdown', description: '将 PDF 转换为带图片的 Markdown bundle(阿里云文档智能,含公式 LaTeX 识别)' },
id: 'pdf_to_md_bundle', { id: 'audio_video_to_text', label: '音视频 → 文本', description: '将音频/视频转写为文本(阿里云文档智能,按秒计费)' },
kind: 'docmind' as const,
label: 'PDF → Markdown',
description: '将 PDF 转换为带图片的 Markdown bundle(阿里云文档智能,含公式 LaTeX 识别)'
},
{
id: 'audio_video_to_text',
kind: 'docmind' as const,
label: '音视频 → 文本',
description: '将音频/视频转写为文本(阿里云文档智能,按秒计费)'
},
{
id: 'pbank',
kind: 'pbank' as const,
label: '题库 (PBank)',
description: '搜索/拉取 Paradigm 题库题目与源工程;Agent 通过 pbank_* 工具访问,凭据不下发到 Agent 进程'
}
] as const; ] as const;
let connections = $state<Map<string, CapabilityConnection>>(new Map()); let connections = $state<Map<string, CapabilityConnection>>(new Map());
@@ -40,17 +23,9 @@
let error = $state<string | null>(null); let error = $state<string | null>(null);
let editingCap = $state<string | null>(null); let editingCap = $state<string | null>(null);
let editingKind = $state<CapKind>('docmind');
let accessKeyId = $state(''); let accessKeyId = $state('');
let accessKeySecret = $state(''); let accessKeySecret = $state('');
let endpoint = $state('docmind-api.cn-hangzhou.aliyuncs.com'); let endpoint = $state('docmind-api.cn-hangzhou.aliyuncs.com');
let baseUrl = $state('https://pbank.paradigm-edu.net/api');
let username = $state('');
let password = $state('');
let rightsStatus = $state('owned');
let rightsHolder = $state('Paradigm Education');
let rightsScope = $state('internal teaching-material production');
let rightsNote = $state('');
let saving = $state(false); let saving = $state(false);
let disabling = $state<string | null>(null); let disabling = $state<string | null>(null);
@@ -67,19 +42,11 @@
} }
} }
function startEdit(capId: string, kind: CapKind) { function startEdit(capId: string) {
editingCap = capId; editingCap = capId;
editingKind = kind;
accessKeyId = ''; accessKeyId = '';
accessKeySecret = ''; accessKeySecret = '';
endpoint = 'docmind-api.cn-hangzhou.aliyuncs.com'; endpoint = 'docmind-api.cn-hangzhou.aliyuncs.com';
baseUrl = 'https://pbank.paradigm-edu.net/api';
username = '';
password = '';
rightsStatus = 'owned';
rightsHolder = 'Paradigm Education';
rightsScope = 'internal teaching-material production';
rightsNote = '';
} }
function cancelEdit() { function cancelEdit() {
@@ -87,36 +54,17 @@
} }
async function save(capId: string) { async function save(capId: string) {
saving = true;
try {
let result: CapabilityConnection;
if (editingKind === 'docmind') {
if (accessKeyId.trim() === '' || accessKeySecret.trim() === '' || endpoint.trim() === '') { if (accessKeyId.trim() === '' || accessKeySecret.trim() === '' || endpoint.trim() === '') {
toastError('AccessKey ID、AccessKey Secret、Endpoint 均为必填'); toastError('AccessKey ID、AccessKey Secret、Endpoint 均为必填');
return; return;
} }
result = await api.rotateCapabilityConnection(slug, capId, { saving = true;
kind: 'docmind', try {
const result = await api.rotateCapabilityConnection(slug, capId, {
accessKeyId: accessKeyId.trim(), accessKeyId: accessKeyId.trim(),
accessKeySecret: accessKeySecret.trim(), accessKeySecret: accessKeySecret.trim(),
endpoint: endpoint.trim() endpoint: endpoint.trim(),
}); });
} else {
if (baseUrl.trim() === '' || username.trim() === '' || password.trim() === '') {
toastError('Base URL、用户名、密码均为必填');
return;
}
result = await api.rotateCapabilityConnection(slug, capId, {
kind: 'pbank',
baseUrl: baseUrl.trim(),
username: username.trim(),
password: password.trim(),
...(rightsStatus.trim() !== '' ? { rightsStatus: rightsStatus.trim() } : {}),
...(rightsHolder.trim() !== '' ? { rightsHolder: rightsHolder.trim() } : {}),
...(rightsScope.trim() !== '' ? { rightsScope: rightsScope.trim() } : {}),
...(rightsNote.trim() !== '' ? { rightsNote: rightsNote.trim() } : {})
});
}
connections.set(capId, result); connections.set(capId, result);
connections = new Map(connections); connections = new Map(connections);
editingCap = null; editingCap = null;
@@ -162,7 +110,7 @@
<PageHeader <PageHeader
title="外部能力" title="外部能力"
description="管理文档/媒体转换与题库等外部服务的组织级凭据(ADR-0027)。凭据按组织隔离、版本化信封存储,缺失或校验失败即 fail-closed。Agent 永不接收能力凭据。" description="管理文档/媒体转换服务的组织级凭据(ADR-0027)。凭据按组织隔离、版本化信封存储,缺失或校验失败即 fail-closed。"
/> />
{#if loading} {#if loading}
@@ -199,7 +147,7 @@
{/if} {/if}
<button <button
class="saas-btn-primary text-sm" class="saas-btn-primary text-sm"
onclick={() => startEdit(cap.id, cap.kind)} onclick={() => startEdit(cap.id)}
disabled={editingCap === cap.id} disabled={editingCap === cap.id}
> >
{conn ? '轮换凭据' : '配置凭据'} {conn ? '轮换凭据' : '配置凭据'}
@@ -226,7 +174,6 @@
{#if editingCap === cap.id} {#if editingCap === cap.id}
<div class="mt-4 border-t border-surface-100 pt-4"> <div class="mt-4 border-t border-surface-100 pt-4">
{#if cap.kind === 'docmind'}
<p class="saas-muted mb-3 text-sm"> <p class="saas-muted mb-3 text-sm">
阿里云 RAM 用户的 AccessKey。密钥仅写入新版本,旧版本归档。 阿里云 RAM 用户的 AccessKey。密钥仅写入新版本,旧版本归档。
</p> </p>
@@ -237,66 +184,13 @@
</div> </div>
<div> <div>
<Label.Root class="saas-label" for="ak-secret-{cap.id}">AccessKey Secret</Label.Root> <Label.Root class="saas-label" for="ak-secret-{cap.id}">AccessKey Secret</Label.Root>
<input <input id="ak-secret-{cap.id}" class="saas-input" type="password" bind:value={accessKeySecret} />
id="ak-secret-{cap.id}"
class="saas-input"
type="password"
bind:value={accessKeySecret}
/>
</div> </div>
<div> <div>
<Label.Root class="saas-label" for="endpoint-{cap.id}">Endpoint</Label.Root> <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} /> <input id="endpoint-{cap.id}" class="saas-input font-mono text-sm" bind:value={endpoint} />
</div> </div>
</div> </div>
{: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>
{/if}
<div class="mt-4 flex items-center justify-end gap-3"> <div class="mt-4 flex items-center justify-end gap-3">
<button class="saas-btn-ghost" onclick={cancelEdit} disabled={saving}>取消</button> <button class="saas-btn-ghost" onclick={cancelEdit} disabled={saving}>取消</button>
<button class="saas-btn-primary" onclick={() => save(cap.id)} disabled={saving}> <button class="saas-btn-primary" onclick={() => save(cap.id)} disabled={saving}>
@@ -88,7 +88,7 @@
projectName = ''; projectName = '';
projectFolder = ''; projectFolder = '';
showProjectModal = false; showProjectModal = false;
window.location.href = `/admin/projects/${res.projectId}`; window.location.href = `/admin/projects/${res.id}`;
} catch (err) { } catch (err) {
toastError(err instanceof Error ? err.message : String(err)); toastError(err instanceof Error ? err.message : String(err));
} }
@@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import { tick } from 'svelte';
import { page } from '$app/state'; import { page } from '$app/state';
import { api, type ProviderConnectionRow } from '$lib/api'; import { api, type ProviderConnectionRow } from '$lib/api';
import { session } from '$lib/session'; import { session } from '$lib/session';
@@ -9,12 +8,7 @@
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte'; import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte'; import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import { toastError, toastInfo, toastSuccess } from '$lib/toast'; import { toastError, 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 org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? ''); const slug = $derived(org?.slug ?? '');
@@ -23,23 +17,11 @@
let loading = $state(true); let loading = $state(true);
let error = $state<string | null>(null); let error = $state<string | null>(null);
let formState = $state<FormState>({ kind: 'new' });
let providerId = $state(''); let providerId = $state('');
let baseUrl = $state(''); let baseUrl = $state('');
let authToken = $state(''); let authToken = $state('');
let anthropicApiKey = $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() { async function load() {
loading = true; loading = true;
@@ -54,77 +36,48 @@
} }
} }
async function startRotate(row: ProviderConnectionRow) { function startRotate(row: ProviderConnectionRow) {
formState = { kind: 'rotate', providerId: row.providerId };
providerId = row.providerId; providerId = row.providerId;
baseUrl = ''; baseUrl = '';
authToken = ''; authToken = '';
anthropicApiKey = ''; anthropicApiKey = '';
await tick();
const el = document.getElementById('base-url');
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
el?.focus();
toastInfo(`已开始轮换 ${row.providerId},请填写新接口地址与访问令牌`);
} }
function resetForm() { function resetForm() {
formState = { kind: 'new' };
providerId = ''; providerId = '';
baseUrl = ''; baseUrl = '';
authToken = ''; authToken = '';
anthropicApiKey = ''; 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() { async function save() {
const targetProviderId = formState.kind === 'rotate' ? formState.providerId : null;
const intent = targetProviderId === null ? 'new' : 'rotate';
const id = providerId.trim(); const id = providerId.trim();
if (id === '') { if (id === '') {
showFormError('请填写供应方 ID', targetProviderId); toastError('请填写供应方 ID');
return; return;
} }
const url = baseUrl.trim(); const url = baseUrl.trim();
const token = authToken.trim(); const token = authToken.trim();
if (url === '' || token === '') { if (url === '' || token === '') {
showFormError('接口地址与访问令牌均为必填', targetProviderId); toastError('接口地址与访问令牌均为必填');
return; return;
} }
saving = true;
const body: { baseUrl: string; authToken: string; anthropicApiKey?: string } = { const body: { baseUrl: string; authToken: string; anthropicApiKey?: string } = {
baseUrl: url, baseUrl: url,
authToken: token, authToken: token,
}; };
const key = anthropicApiKey.trim(); const key = anthropicApiKey.trim();
if (key !== '') body.anthropicApiKey = key; if (key !== '') body.anthropicApiKey = key;
formState =
intent === 'rotate'
? { kind: 'saving', intent, providerId: id }
: { kind: 'saving', intent };
try { try {
const saved = await api.rotateProviderConnection(slug, id, body); await api.rotateProviderConnection(slug, id, body);
toastSuccess('凭据已轮换');
resetForm(); resetForm();
toastSuccess(saved.activeVersion === 1 ? '已创建 BYOK 连接' : '凭据已轮换');
await load(); await load();
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); toastError(err instanceof Error ? err.message : String(err));
formState = } finally {
intent === 'rotate' saving = false;
? { kind: 'rotate', providerId: id, error: message }
: { kind: 'new', error: message };
toastError(message);
} }
} }
@@ -172,14 +125,7 @@
<td class="text-surface-600">{fmtDate(row.updatedAt)}</td> <td class="text-surface-600">{fmtDate(row.updatedAt)}</td>
<td> <td>
{#if row.mode === 'BYOK'} {#if row.mode === 'BYOK'}
<button <button class="saas-btn-ghost px-2! py-1! text-xs" onclick={() => startRotate(row)}>轮换</button>
class="saas-btn-ghost px-2! py-1! text-xs"
onclick={() => startRotate(row)}
disabled={saving}
aria-label={`开始轮换供应方 ${row.providerId}`}
>
开始轮换
</button>
{:else} {:else}
<span class="text-xs text-surface-500">平台管理</span> <span class="text-xs text-surface-500">平台管理</span>
{/if} {/if}
@@ -193,78 +139,33 @@
</div> </div>
<div class="saas-card-pad"> <div class="saas-card-pad">
<h3 class="saas-section-title mb-1"> <h3 class="saas-section-title mb-1">轮换 BYOK 凭据</h3>
{rotationId ? `轮换凭据 · ${rotationId}` : '新建 BYOK 凭据'} <p class="saas-muted mb-4">
</h3> 密钥仅写入新版本,旧版本归档;保存时需重新填写接口地址与访问令牌。平台托管连接不在此处管理。
<p class="saas-muted mb-4" role="status">
{#if saving}
正在验证新凭据;验证通过后才会切换版本,请勿重复提交。
{:else if rotationId}
已选择供应方 {rotationId}。点击“开始轮换”只打开此表单;填写新接口地址和访问令牌后,点击“验证并保存”才会生效。
{:else}
填写供应方 ID、接口地址和访问令牌,保存前会先验证凭据。平台托管连接不在此处管理。
{/if}
</p> </p>
<div class="grid gap-5"> <div class="grid gap-5">
<div> <div>
<Label.Root class="saas-label" for="provider-id">供应方 ID</Label.Root> <Label.Root class="saas-label" for="provider-id">供应方 ID</Label.Root>
<input <input id="provider-id" class="saas-input font-mono text-sm" bind:value={providerId} placeholder="openrouter" />
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>
<div> <div>
<Label.Root class="saas-label" for="base-url">接口地址</Label.Root> <Label.Root class="saas-label" for="base-url">接口地址</Label.Root>
<input <input id="base-url" class="saas-input" placeholder="https://openrouter.ai/api" bind:value={baseUrl} />
id="base-url"
class="saas-input"
placeholder="https://openrouter.ai/api"
bind:value={baseUrl}
disabled={saving}
/>
</div> </div>
<div> <div>
<Label.Root class="saas-label" for="auth-token">访问令牌</Label.Root> <Label.Root class="saas-label" for="auth-token">访问令牌</Label.Root>
<input <input id="auth-token" class="saas-input" type="password" bind:value={authToken} />
id="auth-token"
class="saas-input"
type="password"
bind:value={authToken}
disabled={saving}
/>
</div> </div>
<div> <div>
<Label.Root class="saas-label" for="anthropic-key">Anthropic API Key(可选)</Label.Root> <Label.Root class="saas-label" for="anthropic-key">Anthropic API Key(可选)</Label.Root>
<input <input id="anthropic-key" class="saas-input" type="password" bind:value={anthropicApiKey} />
id="anthropic-key"
class="saas-input"
type="password"
bind:value={anthropicApiKey}
disabled={saving}
/>
</div> </div>
</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="mt-6 flex items-center gap-3 border-t border-surface-100 pt-4">
<div class="flex-1"></div> <div class="flex-1"></div>
<button class="saas-btn-ghost" onclick={cancelOrClear} disabled={saving}> <button class="saas-btn-ghost" onclick={resetForm} disabled={saving}>清空</button>
{rotationId ? '取消轮换' : '清空'}
</button>
<button class="saas-btn-primary" onclick={save} disabled={saving}> <button class="saas-btn-primary" onclick={save} disabled={saving}>
{saving ? '验证并保存中…' : rotationId ? '验证并保存' : '验证并创建'} {saving ? '保存中…' : '保存'}
</button> </button>
</div> </div>
</div> </div>
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { api, type AgentConfigFolderRow, type AgentModelRow, type AgentRoleRow, type AgentSkillRow } from '$lib/api'; import { api, type AgentRoleRow, type AgentModelRow, type AgentSkillRow } from '$lib/api';
import { session } from '$lib/session'; import { session } from '$lib/session';
import { resolveOrg } from '$lib/org'; import { resolveOrg } from '$lib/org';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
@@ -8,9 +8,6 @@
import ErrorBanner from '$lib/components/ErrorBanner.svelte'; import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte'; import EmptyState from '$lib/components/EmptyState.svelte';
import RoleCard from '$lib/components/RoleCard.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'; import { toastError, toastSuccess } from '$lib/toast';
const org = $derived(resolveOrg($session.me, page.url.search)); const org = $derived(resolveOrg($session.me, page.url.search));
@@ -19,32 +16,20 @@
let roles = $state<AgentRoleRow[]>([]); let roles = $state<AgentRoleRow[]>([]);
let models = $state<AgentModelRow[]>([]); let models = $state<AgentModelRow[]>([]);
let skills = $state<AgentSkillRow[]>([]); let skills = $state<AgentSkillRow[]>([]);
let folders = $state<AgentConfigFolderRow[]>([]);
let loading = $state(true); let loading = $state(true);
let error = $state<string | null>(null); let error = $state<string | null>(null);
/** 'all' | 'unfiled' | folder id (ADR-0028: transparent grouping filter) */
let selectedFolder = $state<string>('all');
let newRoleId = $state(''); let newRoleId = $state('');
let newLabel = $state(''); let newLabel = $state('');
let adding = $state(false); 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() { async function load() {
loading = true; loading = true;
error = null; error = null;
try { try {
const [r, s, f] = await Promise.all([api.agentRoles(slug), api.agentSkills(slug), api.agentConfigFolders(slug)]); const [r, s] = await Promise.all([api.agentRoles(slug), api.agentSkills(slug)]);
roles = r.roles; roles = r.roles;
skills = s.skills; skills = s.skills;
folders = f.folders;
// Model fetch hits the provider API and may fail or be slow; load it // Model fetch hits the provider API and may fail or be slow; load it
// independently so roles remain editable even without a model list. // independently so roles remain editable even without a model list.
models = []; models = [];
@@ -58,66 +43,6 @@
} }
} }
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() { async function add() {
const roleId = newRoleId.trim(); const roleId = newRoleId.trim();
const label = newLabel.trim(); const label = newLabel.trim();
@@ -132,12 +57,7 @@
adding = true; adding = true;
try { try {
const created = await api.upsertAgentRole(slug, roleId, { label }); const created = await api.upsertAgentRole(slug, roleId, { label });
let folderId: string | null = null; roles = [...roles, created];
if (selectedFolder !== 'all' && selectedFolder !== 'unfiled') {
await api.setAgentRoleFolder(slug, roleId, selectedFolder);
folderId = selectedFolder;
}
roles = [...roles, { ...created, folderId }];
newRoleId = ''; newRoleId = '';
newLabel = ''; newLabel = '';
toastSuccess('角色已创建'); toastSuccess('角色已创建');
@@ -159,68 +79,6 @@
roles = roles.map((x) => (x.roleId === roleId ? { ...x, skillNames } : x)); 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(() => { $effect(() => {
if (slug) load(); if (slug) load();
}); });
@@ -228,7 +86,7 @@
<PageHeader <PageHeader
title="角色" title="角色"
description="角色是组织级数据:组合默认模型、系统提示词、工具白名单与已绑定技能。文件夹仅作管理分组,不影响角色解析与默认角色约束。" description="角色是组织级数据:组合默认模型、系统提示词、工具白名单与已绑定技能。角色 ID 即飞书斜杠命令(如 /draft)。"
/> />
{#if loading} {#if loading}
@@ -236,22 +94,6 @@
{:else if error} {:else if error}
<ErrorBanner message={error} onretry={load} /> <ErrorBanner message={error} onretry={load} />
{:else} {:else}
<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}
/>
</div>
<div>
<div class="saas-card-pad mb-6"> <div class="saas-card-pad mb-6">
<h2 class="saas-section-title mb-4">新建角色</h2> <h2 class="saas-section-title mb-4">新建角色</h2>
<div class="grid gap-3 sm:grid-cols-[10rem_1fr_auto]"> <div class="grid gap-3 sm:grid-cols-[10rem_1fr_auto]">
@@ -273,56 +115,18 @@
/> />
<button class="saas-btn-primary" onclick={add} disabled={adding}>新建</button> <button class="saas-btn-primary" onclick={add} disabled={adding}>新建</button>
</div> </div>
<p class="mt-2 text-xs text-surface-600">角色 ID 仅允许小写字母、数字、下划线与连字符,且以字母或数字开头;当前选中文件夹时新角色会自动归入其中</p> <p class="mt-2 text-xs text-surface-600">角色 ID 仅允许小写字母、数字、下划线与连字符,且以字母或数字开头。</p>
</div> </div>
{#if roles.length === 0} {#if roles.length === 0}
<div class="saas-card"> <div class="saas-card">
<EmptyState title="暂无角色" description="组织必须且只能有一个启用中的默认角色;新建第一个角色将自动成为默认。" /> <EmptyState title="暂无角色" description="组织必须且只能有一个启用中的默认角色;新建第一个角色将自动成为默认。" />
</div> </div>
{:else if visibleRoles.length === 0}
<div class="saas-card">
<EmptyState title="此分类下暂无角色" description="在角色卡片上可将其移入当前文件夹。" />
</div>
{:else} {:else}
<div class="space-y-4"> <div class="space-y-4">
{#each visibleRoles as r (r.roleId)} {#each roles as r (r.roleId)}
<RoleCard <RoleCard {r} {models} {skills} {slug} onupdated={onRoleUpdated} onskillschanged={onRoleSkillsChanged} />
{r}
{models}
{skills}
{slug}
{folders}
{folderItems}
onupdated={onRoleUpdated}
onskillschanged={onRoleSkillsChanged}
onfolderchanged={onRoleFolderChanged}
/>
{/each} {/each}
</div> </div>
{/if} {/if}
</div>
</div>
<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>
<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>
</Modal>
{/if} {/if}
@@ -1,55 +1,34 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { api, type AgentConfigFolderRow, type AgentSkillRow, type SkillFileEntry } from '$lib/api'; import { api, type AgentSkillRow, type SkillFileEntry } from '$lib/api';
import { session } from '$lib/session'; import { session } from '$lib/session';
import { resolveOrg } from '$lib/org'; import { resolveOrg } from '$lib/org';
import { parseSkillZip } from '$lib/skillZip';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte'; import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte'; import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte'; import EmptyState from '$lib/components/EmptyState.svelte';
import SkillEditor from '$lib/components/SkillEditor.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'; import { toastError, toastSuccess } from '$lib/toast';
const org = $derived(resolveOrg($session.me, page.url.search)); const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? ''); const slug = $derived(org?.slug ?? '');
let skills = $state<AgentSkillRow[]>([]); let skills = $state<AgentSkillRow[]>([]);
let folders = $state<AgentConfigFolderRow[]>([]);
let loading = $state(true); let loading = $state(true);
let error = $state<string | null>(null); 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 showNewSkill = $state(false);
let newSkillName = $state(''); let newSkillName = $state('');
let newSkillVersion = $state('0.1.0'); let newSkillVersion = $state('0.1.0');
let newSkillDescription = $state(''); let newSkillDescription = $state('');
let creating = $state(false); 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() { async function load() {
loading = true; loading = true;
error = null; error = null;
try { try {
const [s, f] = await Promise.all([api.agentSkills(slug), api.agentConfigFolders(slug)]); const res = await api.agentSkills(slug);
skills = s.skills; skills = res.skills;
folders = f.folders;
} catch (err) { } catch (err) {
error = err instanceof Error ? err.message : String(err); error = err instanceof Error ? err.message : String(err);
} finally { } finally {
@@ -57,66 +36,6 @@
} }
} }
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() { async function createSkill() {
const name = newSkillName.trim(); const name = newSkillName.trim();
if (name === '') { if (name === '') {
@@ -137,9 +56,6 @@
const manifest = buildManifest(name, newSkillDescription.trim()); const manifest = buildManifest(name, newSkillDescription.trim());
const files: SkillFileEntry[] = [{ path: 'SKILL.md', content: manifest }]; const files: SkillFileEntry[] = [{ path: 'SKILL.md', content: manifest }];
const result = await api.installAgentSkill(slug, name, { version, files }); const result = await api.installAgentSkill(slug, name, { version, files });
if (selectedFolder !== 'all' && selectedFolder !== 'unfiled') {
await api.setAgentSkillFolder(slug, result.name, selectedFolder);
}
toastSuccess(`技能 ${result.name} 已创建`); toastSuccess(`技能 ${result.name} 已创建`);
newSkillName = ''; newSkillName = '';
newSkillDescription = ''; newSkillDescription = '';
@@ -157,37 +73,6 @@
return `---\nname: ${name}\ndescription: ${desc}\n---\n# ${name}\n\n`; 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 }) { function onInstalled(_result: { id: string; name: string; contentDigest: string }) {
load(); load();
} }
@@ -196,68 +81,6 @@
load(); 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(() => { $effect(() => {
if (slug) load(); if (slug) load();
}); });
@@ -265,7 +88,7 @@
<PageHeader <PageHeader
title="技能" title="技能"
description="技能是组织级 Agent 能力包:一个包含 SKILL.md manifest 的目录。文件夹仅作管理分组,不影响技能解析与绑定。" description="技能是组织级 Agent 能力包:一个包含 SKILL.md manifest 的目录。技能内容按 SHA-256 content-addressed 存储,变更后绑定角色的活跃会话自动归档。"
/> />
{#if loading} {#if loading}
@@ -273,67 +96,15 @@
{:else if error} {:else if error}
<ErrorBanner message={error} onretry={load} /> <ErrorBanner message={error} onretry={load} />
{:else} {:else}
<div class="grid gap-4 lg:grid-cols-[15rem_1fr]"> <div class="saas-card-pad mb-6">
<div class="h-fit lg:sticky lg:top-4"> <div class="flex items-center justify-between">
<AgentConfigFolderNav
{folders}
selected={selectedFolder}
{counts}
totalCount={skills.length}
{unfiledCount}
onselect={(id) => (selectedFolder = id)}
oncreate={openCreateFolder}
onrename={openRenameFolder}
ondelete={deleteFolder}
/>
</div>
<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> <h2 class="saas-section-title">新建技能</h2>
<div class="flex flex-wrap gap-3"> <button class="text-sm text-primary-700 hover:text-primary-900" onclick={() => (showNewSkill = !showNewSkill)}>
<button {showNewSkill ? '取消' : '+ 新建'}
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> </button>
</div> </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} {#if showNewSkill}
<div class="grid gap-3 sm:grid-cols-[12rem_8rem_1fr_auto]"> <div class="mt-4 grid gap-3 sm:grid-cols-[12rem_8rem_1fr_auto]">
<input <input
class="saas-input font-mono text-sm" class="saas-input font-mono text-sm"
placeholder="技能名(如 typst-help" placeholder="技能名(如 typst-help"
@@ -353,8 +124,8 @@
{creating ? '创建中…' : '创建'} {creating ? '创建中…' : '创建'}
</button> </button>
</div> </div>
<p class="text-xs text-surface-600"> <p class="mt-2 text-xs text-surface-600">
技能名称仅允许小写字母、数字和连字符,且以字母或数字开头。创建后会生成 SKILL.md 模板;当前选中文件夹时新技能会自动归入其中 技能名称仅允许小写字母、数字和连字符,且以字母或数字开头。创建后会生成 SKILL.md 模板。
</p> </p>
{/if} {/if}
</div> </div>
@@ -363,46 +134,11 @@
<div class="saas-card"> <div class="saas-card">
<EmptyState title="暂无技能" description="新建一个技能,然后在角色管理中绑定到角色。" /> <EmptyState title="暂无技能" description="新建一个技能,然后在角色管理中绑定到角色。" />
</div> </div>
{:else if visibleSkills.length === 0}
<div class="saas-card">
<EmptyState title="此分类下暂无技能" description="在技能卡片上可将其移入当前文件夹。" />
</div>
{:else} {:else}
<div class="space-y-4"> <div class="space-y-4">
{#each visibleSkills as skill (skill.id)} {#each skills as skill (skill.id)}
<SkillEditor <SkillEditor {slug} {skill} oninstalled={onInstalled} ondisabled={onDisabled} />
{slug}
{skill}
{folderItems}
oninstalled={onInstalled}
ondisabled={onDisabled}
onfolderchanged={onSkillFolderChanged}
/>
{/each} {/each}
</div> </div>
{/if} {/if}
</div>
</div>
<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>
<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>
</Modal>
{/if} {/if}
+6 -24
View File
@@ -406,8 +406,7 @@
resize: vertical; resize: vertical;
} }
.saas-select-trigger, .saas-select-trigger {
.saas-combobox-input {
display: inline-flex; display: inline-flex;
width: 100%; width: 100%;
align-items: center; align-items: center;
@@ -426,25 +425,14 @@
text-align: left; 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:focus-visible,
.saas-select-trigger[data-state='open'], .saas-select-trigger[data-state='open'] {
.saas-combobox-input:focus {
border-color: var(--color-primary-600); border-color: var(--color-primary-600);
box-shadow: inset 0 0 0 1px var(--color-primary-600); box-shadow: inset 0 0 0 1px var(--color-primary-600);
} }
.saas-select-trigger:disabled, .saas-select-trigger:disabled,
.saas-select-trigger[data-disabled], .saas-select-trigger[data-disabled] {
.saas-combobox-input:disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.55; opacity: 0.55;
} }
@@ -455,15 +443,9 @@
.saas-select-content { .saas-select-content {
z-index: 70; z-index: 70;
max-height: min( max-height: min(18rem, var(--bits-select-content-available-height, 18rem));
18rem, width: var(--bits-select-anchor-width);
var( min-width: var(--bits-select-anchor-width);
--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; overflow: hidden;
border-radius: 0; border-radius: 0;
border: 1px solid var(--color-surface-400); border: 1px solid var(--color-surface-400);
+3 -5
View File
@@ -85,7 +85,7 @@ REMOTE
-e "ssh ${SSH_OPTS[*]}" \ -e "ssh ${SSH_OPTS[*]}" \
"$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/" "$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/"
echo "[fleet] npm ci (including build-time dev deps) + build (tsc + admin-web SPA)" echo "[fleet] npm ci + build (tsc + admin-web SPA)"
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" bash -s <<REMOTE ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" bash -s <<REMOTE
set -euo pipefail set -euo pipefail
flock /var/lock/cph-hub-release-publish bash -c ' flock /var/lock/cph-hub-release-publish bash -c '
@@ -95,10 +95,8 @@ flock /var/lock/cph-hub-release-publish bash -c '
exit 0 exit 0
fi fi
cd "$HUB_DIR" cd "$HUB_DIR"
PUPPETEER_SKIP_DOWNLOAD=1 npm ci --include=dev PUPPETEER_SKIP_DOWNLOAD=1 npm ci
npm ci --include=dev --prefix admin-web npm ci --prefix admin-web
test -x node_modules/.bin/tsc
test -x admin-web/node_modules/.bin/vite
npm run audit:production npm run audit:production
npm run build npm run build
test -f admin-web/build/index.html test -f admin-web/build/index.html
+2 -4
View File
@@ -60,12 +60,10 @@ if [ "$release_ready" = false ]; then
-e "ssh ${SSH_OPTS[*]}" \ -e "ssh ${SSH_OPTS[*]}" \
"$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/" "$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/"
# 2. Install deps (including build-time dev deps) for hub + admin-web, # 2. Install deps (hub + admin-web), audit hub prod, build tsc + SPA, mark complete.
# audit hub prod, build tsc + SPA, 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 → admin-web/build for registerStaticSpa. # `npm run build` → tsc then admin:build → admin-web/build for registerStaticSpa.
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" \ ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" \
"cd '$HUB_DIR' && PUPPETEER_SKIP_DOWNLOAD=1 npm ci --include=dev && npm ci --include=dev --prefix admin-web && npm run audit:production && npm run build && touch '$RELEASE_DIR/.complete'" "cd '$HUB_DIR' && PUPPETEER_SKIP_DOWNLOAD=1 npm ci && npm ci --prefix admin-web && npm run audit:production && npm run build && touch '$RELEASE_DIR/.complete'"
fi fi
# 3. Ensure the service is installed (idempotent), then restart. # 3. Ensure the service is installed (idempotent), then restart.
+1 -3
View File
@@ -164,19 +164,17 @@ DATABASE_URL=
HUB_SILO_ORGANIZATION_ID= HUB_SILO_ORGANIZATION_ID=
HUB_SYSTEMD_UNIT=$SERVICE_UNIT HUB_SYSTEMD_UNIT=$SERVICE_UNIT
CPH_BIN=$CPH_BIN_DEFAULT CPH_BIN=$CPH_BIN_DEFAULT
HUB_FEISHU_CLI_BIN=/usr/local/bin/lark-cli
HOST=$HOST HOST=$HOST
PORT=$PORT PORT=$PORT
HUB_PROJECT_WORKSPACE_ROOT=$WORKSPACE_ROOT HUB_PROJECT_WORKSPACE_ROOT=$WORKSPACE_ROOT
HUB_PUBLIC_BASE_URL= HUB_PUBLIC_BASE_URL=
HUB_SESSION_SECRET= HUB_SESSION_SECRET=
HUB_AGENT_MAX_TURNS=150 HUB_AGENT_MAX_TURNS=25
HUB_AGENT_MAX_CONCURRENT_RUNS= HUB_AGENT_MAX_CONCURRENT_RUNS=
HUB_AGENT_MAX_RUN_SECONDS= HUB_AGENT_MAX_RUN_SECONDS=
HUB_HTTP_BODY_LIMIT_BYTES= HUB_HTTP_BODY_LIMIT_BYTES=
HUB_MAX_FILES_PER_MESSAGE= HUB_MAX_FILES_PER_MESSAGE=
HUB_MAX_FILE_BYTES= HUB_MAX_FILE_BYTES=
HUB_PDF_TO_MD_MAX_CONCURRENT=3
HUB_HTTP_REQUESTS_PER_MINUTE= HUB_HTTP_REQUESTS_PER_MINUTE=
HUB_FEISHU_EVENTS_PER_MINUTE= HUB_FEISHU_EVENTS_PER_MINUTE=
HUB_FEISHU_LISTENER_ENABLED=true 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_MODEL "anthropic/claude-sonnet-5"
seed_default DEFAULT_ROLE_ID "draft" seed_default DEFAULT_ROLE_ID "draft"
seed_default DEFAULT_ROLE_LABEL "智能助手" seed_default DEFAULT_ROLE_LABEL "智能助手"
seed_default MAX_TURNS "150" seed_default MAX_TURNS "25"
seed_default MAX_CONCURRENT_RUNS "4" seed_default MAX_CONCURRENT_RUNS "4"
seed_default MAX_RUN_SECONDS "1800" seed_default MAX_RUN_SECONDS "900"
seed_default HTTP_BODY_LIMIT_BYTES "1048576" seed_default HTTP_BODY_LIMIT_BYTES "1048576"
seed_default MAX_FILES_PER_MESSAGE "20" seed_default MAX_FILES_PER_MESSAGE "8"
seed_default MAX_FILE_BYTES "26214400" seed_default MAX_FILE_BYTES "26214400"
seed_default HTTP_REQUESTS_PER_MINUTE "120" seed_default HTTP_REQUESTS_PER_MINUTE "120"
seed_default FEISHU_EVENTS_PER_MINUTE "120" seed_default FEISHU_EVENTS_PER_MINUTE "120"
+71 -95
View File
@@ -1,12 +1,12 @@
{ {
"name": "@paradigm/hub", "name": "@paradigm/hub",
"version": "0.0.42", "version": "0.0.35",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@paradigm/hub", "name": "@paradigm/hub",
"version": "0.0.42", "version": "0.0.35",
"dependencies": { "dependencies": {
"@alicloud/credentials": "^2.4.5", "@alicloud/credentials": "^2.4.5",
"@alicloud/docmind-api20220711": "^1.4.15", "@alicloud/docmind-api20220711": "^1.4.15",
@@ -247,22 +247,22 @@
} }
}, },
"node_modules/@anthropic-ai/claude-agent-sdk": { "node_modules/@anthropic-ai/claude-agent-sdk": {
"version": "0.3.217", "version": "0.3.202",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.217.tgz", "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.202.tgz",
"integrity": "sha512-juszT3itL8R6OQ6nb/8IZE34UjKps8Jf7N8vjCXLx+vbJc+k3EojZOs93tJwT5iTRvfV1a0N53zJbKn/iJpKrQ==", "integrity": "sha512-LnaLxDtsZP7J6g++xRSnnpTX7CHNe4v+cvBRIlD2ar+N+xi0aqY2YDaCsxPsl+haVUB9kqlUMd0zosmwsfTGjQ==",
"license": "SEE LICENSE IN README.md", "license": "SEE LICENSE IN README.md",
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.217", "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.202",
"@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.217", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.202",
"@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.217", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.202",
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.217", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.202",
"@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.217", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.202",
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.217", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.202",
"@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.217", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.202",
"@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.217" "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.202"
}, },
"peerDependencies": { "peerDependencies": {
"@anthropic-ai/sdk": ">=0.93.0", "@anthropic-ai/sdk": ">=0.93.0",
@@ -271,9 +271,9 @@
} }
}, },
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": {
"version": "0.3.217", "version": "0.3.202",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.217.tgz", "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.202.tgz",
"integrity": "sha512-dl119zmL1Ssyd8Fx0xfVMpss2scrGCZwf+rhZwl2lHa2dYuXVluLgqi4DUIWDj3rRYdrAvaMpjCAv6a5w07ddw==", "integrity": "sha512-ujR3zDthDPkZs+AxW95iHpqLT5cuwGImsS3mVxLt1DlDij4qeTnihLX8+EpQTK+oNW9jjvFA86yKwa84fa1KYA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -284,9 +284,9 @@
] ]
}, },
"node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": {
"version": "0.3.217", "version": "0.3.202",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.217.tgz", "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.202.tgz",
"integrity": "sha512-IeKL1HN8fEcRQ4uw5d02by1ThpjhRtOgfHcCTBQ2KS4JfEIHvc1VGWt6Exb2a7VHhT8uRcfjPk9urbmYayZmaw==", "integrity": "sha512-s/RVSGgkVmIMfyt1ndR8braLLu82bARoijmt1kk8d4IptUZ0Sc+zNUWKoFXwR9XqDBu6rBbBF9RIzD02raT57w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -297,15 +297,12 @@
] ]
}, },
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": {
"version": "0.3.217", "version": "0.3.202",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.217.tgz", "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.202.tgz",
"integrity": "sha512-KtrnfEwUSCdq2cc4Pgysl+U66vqw3h7u04N5/OLHmYZ4AZYy8JcqdOaSJZ27iL2bgbAxyKwu5/9YmEk9A4IswA==", "integrity": "sha512-a4YtRkgGYt3ogePJDW8Ts6bNW690jb9LHyZaiWXsi+zT53xCNqJB2zKPyRc7hXWOqzIk4nCfwJpjmhLzMu3WIg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"glibc"
],
"license": "SEE LICENSE IN LICENSE.md", "license": "SEE LICENSE IN LICENSE.md",
"optional": true, "optional": true,
"os": [ "os": [
@@ -313,15 +310,12 @@
] ]
}, },
"node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": {
"version": "0.3.217", "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.217.tgz", "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-Bb4AJxqrVPouM4sYIdvX3/AO5womhe70u3Euv+6B5J2OoqcRaWarVvYevX3KRruC5TvlV2Josw14dsL5qVNL+A==", "integrity": "sha512-abSb3Gah45kUNyOeKjmQ/dd1KZ4CaQz5JAr9YQxRDXoOwx8wJVx6huBIpDxjms9wyS9X5Rqxn0Lx7zFP+wV2zQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"libc": [
"musl"
],
"license": "SEE LICENSE IN LICENSE.md", "license": "SEE LICENSE IN LICENSE.md",
"optional": true, "optional": true,
"os": [ "os": [
@@ -329,15 +323,12 @@
] ]
}, },
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": {
"version": "0.3.217", "version": "0.3.202",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.217.tgz", "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.202.tgz",
"integrity": "sha512-JsAQyfl4n0PR4LX0h1SxMo0raERGb8B8dvbaoNQRRSpb9A2vvcwPEjyKu0eRKHRhTvspvuD6TfNxzxrmnouX9A==", "integrity": "sha512-XIvhdCWAAT4OdOA82fOJII+WH0Tf8pFckckEbJMMmOgQBKOnHT+609Pd3Ehw6zGcA9iFrhG5mY8Ncuckeo1aMw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"glibc"
],
"license": "SEE LICENSE IN LICENSE.md", "license": "SEE LICENSE IN LICENSE.md",
"optional": true, "optional": true,
"os": [ "os": [
@@ -345,15 +336,12 @@
] ]
}, },
"node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": {
"version": "0.3.217", "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.217.tgz", "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-qhugNZd77vAoPMIGM8vFHlbwTltFyI1POmfyl0ZJSpc6v7RE9+5+nqL2aGbGSDsDQkEHrJasXURxIeTMn9ut2w==", "integrity": "sha512-fze5nAQL1ErcMCQNB10ILaWdM0QbJSaTQzBz8NVAy0FGW8ZL0t4Wf/VgFkfzXbfkaxmPuM1C27Dn5HiU7UDEHQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"libc": [
"musl"
],
"license": "SEE LICENSE IN LICENSE.md", "license": "SEE LICENSE IN LICENSE.md",
"optional": true, "optional": true,
"os": [ "os": [
@@ -361,9 +349,9 @@
] ]
}, },
"node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": {
"version": "0.3.217", "version": "0.3.202",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.217.tgz", "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.202.tgz",
"integrity": "sha512-LuaQ+PXZvIToAR81JoiGa6Me9HDma2WH2oiYlAWh43IWaXHyOqgaI1aqSM0BjDhy2UiYWTvGzAopnqPnk+jSBw==", "integrity": "sha512-N1J0HRvC+8a69bqNY7+ENIYQzR0i7s+rOIGH5XtuLxvLqOnZO8LHxWEZOe8ezabGq5eZqphSCgL6vQnQQpNh+A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -374,9 +362,9 @@
] ]
}, },
"node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": {
"version": "0.3.217", "version": "0.3.202",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.217.tgz", "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.202.tgz",
"integrity": "sha512-4r/T+ze/S/CLZ58tP4Mw52XPmsc/LOrCOd8jZOqM13FCPWdCMU2osWmszEIKGVMRG2cGsaLVDYcks5cWFqjCjw==", "integrity": "sha512-ytLGEC1fjTSiVSoXukS+j9G+06Mi20NSzxxzlG6uE75SEB0+17tHdWUaHqd8PhH/6GPzcYx81czxWQl1MVbq4Q==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -437,34 +425,22 @@
} }
}, },
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.11.3", "version": "1.11.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz",
"integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==",
"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, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@emnapi/runtime": { "node_modules/@emnapi/runtime": {
"version": "1.11.3", "version": "1.11.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@@ -1058,13 +1034,13 @@
} }
}, },
"node_modules/@hono/node-server": { "node_modules/@hono/node-server": {
"version": "2.1.0", "version": "1.19.14",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.0.tgz", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
"integrity": "sha512-XovyyCCnBzW+zKu+z/zq8hwNs4KOR5rEMAOxo2f40Q5xoOI37IMm6MIg2COOUtUApo0i6850MTBKH2u4QLGIqg==", "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"engines": { "engines": {
"node": ">=20" "node": ">=18.14.1"
}, },
"peerDependencies": { "peerDependencies": {
"hono": "^4" "hono": "^4"
@@ -1093,13 +1069,13 @@
} }
}, },
"node_modules/@modelcontextprotocol/sdk": { "node_modules/@modelcontextprotocol/sdk": {
"version": "1.30.0", "version": "1.29.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
"integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==",
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"@hono/node-server": "^1.19.9 || ^2.0.5", "@hono/node-server": "^1.19.9",
"ajv": "^8.17.1", "ajv": "^8.17.1",
"ajv-formats": "^3.0.1", "ajv-formats": "^3.0.1",
"content-type": "^1.0.5", "content-type": "^1.0.5",
@@ -2709,9 +2685,9 @@
"peer": true "peer": true
}, },
"node_modules/fast-uri": { "node_modules/fast-uri": {
"version": "3.1.5", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -2823,9 +2799,9 @@
} }
}, },
"node_modules/find-my-way": { "node_modules/find-my-way": {
"version": "9.7.0", "version": "9.6.0",
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.7.0.tgz", "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz",
"integrity": "sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==", "integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
@@ -3023,9 +2999,9 @@
} }
}, },
"node_modules/hono": { "node_modules/hono": {
"version": "4.13.0", "version": "4.12.28",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.0.tgz", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.28.tgz",
"integrity": "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==", "integrity": "sha512-YwUvVpSF7m1yOblFPrU3Hbo8XhPheBoiyfGuII6z19LnOr6JpDnyyp7LFNrfV56wS8tpvtBFGRISHN02pDdLOA==",
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"engines": { "engines": {
@@ -3130,9 +3106,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/ip-address": { "node_modules/ip-address": {
"version": "10.4.0", "version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 12" "node": ">= 12"
@@ -3661,9 +3637,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.17", "version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
"integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -3921,9 +3897,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.25", "version": "8.5.16",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
"integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -3941,7 +3917,7 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"nanoid": "^3.3.16", "nanoid": "^3.3.12",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
}, },
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@paradigm/hub", "name": "@paradigm/hub",
"version": "0.0.42", "version": "0.0.35",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
@@ -30,7 +30,7 @@
"axios": "1.18.1" "axios": "1.18.1"
} }
}, },
"description": "Curriculum Project Hub — org-scoped Feishu collaboration and confined Agent runtime. Semantics pinned by docs/adr/ (ADR-0001 through ADR-0027).", "description": "Curriculum Project Hub — org-scoped Feishu collaboration and confined Agent runtime. Aligns to spec/System through ADR-0024.",
"scripts": { "scripts": {
"dev": "npm run prisma:migrate && tsx watch src/server.ts", "dev": "npm run prisma:migrate && tsx watch src/server.ts",
"build": "tsc -p tsconfig.json && npm run admin:build", "build": "tsc -p tsconfig.json && npm run admin:build",
@@ -1,6 +1,6 @@
-- ADR-0023 rejected the legacy `PlatformRoleAssignment` / `PlatformRole`{ADMIN,TEACHER} -- ADR-0023 rejected the legacy `PlatformRoleAssignment` / `PlatformRole`{ADMIN,TEACHER}
-- model: the platform administration control plane is a separate identity/session/ -- model: the platform administration control plane is a separate identity/session/
-- audit surface, intentionally not built -- audit surface (see `Spec.System.PlatformAdministration`), intentionally not built
-- in alpha (ADR-0025, `hub/deploy/README.md`). The legacy table has no runtime -- in alpha (ADR-0025, `hub/deploy/README.md`). The legacy table has no runtime
-- reader — no guard, route, or service queries it for an authorization decision — -- reader — no guard, route, or service queries it for an authorization decision —
-- and ADR-0023 requires it to be migrated/replaced before the platform panel ships. -- and ADR-0023 requires it to be migrated/replaced before the platform panel ships.
@@ -1,33 +0,0 @@
-- 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;
@@ -1,5 +0,0 @@
-- 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;
+4 -34
View File
@@ -1,6 +1,6 @@
// Prisma schema for Curriculum Project Hub. // Prisma schema for Curriculum Project Hub.
// //
// Aligns to ADR-0001..0004, 0017. Key divergences from the // Aligns to spec/System (ADR-0001..0004, 0017). Key divergences from the
// legacy teaching-material-host-service schema, each deliberate: // legacy teaching-material-host-service schema, each deliberate:
// //
// - AgentSession is provider/model-bound. Provider runtime cursors such as // - AgentSession is provider/model-bound. Provider runtime cursors such as
@@ -11,8 +11,8 @@
// - ProjectGroupBinding is project→chat only (ADR-0001 1:1); legacy mixed // - ProjectGroupBinding is project→chat only (ADR-0001 1:1); legacy mixed
// user/chat targets into one binding table. // user/chat targets into one binding table.
// - PermissionGrant + PermissionSettings land (ADR-0004), missing in legacy. // - PermissionGrant + PermissionSettings land (ADR-0004), missing in legacy.
// - AgentRunStatus adds WAITING_FOR_USER + TIMED_OUT (the run-state set is // - AgentRunStatus adds WAITING_FOR_USER + TIMED_OUT (spec RunState; enum
// open — add states without a schema migration war). // completeness OPEN — add states without a schema migration war).
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
@@ -47,7 +47,6 @@ model Organization {
capabilityConnections OrganizationCapabilityConnection[] capabilityConnections OrganizationCapabilityConnection[]
agentSkills OrganizationAgentSkill[] agentSkills OrganizationAgentSkill[]
agentRoles OrganizationAgentRole[] agentRoles OrganizationAgentRole[]
agentConfigFolders OrganizationAgentConfigFolder[]
projectGroupBindings ProjectGroupBinding[] projectGroupBindings ProjectGroupBinding[]
auditEntries AuditEntry[] @relation("organizationAudit") auditEntries AuditEntry[] @relation("organizationAudit")
projectSearchDocuments ProjectSearchDocument[] projectSearchDocuments ProjectSearchDocument[]
@@ -62,7 +61,7 @@ enum OrganizationStatus {
} }
/// Org-scoped membership role. Distinct from project PermissionRole and from /// Org-scoped membership role. Distinct from project PermissionRole and from
/// the platform administrator surface (ADR-0023), /// the platform administrator surface (ADR-0023 / Spec.System.PlatformAdministration),
/// which is a separate control plane not modeled in alpha (ADR-0025). /// which is a separate control plane not modeled in alpha (ADR-0025).
model OrganizationMembership { model OrganizationMembership {
id String @id @default(cuid()) id String @id @default(cuid())
@@ -96,19 +95,16 @@ model OrganizationAgentSkill {
version String version String
description String? description String?
contentDigest String contentDigest String
folderId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
disabledAt DateTime? 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[] roleBindings OrganizationAgentRoleSkill[]
@@unique([organizationId, name]) @@unique([organizationId, name])
@@unique([organizationId, id]) @@unique([organizationId, id])
@@index([organizationId, disabledAt]) @@index([organizationId, disabledAt])
@@index([organizationId, folderId])
@@index([contentDigest]) @@index([contentDigest])
} }
@@ -125,20 +121,17 @@ model OrganizationAgentRole {
tools Json? tools Json?
sortOrder Int @default(0) sortOrder Int @default(0)
isDefault Boolean @default(false) isDefault Boolean @default(false)
folderId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
disabledAt DateTime? 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[] skillBindings OrganizationAgentRoleSkill[]
selectedByBindings ProjectGroupBinding[] @relation("selectedAgentRole") selectedByBindings ProjectGroupBinding[] @relation("selectedAgentRole")
@@unique([organizationId, roleId]) @@unique([organizationId, roleId])
@@unique([organizationId, id]) @@unique([organizationId, id])
@@index([organizationId, disabledAt, sortOrder]) @@index([organizationId, disabledAt, sortOrder])
@@index([organizationId, folderId])
} }
/// Same-Organization join enforced by both composite foreign keys. `sortOrder` /// Same-Organization join enforced by both composite foreign keys. `sortOrder`
@@ -158,29 +151,6 @@ model OrganizationAgentRoleSkill {
@@index([organizationId, agentSkillId]) @@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 /// ADR-0021: org-level project onboarding policy. Ordinary Feishu users can
/// create projects from unbound chats only when membersCanCreateProjects=true. /// create projects from unbound chats only when membersCanCreateProjects=true.
model OrganizationProjectSettings { model OrganizationProjectSettings {
+1 -5
View File
@@ -26,11 +26,7 @@ const client = new DocmindClient.default({
} as never); } as never);
const fileStream = createReadStream(pdfPath); 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..."); console.log("Submitting job...");
const submitResp = await client.submitDocParserJobAdvance( const submitResp = await client.submitDocParserJobAdvance(
-60
View File
@@ -1,60 +0,0 @@
---
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.
+19 -52
View File
@@ -2,95 +2,62 @@
name: pdf-to-md name: pdf-to-md
description: > description: >
Convert PDF documents to Markdown bundles using the convert_pdf_to_md tool. Convert PDF documents to Markdown bundles using the convert_pdf_to_md tool.
Handles single or multiple PDFs (concurrent batch), Feishu attachments, and Handles PDFs from Feishu messages, local workspace files, and produces
local workspace files. Produces high-quality Markdown with LaTeX formulas high-quality Markdown with LaTeX formulas and extracted images.
and extracted images.
--- ---
# PDF to Markdown Conversion # PDF to Markdown Conversion
## When to use ## When to use
Use this skill when the user asks to convert a PDF (or several PDFs) to Use this skill when the user asks to convert a PDF to Markdown, extract text
Markdown, extract text from a PDF, or turn PDF documents into an editable from a PDF, or turn a PDF document into an editable format.
format.
## How it works ## How it works
The `convert_pdf_to_md` tool (provided by the in-process `cph_hub` MCP server) The `convert_pdf_to_md` tool (provided by the `cph_hub` MCP server) calls
calls Alibaba Cloud Document Mind to parse each PDF. It: Alibaba Cloud Document Mind to parse the PDF. It:
- Extracts text in reading order (handles multi-column, scanned, and - Extracts text in reading order (handles multi-column, scanned, and
multi-language documents) multi-language documents)
- Converts mathematical formulas to **LaTeX** (`$...$` inline, `$$...$$` block) - Converts mathematical formulas to **LaTeX** (`$...$` inline, `$$...$$` block)
- Extracts tables as Markdown tables - Extracts tables as Markdown tables
- Downloads embedded images into the output directory - Downloads embedded images into the output directory
- Writes a single `document.md` file plus image files **per** `output_dir` - Writes a single `document.md` file plus image files
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 ## Workflow
### One PDF from a Feishu message ### PDF from a Feishu message
1. Use `feishu_read_context` to find the `file_key` of the PDF attachment. 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. 2. Use `feishu_download_resource` to download it into the workspace.
3. Use `convert_pdf_to_md` with `input_path` + `output_dir`. 3. Use `convert_pdf_to_md` with the downloaded file path and an output directory.
### PDF already in the workspace ### PDF already in the workspace
1. Use `convert_pdf_to_md` with `input_path` and `output_dir`. 1. Use `convert_pdf_to_md` directly with the file path and an output directory.
### 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 ## Important rules
- **Always** use `convert_pdf_to_md` for PDF→Markdown. Do NOT attempt to parse - **Always** use `convert_pdf_to_md` for PDF→Markdown. Do NOT attempt to parse
PDFs yourself with Read, Bash, Python, or any other method. PDFs yourself with Read, Bash, Python, or any other method. The tool provides
accurate formula, table, and image extraction that manual methods cannot
match.
- If `convert_pdf_to_md` fails because no capability connection is configured, - If `convert_pdf_to_md` fails because no capability connection is configured,
tell the user to ask their organization admin to configure the Aliyun tell the user to ask their organization admin to configure the Aliyun
docmind credential in the admin web UI (组织后台 → 能力). docmind credential in the admin web UI (组织后台 → 能力).
- The output directory will be created if it does not exist. - The output directory will be created if it does not exist.
- After conversion, use `send_file` to send generated markdown (or a zip you - After conversion, use `send_file` to send the generated markdown back to the
assemble) back to the user if they requested delivery. user if they requested it.
## Output ## Output
Per `output_dir`: The tool returns a list of generated files:
- `document.md` — the main markdown file - `document.md` — the main markdown file
- `*.jpg` / `*.png` — extracted images, referenced from the markdown - `*.jpg` / `*.png` — extracted images, referenced from the markdown
## Cost ## Cost
Billed per page (0.04 CNY/page ≈ $0.0056/page for enhanced formula mode). The conversion is billed per page (0.04 CNY/page ≈ $0.0056/page for the
Each successful file records its own usage fact on the run ledger. enhanced formula mode). The cost is automatically recorded on the run's
usage ledger.
-121
View File
@@ -15,10 +15,6 @@
* `commitSkillContent`, so the web path and CLI path share one ingestion * `commitSkillContent`, so the web path and CLI path share one ingestion
* pipeline and one set of safety checks (SKILL.md manifest required, 512-file * pipeline and one set of safety checks (SKILL.md manifest required, 512-file
* / 16-byte limits, symlink rejection). * / 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 { PrismaClient } from "@prisma/client";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
@@ -247,121 +243,4 @@ export async function registerAgentConfigRoutes(
return handleRouteError(reply, err); 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,12 +7,8 @@
*/ */
import type { PrismaClient } from "@prisma/client"; import type { PrismaClient } from "@prisma/client";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { import { CapabilityConnectionService } from "../../capability/capabilityConnectionService.js";
CapabilityConnectionService,
type CapabilityCredentialInput,
} from "../../capability/capabilityConnectionService.js";
import { CapabilityReadinessError, type CapabilityReadinessProbe } from "../../capability/capabilityReadiness.js"; import { CapabilityReadinessError, type CapabilityReadinessProbe } from "../../capability/capabilityReadiness.js";
import { secretKindForCapability } from "../../capability/types.js";
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js"; import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
import { requireOrgRole, type GuardDeps } from "../auth/guards.js"; import { requireOrgRole, type GuardDeps } from "../auth/guards.js";
import { handleRouteError } from "../errors.js"; import { handleRouteError } from "../errors.js";
@@ -29,10 +25,11 @@ export async function registerCapabilityConnectionRoutes(
config: CapabilityConnectionRouteConfig, config: CapabilityConnectionRouteConfig,
): Promise<void> { ): Promise<void> {
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret }; const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
const connections = const connections = new CapabilityConnectionService(
config.readinessProbe === undefined config.prisma,
? new CapabilityConnectionService(config.prisma, config.secretEnvelope) config.secretEnvelope,
: new CapabilityConnectionService(config.prisma, config.secretEnvelope, config.readinessProbe); config.readinessProbe,
);
app.get("/api/org/:orgSlug/capability-connections", async (request, reply) => { app.get("/api/org/:orgSlug/capability-connections", async (request, reply) => {
try { try {
@@ -63,12 +60,12 @@ export async function registerCapabilityConnectionRoutes(
const { orgSlug, capabilityId } = request.params as { orgSlug: string; capabilityId: string }; const { orgSlug, capabilityId } = request.params as { orgSlug: string; capabilityId: string };
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug }); const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
if (auth === null) return; if (auth === null) return;
const credential = parseCredentialBody(capabilityId, request.body); const body = parseBody(request.body);
const result = await connections.rotate({ const result = await connections.rotate({
organizationId: auth.organization.id, organizationId: auth.organization.id,
capabilityId, capabilityId,
actorUserId: auth.user.id, actorUserId: auth.user.id,
credential, ...body,
}); });
request.log.info({ request.log.info({
organizationId: auth.organization.id, organizationId: auth.organization.id,
@@ -116,57 +113,19 @@ export async function registerCapabilityConnectionRoutes(
}); });
} }
function parseCredentialBody(capabilityId: string, value: unknown): CapabilityCredentialInput { function parseBody(value: unknown): { readonly accessKeyId: string; readonly accessKeySecret: string; readonly endpoint: string } {
if (typeof value !== "object" || value === null || Array.isArray(value)) { if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error("invalid capability credential body"); throw new Error("invalid capability credential body");
} }
const body = value as Record<string, unknown>; const body = value as Record<string, unknown>;
const expectedKind = secretKindForCapability(capabilityId); for (const name of ["accessKeyId", "accessKeySecret", "endpoint"] as const) {
const kind = if (typeof body[name] !== "string" || (body[name] as string).trim() === "") {
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 {
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`); throw new Error(`${name} is required`);
} }
return value; }
} return {
accessKeyId: body["accessKeyId"] as string,
function optionalStringField(body: Record<string, unknown>, name: string): string | undefined { accessKeySecret: body["accessKeySecret"] as string,
const value = body[name]; endpoint: body["endpoint"] as string,
if (typeof value !== "string") return undefined; };
const trimmed = value.trim();
return trimmed === "" ? undefined : trimmed;
} }
+1 -6
View File
@@ -155,12 +155,7 @@ export async function registerExplorerRoutes(
workspaceRoot: config.projectWorkspaceRoot, workspaceRoot: config.projectWorkspaceRoot,
...(typeof body.folderId === "string" ? { folderId: body.folderId } : {}), ...(typeof body.folderId === "string" ? { folderId: body.folderId } : {}),
}); });
return reply.status(201).send({ return reply.status(201).send({ id: result.projectId, name: body.name });
projectId: result.projectId,
folderId: result.folderId,
workspaceDir: result.workspaceDir,
name: body.name,
});
} catch (err) { } catch (err) {
return handleRouteError(reply, err); return handleRouteError(reply, err);
} }
+11 -256
View File
@@ -18,7 +18,6 @@ export interface AgentRoleRow {
readonly createdAt: string; readonly createdAt: string;
readonly updatedAt: string; readonly updatedAt: string;
readonly skillNames: readonly string[]; readonly skillNames: readonly string[];
readonly folderId: string | null;
} }
export interface AgentSkillRow { export interface AgentSkillRow {
@@ -31,17 +30,6 @@ export interface AgentSkillRow {
readonly createdAt: string; readonly createdAt: string;
readonly updatedAt: string; readonly updatedAt: string;
readonly boundRoleIds: readonly 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;
} }
/** /**
@@ -92,213 +80,9 @@ export class OrganizationAgentConfiguration {
createdAt: skill.createdAt.toISOString(), createdAt: skill.createdAt.toISOString(),
updatedAt: skill.updatedAt.toISOString(), updatedAt: skill.updatedAt.toISOString(),
boundRoleIds: skill.roleBindings.map((binding) => binding.role.roleId), 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: { async installSkill(input: {
readonly organizationId: string; readonly organizationId: string;
readonly sourceDir: string; readonly sourceDir: string;
@@ -389,7 +173,7 @@ export class OrganizationAgentConfiguration {
where: { id: skill.id }, where: { id: skill.id },
data: { disabledAt: new Date() }, data: { disabledAt: new Date() },
}); });
await invalidateRoleSessionClaudeIds( await archiveRoleSessions(
tx, tx,
input.organizationId, input.organizationId,
skill.roleBindings.map((binding) => binding.role.roleId), skill.roleBindings.map((binding) => binding.role.roleId),
@@ -487,7 +271,7 @@ export class OrganizationAgentConfiguration {
}, },
}); });
if (previous !== null && previous.contentDigest !== skill.contentDigest) { if (previous !== null && previous.contentDigest !== skill.contentDigest) {
await invalidateRoleSessionClaudeIds( await archiveRoleSessions(
tx, tx,
input.organizationId, input.organizationId,
skill.roleBindings.map((binding) => binding.role.roleId), skill.roleBindings.map((binding) => binding.role.roleId),
@@ -595,7 +379,7 @@ export class OrganizationAgentConfiguration {
if (activeDefaultCount !== 1) { if (activeDefaultCount !== 1) {
throw new Error(`organization ${input.organizationId} must have exactly one active default role`); throw new Error(`organization ${input.organizationId} must have exactly one active default role`);
} }
if (executionSurfaceChanged) await invalidateRoleSessionClaudeIds(tx, input.organizationId, [input.roleId]); if (executionSurfaceChanged) await archiveRoleSessions(tx, input.organizationId, [input.roleId]);
await tx.auditEntry.create({ await tx.auditEntry.create({
data: { data: {
organizationId: input.organizationId, organizationId: input.organizationId,
@@ -665,7 +449,7 @@ export class OrganizationAgentConfiguration {
})), })),
}); });
} }
await invalidateRoleSessionClaudeIds(tx, input.organizationId, [input.roleId]); await archiveRoleSessions(tx, input.organizationId, [input.roleId]);
await tx.auditEntry.create({ await tx.auditEntry.create({
data: { data: {
organizationId: input.organizationId, organizationId: input.organizationId,
@@ -688,22 +472,7 @@ 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, tx: Prisma.TransactionClient,
organizationId: string, organizationId: string,
roleIds: readonly string[], roleIds: readonly string[],
@@ -713,36 +482,24 @@ async function invalidateRoleSessionClaudeIds(
where: { where: {
roleId: { in: [...new Set(roleIds)] }, roleId: { in: [...new Set(roleIds)] },
project: { organizationId }, project: { organizationId },
archivedAt: null,
}, },
select: { id: true, metadata: true }, select: { id: true, archivedAt: true, metadata: true },
}); });
const archivedAt = new Date();
for (const session of sessions) { for (const session of sessions) {
const metadata = typeof session.metadata === "object" && session.metadata !== null && !Array.isArray(session.metadata) const metadata = typeof session.metadata === "object" && session.metadata !== null && !Array.isArray(session.metadata)
? session.metadata as Prisma.JsonObject ? session.metadata as Prisma.JsonObject
: {}; : {};
const { claudeSessionId: _drop, ...rest } = metadata;
await tx.agentSession.update({ await tx.agentSession.update({
where: { id: session.id }, where: { id: session.id },
data: { metadata: { ...rest, userResumable: false } }, data: {
...(session.archivedAt === null ? { archivedAt } : {}),
metadata: { ...metadata, 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 { function nonEmpty(value: string, label: string): string {
const normalized = value.trim(); const normalized = value.trim();
if (normalized === "") throw new Error(`${label} is required`); if (normalized === "") throw new Error(`${label} is required`);
@@ -773,7 +530,6 @@ function toRoleRow(role: {
readonly disabledAt: Date | null; readonly disabledAt: Date | null;
readonly createdAt: Date; readonly createdAt: Date;
readonly updatedAt: Date; readonly updatedAt: Date;
readonly folderId: string | null;
readonly skillBindings: ReadonlyArray<{ readonly skillBindings: ReadonlyArray<{
readonly skill: { readonly name: string; readonly disabledAt: Date | null }; readonly skill: { readonly name: string; readonly disabledAt: Date | null };
}>; }>;
@@ -793,6 +549,5 @@ function toRoleRow(role: {
skillNames: role.skillBindings skillNames: role.skillBindings
.filter((binding) => binding.skill.disabledAt === null) .filter((binding) => binding.skill.disabledAt === null)
.map((binding) => binding.skill.name), .map((binding) => binding.skill.name),
folderId: role.folderId,
}; };
} }
+48 -83
View File
@@ -1,13 +1,11 @@
export const DEFAULT_CLAUDE_BUILT_IN_TOOLS = [ export const DEFAULT_CLAUDE_BUILT_IN_TOOLS = [
"Read", "Read",
"Write", "Write",
"Edit",
"Bash", "Bash",
"Glob", "Glob",
"Grep", "Grep",
"WebFetch", "WebFetch",
"WebSearch", "WebSearch",
"TodoWrite",
] as const; ] as const;
export const CPH_HUB_MCP_SERVER_NAME = "cph_hub"; export const CPH_HUB_MCP_SERVER_NAME = "cph_hub";
@@ -17,10 +15,6 @@ export const CPH_HUB_MCP_TOOL_IDS = [
"feishu_download_resource", "feishu_download_resource",
"request_approval", "request_approval",
"convert_pdf_to_md", "convert_pdf_to_md",
"pbank_search_problems",
"pbank_get_problem",
"pbank_get_many_problems",
"todo_write",
] as const; ] as const;
export type CphHubMcpToolId = (typeof CPH_HUB_MCP_TOOL_IDS)[number]; export type CphHubMcpToolId = (typeof CPH_HUB_MCP_TOOL_IDS)[number];
@@ -30,65 +24,48 @@ export interface ClaudeSdkToolConfig {
readonly allowedTools: readonly string[]; readonly allowedTools: readonly string[];
} }
const ROLE_TOOL_TO_CLAUDE_BUILT_INS: Readonly<Record<string, readonly string[]>> = { const ROLE_TOOL_TO_CLAUDE_BUILT_INS = new Map<string, readonly string[]>([
read_file: ["Read"], ["read_file", ["Read"]],
write_file: ["Write", "Edit"], ["write_file", ["Write"]],
list_files: ["Glob"], ["list_files", ["Glob"]],
search_files: ["Grep"], ["search_files", ["Grep"]],
bash: ["Bash"], ["bash", ["Bash"]],
// ADR-0017 replaced cph custom tools with Bash commands. Granting either // ADR-0017 replaced cph custom tools with Bash commands. Granting either
// cph role tool therefore exposes the SDK Bash tool; cph-only Bash narrowing // cph role tool therefore exposes the SDK Bash tool; cph-only Bash narrowing
// would need a separate command-policy layer. // would need a separate command-policy layer.
cph_check: ["Bash"], ["cph_check", ["Bash"]],
cph_build: ["Bash"], ["cph_build", ["Bash"]],
web_fetch: ["WebFetch"], ["web_fetch", ["WebFetch"]],
web_search: ["WebSearch"], ["web_search", ["WebSearch"]],
todo: ["TodoWrite"], ["Read", ["Read"]],
TodoWrite: ["TodoWrite"], ["Write", ["Write"]],
Read: ["Read"], ["Bash", ["Bash"]],
Write: ["Write"], ["Glob", ["Glob"]],
Edit: ["Edit"], ["Grep", ["Grep"]],
Bash: ["Bash"], ["WebFetch", ["WebFetch"]],
Glob: ["Glob"], ["WebSearch", ["WebSearch"]],
Grep: ["Grep"],
WebFetch: ["WebFetch"],
WebSearch: ["WebSearch"],
};
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([
...Object.keys(ROLE_TOOL_TO_CLAUDE_BUILT_INS),
...Object.keys(ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS),
]); ]);
export function claudeSdkToolConfigForRole( const ROLE_TOOL_TO_CPH_HUB_MCP_TOOL = new Map<string, CphHubMcpToolId>([
roleTools: readonly string[] | null | undefined, ["send_file", "send_file"],
): ClaudeSdkToolConfig { ["feishu_read_context", "feishu_read_context"],
// DB/runtime "unrestricted" is JSON null; treat the same as undefined. ["feishu_download_resource", "feishu_download_resource"],
if (roleTools === undefined || roleTools === null) { ["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 SUPPORTED_ROLE_TOOLS = new Set([
...ROLE_TOOL_TO_CLAUDE_BUILT_INS.keys(),
...ROLE_TOOL_TO_CPH_HUB_MCP_TOOL.keys(),
]);
export function claudeSdkToolConfigForRole(roleTools: readonly string[] | undefined): ClaudeSdkToolConfig {
if (roleTools === undefined) {
const mcpTools = CPH_HUB_MCP_TOOL_IDS.map(claudeMcpToolName); const mcpTools = CPH_HUB_MCP_TOOL_IDS.map(claudeMcpToolName);
return { return {
tools: [...DEFAULT_CLAUDE_BUILT_IN_TOOLS], tools: [...DEFAULT_CLAUDE_BUILT_IN_TOOLS],
@@ -100,12 +77,13 @@ export function claudeSdkToolConfigForRole(
const allowedTools: string[] = []; const allowedTools: string[] = [];
for (const roleTool of roleTools) { for (const roleTool of roleTools) {
assertSupportedRoleTool(roleTool); assertSupportedRoleTool(roleTool);
for (const tool of ROLE_TOOL_TO_CLAUDE_BUILT_INS[roleTool] ?? []) { for (const tool of ROLE_TOOL_TO_CLAUDE_BUILT_INS.get(roleTool) ?? []) {
pushUnique(builtIns, tool); pushUnique(builtIns, tool);
pushUnique(allowedTools, tool); pushUnique(allowedTools, tool);
} }
for (const mcpTool of ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS[roleTool] ?? []) { const mcpTool = ROLE_TOOL_TO_CPH_HUB_MCP_TOOL.get(roleTool);
if (mcpTool !== undefined) {
pushUnique(allowedTools, claudeMcpToolName(mcpTool)); pushUnique(allowedTools, claudeMcpToolName(mcpTool));
} }
} }
@@ -113,37 +91,24 @@ export function claudeSdkToolConfigForRole(
return { tools: builtIns, allowedTools }; return { tools: builtIns, allowedTools };
} }
export function cphHubMcpToolsForRole( export function cphHubMcpToolsForRole(roleTools: readonly string[] | undefined): readonly CphHubMcpToolId[] {
roleTools: readonly string[] | null | undefined, if (roleTools === undefined) return [...CPH_HUB_MCP_TOOL_IDS];
): 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[] = ["todo_write"]; const tools: CphHubMcpToolId[] = [];
for (const roleTool of roleTools) { for (const roleTool of roleTools) {
assertSupportedRoleTool(roleTool); assertSupportedRoleTool(roleTool);
for (const mcpTool of ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS[roleTool] ?? []) { const mcpTool = ROLE_TOOL_TO_CPH_HUB_MCP_TOOL.get(roleTool);
pushUnique(tools, mcpTool); if (mcpTool !== undefined) pushUnique(tools, mcpTool);
}
} }
return tools; return tools;
} }
export function roleToolsAllow( export function roleToolsAllow(roleTools: readonly string[] | undefined, roleTool: string): boolean {
roleTools: readonly string[] | null | undefined, if (roleTools === undefined) return true;
roleTool: string,
): boolean {
if (roleTools === undefined || roleTools === null) return true;
for (const configured of roleTools) { for (const configured of roleTools) {
assertSupportedRoleTool(configured); assertSupportedRoleTool(configured);
if (configured === roleTool) return true; if (configured === roleTool) return true;
const mapped = ROLE_TOOL_TO_CPH_HUB_MCP_TOOLS[configured]; if (ROLE_TOOL_TO_CPH_HUB_MCP_TOOL.get(configured) === roleTool) return true;
if (mapped !== undefined && mapped.includes(roleTool as CphHubMcpToolId)) return true;
// Umbrella: role tool "pbank" allows any pbank_* MCP or role tool.
if (configured === "pbank" && roleTool.startsWith("pbank")) return true;
} }
return false; return false;
} }
+14 -113
View File
@@ -24,10 +24,10 @@
* denied by default and re-opened only for the workspace plus named system * denied by default and re-opened only for the workspace plus named system
* runtimes, and `failIfUnavailable` hard-fails if the sandbox can't start. The * runtimes, and `failIfUnavailable` hard-fails if the sandbox can't start. The
* subprocess gets a minimal environment and SDK credential protection removes * subprocess gets a minimal environment and SDK credential protection removes
* provider secrets from Bash. This upholds the workspace-bounded file-op * provider secrets from Bash. This upholds `AgentFileOp.Authorized`
* invariant (ADR-0018) without re-implementing the * (ADR-0018 / `Spec.System.AgentSurface`) without re-implementing the
* `workspace.ts` `confine()` path validator as a tool wrapper — the OS sandbox * `workspace.ts` `confine()` path validator as a tool wrapper — the OS sandbox
* is the mechanism, the ADR pins the invariant. * is the mechanism, the contract pins the invariant.
*/ */
import { query, type HookCallback, type McpServerConfig, type SDKMessage, type SDKAssistantMessage, type SDKUserMessage, type SDKResultMessage, type SDKPartialAssistantMessage, type SDKSystemMessage } from "@anthropic-ai/claude-agent-sdk"; import { query, type HookCallback, type McpServerConfig, type SDKMessage, type SDKAssistantMessage, type SDKUserMessage, type SDKResultMessage, type SDKPartialAssistantMessage, type SDKSystemMessage } from "@anthropic-ai/claude-agent-sdk";
import type { PrismaClient } from "@prisma/client"; import type { PrismaClient } from "@prisma/client";
@@ -47,7 +47,7 @@ export type StreamEvent =
| { readonly type: "thinking-delta"; readonly text: string } | { readonly type: "thinking-delta"; readonly text: string }
| { readonly type: "tool-start"; readonly toolName: string; readonly toolUseId: 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-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 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: "finish" }; | { readonly type: "finish" };
export type StreamCallback = (event: StreamEvent) => void; export type StreamCallback = (event: StreamEvent) => void;
@@ -140,9 +140,7 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
let cleanupSecurity = async (): Promise<void> => {}; let cleanupSecurity = async (): Promise<void> => {};
try { try {
await persistAgentMessage(req, "user", req.prompt); await persistAgentMessage(req, "user", req.prompt);
// Role tools JSON null means the default single-agent tool set, not "deny all". const toolConfig = claudeSdkToolConfigForRole(req.tools);
const roleToolIds = req.tools === null ? undefined : req.tools;
const toolConfig = claudeSdkToolConfigForRole(roleToolIds);
const workspaceRoot = req.project.workspaceRoot?.trim(); const workspaceRoot = req.project.workspaceRoot?.trim();
if (workspaceRoot === undefined || workspaceRoot === "") { if (workspaceRoot === undefined || workspaceRoot === "") {
throw new Error("Agent run requires the configured workspace root"); throw new Error("Agent run requires the configured workspace root");
@@ -156,42 +154,14 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
}); });
cleanupSecurity = security.cleanup; cleanupSecurity = security.cleanup;
const hasSkills = security.skillIds.length > 0; 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;
type QueryOptions = NonNullable<Parameters<typeof query>[0]["options"]>;
const options: QueryOptions = { const options: QueryOptions = {
cwd: security.cwd, cwd: security.cwd,
tools: toolsOption, // `skills` controls discovery/allowlisting, but an explicit `tools`
allowedTools: allowedToolsOption, // list still has to expose the Skill dispatcher itself.
disallowedTools: [...disallowedToolsOption], tools: [...toolConfig.tools, ...(hasSkills ? ["Skill"] : [])],
allowedTools: [...toolConfig.allowedTools],
maxTurns: cap, maxTurns: cap,
includePartialMessages: true, includePartialMessages: true,
// ADR-0018: bypass interactive prompts (headless server); the sandbox // ADR-0018: bypass interactive prompts (headless server); the sandbox
@@ -208,16 +178,7 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
// The project workspace is untrusted input. Do not load user/project // The project workspace is untrusted input. Do not load user/project
// settings that could widen tools, hooks, MCP servers, or sandbox paths. // settings that could widen tools, hooks, MCP servers, or sandbox paths.
settingSources: [], settingSources: [],
settings: { settings: { disableBundledSkills: true },
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 ...(hasSkills && security.skillPluginRoot !== undefined
? { plugins: [{ type: "local" as const, path: security.skillPluginRoot, skipMcpDiscovery: true }] } ? { plugins: [{ type: "local" as const, path: security.skillPluginRoot, skipMcpDiscovery: true }] }
: {}), : {}),
@@ -237,25 +198,13 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
if (req.abortController !== undefined) options.abortController = req.abortController; if (req.abortController !== undefined) options.abortController = req.abortController;
if (req.onSdkStderr !== undefined) options.stderr = req.onSdkStderr; 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({ const conversation = query({
prompt: promptForAgent, prompt: req.prompt,
options, options,
}); });
// Track tool start timestamps and names/inputs for duration + tool-result attribution. // Track tool start timestamps for duration calculation
const toolStartTimestamps = new Map<string, number>(); const toolStartTimestamps = new Map<string, number>();
const toolMetaByUseId = new Map<string, { readonly name: string; readonly input: unknown }>();
for await (const message of conversation) { for await (const message of conversation) {
switch (message.type) { switch (message.type) {
@@ -276,10 +225,6 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
if (evt.type === "content_block_start" && evt.content_block.type === "tool_use") { if (evt.type === "content_block_start" && evt.content_block.type === "tool_use") {
const toolUseId = evt.content_block.id; const toolUseId = evt.content_block.id;
toolStartTimestamps.set(toolUseId, Date.now()); 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 }); onStream?.({ type: "tool-start", toolName: evt.content_block.name, toolUseId });
} }
if (evt.type === "content_block_stop") { if (evt.type === "content_block_stop") {
@@ -301,7 +246,6 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
const durationMs = toolStartTimestamps.has(block.id) const durationMs = toolStartTimestamps.has(block.id)
? Date.now() - (toolStartTimestamps.get(block.id) ?? 0) ? Date.now() - (toolStartTimestamps.get(block.id) ?? 0)
: undefined; : undefined;
toolMetaByUseId.set(block.id, { name: block.name, input: block.input });
onStream?.({ onStream?.({
type: "tool-end", type: "tool-end",
toolName: block.name, toolName: block.name,
@@ -335,14 +279,12 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
const isError = block.is_error === true; const isError = block.is_error === true;
const resultText = extractToolResultText(block.content); const resultText = extractToolResultText(block.content);
const durationMs = toolStartTimestamps.get(toolUseId); const durationMs = toolStartTimestamps.get(toolUseId);
const meta = toolMetaByUseId.get(toolUseId);
onStream?.({ onStream?.({
type: "tool-result", type: "tool-result",
toolUseId, toolUseId,
toolName: meta?.name ?? toolUseId, toolName: toolUseId,
result: resultText, result: resultText,
isError, isError,
...(meta?.input !== undefined ? { input: meta.input } : {}),
...(durationMs !== undefined ? { durationMs: Date.now() - durationMs } : {}), ...(durationMs !== undefined ? { durationMs: Date.now() - durationMs } : {}),
}); });
} }
@@ -391,39 +333,6 @@ 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> { async function persistAgentMessage(req: RunRequest, role: string, content: string): Promise<void> {
if (content === "") return; if (content === "") return;
try { try {
@@ -452,11 +361,3 @@ function extractToolResultText(content: unknown): string {
} }
return parts.join("\n"); 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;
}
+2 -57
View File
@@ -1,4 +1,4 @@
import { chmod, cp, lstat, mkdir, readdir, realpath, rm } from "node:fs/promises"; import { chmod, lstat, mkdir, realpath } from "node:fs/promises";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { isAbsolute, join, relative, resolve } from "node:path"; import { isAbsolute, join, relative, resolve } from "node:path";
import type { RoleSkillEntry } from "./models.js"; import type { RoleSkillEntry } from "./models.js";
@@ -21,20 +21,6 @@ const SAFE_HOST_ENV_KEYS = [
"LOGNAME", "LOGNAME",
"SHELL", "SHELL",
"CPH_BIN", "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; ] as const;
const SANDBOX_HIDDEN_ENV_KEYS = [ const SANDBOX_HIDDEN_ENV_KEYS = [
@@ -136,7 +122,6 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
const sensitiveReadPaths = hostSensitiveReadPaths(hostEnv); const sensitiveReadPaths = hostSensitiveReadPaths(hostEnv);
const runtimeReadPaths = hostRuntimeReadPaths(hostEnv); const runtimeReadPaths = hostRuntimeReadPaths(hostEnv);
const typstCacheWritePaths = hostTypstCacheWritePaths(hostEnv);
const selectedSkills = input.skills ?? []; const selectedSkills = input.skills ?? [];
const skillPlugin = selectedSkills.length === 0 const skillPlugin = selectedSkills.length === 0
? null ? null
@@ -145,25 +130,6 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
runId: input.runId, runId: input.runId,
skills: selectedSkills, 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 { return {
cwd: workspaceDir, cwd: workspaceDir,
workspaceRoot, workspaceRoot,
@@ -177,7 +143,7 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
autoAllowBashIfSandboxed: true, autoAllowBashIfSandboxed: true,
allowUnsandboxedCommands: false, allowUnsandboxedCommands: false,
filesystem: { filesystem: {
allowWrite: [...new Set([workspaceDir, ...typstCacheWritePaths])], allowWrite: [workspaceDir],
// Reject every write path by default, then re-open only the canonical // Reject every write path by default, then re-open only the canonical
// workspace. This prevents bubblewrap's ordinary temp exceptions from // workspace. This prevents bubblewrap's ordinary temp exceptions from
// turning an unauthorized path into a successful ephemeral write. // turning an unauthorized path into a successful ephemeral write.
@@ -247,30 +213,9 @@ function hostRuntimeReadPaths(env: Readonly<Record<string, string | undefined>>)
if (!isAbsolute(cphBin)) throw new Error("CPH_BIN must be absolute for the Agent subprocess"); if (!isAbsolute(cphBin)) throw new Error("CPH_BIN must be absolute for the Agent subprocess");
platformPaths.push(resolve(cphBin)); platformPaths.push(resolve(cphBin));
} }
platformPaths.push(...configuredTypstPackagePaths(env, ["TYPST_PACKAGE_PATH", "TYPST_PACKAGE_CACHE_PATH"]));
return [...new Set(platformPaths.map((path) => resolve(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[] { function hostSensitiveReadPaths(env: Readonly<Record<string, string | undefined>>): string[] {
const home = homedir(); const home = homedir();
const paths = [ const paths = [
-266
View File
@@ -1,266 +0,0 @@
/**
* 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({ inputSchema: z.object({
chat_id: z.string().describe("The Feishu chat id to read from."), 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."), anchor: z.enum(["trigger_message", "status_card", "reply", "thread"]).describe("Which kind of anchor to read."),
id: z.string().describe("The anchor id: a message_id for trigger_message/status_card/reply, or a thread_id for thread."), id: z.string().describe("The anchor id (message id or run id)."),
}), }),
execute: async (args): Promise<string> => { execute: async (args): Promise<string> => {
if (args.chat_id !== ctx.boundChatId) { if (args.chat_id !== ctx.boundChatId) {
@@ -10,41 +10,22 @@ import { randomUUID } from "node:crypto";
import type { Prisma, PrismaClient } from "@prisma/client"; import type { Prisma, PrismaClient } from "@prisma/client";
import { lockActiveOrganization } from "../org/status.js"; import { lockActiveOrganization } from "../org/status.js";
import { LocalSecretEnvelope } from "../security/secretEnvelope.js"; import { LocalSecretEnvelope } from "../security/secretEnvelope.js";
import { probeCapabilityCredential, type CapabilityReadinessProbe } from "./capabilityReadiness.js"; import { probeDocmindCredential, type CapabilityReadinessProbe } from "./capabilityReadiness.js";
import { import type { CapabilitySecretPayload } from "./types.js";
CAPABILITY_IDS,
type CapabilitySecretPayload,
type DocmindCapabilitySecretPayload,
type PbankCapabilitySecretPayload,
secretKindForCapability,
} from "./types.js";
const CAPABILITY_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/; const CAPABILITY_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
const KNOWN_CAPABILITY_IDS = new Set<string>(CAPABILITY_IDS); const KNOWN_CAPABILITY_IDS = new Set(["pdf_to_md_bundle", "audio_video_to_text"]);
export type CapabilityCredentialInput = export interface CapabilityCredentialInput {
| {
readonly kind: "docmind";
readonly accessKeyId: string; readonly accessKeyId: string;
readonly accessKeySecret: string; readonly accessKeySecret: string;
readonly endpoint: 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 { export interface RotateCapabilityInput extends CapabilityCredentialInput {
readonly organizationId: string; readonly organizationId: string;
readonly capabilityId: string; readonly capabilityId: string;
readonly actorUserId: string; readonly actorUserId: string;
readonly credential: CapabilityCredentialInput;
} }
export interface CapabilityConnectionMetadata { export interface CapabilityConnectionMetadata {
@@ -64,24 +45,25 @@ export interface CapabilityConnectionWriteResult extends CapabilityConnectionMet
export type CapabilitySecretPayloadV1 = CapabilitySecretPayload; export type CapabilitySecretPayloadV1 = CapabilitySecretPayload;
export class CapabilityConnectionService { export class CapabilityConnectionService {
private readonly readinessProbe: CapabilityReadinessProbe;
constructor( constructor(
private readonly prisma: PrismaClient, private readonly prisma: PrismaClient,
private readonly secrets: LocalSecretEnvelope, private readonly secrets: LocalSecretEnvelope,
readinessProbe: CapabilityReadinessProbe = probeCapabilityCredential, private readonly readinessProbe: CapabilityReadinessProbe = probeDocmindCredential,
) { ) {}
this.readinessProbe = readinessProbe;
}
async rotate(input: RotateCapabilityInput): Promise<CapabilityConnectionWriteResult> { async rotate(input: RotateCapabilityInput): Promise<CapabilityConnectionWriteResult> {
if (!CAPABILITY_ID_PATTERN.test(input.capabilityId)) { if (!CAPABILITY_ID_PATTERN.test(input.capabilityId)) {
throw new Error(`invalid capabilityId: ${input.capabilityId}`); throw new Error(`invalid capabilityId: ${input.capabilityId}`);
} }
const payload = validateCredential(input.capabilityId, input.credential); const payload = validateCredential(input);
await this.prisma.$transaction(async (tx) => { await this.prisma.$transaction(async (tx) => {
await requireCapabilityAdmin(tx, input); await requireCapabilityAdmin(tx, input);
}); });
await this.readinessProbe(payload); await this.readinessProbe({
endpoint: payload.endpoint,
accessKeyId: payload.accessKeyId,
accessKeySecret: payload.accessKeySecret,
});
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
await requireCapabilityAdmin(tx, input); await requireCapabilityAdmin(tx, input);
@@ -160,7 +142,6 @@ export class CapabilityConnectionService {
status: "ACTIVE", status: "ACTIVE",
secretVersion: version, secretVersion: version,
keyId: envelope.keyId, keyId: envelope.keyId,
secretKind: payload.kind,
}, },
}, },
}); });
@@ -228,47 +209,14 @@ export class CapabilityConnectionService {
} }
} }
function validateCredential( function validateCredential(input: RotateCapabilityInput): CapabilitySecretPayloadV1 {
capabilityId: string, if (!KNOWN_CAPABILITY_IDS.has(input.capabilityId)) {
input: CapabilityCredentialInput, throw new Error(`unsupported capabilityId: ${input.capabilityId}`);
): CapabilitySecretPayload {
if (!KNOWN_CAPABILITY_IDS.has(capabilityId)) {
throw new Error(`unsupported capabilityId: ${capabilityId}`);
} }
const expectedKind = secretKindForCapability(capabilityId); const accessKeyId = nonEmpty(input.accessKeyId, "accessKeyId");
if (input.kind !== expectedKind) { const accessKeySecret = nonEmpty(input.accessKeySecret, "accessKeySecret");
throw new Error(`capability ${capabilityId} requires kind=${expectedKind}, got ${input.kind}`); const endpoint = nonEmpty(input.endpoint, "endpoint");
} return { schemaVersion: 1, accessKeyId, accessKeySecret, endpoint };
if (input.kind === "docmind") {
return {
schemaVersion: 1,
kind: "docmind",
accessKeyId: nonEmpty(input.accessKeyId, "accessKeyId"),
accessKeySecret: nonEmpty(input.accessKeySecret, "accessKeySecret"),
endpoint: nonEmpty(input.endpoint, "endpoint"),
};
}
const baseUrl = normalizeBaseUrl(nonEmpty(input.baseUrl, "baseUrl"));
if (!baseUrl.startsWith("https://") && !baseUrl.startsWith("http://")) {
throw new Error("baseUrl must be an absolute http(s) URL");
}
const rightsStatus = optional(input.rightsStatus);
const rightsHolder = optional(input.rightsHolder);
const rightsScope = optional(input.rightsScope);
const rightsNote = optional(input.rightsNote);
return {
schemaVersion: 1,
kind: "pbank",
baseUrl,
username: nonEmpty(input.username, "username"),
password: nonEmpty(input.password, "password"),
...(rightsStatus !== undefined ? { rightsStatus } : {}),
...(rightsHolder !== undefined ? { rightsHolder } : {}),
...(rightsScope !== undefined ? { rightsScope } : {}),
...(rightsNote !== undefined ? { rightsNote } : {}),
};
} }
function toMetadata( function toMetadata(
@@ -316,13 +264,3 @@ function nonEmpty(value: string, label: string): string {
if (trimmed === "") throw new Error(`${label} must not be empty`); if (trimmed === "") throw new Error(`${label} must not be empty`);
return trimmed; return trimmed;
} }
function optional(value: string | undefined): string | undefined {
if (value === undefined) return undefined;
const trimmed = value.trim();
return trimmed === "" ? undefined : trimmed;
}
function normalizeBaseUrl(value: string): string {
return value.replace(/\/+$/, "");
}
+9 -13
View File
@@ -12,19 +12,17 @@ import type { PrismaClient } from "@prisma/client";
import { LocalSecretEnvelope, type SecretEnvelopeV1 } from "../security/secretEnvelope.js"; import { LocalSecretEnvelope, type SecretEnvelopeV1 } from "../security/secretEnvelope.js";
import { import {
CapabilityConnectionUnavailable, CapabilityConnectionUnavailable,
normalizeCapabilitySecretPayload,
secretKindForCapability,
type CapabilitySecretPayload, type CapabilitySecretPayload,
type CapabilityId, type CapabilityId,
} from "./types.js"; } from "./types.js";
const CAPABILITY_PURPOSE = "capability"; const CAPABILITY_PURPOSE = "capability";
export type ResolvedCapabilityCredential = CapabilitySecretPayload & { export interface ResolvedCapabilityCredential extends CapabilitySecretPayload {
readonly connectionId: string; readonly connectionId: string;
readonly organizationId: string; readonly organizationId: string;
readonly capabilityId: string; readonly capabilityId: string;
}; }
/** /**
* Resolve the active capability credential for an organization. Throws * Resolve the active capability credential for an organization. Throws
@@ -56,19 +54,17 @@ export async function resolveCapabilityCredential(
connectionId: connection.id, connectionId: connection.id,
secretVersionId: version.id, secretVersionId: version.id,
}; };
const payload = normalizeCapabilitySecretPayload( const payload = secrets.decryptJson<CapabilitySecretPayload>(binding, version.envelope as unknown as SecretEnvelopeV1);
secrets.decryptJson<unknown>(binding, version.envelope as unknown as SecretEnvelopeV1), if (payload.schemaVersion !== 1) {
); throw new Error(`unsupported capability secret schemaVersion: ${payload.schemaVersion}`);
const expectedKind = secretKindForCapability(input.capabilityId);
if (payload.kind !== expectedKind) {
throw new Error(
`capability ${input.capabilityId} secret kind mismatch: expected ${expectedKind}, got ${payload.kind}`,
);
} }
return { return {
connectionId: connection.id, connectionId: connection.id,
organizationId: connection.organizationId, organizationId: connection.organizationId,
capabilityId: connection.capabilityId, capabilityId: connection.capabilityId,
...payload, schemaVersion: 1,
accessKeyId: payload.accessKeyId,
accessKeySecret: payload.accessKeySecret,
endpoint: payload.endpoint,
}; };
} }
+12 -74
View File
@@ -1,11 +1,18 @@
/** /**
* ADR-0027: Capability readiness probes. Validate credentials before * ADR-0027: Capability readiness probe. Validates the Alibaba Cloud docmind
* activation. Docmind uses QueryDocParserStatus; PBank uses /login. * 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.
*/ */
import { classifyNetworkFailure, type NetworkFailureCategory } from "../connections/networkFailure.js"; import { classifyNetworkFailure, type NetworkFailureCategory } from "../connections/networkFailure.js";
import type { CapabilitySecretPayload, DocmindCapabilitySecretPayload, PbankCapabilitySecretPayload } from "./types.js";
export type CapabilityReadinessProbe = (payload: CapabilitySecretPayload) => Promise<void>; export interface CapabilityReadinessInput {
readonly endpoint: string;
readonly accessKeyId: string;
readonly accessKeySecret: string;
}
export type CapabilityReadinessProbe = (input: CapabilityReadinessInput) => Promise<void>;
export class CapabilityReadinessError extends Error { export class CapabilityReadinessError extends Error {
constructor( constructor(
@@ -26,7 +33,7 @@ export class CapabilityReadinessError extends Error {
* - 401/403 (InvalidAccessKey/Forbidden) → credential invalid → probe fails * - 401/403 (InvalidAccessKey/Forbidden) → credential invalid → probe fails
* - network error → unreachable * - network error → unreachable
*/ */
export async function probeDocmindCredentialPayload(input: DocmindCapabilitySecretPayload): Promise<void> { export const probeDocmindCredential: CapabilityReadinessProbe = async (input) => {
const url = `https://${input.endpoint}/?Action=QueryDocParserStatus&Id=probe-test&Version=2022-07-11`; const url = `https://${input.endpoint}/?Action=QueryDocParserStatus&Id=probe-test&Version=2022-07-11`;
const authHeader = makeBasicAuth(input.accessKeyId, input.accessKeySecret); const authHeader = makeBasicAuth(input.accessKeyId, input.accessKeySecret);
@@ -54,75 +61,6 @@ export async function probeDocmindCredentialPayload(input: DocmindCapabilitySecr
response.status, response.status,
); );
} }
}
/** Probe PBank by logging in and ensuring a token is returned. */
export async function probePbankCredentialPayload(input: PbankCapabilitySecretPayload): Promise<void> {
const baseUrl = input.baseUrl.replace(/\/+$/, "");
let response: Response;
try {
response = await fetch(`${baseUrl}/login`, {
method: "POST",
headers: { accept: "application/json", "content-type": "application/json" },
body: JSON.stringify({ username: input.username, password: input.password }),
redirect: "manual",
signal: AbortSignal.timeout(15_000),
});
} catch (error) {
throw new CapabilityReadinessError(
"capability_readiness_unreachable",
"PBank credential readiness check could not reach the API",
classifyNetworkFailure(error),
);
}
const data: unknown = await response.json().catch(() => null);
if (response.status === 401 || response.status === 403) {
throw new CapabilityReadinessError(
"capability_readiness_rejected",
`PBank credential rejected: status ${response.status}`,
"http",
response.status,
);
}
if (!response.ok) {
throw new CapabilityReadinessError(
"capability_readiness_rejected",
`PBank login failed: status ${response.status}`,
"http",
response.status,
);
}
if (
typeof data !== "object" ||
data === null ||
!("token" in data) ||
typeof data.token !== "string" ||
data.token.trim() === ""
) {
throw new CapabilityReadinessError(
"capability_readiness_rejected",
"PBank login did not return a token",
"http",
response.status,
);
}
}
/** Default readiness probe: dispatch by secret kind. */
export const probeCapabilityCredential: CapabilityReadinessProbe = async (payload) => {
if (payload.kind === "docmind") {
await probeDocmindCredentialPayload(payload);
return;
}
if (payload.kind === "pbank") {
await probePbankCredentialPayload(payload);
return;
}
throw new CapabilityReadinessError(
"capability_readiness_unsupported",
"unsupported capability secret kind",
"configuration",
);
}; };
function makeBasicAuth(accessKeyId: string, accessKeySecret: string): string { function makeBasicAuth(accessKeyId: string, accessKeySecret: string): string {
+8 -55
View File
@@ -19,10 +19,9 @@ import $DocmindClient, {
QueryDocParserStatusRequest, QueryDocParserStatusRequest,
} from "@alicloud/docmind-api20220711"; } from "@alicloud/docmind-api20220711";
import { RuntimeOptions } from "@alicloud/tea-util"; import { RuntimeOptions } from "@alicloud/tea-util";
import { createReadStream, type ReadStream } from "node:fs"; import { createReadStream } from "node:fs";
import { once } from "node:events";
import { basename } from "node:path"; import { basename } from "node:path";
import type { DocmindCapabilitySecretPayload } from "./types.js"; import type { CapabilitySecretPayload } from "./types.js";
/** A single extracted image downloaded from the markdown's OSS image URLs. */ /** A single extracted image downloaded from the markdown's OSS image URLs. */
export interface DocmindExtractedImage { export interface DocmindExtractedImage {
@@ -44,7 +43,7 @@ export interface DocmindParseOptions {
} }
export interface CapabilityProviderClient { export interface CapabilityProviderClient {
parse(credential: DocmindCapabilitySecretPayload, options: DocmindParseOptions): Promise<DocmindParseResult>; parse(credential: CapabilitySecretPayload, options: DocmindParseOptions): Promise<DocmindParseResult>;
} }
export class DocmindClientError extends Error { export class DocmindClientError extends Error {
@@ -62,33 +61,11 @@ export class DocmindClientError extends Error {
const COST_PER_PAGE_USD = 0.0056; const COST_PER_PAGE_USD = 0.0056;
const POLL_INTERVAL_MS = 10_000; const POLL_INTERVAL_MS = 10_000;
const POLL_TIMEOUT_MS = 5 * 60_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]; type DocmindConfig = ConstructorParameters<typeof $DocmindClient.default>[0];
export class AliyunDocmindClient implements CapabilityProviderClient { export class AliyunDocmindClient implements CapabilityProviderClient {
async parse(credential: DocmindCapabilitySecretPayload, options: DocmindParseOptions): Promise<DocmindParseResult> { async parse(credential: CapabilitySecretPayload, 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 = { const config: DocmindConfig = {
endpoint: credential.endpoint, endpoint: credential.endpoint,
accessKeyId: credential.accessKeyId, accessKeyId: credential.accessKeyId,
@@ -98,21 +75,22 @@ export class AliyunDocmindClient implements CapabilityProviderClient {
} as DocmindConfig; } as DocmindConfig;
const client = new $DocmindClient.default(config); const client = new $DocmindClient.default(config);
// Submit job with local file as a ReadStream (not a Buffer — the SDK // 1. Submit job with local file as a ReadStream (not a Buffer — the SDK
// serializes Buffers as JSON {type:"Buffer",data:[...]} which the API // serializes Buffers as JSON {type:"Buffer",data:[...]} which the API
// can't read; a Stream is uploaded as multipart form data). // can't read; a Stream is uploaded as multipart form data).
const fileName = basename(options.inputFilePath);
const fileStream = createReadStream(options.inputFilePath);
const advanceRequest = new SubmitDocParserJobAdvanceRequest({ const advanceRequest = new SubmitDocParserJobAdvanceRequest({
fileUrlObject: fileStream, fileUrlObject: fileStream,
fileName, fileName,
outputFormat: ["markdown"], outputFormat: ["markdown"],
formulaEnhancement: true, formulaEnhancement: true,
}); });
const runtime = createDocmindRuntimeOptions(); const runtime = new RuntimeOptions({});
let submitResponse; let submitResponse;
try { try {
submitResponse = await client.submitDocParserJobAdvance(advanceRequest, runtime); submitResponse = await client.submitDocParserJobAdvance(advanceRequest, runtime);
} catch (e) { } catch (e) {
fileStream.destroy();
throw new DocmindClientError( throw new DocmindClientError(
e instanceof Error ? e.message : String(e), e instanceof Error ? e.message : String(e),
"docmind_unreachable", "docmind_unreachable",
@@ -251,28 +229,3 @@ function extractFilename(altText: string, url: string, index: number): string {
if (base !== "" && base !== "/") return base; if (base !== "" && base !== "/") return base;
return `image_${index + 1}.png`; 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
@@ -1,664 +0,0 @@
/**
* 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
@@ -1,279 +0,0 @@
/**
* 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;
}
+1 -131
View File
@@ -23,7 +23,6 @@ import { resolveCapabilityCredential } from "./capabilityConnections.js";
import { DocmindClientError, type CapabilityProviderClient } from "./docmindClient.js"; import { DocmindClientError, type CapabilityProviderClient } from "./docmindClient.js";
import { import {
CAPABILITIES, CAPABILITIES,
asDocmindSecret,
type CapabilityAdapter, type CapabilityAdapter,
type CapabilityInvocationInput, type CapabilityInvocationInput,
type CapabilityInvocationResult, type CapabilityInvocationResult,
@@ -64,135 +63,6 @@ export interface PdfToMdBundleDeps {
readonly prisma: PrismaClient; 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. */ /** Build the pdf_to_md_bundle adapter. The client is injectable for testing. */
export function createPdfToMdBundleAdapter(deps: PdfToMdBundleDeps): CapabilityAdapter { export function createPdfToMdBundleAdapter(deps: PdfToMdBundleDeps): CapabilityAdapter {
return { return {
@@ -213,7 +83,7 @@ export function createPdfToMdBundleAdapter(deps: PdfToMdBundleDeps): CapabilityA
// 3. Call the backing service. // 3. Call the backing service.
let result; let result;
try { try {
result = await deps.client.parse(asDocmindSecret(credential), { inputFilePath: absoluteInput }); result = await deps.client.parse(credential, { inputFilePath: absoluteInput });
} catch (e) { } catch (e) {
if (e instanceof DocmindClientError) throw e; if (e instanceof DocmindClientError) throw e;
throw new DocmindClientError( throw new DocmindClientError(
+7 -123
View File
@@ -1,11 +1,11 @@
/** /**
* ADR-0027: External capability types shared across the adapter layer. * ADR-0027: External capability types shared across the adapter layer.
* *
* A capability is a platform-registered, org-enabled external service * A capability is a platform-registered, org-enabled document/media transform
* invoked as a side effect of an AgentRun. The adapter resolves the org's * invoked as a side effect of an AgentRun. The adapter resolves the org's
* active capability connection, calls the backing service via an injectable * active capability connection, calls the backing service via an injectable
* client, writes output into the run's workspace when applicable (AgentSurface, * client, writes output into the run's workspace (AgentSurface, ADR-0018),
* ADR-0018), and records consumption on a UsageFact (ADR-0026). * and records consumption on a UsageFact (ADR-0026).
*/ */
import type { PrismaClient, Prisma } from "@prisma/client"; import type { PrismaClient, Prisma } from "@prisma/client";
@@ -13,7 +13,6 @@ import type { PrismaClient, Prisma } from "@prisma/client";
export const CAPABILITY_IDS = [ export const CAPABILITY_IDS = [
"pdf_to_md_bundle", "pdf_to_md_bundle",
"audio_video_to_text", "audio_video_to_text",
"pbank",
] as const; ] as const;
export type CapabilityId = (typeof CAPABILITY_IDS)[number]; export type CapabilityId = (typeof CAPABILITY_IDS)[number];
@@ -28,7 +27,6 @@ export interface CapabilityDescriptor {
export const CAPABILITIES: Readonly<Record<CapabilityId, CapabilityDescriptor>> = { export const CAPABILITIES: Readonly<Record<CapabilityId, CapabilityDescriptor>> = {
pdf_to_md_bundle: { id: "pdf_to_md_bundle", meteringUnit: "pages" }, pdf_to_md_bundle: { id: "pdf_to_md_bundle", meteringUnit: "pages" },
audio_video_to_text: { id: "audio_video_to_text", meteringUnit: "audio_seconds" }, audio_video_to_text: { id: "audio_video_to_text", meteringUnit: "audio_seconds" },
pbank: { id: "pbank", meteringUnit: "requests" },
}; };
/** Input passed to a capability adapter invocation. */ /** Input passed to a capability adapter invocation. */
@@ -61,7 +59,7 @@ export interface CapabilityConsumption {
readonly model: string | null; readonly model: string | null;
readonly inputTokens: number | null; readonly inputTokens: number | null;
readonly outputTokens: number | null; readonly outputTokens: number | null;
/** Non-token meter (page count, audio seconds, request count). */ /** Non-token meter (page count, audio seconds). */
readonly quantity: number; readonly quantity: number;
readonly unit: string; readonly unit: string;
/** USD cost if the service reported one; null = unknown (ADR-0022). */ /** USD cost if the service reported one; null = unknown (ADR-0022). */
@@ -82,39 +80,15 @@ export interface CapabilityAdapter {
invoke(input: CapabilityInvocationInput): Promise<CapabilityInvocationResult>; invoke(input: CapabilityInvocationInput): Promise<CapabilityInvocationResult>;
} }
/** Alibaba Cloud Document Mind (docmind) AccessKey + endpoint. */ /** Decrypted capability credential (CapabilitySecretPayloadV1, ADR-0027).
export interface DocmindCapabilitySecretPayload { * Alibaba Cloud Document Mind (docmind) uses AccessKey ID + Secret + endpoint. */
export interface CapabilitySecretPayload {
readonly schemaVersion: 1; readonly schemaVersion: 1;
readonly kind: "docmind";
readonly accessKeyId: string; readonly accessKeyId: string;
readonly accessKeySecret: string; readonly accessKeySecret: string;
readonly endpoint: string; readonly endpoint: string;
} }
/**
* Paradigm PBank (题库) login credentials.
* Rights fields are operator-confirmed license signals echoed to the agent;
* the credential secret itself never reaches the agent process (ADR-0024/0027).
*/
export interface PbankCapabilitySecretPayload {
readonly schemaVersion: 1;
readonly kind: "pbank";
readonly baseUrl: string;
readonly username: string;
readonly password: string;
readonly rightsStatus?: string;
readonly rightsHolder?: string;
readonly rightsScope?: string;
readonly rightsNote?: string;
}
/**
* Decrypted capability credential (CapabilitySecretPayloadV1, ADR-0027).
* Discriminated by `kind`. Legacy envelopes without `kind` are normalized to
* `docmind` when accessKey fields are present.
*/
export type CapabilitySecretPayload = DocmindCapabilitySecretPayload | PbankCapabilitySecretPayload;
/** Thrown when an org has no ACTIVE capability connection (fail-closed, ADR-0024). */ /** Thrown when an org has no ACTIVE capability connection (fail-closed, ADR-0024). */
export class CapabilityConnectionUnavailable extends Error { export class CapabilityConnectionUnavailable extends Error {
constructor(readonly capabilityId: string, readonly organizationId: string) { constructor(readonly capabilityId: string, readonly organizationId: string) {
@@ -125,93 +99,3 @@ export class CapabilityConnectionUnavailable extends Error {
/** Prisma transaction client type alias (for resolver signatures). */ /** Prisma transaction client type alias (for resolver signatures). */
export type TxClient = Prisma.TransactionClient; export type TxClient = Prisma.TransactionClient;
/** Map capability id → expected secret kind. */
export function secretKindForCapability(capabilityId: string): "docmind" | "pbank" {
switch (capabilityId) {
case "pdf_to_md_bundle":
case "audio_video_to_text":
return "docmind";
case "pbank":
return "pbank";
default:
throw new Error(`unsupported capabilityId: ${capabilityId}`);
}
}
/**
* Normalize a decrypted envelope payload into a full CapabilitySecretPayload.
* Accepts legacy docmind payloads that omit `kind`.
*/
export function normalizeCapabilitySecretPayload(raw: unknown): CapabilitySecretPayload {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
throw new Error("invalid capability secret payload");
}
const payload = raw as Record<string, unknown>;
if (payload.schemaVersion !== 1) {
throw new Error(`unsupported capability secret schemaVersion: ${String(payload.schemaVersion)}`);
}
const kind =
payload.kind === "pbank" || payload.kind === "docmind"
? payload.kind
: typeof payload.accessKeyId === "string"
? "docmind"
: typeof payload.username === "string"
? "pbank"
: null;
if (kind === null) throw new Error("capability secret payload missing kind");
if (kind === "docmind") {
return {
schemaVersion: 1,
kind: "docmind",
accessKeyId: requireString(payload.accessKeyId, "accessKeyId"),
accessKeySecret: requireString(payload.accessKeySecret, "accessKeySecret"),
endpoint: requireString(payload.endpoint, "endpoint"),
};
}
const rightsStatus = optionalString(payload.rightsStatus);
const rightsHolder = optionalString(payload.rightsHolder);
const rightsScope = optionalString(payload.rightsScope);
const rightsNote = optionalString(payload.rightsNote);
return {
schemaVersion: 1,
kind: "pbank",
baseUrl: requireString(payload.baseUrl, "baseUrl"),
username: requireString(payload.username, "username"),
password: requireString(payload.password, "password"),
...(rightsStatus !== undefined ? { rightsStatus } : {}),
...(rightsHolder !== undefined ? { rightsHolder } : {}),
...(rightsScope !== undefined ? { rightsScope } : {}),
...(rightsNote !== undefined ? { rightsNote } : {}),
};
}
export function asDocmindSecret(payload: CapabilitySecretPayload): DocmindCapabilitySecretPayload {
if (payload.kind !== "docmind") {
throw new Error(`expected docmind capability secret, got ${payload.kind}`);
}
return payload;
}
export function asPbankSecret(payload: CapabilitySecretPayload): PbankCapabilitySecretPayload {
if (payload.kind !== "pbank") {
throw new Error(`expected pbank capability secret, got ${payload.kind}`);
}
return payload;
}
function requireString(value: unknown, label: string): string {
if (typeof value !== "string" || value.trim() === "") {
throw new Error(`${label} must not be empty`);
}
return value.trim();
}
function optionalString(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed === "" ? undefined : trimmed;
}
+2 -2
View File
@@ -1,6 +1,6 @@
/** /**
* ADR-0022 capacity dimensions. * ADR-0022 capacity dimensions (spec `Spec.System.Capacity.CapacityDimension`).
* The 23 pinned dimensions; exact numeric ceilings are open and calibrated by * The 23 PINNED dimensions; exact numeric ceilings are `OPEN` and calibrated by
* capacity testing. This module is the single source of the dimension set shared * capacity testing. This module is the single source of the dimension set shared
* by the platform-ceiling config and the org capacity-policy service. * by the platform-ceiling config and the org capacity-policy service.
*/ */
-1
View File
@@ -250,7 +250,6 @@ async function initializeSilo(
data: { data: {
organizationId: input.organization.id, organizationId: input.organization.id,
name: "Inbox", name: "Inbox",
kind: "SYSTEM_INBOX",
sortKey: "000000", sortKey: "000000",
}, },
}); });
-227
View File
@@ -1,227 +0,0 @@
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);
});
}
+16 -150
View File
@@ -2,10 +2,9 @@
* Feishu interactive card builder for agent run output. * Feishu interactive card builder for agent run output.
* *
* Produces card JSON with: * Produces card JSON with:
* 1. A live todo checklist when the agent uses TodoWrite (Manus-style progress) * 1. A collapsible tool-use panel (tool steps with status, params, results)
* 2. A collapsible tool-use panel (tool steps with status, params, results) * 2. A collapsible reasoning panel (thinking text)
* 3. A collapsible reasoning panel (thinking text) * 3. The streaming/final answer text (markdown)
* 4. The streaming/final answer text (markdown)
* *
* Adapted from openclaw-lark's builder.ts, simplified for our * Adapted from openclaw-lark's builder.ts, simplified for our
* message.patch-based approach (no CardKit 2.0 streaming_mode). * message.patch-based approach (no CardKit 2.0 streaming_mode).
@@ -13,9 +12,6 @@
*/ */
import type { ToolUseTraceStep } from "./trace-store.js"; import type { ToolUseTraceStep } from "./trace-store.js";
import type { AgentTodoItem } from "../../agent/todoList.js";
import { todoProgressSummary } from "../../agent/todoList.js";
import { maskMarkdownImagesForStreaming, type CardContentSegment } from "../outboundImages.js";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
@@ -42,8 +38,6 @@ const TOOL_ICONS: Record<string, string> = {
glob: "search-filled", glob: "search-filled",
grep: "search-filled", grep: "search-filled",
edit: "edit-filled", edit: "edit-filled",
todowrite: "todo-filled",
todo_write: "todo-filled",
send_file: "send-filled", send_file: "send-filled",
request_approval: "thumb-up-filled", request_approval: "thumb-up-filled",
feishu_read_context: "search-filled", feishu_read_context: "search-filled",
@@ -53,26 +47,10 @@ const TOOL_ICONS: Record<string, string> = {
}; };
function toolIcon(toolName: string): string { function toolIcon(toolName: string): string {
const normalized = toolName.toLowerCase().replace(/^mcp_/, "").replace(/^cph_hub__/, ""); const normalized = toolName.toLowerCase().replace(/^mcp_/, "");
return TOOL_ICONS[normalized] ?? "tool-filled"; 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 // Card builder
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -80,39 +58,26 @@ function isTodoToolName(toolName: string): boolean {
export function buildAgentCard(params: { export function buildAgentCard(params: {
phase: CardPhase; phase: CardPhase;
text: string; text: string;
contentSegments?: readonly CardContentSegment[] | undefined;
reasoningText: string | undefined; reasoningText: string | undefined;
todos: readonly AgentTodoItem[] | undefined;
toolUseSteps: ToolUseTraceStep[]; toolUseSteps: ToolUseTraceStep[];
toolUseElapsedMs: number | undefined; toolUseElapsedMs: number | undefined;
isError: boolean | undefined; isError: boolean | undefined;
interrupted: boolean | undefined; interrupted: boolean | undefined;
runId: string | undefined; runId: string | undefined;
}): Record<string, unknown> { }): Record<string, unknown> {
const { phase, text, contentSegments, reasoningText, todos, toolUseSteps, toolUseElapsedMs, isError, interrupted } = params; const { phase, text, reasoningText, toolUseSteps, toolUseElapsedMs, isError, interrupted } = params;
const elements: unknown[] = []; const elements: unknown[] = [];
// Todo checklist panel — primary progress signal; hide bare TodoWrite noise below. // Tool-use panel (always present if there are steps)
if (todos !== undefined && todos.length > 0) { if (toolUseSteps.length > 0) {
elements.push(buildTodoPanel(todos, phase !== "complete")); elements.push(buildToolUsePanel(toolUseSteps, toolUseElapsedMs, phase !== "complete"));
} } else if (phase === "thinking" || (phase === "streaming" && text === "")) {
// 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()); elements.push(buildPendingToolUsePanel());
} }
}
// Reasoning panel // Reasoning panel
if (reasoningText !== undefined && reasoningText !== "") { if (reasoningText !== undefined && reasoningText !== "") {
if (phase === "streaming" && text === "" && (contentSegments === undefined || contentSegments.length === 0)) { if (phase === "streaming" && text === "") {
// Still thinking: show reasoning inline // Still thinking: show reasoning inline
elements.push({ elements.push({
tag: "markdown", tag: "markdown",
@@ -125,11 +90,12 @@ export function buildAgentCard(params: {
} }
} }
// Main answer: either materialized segments (markdown + Feishu-hosted images) // Main text content
// or a single markdown block. if (text !== "") {
const answerElements = buildAnswerElements(text, contentSegments); elements.push({
if (answerElements.length > 0) { tag: "markdown",
elements.push(...answerElements); content: truncateText(text, MAX_TEXT_LENGTH),
});
} else if (phase === "thinking" && toolUseSteps.length === 0 && (reasoningText === undefined || reasoningText === "")) { } else if (phase === "thinking" && toolUseSteps.length === 0 && (reasoningText === undefined || reasoningText === "")) {
elements.push({ elements.push({
tag: "markdown", tag: "markdown",
@@ -194,62 +160,6 @@ 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 // Tool-use panel
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -491,50 +401,6 @@ function escapeMarkdown(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/([`*_{}[\]<>])/g, "\\$1"); return value.replace(/\\/g, "\\\\").replace(/([`*_{}[\]<>])/g, "\\$1");
} }
function buildAnswerElements(
text: string,
contentSegments: readonly CardContentSegment[] | undefined,
): unknown[] {
if (contentSegments !== undefined && contentSegments.length > 0) {
const elements: unknown[] = [];
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,
alt: { tag: "plain_text", content: segment.alt },
mode: "fit_horizontal",
preview: true,
});
continue;
}
if (segment.content === "" || remaining <= 0) continue;
// 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",
content: slice,
});
}
return elements;
}
if (text === "") return [];
const safe = maskMarkdownImagesForStreaming(text);
if (safe === "") return [];
return [{
tag: "markdown",
content: truncateText(safe, MAX_TEXT_LENGTH),
}];
}
function truncateText(value: string, maxLength: number): string { function truncateText(value: string, maxLength: number): string {
return value.length <= maxLength ? value : `${value.slice(0, maxLength - 3)}...`; return value.length <= maxLength ? value : `${value.slice(0, maxLength - 3)}...`;
} }
+38 -164
View File
@@ -18,13 +18,10 @@
* 4. onToolEnd(name, id, input, result?, error?) complete a tool step * 4. onToolEnd(name, id, input, result?, error?) complete a tool step
* 5. finish(finalText) flush + transition to complete card * 5. finish(finalText) flush + transition to complete card
* 6. fail(errorText) flush + transition to error card * 6. fail(errorText) flush + transition to error card
*
* On finish, markdown image references (`![](url|path)`) are downloaded /
* read, uploaded to Feishu as message images, and embedded as native card
* `img` elements so external URLs never hit Feishu content-security checks.
*/ */
import type { FeishuRuntime, SendMessageOptions } from "../client.js"; import type { FeishuRuntime, SendMessageOptions } from "../client.js";
import { sendCard, patchCard, sendText, sendLongText } from "../client.js"; import { sendCard, patchCard, sendText } from "../client.js";
import { DEFAULT_MAX_MESSAGE_LENGTH, splitAtBoundary } from "../textStream.js"; import { DEFAULT_MAX_MESSAGE_LENGTH, splitAtBoundary } from "../textStream.js";
import { import {
startToolUseTraceRun, startToolUseTraceRun,
@@ -34,17 +31,6 @@ import {
getToolUseTraceSteps, getToolUseTraceSteps,
} from "./trace-store.js"; } from "./trace-store.js";
import { buildAgentCard, type CardPhase } from "./builder.js"; import { buildAgentCard, type CardPhase } from "./builder.js";
import {
applyChecklistToolEvent,
isChecklistProgressTool,
type AgentTodoItem,
} from "../../agent/todoList.js";
import {
type CardContentSegment,
maskMarkdownImagesForStreaming,
materializeAnswerSegments,
sendImageMessage,
} from "../outboundImages.js";
export interface StreamingCardSink { export interface StreamingCardSink {
readonly create: (card: Record<string, unknown>) => Promise<string | null>; readonly create: (card: Record<string, unknown>) => Promise<string | null>;
@@ -58,11 +44,6 @@ export interface StreamingCardOptions {
readonly sendOptions?: SendMessageOptions | undefined; readonly sendOptions?: SendMessageOptions | undefined;
readonly patchIntervalMs: number | undefined; readonly patchIntervalMs: number | undefined;
readonly maxMessageLength: number | undefined; readonly maxMessageLength: number | undefined;
/** Project workspace root; required to resolve local image paths. */
readonly workspaceRoot?: string | undefined;
/** Project workspace directory; required to resolve local image paths. */
readonly workspaceDir?: string | undefined;
readonly maxImageBytes?: number | undefined;
} }
const DEFAULT_PATCH_INTERVAL_MS = 400; const DEFAULT_PATCH_INTERVAL_MS = 400;
@@ -71,7 +52,6 @@ export class StreamingAgentCard {
private currentMessageId: string | null = null; private currentMessageId: string | null = null;
private text = ""; private text = "";
private reasoningText = ""; private reasoningText = "";
private todos: readonly AgentTodoItem[] = [];
private runStartedAt = Date.now(); private runStartedAt = Date.now();
private toolUseElapsedMs: number | undefined; private toolUseElapsedMs: number | undefined;
private flushChain: Promise<void> = Promise.resolve(); private flushChain: Promise<void> = Promise.resolve();
@@ -85,9 +65,6 @@ export class StreamingAgentCard {
private readonly sendOptions: SendMessageOptions | undefined; private readonly sendOptions: SendMessageOptions | undefined;
private readonly patchIntervalMs: number; private readonly patchIntervalMs: number;
private readonly maxMessageLength: number; private readonly maxMessageLength: number;
private readonly workspaceRoot: string | undefined;
private readonly workspaceDir: string | undefined;
private readonly maxImageBytes: number | undefined;
constructor(options: StreamingCardOptions) { constructor(options: StreamingCardOptions) {
this.runId = options.runId; this.runId = options.runId;
@@ -96,9 +73,6 @@ export class StreamingAgentCard {
this.sendOptions = options.sendOptions; this.sendOptions = options.sendOptions;
this.patchIntervalMs = options.patchIntervalMs ?? DEFAULT_PATCH_INTERVAL_MS; this.patchIntervalMs = options.patchIntervalMs ?? DEFAULT_PATCH_INTERVAL_MS;
this.maxMessageLength = options.maxMessageLength ?? DEFAULT_MAX_MESSAGE_LENGTH; this.maxMessageLength = options.maxMessageLength ?? DEFAULT_MAX_MESSAGE_LENGTH;
this.workspaceRoot = options.workspaceRoot;
this.workspaceDir = options.workspaceDir;
this.maxImageBytes = options.maxImageBytes;
startToolUseTraceRun(this.runId); startToolUseTraceRun(this.runId);
} }
@@ -129,64 +103,28 @@ export class StreamingAgentCard {
error: string | undefined; error: string | undefined;
durationMs: number | undefined; durationMs: number | undefined;
}): void { }): 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 }); recordToolUseEnd({ runId: this.runId, ...params });
this.scheduleFlush(); this.scheduleFlush();
} }
async finish(fallbackText: string, options: { readonly interrupted?: boolean; readonly footerText?: string | undefined } = {}): Promise<void> {
async finish(
fallbackText: string,
options: {
readonly interrupted?: boolean;
readonly footerText?: string | undefined;
readonly isError?: boolean;
} = {},
): Promise<void> {
await this.flushChain; await this.flushChain;
this.interrupted = options.interrupted === true; this.interrupted = options.interrupted === true;
const footerText = options.footerText ?? ""; const footerText = options.footerText ?? "";
const isError = options.isError === true;
const fallbackWithFooter = appendFooter(fallbackText, footerText); const fallbackWithFooter = appendFooter(fallbackText, footerText);
try { try {
let answerText = let updated = true;
this.text.length > 0 ? appendFooter(this.text, footerText) : fallbackWithFooter; if (this.text.length > 0) {
this.text = answerText; this.text = appendFooter(this.text, footerText);
updated = await this.flushCard("complete", this.text);
const { segments, unresolved } = await materializeAnswerSegments(answerText, { } else if (this.currentMessageId === null && fallbackWithFooter.length > 0) {
rt: this.rt, // No streaming text was sent. If we never created a card, send one now.
workspaceRoot: this.workspaceRoot, this.text = fallbackWithFooter;
workspaceDir: this.workspaceDir, updated = await this.flushCard("complete", this.text);
maxImageBytes: this.maxImageBytes,
});
if (unresolved.length > 0) {
this.rt.logger.warn(
{ runId: this.runId, unresolvedCount: unresolved.length, unresolved: unresolved.slice(0, 5) },
"some answer images could not be uploaded to Feishu",
);
}
let cardUpdated = true;
if (answerText.length > 0 || segments.length > 0) {
cardUpdated = await this.flushCard("complete", answerText, isError, segments);
} else if (this.currentMessageId !== null) { } else if (this.currentMessageId !== null) {
cardUpdated = await this.flushCard("complete", "", isError, []); // Patch the existing card with the final text.
updated = await this.flushCard("complete", fallbackWithFooter);
} }
if (!cardUpdated) { if (!updated && this.interrupted) {
// Card path failed (e.g. residual content policy). Deliver text + standalone images.
await this.deliverPlainFallback(segments, answerText);
}
// 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); await sendText(this.rt, this.chatId, "\u5DF2\u4E2D\u65AD\u5F53\u524D\u8FD0\u884C\u3002", this.sendOptions);
} }
} finally { } finally {
@@ -228,32 +166,16 @@ export class StreamingAgentCard {
return this.flushCard(this.currentPhase(), this.text); return this.flushCard(this.currentPhase(), this.text);
} }
private async flushCard( private async flushCard(phase: CardPhase, text: string, isError = false): Promise<boolean> {
phase: CardPhase, const chunks = splitAtBoundary(text, this.maxMessageLength);
text: string, const firstChunk = chunks[0];
isError = false, if (firstChunk === undefined) return true;
contentSegments?: readonly CardContentSegment[],
): Promise<boolean> {
// During live streaming, strip image URLs so Feishu never fetches remote
// ranks mid-run. Materialized segments are only used on the complete pass.
const displayText =
phase === "complete" && contentSegments !== undefined
? text
: maskMarkdownImagesForStreaming(text);
const chunks = splitAtBoundary(displayText, this.maxMessageLength);
const firstChunk = chunks[0] ?? "";
// When we have segments (complete+images), keep first-card complete content
// on segments only; overflow text (rare) falls back to plain chunked cards.
const toolUseSteps = getToolUseTraceSteps(this.runId); const toolUseSteps = getToolUseTraceSteps(this.runId);
const card = buildAgentCard({ const card = buildAgentCard({
phase, phase,
text: contentSegments !== undefined && contentSegments.length > 0 ? "" : firstChunk, text: firstChunk,
contentSegments: contentSegments !== undefined && contentSegments.length > 0
? contentSegments
: undefined,
reasoningText: this.reasoningText || undefined, reasoningText: this.reasoningText || undefined,
todos: this.todos.length > 0 ? this.todos : undefined,
toolUseSteps, toolUseSteps,
toolUseElapsedMs: this.toolUseElapsedMs, toolUseElapsedMs: this.toolUseElapsedMs,
isError, isError,
@@ -264,15 +186,12 @@ export class StreamingAgentCard {
if (this.currentMessageId === null) { if (this.currentMessageId === null) {
this.currentMessageId = await sendCard(this.rt, this.chatId, card, this.sendOptions); this.currentMessageId = await sendCard(this.rt, this.chatId, card, this.sendOptions);
let updated = this.currentMessageId !== null; let updated = this.currentMessageId !== null;
// Send overflow chunks as new messages (rare for agent output). Segments // Send overflow chunks as new messages (rare for agent output)
// already include the whole answer; only plain text overflows.
if (contentSegments === undefined || contentSegments.length === 0) {
for (const chunk of chunks.slice(1)) { for (const chunk of chunks.slice(1)) {
const overflowCard = buildAgentCard({ const overflowCard = buildAgentCard({
phase, phase,
text: chunk, text: chunk,
reasoningText: undefined, reasoningText: undefined,
todos: undefined,
toolUseSteps: [], toolUseSteps: [],
toolUseElapsedMs: undefined, toolUseElapsedMs: undefined,
isError, isError,
@@ -283,72 +202,27 @@ export class StreamingAgentCard {
updated = updated && overflowMessageId !== null; updated = updated && overflowMessageId !== null;
this.currentMessageId = overflowMessageId; this.currentMessageId = overflowMessageId;
} }
}
return updated; return updated;
}
let updated = await patchCard(this.rt, this.currentMessageId, card);
if (contentSegments === undefined || contentSegments.length === 0) {
for (const chunk of chunks.slice(1)) {
const overflowCard = buildAgentCard({
phase,
text: chunk,
reasoningText: undefined,
todos: undefined,
toolUseSteps: [],
toolUseElapsedMs: undefined,
isError,
interrupted: this.interrupted,
runId: undefined,
});
const overflowMessageId = await sendCard(this.rt, this.chatId, overflowCard, this.sendOptions);
updated = updated && overflowMessageId !== null;
this.currentMessageId = overflowMessageId;
}
}
return updated;
}
private async deliverPlainFallback(
segments: readonly CardContentSegment[],
answerText: string,
): Promise<boolean> {
const textParts: string[] = [];
const imageKeys: string[] = [];
if (segments.length > 0) {
for (const segment of segments) {
if (segment.type === "markdown") {
if (segment.content.trim() !== "") textParts.push(segment.content);
} else { } else {
imageKeys.push(segment.imgKey); let updated = await patchCard(this.rt, this.currentMessageId, card);
// For overflow, create new messages
for (const chunk of chunks.slice(1)) {
const overflowCard = buildAgentCard({
phase,
text: chunk,
reasoningText: undefined,
toolUseSteps: [],
toolUseElapsedMs: undefined,
isError,
interrupted: this.interrupted,
runId: undefined,
});
const overflowMessageId = await sendCard(this.rt, this.chatId, overflowCard, this.sendOptions);
updated = updated && overflowMessageId !== null;
this.currentMessageId = overflowMessageId;
} }
return updated;
} }
} else if (answerText.trim() !== "") {
textParts.push(maskMarkdownImagesForStreaming(answerText));
}
let any = false;
if (textParts.length > 0) {
const messageId = await sendLongText(
this.rt,
this.chatId,
textParts.join("\n\n"),
this.sendOptions,
);
any = messageId !== null;
}
for (const imageKey of imageKeys) {
try {
const messageId = await sendImageMessage(this.rt, this.chatId, imageKey, this.sendOptions);
any = any || messageId !== null;
} catch (error) {
this.rt.logger.warn(
{ runId: this.runId, err: error instanceof Error ? error.message : String(error) },
"standalone image fallback failed",
);
}
}
return any;
} }
private currentPhase(): CardPhase { private currentPhase(): CardPhase {
@@ -361,5 +235,5 @@ export class StreamingAgentCard {
function appendFooter(text: string, footerText: string): string { function appendFooter(text: string, footerText: string): string {
if (footerText === "") return text; if (footerText === "") return text;
if (text === "") return footerText; if (text === "") return footerText;
return `${text}\n\n${footerText}`; return `${text.trimEnd()}\n\n${footerText}`;
} }
+2 -3
View File
@@ -316,8 +316,7 @@ export async function sendCard(
{ msgType: "interactive", content: JSON.stringify(card) }, { msgType: "interactive", content: JSON.stringify(card) },
options, options,
); );
} catch (e) { } catch {
rt.logger.warn({ chatId, err: errorText(e) }, "sendCard failed");
return null; return null;
} }
} }
@@ -335,7 +334,7 @@ export async function patchCard(rt: FeishuRuntime, messageId: string, card: Reco
}); });
return true; return true;
} catch (e) { } catch (e) {
rt.logger.warn({ messageId, err: errorText(e) }, "patchCard failed"); rt.logger.warn({ messageId, err: e instanceof Error ? e.message : String(e) }, "patchCard failed");
return false; return false;
} }
} }
+10 -30
View File
@@ -1,8 +1,7 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { join } from "node:path"; import { join } from "node:path";
import type { ToolContext } from "../agent/tools.js"; import type { ToolContext } from "../agent/tools.js";
import type { FeishuBotCli } from "./botCli.js"; import { downloadMessageFile, type FeishuRuntime } from "./client.js";
import type { FeishuRuntime } from "./client.js";
export interface FeishuMessageResourceArgs { export interface FeishuMessageResourceArgs {
readonly messageId: string; readonly messageId: string;
@@ -14,11 +13,7 @@ export interface DownloadedFeishuMessageResource extends FeishuMessageResourceAr
readonly path: string; readonly path: string;
} }
type DownloadContext = Pick<ToolContext, "boundChatId" | "workspaceDir"> & { type DownloadContext = Pick<ToolContext, "boundChatId" | "workspaceDir"> & { readonly workspaceRoot: string };
readonly workspaceRoot: string;
readonly botCli: FeishuBotCli;
readonly maxFileBytes?: number | undefined;
};
interface MessageLookupResult { interface MessageLookupResult {
readonly data?: { readonly data?: {
@@ -56,29 +51,14 @@ export async function downloadFeishuMessageResource(
"inbox", "inbox",
`feishu-${args.resourceType}-${randomUUID()}${extension}`, `feishu-${args.resourceType}-${randomUUID()}${extension}`,
); );
try { const savePath = await downloadMessageFile(
const savePath = await context.botCli.downloadResource({ rt,
messageId: args.messageId, args.messageId,
fileKey: args.fileKey, args.fileKey,
resourceType: args.resourceType, context.workspaceRoot,
workspaceRoot: context.workspaceRoot, context.workspaceDir,
workspaceDir: context.workspaceDir,
workspaceRelativePath, workspaceRelativePath,
maxBytes: context.maxFileBytes, args.resourceType,
});
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; return { ...args, path: savePath };
}
} }
+12 -261
View File
@@ -1,7 +1,6 @@
import { createSdkMcpServer, tool, type McpSdkServerConfigWithInstance, type SdkMcpToolDefinition } from "@anthropic-ai/claude-agent-sdk"; import { createSdkMcpServer, tool, type McpSdkServerConfigWithInstance, type SdkMcpToolDefinition } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod"; import { z } from "zod";
import { sendApprovalCard, sendFileData, type FeishuRuntime, type SendMessageOptions } from "./client.js"; import { sendApprovalCard, sendFileData, type FeishuRuntime, type SendMessageOptions } from "./client.js";
import { createFeishuBotCli } from "./botCli.js";
import { resolveDeliverableFile } from "./fileDelivery.js"; import { resolveDeliverableFile } from "./fileDelivery.js";
import { downloadFeishuMessageResource } from "./download.js"; import { downloadFeishuMessageResource } from "./download.js";
import { readFeishuContext } from "./read.js"; import { readFeishuContext } from "./read.js";
@@ -10,16 +9,8 @@ import { CPH_HUB_MCP_TOOL_IDS, type CphHubMcpToolId } from "../agent/roleTools.j
import { WorkspaceFileBoundaryError } from "../security/workspaceFiles.js"; import { WorkspaceFileBoundaryError } from "../security/workspaceFiles.js";
import type { PrismaClient } from "@prisma/client"; import type { PrismaClient } from "@prisma/client";
import type { LocalSecretEnvelope } from "../security/secretEnvelope.js"; import type { LocalSecretEnvelope } from "../security/secretEnvelope.js";
import { import { createPdfToMdBundleAdapter } from "../capability/pdfToMdBundle.js";
createPdfToMdBundleAdapter,
invokePdfToMdBatch,
readPdfToMdConcurrency,
MAX_PDF_TO_MD_BATCH_ITEMS,
type PdfToMdBatchItemResult,
} from "../capability/pdfToMdBundle.js";
import { AliyunDocmindClient } from "../capability/docmindClient.js"; import { AliyunDocmindClient } from "../capability/docmindClient.js";
import { createPbankService, type PbankToolResult } from "../capability/pbank.js";
import { CapabilityConnectionUnavailable } from "../capability/types.js";
export interface FileDeliveryToolOptions { export interface FileDeliveryToolOptions {
readonly rt: FeishuRuntime; readonly rt: FeishuRuntime;
@@ -39,11 +30,6 @@ export interface FileDeliveryToolOptions {
} }
export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): McpSdkServerConfigWithInstance { 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 enabledTools = new Set(options.tools ?? CPH_HUB_MCP_TOOL_IDS);
const tools: Array<SdkMcpToolDefinition<any>> = []; const tools: Array<SdkMcpToolDefinition<any>> = [];
@@ -161,7 +147,7 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
tools.push( tools.push(
tool( tool(
"feishu_download_resource", "feishu_download_resource",
"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.", "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.",
{ {
message_id: z.string().describe("The Feishu message id containing the resource."), 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."), file_key: z.string().describe("The image_key or file_key from that message's content."),
@@ -189,8 +175,6 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
boundChatId: options.chatId, boundChatId: options.chatId,
workspaceRoot, workspaceRoot,
workspaceDir: options.workspaceDir, workspaceDir: options.workspaceDir,
botCli,
maxFileBytes: options.maxFileBytes,
}, },
options.rt, options.rt,
); );
@@ -262,54 +246,21 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
tools.push( tools.push(
tool( tool(
"convert_pdf_to_md", "convert_pdf_to_md",
"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.", "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.",
{ {
input_path: z.string().optional().describe("Single-file mode: workspace-relative path to the input PDF. Required when `items` is omitted."), input_path: z.string().describe("Relative path to the input PDF within the workspace."),
output_dir: z.string().optional().describe("Single-file mode: workspace-relative directory for document.md + images. Required when `items` is omitted."), 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."),
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) => { async (args) => {
const base = { try {
const result = await adapter.invoke({
runId: options.runId, runId: options.runId,
organizationId: options.organizationId, organizationId: options.organizationId,
projectId: options.projectId, projectId: options.projectId,
workspaceDir: options.workspaceDir, 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({
...base,
inputPath: args.input_path, inputPath: args.input_path,
outputDir: args.output_dir, outputDir: args.output_dir,
prisma: options.prisma,
}); });
const lines = [`Converted PDF to markdown. ${result.artifacts.length} files written:`]; const lines = [`Converted PDF to markdown. ${result.artifacts.length} files written:`];
for (const artifact of result.artifacts) { for (const artifact of result.artifacts) {
@@ -331,127 +282,6 @@ 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); const instructions = mcpInstructions(enabledTools);
return createSdkMcpServer({ return createSdkMcpServer({
name: "cph_hub", name: "cph_hub",
@@ -462,68 +292,11 @@ 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 { function mcpInstructions(enabledTools: ReadonlySet<CphHubMcpToolId>): string {
const instructions: string[] = []; const instructions: string[] = [];
if (enabledTools.has("send_file")) { if (enabledTools.has("send_file")) {
instructions.push( instructions.push(
"Use send_file only for downloadable attachments the user should save (PDF, DOCX, ZIP, etc.).", "Use send_file when the user asks to receive, resend, download, or attach a file.",
"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.", "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.", "If a generated file path is uncertain, list or inspect the workspace first, then call send_file with the actual path.",
); );
@@ -541,32 +314,10 @@ function mcpInstructions(enabledTools: ReadonlySet<CphHubMcpToolId>): string {
} }
if (enabledTools.has("convert_pdf_to_md")) { if (enabledTools.has("convert_pdf_to_md")) {
instructions.push( instructions.push(
"Use convert_pdf_to_md when the user asks to convert a PDF (or several PDFs) to Markdown.", "Use convert_pdf_to_md when the user asks to convert a PDF to Markdown.",
"If PDFs came from Feishu, download each with feishu_download_resource first, then convert.", "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.",
"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 for accurate text, formula, and image extraction.",
"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(" "); return instructions.join(" ");
} }
-429
View File
@@ -1,429 +0,0 @@
/**
* Resolve markdown image references in agent answers into Feishu-hosted
* image_keys so cards can embed them without remote URLs (which trip Feishu
* content-security controls).
*/
import { isIP } from "node:net";
import type { FeishuRuntime } from "./client.js";
import { withRetry } from "./client.js";
import {
WorkspaceFileBoundaryError,
readWorkspaceFileNoFollow,
} from "../security/workspaceFiles.js";
export const FEISHU_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
export const DEFAULT_MAX_OUTBOUND_IMAGES = 10;
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 }
| { readonly type: "image"; readonly imgKey: string; readonly alt: string };
export interface MarkdownImageRef {
readonly fullMatch: string;
readonly alt: string;
readonly src: string;
readonly index: number;
readonly length: number;
}
export interface OutboundImageContext {
readonly rt: FeishuRuntime;
readonly workspaceRoot?: string | undefined;
readonly workspaceDir?: string | undefined;
readonly maxImageBytes?: number | undefined;
readonly maxImages?: number | undefined;
readonly fetchImpl?: typeof fetch | undefined;
}
type ImageCreateResponse = {
image_key?: string;
data?: { image_key?: string };
} | null;
type MessageCreateResponse = {
message_id?: string;
data?: { message_id?: string };
} | null;
/** Streaming-safe view: drop markdown image URLs so partial cards do not hit Feishu URL checks. */
export function maskMarkdownImagesForStreaming(text: string): string {
return rewriteMarkdownImagesOutsideCode(text, (ref) => {
const alt = ref.alt.trim();
return alt === "" ? "\u3010\u56fe\u7247\u3011" : alt;
});
}
export function findMarkdownImagesOutsideCode(text: string): MarkdownImageRef[] {
const blocked = blockedRanges(text);
const refs: MarkdownImageRef[] = [];
IMAGE_MARKDOWN_RE.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = IMAGE_MARKDOWN_RE.exec(text)) !== null) {
const index = match.index;
if (blocked.some((range) => index >= range.start && index < range.end)) continue;
const fullMatch = match[0];
const alt = match[1] ?? "";
const rawSrc = (match[2] ?? "").trim();
const src = stripUrlTitle(rawSrc);
if (src === "") continue;
refs.push({ fullMatch, alt, src, index, length: fullMatch.length });
}
return refs;
}
/**
* Upload reachable markdown images and split the answer into card segments
* (markdown + Feishu img elements). Unresolved images become visible alt text.
*/
export async function materializeAnswerSegments(
text: string,
ctx: OutboundImageContext,
): Promise<{ segments: CardContentSegment[]; unresolved: string[] }> {
const refs = findMarkdownImagesOutsideCode(text);
if (refs.length === 0) {
return {
segments: text === "" ? [] : [{ type: "markdown", content: text }],
unresolved: [],
};
}
const maxImages = ctx.maxImages ?? DEFAULT_MAX_OUTBOUND_IMAGES;
const maxBytes = ctx.maxImageBytes ?? FEISHU_MAX_IMAGE_BYTES;
const keyBySrc = new Map<string, string>();
const unresolved: string[] = [];
const uniqueSrcs: string[] = [];
for (const ref of refs) {
if (!uniqueSrcs.includes(ref.src)) uniqueSrcs.push(ref.src);
}
for (const src of uniqueSrcs.slice(0, maxImages)) {
try {
const bytes = await resolveOutboundImageBytes(src, ctx, maxBytes);
if (bytes === null) {
unresolved.push(src);
continue;
}
const imageKey = await uploadMessageImage(ctx.rt, bytes);
keyBySrc.set(src, imageKey);
} catch (error) {
ctx.rt.logger.warn(
{ src, err: error instanceof Error ? error.message : String(error) },
"outbound image materialize failed",
);
unresolved.push(src);
}
}
for (const src of uniqueSrcs.slice(maxImages)) {
unresolved.push(src);
}
const segments: CardContentSegment[] = [];
let cursor = 0;
for (const ref of refs) {
if (ref.index > cursor) {
pushMarkdown(segments, text.slice(cursor, ref.index));
}
const imageKey = keyBySrc.get(ref.src);
if (imageKey !== undefined) {
segments.push({
type: "image",
imgKey: imageKey,
alt: ref.alt.trim() === "" ? "\u56fe\u7247" : ref.alt.trim(),
});
} else {
const alt = ref.alt.trim();
pushMarkdown(segments, alt === "" ? "\u3010\u56fe\u7247\u3011" : alt);
}
cursor = ref.index + ref.length;
}
if (cursor < text.length) {
pushMarkdown(segments, text.slice(cursor));
}
return { segments, unresolved };
}
export async function uploadMessageImage(rt: FeishuRuntime, image: Buffer): Promise<string> {
if (image.byteLength === 0) {
throw new Error("image is empty");
}
if (image.byteLength > FEISHU_MAX_IMAGE_BYTES) {
throw new Error(`image exceeds Feishu limit of ${FEISHU_MAX_IMAGE_BYTES} bytes`);
}
const client = rt.client as unknown as {
im: { v1: { image: { create: (p: unknown) => Promise<ImageCreateResponse> } } };
};
const res = await withRetry(async () =>
client.im.v1.image.create({
data: { image_type: "message", image },
}),
);
const imageKey = res?.image_key ?? res?.data?.image_key;
if (imageKey === undefined || imageKey === "") {
throw new Error("Feishu image upload response is missing image_key");
}
return imageKey;
}
/** Send a standalone image message (fallback if card embed is unavailable). */
export async function sendImageMessage(
rt: FeishuRuntime,
chatId: string,
imageKey: string,
options?: { readonly replyToMessageId?: string | undefined },
): Promise<string | null> {
const client = rt.client as unknown as {
im: {
v1: {
message: {
create: (p: unknown) => Promise<MessageCreateResponse>;
reply: (p: unknown) => Promise<MessageCreateResponse>;
};
};
};
};
const replyTo = options?.replyToMessageId;
if (replyTo !== undefined && replyTo !== "") {
const res = await client.im.v1.message.reply({
path: { message_id: replyTo },
data: { msg_type: "image", content: JSON.stringify({ image_key: imageKey }) },
});
return res?.data?.message_id ?? res?.message_id ?? null;
}
const res = await client.im.v1.message.create({
params: { receive_id_type: "chat_id" },
data: {
receive_id: chatId,
msg_type: "image",
content: JSON.stringify({ image_key: imageKey }),
},
});
return res?.data?.message_id ?? res?.message_id ?? null;
}
export async function resolveOutboundImageBytes(
src: string,
ctx: OutboundImageContext,
maxBytes: number,
): Promise<Buffer | null> {
if (isRemoteUrl(src)) {
return fetchRemoteImage(src, ctx.fetchImpl ?? fetch, maxBytes);
}
const root = ctx.workspaceRoot?.trim();
const dir = ctx.workspaceDir?.trim();
if (root === undefined || root === "" || dir === undefined || dir === "") {
return null;
}
try {
const file = await readWorkspaceFileNoFollow(root, dir, src, maxBytes);
return file.data;
} catch (error) {
if (error instanceof WorkspaceFileBoundaryError && error.reason === "not_found") {
return null;
}
throw error;
}
}
function rewriteMarkdownImagesOutsideCode(
text: string,
replace: (ref: MarkdownImageRef) => string,
): string {
const refs = findMarkdownImagesOutsideCode(text);
if (refs.length === 0) return text;
let out = "";
let cursor = 0;
for (const ref of refs) {
out += text.slice(cursor, ref.index);
out += replace(ref);
cursor = ref.index + ref.length;
}
out += text.slice(cursor);
return out;
}
function pushMarkdown(segments: CardContentSegment[], content: string): void {
if (content === "") return;
const last = segments[segments.length - 1];
if (last !== undefined && last.type === "markdown") {
segments[segments.length - 1] = { type: "markdown", content: last.content + content };
return;
}
segments.push({ type: "markdown", content });
}
function blockedRanges(text: string): Array<{ start: number; end: number }> {
const ranges: Array<{ start: number; end: number }> = [];
FENCED_CODE_RE.lastIndex = 0;
let match: RegExpExecArray | null;
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;
}
function stripUrlTitle(raw: string): string {
const trimmed = raw.trim();
// Markdown optional title: url "title" or url 'title'
const titled = /^(\S+)\s+(".*"|'.*')$/.exec(trimmed);
return (titled?.[1] ?? trimmed).trim();
}
function isRemoteUrl(src: string): boolean {
try {
const url = new URL(src);
return url.protocol === "http:" || url.protocol === "https:";
} catch {
return false;
}
}
async function fetchRemoteImage(
src: string,
fetchImpl: typeof fetch,
maxBytes: number,
): Promise<Buffer | null> {
let url: URL;
try {
url = new URL(src);
} catch {
return null;
}
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
if (!isPublicHttpHost(url.hostname)) return null;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15_000);
try {
// 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: IMAGE_FETCH_HEADERS,
});
// One safe redirect hop to another public http(s) host.
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get("location");
if (location === null || location === "") return null;
let redirected: URL;
try {
redirected = new URL(location, url);
} catch {
return null;
}
if (redirected.protocol !== "http:" && redirected.protocol !== "https:") return null;
if (!isPublicHttpHost(redirected.hostname)) return null;
const second = await fetchWithoutEnvProxy(fetchImpl, redirected, {
method: "GET",
redirect: "manual",
signal: controller.signal,
headers: IMAGE_FETCH_HEADERS,
});
return readImageBody(second, maxBytes);
}
return readImageBody(response, maxBytes);
} finally {
clearTimeout(timer);
}
}
/**
* 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();
if (
contentType !== "" &&
!contentType.startsWith("image/") &&
!contentType.includes("octet-stream") &&
(contentType.startsWith("text/") || contentType.includes("json") || contentType.includes("html"))
) {
return null;
}
const contentLength = Number(response.headers.get("content-length") ?? "NaN");
if (Number.isFinite(contentLength) && contentLength > maxBytes) return null;
const buf = Buffer.from(await response.arrayBuffer());
if (buf.byteLength === 0 || buf.byteLength > maxBytes) return null;
return buf;
}
function isPublicHttpHost(hostname: string): boolean {
const host = hostname.trim().toLowerCase().replace(/\.$/, "");
if (host === "" || host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local")) {
return false;
}
if (host === "0.0.0.0" || host === "::" || host === "[::]" || host === "::1" || host === "[::1]") {
return false;
}
const unbracketed = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
const ipVersion = isIP(unbracketed);
if (ipVersion === 4) return !isPrivateIPv4(unbracketed);
if (ipVersion === 6) return !isPrivateIPv6(unbracketed);
return true;
}
function isPrivateIPv4(ip: string): boolean {
const parts = ip.split(".").map((part) => Number(part));
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
return true;
}
const a = parts[0]!;
const b = parts[1]!;
if (a === 10 || a === 127 || a === 0) return true;
if (a === 169 && b === 254) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 168) return true;
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
if (a >= 224) return true; // multicast / reserved
return false;
}
function isPrivateIPv6(ip: string): boolean {
const normalized = ip.toLowerCase();
if (normalized === "::1") return true;
if (normalized.startsWith("fc") || normalized.startsWith("fd")) return true; // unique local
if (normalized.startsWith("fe80:")) return true; // link-local
const mapped = /^:ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(normalized);
if (mapped?.[1] !== undefined) return isPrivateIPv4(mapped[1]);
return false;
}
+5 -10
View File
@@ -7,10 +7,8 @@
* *
* - `trigger_message` / `reply`: `message.get` by message_id. * - `trigger_message` / `reply`: `message.get` by message_id.
* - `status_card`: the run's status card message same `message.get` by id. * - `status_card`: the run's status card message same `message.get` by id.
* - `thread`: lark's thread replies. `im.v1.message.list` with * - `thread`: lark's thread replies. The SDK exposes `message.list` with a
* `container_id_type="thread"` and `container_id` = the thread_id (NOT a * `parent_message_id` filter; we map "thread" to that.
* 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); * 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 * we cast through a known request/response shape and return a compact JSON for
@@ -71,14 +69,11 @@ export async function readFeishuContext(
return JSON.stringify(compact(msg)); return JSON.stringify(compact(msg));
} }
case "thread": { case "thread": {
// Thread = replies to a topic. Feishu's `im.v1.message.list` scopes // Thread = replies to a parent message. `container_id` is the parent's
// thread replies when `container_id_type="thread"` and `container_id` // message_id; container_id_type=message_id scopes the list to that thread.
// 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({ const res = await api.list({
params: { params: {
container_id_type: "thread", container_id_type: "message_id",
container_id: args.id, container_id: args.id,
page_size: 50, page_size: 50,
}, },
+10 -12
View File
@@ -6,7 +6,7 @@ import {
removeWorkspaceFileIfUnchangedNoFollow, removeWorkspaceFileIfUnchangedNoFollow,
type WorkspaceFileWriteResult, type WorkspaceFileWriteResult,
} from "../security/workspaceFiles.js"; } from "../security/workspaceFiles.js";
import type { FeishuBotCli } from "./botCli.js"; import { downloadMessageFile, type FeishuRuntime } from "./client.js";
export interface MessageResourceStageRequest { export interface MessageResourceStageRequest {
readonly fileKey: string; readonly fileKey: string;
@@ -33,7 +33,7 @@ export interface PublishedMessageResource extends WorkspaceFileWriteResult {
/** Download Feishu resources into a private temporary workspace, never the tenant workspace. */ /** Download Feishu resources into a private temporary workspace, never the tenant workspace. */
export async function stageMessageResources( export async function stageMessageResources(
botCli: FeishuBotCli, rt: FeishuRuntime,
messageId: string, messageId: string,
requests: readonly MessageResourceStageRequest[], requests: readonly MessageResourceStageRequest[],
workspaceRoot: string, workspaceRoot: string,
@@ -49,18 +49,16 @@ export async function stageMessageResources(
try { try {
await mkdir(stagingRoot, { mode: 0o700 }); await mkdir(stagingRoot, { mode: 0o700 });
for (const [index, request] of requests.entries()) { for (const [index, request] of requests.entries()) {
// Bot-identity transport only (ADR-0024): org App Secret stays in Hub, const stagedPath = await downloadMessageFile(
// never crosses into the Agent surface. Staging still lands under the rt,
// private .cph-staging tree before publish link into the tenant workspace.
const stagedPath = await botCli.downloadResource({
messageId, messageId,
fileKey: request.fileKey, request.fileKey,
resourceType: request.resourceType,
workspaceRoot, workspaceRoot,
workspaceDir: stagingRoot, stagingRoot,
workspaceRelativePath: `resource-${index}`, `resource-${index}`,
maxBytes: limits?.maxBytesPerFile, request.resourceType,
}); limits?.maxBytesPerFile,
);
resources.push({ resources.push({
resourceType: request.resourceType, resourceType: request.resourceType,
workspaceRelativePath: request.workspaceRelativePath, workspaceRelativePath: request.workspaceRelativePath,
-106
View File
@@ -1,106 +0,0 @@
/**
* 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)}...`;
}
+12 -59
View File
@@ -1,8 +1,7 @@
/** /**
* Sends a "processing" reaction immediately, then streams a single * Sends a "processing" reaction immediately, then streams a single
* interactive card through the full agent run lifecycle: thinking tool * interactive card through the full agent run lifecycle: thinking tool
* calls (with trace panel) streaming answer text final card. On finish, * calls (with trace panel) streaming answer text final card. The card
* replaces Typing with CheckMark (success) or CrossMark (failure). The card
* shows a collapsible tool-use panel, a collapsible reasoning panel, and * shows a collapsible tool-use panel, a collapsible reasoning panel, and
* the markdown answer text. Throttled to ~2.5 patches/sec to avoid * the markdown answer text. Throttled to ~2.5 patches/sec to avoid
* spamming the Feishu API. * spamming the Feishu API.
@@ -41,7 +40,6 @@ import { createAgentSdkStderrSink } from "../agent/diagnostics.js";
import { InactiveOrganizationError, lockActiveOrganization } from "../org/status.js"; import { InactiveOrganizationError, lockActiveOrganization } from "../org/status.js";
import { StreamingAgentCard } from "./card/streaming-card.js"; import { StreamingAgentCard } from "./card/streaming-card.js";
import { createFileDeliveryMcpServer } from "./fileDeliveryTool.js"; import { createFileDeliveryMcpServer } from "./fileDeliveryTool.js";
import { appendTeacherNotice, teacherFacingRunOutcome } from "./runOutcomeNotice.js";
import { readFeishuContext } from "./read.js"; import { readFeishuContext } from "./read.js";
import { MessageBatcher, messageBatchKey, type MessageBatcherOptions } from "./messageBatcher.js"; import { MessageBatcher, messageBatchKey, type MessageBatcherOptions } from "./messageBatcher.js";
import { ApprovalManager } from "./approval.js"; import { ApprovalManager } from "./approval.js";
@@ -54,7 +52,6 @@ import {
type MessageResourceStageRequest, type MessageResourceStageRequest,
type StagedMessageResourceBatch, type StagedMessageResourceBatch,
} from "./resourceStaging.js"; } from "./resourceStaging.js";
import { createFeishuBotCli, type FeishuBotCli } from "./botCli.js";
import { TriggerQueue, triggerQueue as defaultTriggerQueue, type QueuedTrigger } from "./triggerQueue.js"; import { TriggerQueue, triggerQueue as defaultTriggerQueue, type QueuedTrigger } from "./triggerQueue.js";
import { createSlashCommandRegistry, parseSlashInvocation } from "./slashCommands.js"; import { createSlashCommandRegistry, parseSlashInvocation } from "./slashCommands.js";
import { cphHubMcpToolsForRole, roleToolsAllow } from "../agent/roleTools.js"; import { cphHubMcpToolsForRole, roleToolsAllow } from "../agent/roleTools.js";
@@ -116,8 +113,6 @@ interface TriggerDeps {
readonly allowLegacyFeishuIdentity?: boolean | undefined; readonly allowLegacyFeishuIdentity?: boolean | undefined;
/** Alpha Silo aggregate ingress ceiling across message and card events. */ /** Alpha Silo aggregate ingress ceiling across message and card events. */
readonly maxFeishuEventsPerMinute?: number | undefined; readonly maxFeishuEventsPerMinute?: number | undefined;
/** Test/injection seam for bot-identity Feishu resource downloads. */
readonly feishuBotCli?: FeishuBotCli | undefined;
} }
interface TriggerActor { interface TriggerActor {
@@ -306,13 +301,8 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
senderOpenId, senderOpenId,
}); });
const senderMetadata = await senderAuditMetadata(rt, 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( const stagedResources = await stageTriggerMessageResources(
botCli, rt,
msg, msg,
projectWorkspaceRoot, projectWorkspaceRoot,
deps.resourceLimits, deps.resourceLimits,
@@ -506,9 +496,6 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
sendOptions, sendOptions,
patchIntervalMs: undefined, patchIntervalMs: undefined,
maxMessageLength: undefined, maxMessageLength: undefined,
workspaceRoot: projectWorkspaceRoot,
workspaceDir: project.workspaceDir,
maxImageBytes: deps.resourceLimits?.maxBytesPerFile,
}); });
const fileDeliveryMcpServer = createFileDeliveryMcpServer({ const fileDeliveryMcpServer = createFileDeliveryMcpServer({
rt, rt,
@@ -589,7 +576,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
card.onToolEnd({ card.onToolEnd({
toolName: event.toolName, toolName: event.toolName,
toolUseId: event.toolUseId, toolUseId: event.toolUseId,
input: event.input, input: undefined,
result: event.result, result: event.result,
error: event.isError ? event.result : undefined, error: event.isError ? event.result : undefined,
durationMs: event.durationMs, durationMs: event.durationMs,
@@ -608,24 +595,13 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
agentExecution agentExecution
.then(async (result) => { .then(async (result) => {
const interrupted = result.status === "interrupted" && !wallTimeExceeded; const interrupted = result.status === "interrupted" && !wallTimeExceeded;
const hasPartialText = result.text.trim() !== ""; const finalText =
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.text ? result.text
: result.status === "failed" && result.error !== undefined && outcome.notice === undefined : result.status === "failed" && result.error !== undefined
? `\u5904\u7406\u5931\u8D25: ${result.error}` ? `\u5904\u7406\u5931\u8D25: ${result.error}`
: result.text; : result.text;
const finalText = appendTeacherNotice(baseText, outcome.notice); await card.finish(finalText, { interrupted });
await card.finish(finalText, { interrupted, isError: outcome.isError });
const metadataPatch = sessionMetadataPatch(result.sdkSessionId); const metadataPatch = sessionMetadataPatch(result.sdkSessionId);
if (metadataPatch !== null) { if (metadataPatch !== null) {
await deps.prisma.agentSession.update({ await deps.prisma.agentSession.update({
@@ -689,36 +665,14 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
initializedSkills: [...(result.initializedSkillIds ?? [])], initializedSkills: [...(result.initializedSkillIds ?? [])],
}, },
}); });
// Mirror the start "Typing" reaction: drop processing, then stamp a await removeProcessingReaction();
// 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) => { .catch(async (e) => {
const removedProcessingReaction = await removeProcessingReaction(); const removedProcessingReaction = await removeProcessingReaction();
if (removedProcessingReaction) { if (removedProcessingReaction) {
await addReaction(rt, msg.message_id, "CrossMark"); await addReaction(rt, msg.message_id, "CrossMark");
} }
await card.fail( await card.fail(e instanceof Error ? e.message : String(e));
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 { try {
await deps.prisma.agentRun.update({ await deps.prisma.agentRun.update({
where: { id: run.id }, where: { id: run.id },
@@ -1877,9 +1831,8 @@ async function senderAuditMetadata(rt: FeishuRuntime, openId: string): Promise<P
function withFileDeliveryInstructions(systemPrompt: string | undefined, roleTools: readonly string[] | undefined): string | undefined { function withFileDeliveryInstructions(systemPrompt: string | undefined, roleTools: readonly string[] | undefined): string | undefined {
if (!roleToolsAllow(roleTools, "send_file")) return systemPrompt; if (!roleToolsAllow(roleTools, "send_file")) return systemPrompt;
const fileDeliveryPrompt = const fileDeliveryPrompt =
"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. " + "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. " + "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}`; return systemPrompt === undefined ? fileDeliveryPrompt : `${systemPrompt}\n\n${fileDeliveryPrompt}`;
} }
@@ -1960,7 +1913,7 @@ function isPrismaUniqueConstraintError(error: unknown): boolean {
} }
async function stageTriggerMessageResources( async function stageTriggerMessageResources(
botCli: FeishuBotCli, rt: FeishuRuntime,
msg: MessageReceiveEvent["message"], msg: MessageReceiveEvent["message"],
workspaceRoot: string, workspaceRoot: string,
limits?: TriggerDeps["resourceLimits"], limits?: TriggerDeps["resourceLimits"],
@@ -1994,7 +1947,7 @@ async function stageTriggerMessageResources(
} }
} }
return stageMessageResources( return stageMessageResources(
botCli, rt,
msg.message_id, msg.message_id,
requests, requests,
workspaceRoot, workspaceRoot,
+6 -48
View File
@@ -1,7 +1,7 @@
import Fastify from "fastify"; import Fastify from "fastify";
import { registerAdminPlugin } from "./admin/plugin.js"; import { registerAdminPlugin } from "./admin/plugin.js";
import { prisma } from "./db.js"; import { prisma } from "./db.js";
import { createLarkClient, sendText, startFeishuListenerWithClient, type FeishuRuntime } from "./feishu/client.js"; import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client.js";
import { archiveFeishuBindingForLifecycleEvent } from "./feishu/bindingLifecycle.js"; import { archiveFeishuBindingForLifecycleEvent } from "./feishu/bindingLifecycle.js";
import { makeTriggerHandler } from "./feishu/trigger.js"; import { makeTriggerHandler } from "./feishu/trigger.js";
import { removeAbandonedMessageResourceStages } from "./feishu/resourceStaging.js"; import { removeAbandonedMessageResourceStages } from "./feishu/resourceStaging.js";
@@ -118,22 +118,15 @@ export async function startHub(): Promise<void> {
const publicBaseUrl = process.env["HUB_PUBLIC_BASE_URL"] ?? "http://127.0.0.1:8788"; const publicBaseUrl = process.env["HUB_PUBLIC_BASE_URL"] ?? "http://127.0.0.1:8788";
const bind = readServerBinding(); const bind = readServerBinding();
// Startup reset: clear stale locks + mark dead runs as FAILED. Capture the // Startup reset: clear stale locks + mark dead runs as FAILED.
// killed runs first so we can tell their Feishu chats after the listener is up.
const interruptedRuns = await prisma.agentRun.findMany({
where: { status: "ACTIVE" },
select: { id: true, projectId: true },
});
await prisma.projectAgentLock.deleteMany({}); await prisma.projectAgentLock.deleteMany({});
if (interruptedRuns.length > 0) {
await prisma.agentRun.updateMany({ await prisma.agentRun.updateMany({
where: { id: { in: interruptedRuns.map((run) => run.id) } }, where: { status: "ACTIVE" },
data: { status: "FAILED", error: "process restart", finishedAt: new Date() }, data: { status: "FAILED", error: "process restart", finishedAt: new Date() },
}); });
} app.log.info("startup: cleared stale locks + dead runs");
app.log.info({ killedRuns: interruptedRuns.length }, "startup: cleared stale locks + dead runs");
let feishuRuntime: FeishuRuntime | undefined; let feishuRuntime: { readonly isListenerReady?: () => boolean } | undefined;
app.get("/api/healthz", async (_request, reply) => { app.get("/api/healthz", async (_request, reply) => {
const feishuReady = feishuRuntime?.isListenerReady?.() ?? !booleanEnv("HUB_FEISHU_LISTENER_ENABLED", true); const feishuReady = feishuRuntime?.isListenerReady?.() ?? !booleanEnv("HUB_FEISHU_LISTENER_ENABLED", true);
if (!feishuReady) return reply.status(503).send({ ok: false, feishuReady, ts: Date.now() }); if (!feishuReady) return reply.status(503).send({ ok: false, feishuReady, ts: Date.now() });
@@ -178,7 +171,7 @@ export async function startHub(): Promise<void> {
allowLegacyFeishuIdentity: false, allowLegacyFeishuIdentity: false,
maxFeishuEventsPerMinute: feishuEventsPerMinute, maxFeishuEventsPerMinute: feishuEventsPerMinute,
}); });
const runtime = await startFeishuListenerWithClient( feishuRuntime = await startFeishuListenerWithClient(
feishuConfig, feishuConfig,
larkClient, larkClient,
app.log, app.log,
@@ -193,8 +186,6 @@ export async function startHub(): Promise<void> {
app.log.info({ ...event, archived: result.archived, projectId: result.projectId }, "feishu binding lifecycle event handled"); 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 { } else {
app.log.info("feishu listener disabled by HUB_FEISHU_LISTENER_ENABLED"); app.log.info("feishu listener disabled by HUB_FEISHU_LISTENER_ENABLED");
} }
@@ -202,39 +193,6 @@ export async function startHub(): Promise<void> {
app.log.info({ address }, "hub listening"); 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 { function positiveIntegerEnv(name: string): number {
const raw = requireEnv(name); const raw = requireEnv(name);
const value = Number(raw); const value = Number(raw);
+3 -3
View File
@@ -1,9 +1,9 @@
/** /**
* Org capacity policy service (ADR-0022). * Org capacity policy service (ADR-0022 / spec `Spec.System.Capacity`).
* *
* Stores per-Organization lower `organizationLimit` overrides per * Stores per-Organization lower `organizationLimit` overrides per
* `CapacityDimension`. Enforces the layered-limit invariant: a set limit must be the * `CapacityDimension`. Enforces `LayeredLimit.Valid`: a set limit must be the
* platform ceiling for that dimension. The effective limit (min of the two) * platform ceiling for that dimension. `LayeredLimit.effective` (min of the two)
* is the value capacity admission should use; dimensions with no org override * is the value capacity admission should use; dimensions with no org override
* fall back to the platform ceiling. * fall back to the platform ceiling.
*/ */
+1 -1
View File
@@ -1,7 +1,7 @@
/** /**
* Organization membership management for org admin (ADR-0021). * Organization membership management for org admin (ADR-0021).
* *
* Role rules (product pin, not yet recorded in an ADR): * Role rules (product pin, not yet in Lean):
* 1. Actor must be OWNER or ADMIN (enforced at HTTP layer). * 1. Actor must be OWNER or ADMIN (enforced at HTTP layer).
* 2. Only OWNER can grant/revoke OWNER or modify another OWNER. * 2. Only OWNER can grant/revoke OWNER or modify another OWNER.
* 3. Cannot revoke or demote the last remaining OWNER. * 3. Cannot revoke or demote the last remaining OWNER.
-20
View File
@@ -558,26 +558,6 @@ async function ensureInboxFolder(prisma: Prisma.TransactionClient, organizationI
select: { id: true }, select: { id: true },
}); });
if (existing !== null) return existing; if (existing !== null) return existing;
// Bootstrap once created a root "Inbox" without kind=SYSTEM_INBOX. Sibling-name
// uniqueness then makes a second create fail. Recover by promoting that row.
const legacyRootInbox = await prisma.folder.findFirst({
where: {
organizationId,
parentId: null,
name: "Inbox",
archivedAt: null,
},
select: { id: true },
});
if (legacyRootInbox !== null) {
return prisma.folder.update({
where: { id: legacyRootInbox.id },
data: { kind: "SYSTEM_INBOX" },
select: { id: true },
});
}
return prisma.folder.create({ return prisma.folder.create({
data: { organizationId, name: "Inbox", kind: "SYSTEM_INBOX", sortKey: "000000" }, data: { organizationId, name: "Inbox", kind: "SYSTEM_INBOX", sortKey: "000000" },
select: { id: true }, select: { id: true },
+2 -2
View File
@@ -11,9 +11,9 @@ type EnvSource = Env | (() => Env);
const DEFAULT_SONNET_MODEL = "anthropic/claude-sonnet-5"; const DEFAULT_SONNET_MODEL = "anthropic/claude-sonnet-5";
const DEFAULT_SONNET_LABEL = "Claude Sonnet 5"; const DEFAULT_SONNET_LABEL = "Claude Sonnet 5";
const DEFAULT_AGENT_MAX_TURNS = 150; const DEFAULT_AGENT_MAX_TURNS = 25;
const DEFAULT_AGENT_MAX_CONCURRENT_RUNS = 1; const DEFAULT_AGENT_MAX_CONCURRENT_RUNS = 1;
const DEFAULT_AGENT_MAX_RUN_SECONDS = 1800; const DEFAULT_AGENT_MAX_RUN_SECONDS = 900;
export interface ProviderRuntimeSettings { export interface ProviderRuntimeSettings {
readonly id: string; readonly id: string;
+1 -2
View File
@@ -242,8 +242,7 @@ describe("admin auth + org API guards", () => {
headers: { cookie: `${OAUTH_STATE_COOKIE_NAME}=${nonce}` }, headers: { cookie: `${OAUTH_STATE_COOKIE_NAME}=${nonce}` },
}); });
expect(res.statusCode).toBe(302); expect(res.statusCode).toBe(302);
// New users without membership land on login error; session is still set. expect(res.headers.location).toBe("/admin");
expect(res.headers.location).toBe("/admin/login?error=no_organization");
expect(JSON.stringify(res.headers["set-cookie"])).toContain("cph_session="); expect(JSON.stringify(res.headers["set-cookie"])).toContain("cph_session=");
const user = await prisma.user.findUnique({ where: { feishuOpenId: "ou_new" } }); const user = await prisma.user.findUnique({ where: { feishuOpenId: "ou_new" } });
@@ -43,7 +43,7 @@ describe("Organization Agent configuration management", () => {
provider: "openrouter", provider: "openrouter",
roleId: "draft", roleId: "draft",
model: "anthropic/claude-sonnet-5", model: "anthropic/claude-sonnet-5",
metadata: { claudeSessionId: "sdk-session-old", userResumable: true }, metadata: {},
}, },
}); });
await configuration.setRoleSkills({ await configuration.setRoleSkills({
@@ -59,12 +59,11 @@ describe("Organization Agent configuration management", () => {
expect(role).toMatchObject({ label: "课程草稿", systemPrompt: "write carefully" }); expect(role).toMatchObject({ label: "课程草稿", systemPrompt: "write carefully" });
expect(role.tools).toEqual(["read_file", "write_file", "cph_build"]); expect(role.tools).toEqual(["read_file", "write_file", "cph_build"]);
expect(role.skillBindings.map((binding) => binding.skill.name)).toEqual(["outline", "typst"]); expect(role.skillBindings.map((binding) => binding.skill.name)).toEqual(["outline", "typst"]);
// Execution-surface change invalidates the provider session cursor but await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: "session-old-role-config" } }))
// keeps the Hub session alive so its transcript stays reachable. .resolves.toMatchObject({
const invalidated = await prisma.agentSession.findUniqueOrThrow({ where: { id: "session-old-role-config" } }); archivedAt: expect.any(Date),
expect(invalidated.archivedAt).toBeNull(); metadata: expect.objectContaining({ userResumable: false }),
expect(invalidated.metadata).toEqual(expect.objectContaining({ userResumable: false })); });
expect(invalidated.metadata).not.toHaveProperty("claudeSessionId");
}); });
it("rejects unknown, disabled and cross-Organization skills", async () => { it("rejects unknown, disabled and cross-Organization skills", async () => {
@@ -119,105 +118,6 @@ describe("Organization Agent configuration management", () => {
})).rejects.toThrow("must have exactly one active default Agent role"); })).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> { async function makeSkill(parent: string, name: string): Promise<string> {
const source = join(parent, "sources", name); const source = join(parent, "sources", name);
await mkdir(source, { recursive: true }); await mkdir(source, { recursive: true });
@@ -157,10 +157,9 @@ describe("real Claude SDK sandbox boundary", () => {
[result.error, sdkStderr.join(""), JSON.stringify(streamEvents)].filter(Boolean).join("\n"), [result.error, sdkStderr.join(""), JSON.stringify(streamEvents)].filter(Boolean).join("\n"),
).toBe("completed"); ).toBe("completed");
expect(stub.requestCount()).toBeGreaterThanOrEqual(3); expect(stub.requestCount()).toBeGreaterThanOrEqual(3);
const skillIds = new Set(result.initializedSkillIds ?? []); expect(new Set(result.initializedSkillIds)).toEqual(new Set([
expect(skillIds.has("cph-runtime:outline")).toBe(true); "cph-runtime:outline",
// 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"); const toolResults = streamEvents.filter((event) => event.type === "tool-result");
expect(toolResults).toHaveLength(2); expect(toolResults).toHaveLength(2);
const rejectedOptOut = toolResults[0]; const rejectedOptOut = toolResults[0];
@@ -1,187 +0,0 @@
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,7 +58,6 @@ describe("pdf_to_md_bundle capability adapter (ADR-0027)", () => {
async function seedActiveCapabilityConnection(): Promise<void> { async function seedActiveCapabilityConnection(): Promise<void> {
const payload: CapabilitySecretPayload = { const payload: CapabilitySecretPayload = {
schemaVersion: 1, schemaVersion: 1,
kind: "docmind",
accessKeyId: "LTAI-test-key-id", accessKeyId: "LTAI-test-key-id",
accessKeySecret: "test-secret-never-log", accessKeySecret: "test-secret-never-log",
endpoint: "docmind-api.cn-hangzhou.aliyuncs.com", endpoint: "docmind-api.cn-hangzhou.aliyuncs.com",
@@ -16,9 +16,7 @@ import { ProviderConnectionService } from "../../src/connections/providerConnect
import { FeishuApplicationConnectionService } from "../../src/connections/feishuApplicationConnections.js"; import { FeishuApplicationConnectionService } from "../../src/connections/feishuApplicationConnections.js";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
const TEST_DATABASE_URL = const TEST_DATABASE_URL = "postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test";
process.env.DATABASE_URL?.trim() ||
"postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test";
describe("deployment preflight CLI", { timeout: 20_000 }, () => { describe("deployment preflight CLI", { timeout: 20_000 }, () => {
let root: string; let root: string;
+34 -61
View File
@@ -5,8 +5,6 @@
* FeishuRuntime (sendText/sendCard are no-ops that record calls), and a mock * FeishuRuntime (sendText/sendCard are no-ops that record calls), and a mock
* AI SDK model factory (doGenerate() returns canned responses - no network). * 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 { PrismaClient } from "@prisma/client";
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
import type { import type {
@@ -21,21 +19,7 @@ import type { FeishuRuntime } from "../../src/feishu/client.js";
import type { ModelFactory } from "../../src/agent/runner.js"; import type { ModelFactory } from "../../src/agent/runner.js";
import { LocalSecretEnvelope } from "../../src/security/secretEnvelope.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 DEFAULT_ORG_ID = "org_test_default";
export const TEST_SECRET_KEY_ID = "test-active"; export const TEST_SECRET_KEY_ID = "test-active";
export const TEST_SECRET_KEY = Buffer.alloc(32, "k"); export const TEST_SECRET_KEY = Buffer.alloc(32, "k");
@@ -50,29 +34,20 @@ export const prisma = new PrismaClient({
/** Truncate all tables before each test for isolation. */ /** Truncate all tables before each test for isolation. */
export async function resetDb(): Promise<void> { export async function resetDb(): Promise<void> {
// Hard reset via TRUNCATE CASCADE. Parent RESTRICT edges and leftover // User and Organization are the aggregate roots for all domain rows; their
// folder trees made deleteMany-based cleanup race Prisma upserts // declared FK cascades clear projects, search documents, permissions,
// ("Unique constraint failed on id" while the where-branch saw no row). // sessions and connections without repeatedly truncating pg_trgm indexes.
await prisma.$executeRawUnsafe(` // Event receipts and global audit rows are independent roots.
DO $$ await prisma.$transaction([
DECLARE prisma.feishuEventReceipt.deleteMany(),
stmt text; prisma.auditEntry.deleteMany(),
BEGIN // Permission resource ids are intentionally polymorphic strings, so these
SELECT 'TRUNCATE TABLE ' || string_agg(format('%I.%I', schemaname, tablename), ', ') // two tables have no FK to Project and must be cleared explicitly.
|| ' RESTART IDENTITY CASCADE' prisma.permissionGrant.deleteMany(),
INTO stmt prisma.permissionSettings.deleteMany(),
FROM pg_tables prisma.user.deleteMany(),
WHERE schemaname = 'public' prisma.organization.deleteMany(),
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(); await seedTestOrganization();
} }
@@ -80,41 +55,40 @@ export async function seedTestOrganization(
id: string = DEFAULT_ORG_ID, id: string = DEFAULT_ORG_ID,
slug: string = "test-default", slug: string = "test-default",
): Promise<void> { ): 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 prisma.$transaction(async (tx) => {
const existing = await tx.organization.findUnique({ await tx.organization.upsert({
where: { id }, where: { id },
select: { id: true }, update: {},
create: { id, slug, name: "Test Default Organization" },
}); });
if (existing === null) { await tx.organizationProjectSettings.upsert({
await tx.organization.create({ where: { organizationId: id },
data: { update: {},
id, create: { organizationId: id, membersCanCreateProjects: true },
slug, });
name: "Test Default Organization", const defaultRole = await tx.organizationAgentRole.upsert({
projectSettings: { where: { organizationId_roleId: { organizationId: id, roleId: "draft" } },
create: { membersCanCreateProjects: true }, update: { label: "草稿", isDefault: true, disabledAt: null },
},
agentRoles: {
create: { create: {
id: `agent_role_draft_${id}`, id: `agent_role_draft_${id}`,
organizationId: id,
roleId: "draft", roleId: "draft",
label: "草稿", label: "草稿",
sortOrder: 10, sortOrder: 10,
isDefault: true, isDefault: true,
}, },
},
},
}); });
} await tx.organizationAgentRole.updateMany({
where: { organizationId: id, id: { not: defaultRole.id }, isDefault: true },
const inbox = await tx.folder.findFirst({ data: { isDefault: false },
});
});
const inbox = await prisma.folder.findFirst({
where: { organizationId: id, kind: "SYSTEM_INBOX", archivedAt: null }, where: { organizationId: id, kind: "SYSTEM_INBOX", archivedAt: null },
select: { id: true }, select: { id: true },
}); });
if (inbox === null) { if (inbox === null) {
await tx.folder.create({ await prisma.folder.create({
data: { data: {
id: `folder_inbox_${id}`, id: `folder_inbox_${id}`,
organizationId: id, organizationId: id,
@@ -124,7 +98,6 @@ export async function seedTestOrganization(
}, },
}); });
} }
});
} }
/** A logger that discards everything (tests don't need fastify's pino). */ /** A logger that discards everything (tests don't need fastify's pino). */
@@ -101,43 +101,6 @@ describe("ADR-0021 project onboarding", () => {
])); ]));
}); });
it("promotes a legacy root Inbox when Feishu chat creates a project", async () => {
await seedUser("u-member", "ou_member", "MEMBER");
// Production drift after bootstrap omitted kind=SYSTEM_INBOX. The protect
// trigger refuses demotion, so the test plants the legacy shape directly.
await prisma.$executeRawUnsafe(`ALTER TABLE "Folder" DISABLE TRIGGER cph_protect_system_inbox`);
try {
await prisma.folder.updateMany({
where: { organizationId: DEFAULT_ORG_ID, kind: "SYSTEM_INBOX", archivedAt: null },
data: { kind: "REGULAR" },
});
} finally {
await prisma.$executeRawUnsafe(`ALTER TABLE "Folder" ENABLE TRIGGER cph_protect_system_inbox`);
}
const legacy = await prisma.folder.findFirstOrThrow({
where: { organizationId: DEFAULT_ORG_ID, parentId: null, name: "Inbox", archivedAt: null },
select: { id: true, kind: true },
});
expect(legacy.kind).toBe("REGULAR");
const result = await createProjectFromFeishuChat(prisma, {
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_member",
chatId: "chat-legacy-inbox",
name: "Recovered Inbox Project",
workspaceRoot: await tempWorkspaceRoot(),
});
expect(result.folderId).toBe(legacy.id);
await expect(prisma.folder.findUniqueOrThrow({
where: { id: legacy.id },
select: { kind: true },
})).resolves.toEqual({ kind: "SYSTEM_INBOX" });
expect(await prisma.folder.count({
where: { organizationId: DEFAULT_ORG_ID, name: "Inbox", archivedAt: null },
})).toBe(1);
});
it("blocks ordinary Feishu project creation when the org setting is off", async () => { it("blocks ordinary Feishu project creation when the org setting is off", async () => {
await seedUser("u-member", "ou_member", "MEMBER"); await seedUser("u-member", "ou_member", "MEMBER");
await setMembersCanCreateProjects(prisma, { await setMembersCanCreateProjects(prisma, {
@@ -62,11 +62,6 @@ describe("Alpha Silo bootstrap", () => {
{ roleId: "draft", label: "草稿", isDefault: true }, { roleId: "draft", label: "草稿", isDefault: true },
{ roleId: "review", label: "审校", isDefault: false }, { roleId: "review", label: "审校", isDefault: false },
]); ]);
await expect(prisma.folder.findMany({
where: { organizationId: "org_alpha", archivedAt: null },
select: { name: true, kind: true, parentId: true },
})).resolves.toEqual([{ name: "Inbox", kind: "SYSTEM_INBOX", parentId: null }]);
const persisted = JSON.stringify({ const persisted = JSON.stringify({
feishu: await prisma.feishuApplicationCredentialVersion.findMany(), feishu: await prisma.feishuApplicationCredentialVersion.findMany(),
+17 -30
View File
@@ -11,14 +11,11 @@ import {
seedProject, seedProject,
seedTestOrganization, seedTestOrganization,
silentLogger, silentLogger,
testSecretEnvelope,
} from "./helpers.js"; } from "./helpers.js";
import { InMemoryModelRegistry } from "../../src/agent/models.js"; import { InMemoryModelRegistry } from "../../src/agent/models.js";
import { makeTriggerHandler as makeProductionTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js"; import { makeTriggerHandler as makeProductionTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js";
import { TriggerQueue } from "../../src/feishu/triggerQueue.js"; import { TriggerQueue } from "../../src/feishu/triggerQueue.js";
import type { MessageReceiveEvent, CardActionEvent } from "../../src/feishu/client.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 { RunRequest, RunResult } from "../../src/agent/runner.js";
import type { RuntimeSettings } from "../../src/settings/runtime.js"; import type { RuntimeSettings } from "../../src/settings/runtime.js";
@@ -37,7 +34,6 @@ function makeTriggerHandler(deps: TestTriggerDeps): ReturnType<typeof makeProduc
publicBaseUrl: "https://educraft.example.test", publicBaseUrl: "https://educraft.example.test",
siloOrganizationId: DEFAULT_ORG_ID, siloOrganizationId: DEFAULT_ORG_ID,
allowLegacyFeishuIdentity: true, allowLegacyFeishuIdentity: true,
secretEnvelope: testSecretEnvelope,
...deps, ...deps,
}); });
} }
@@ -194,14 +190,13 @@ describe("trigger full lifecycle (integration)", () => {
where: { id: "proj-post-image" }, where: { id: "proj-post-image" },
data: { workspaceDir }, data: { workspaceDir },
}); });
const downloadResource = vi.fn(async (request) => writeNewWorkspaceFileNoFollow( const messageResourceGet = vi.fn(async () => ({
request.workspaceRoot, getReadableStream: () => Readable.from([Buffer.from("image bytes")]),
request.workspaceDir, }));
request.workspaceRelativePath, const imV1 = (rt.client as unknown as {
Readable.from([Buffer.from("image bytes")]), im: { v1: { messageResource?: { get: typeof messageResourceGet } } };
request.maxBytes, }).im.v1;
)); imV1.messageResource = { get: messageResourceGet };
const feishuBotCli: FeishuBotCli = { downloadResource };
const baseEvent = makeEvent("chat-post-image", "@_user_1 看看这张图"); const baseEvent = makeEvent("chat-post-image", "@_user_1 看看这张图");
const event: MessageReceiveEvent = { const event: MessageReceiveEvent = {
...baseEvent, ...baseEvent,
@@ -225,7 +220,6 @@ describe("trigger full lifecycle (integration)", () => {
runAgent, runAgent,
projectWorkspaceRoot: workspaceRoot, projectWorkspaceRoot: workspaceRoot,
messageBatcherOptions: { maxMessages: 1 }, messageBatcherOptions: { maxMessages: 1 },
feishuBotCli,
}); });
await trigger(event, rt); await trigger(event, rt);
@@ -234,11 +228,10 @@ describe("trigger full lifecycle (integration)", () => {
expect(runAgentCalls).toHaveLength(1); expect(runAgentCalls).toHaveLength(1);
}); });
expect(runAgentCalls[0]?.prompt).toContain(join(await realpath(workspaceDir), ".cph", "inbox")); expect(runAgentCalls[0]?.prompt).toContain(join(await realpath(workspaceDir), ".cph", "inbox"));
expect(downloadResource).toHaveBeenCalledWith(expect.objectContaining({ expect(messageResourceGet).toHaveBeenCalledWith({
messageId: event.message.message_id, params: { type: "image" },
fileKey: "img-key-1", path: { message_id: event.message.message_id, file_key: "img-key-1" },
resourceType: "image", });
}));
const inboxFiles = await readdir(join(workspaceDir, ".cph", "inbox")); const inboxFiles = await readdir(join(workspaceDir, ".cph", "inbox"));
expect(inboxFiles).toHaveLength(1); expect(inboxFiles).toHaveLength(1);
await expect(readFile(join(workspaceDir, ".cph", "inbox", inboxFiles[0]!))).resolves.toEqual(Buffer.from("image bytes")); await expect(readFile(join(workspaceDir, ".cph", "inbox", inboxFiles[0]!))).resolves.toEqual(Buffer.from("image bytes"));
@@ -1116,18 +1109,15 @@ describe("trigger full lifecycle (integration)", () => {
}); });
const resourceEntered = deferred<void>(); const resourceEntered = deferred<void>();
const releaseResource = deferred<void>(); const releaseResource = deferred<void>();
const downloadResource = vi.fn(async (request) => { const messageResourceGet = vi.fn(async () => {
resourceEntered.resolve(); resourceEntered.resolve();
await releaseResource.promise; await releaseResource.promise;
return writeNewWorkspaceFileNoFollow( return { getReadableStream: () => Readable.from([Buffer.from("staged image bytes")]) };
request.workspaceRoot,
request.workspaceDir,
request.workspaceRelativePath,
Readable.from([Buffer.from("staged image bytes")]),
request.maxBytes,
);
}); });
const feishuBotCli: FeishuBotCli = { downloadResource }; const imV1 = (rt.client as unknown as {
im: { v1: { messageResource?: { get: typeof messageResourceGet } } };
}).im.v1;
imV1.messageResource = { get: messageResourceGet };
const baseEvent = makeEvent("chat-attachment-race", "@_user_1 附件竞态"); const baseEvent = makeEvent("chat-attachment-race", "@_user_1 附件竞态");
const event: MessageReceiveEvent = { const event: MessageReceiveEvent = {
...baseEvent, ...baseEvent,
@@ -1151,7 +1141,6 @@ describe("trigger full lifecycle (integration)", () => {
runAgent, runAgent,
projectWorkspaceRoot: workspaceRoot, projectWorkspaceRoot: workspaceRoot,
messageBatcherOptions: { maxMessages: 1 }, messageBatcherOptions: { maxMessages: 1 },
feishuBotCli,
}); });
const pendingTrigger = trigger(event, rt); const pendingTrigger = trigger(event, rt);
@@ -1611,10 +1600,8 @@ describe("trigger full lifecycle (integration)", () => {
expect(runs[0]?.status).toBe("CANCELED"); expect(runs[0]?.status).toBe("CANCELED");
}); });
expect(patch).toHaveBeenCalled(); expect(patch).toHaveBeenCalled();
await vi.waitFor(() => {
expect(rt.sentTexts).toContain("已中断当前运行。"); expect(rt.sentTexts).toContain("已中断当前运行。");
}); });
});
it("denies interrupt when the operator lacks agent.cancel permission", async () => { it("denies interrupt when the operator lacks agent.cancel permission", async () => {
// EDIT role can trigger but cannot cancel (agent.cancel requires MANAGE). // EDIT role can trigger but cannot cancel (agent.cancel requires MANAGE).
+2 -110
View File
@@ -1,9 +1,8 @@
import { mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; import { mkdir, mkdtemp, realpath, rm, symlink } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { createAgentSecurityPolicy } from "../../src/agent/security.js"; import { createAgentSecurityPolicy } from "../../src/agent/security.js";
import { importSkillDirectory } from "../../src/agent/skillStore.js";
describe("agent subprocess security policy", () => { describe("agent subprocess security policy", () => {
const roots: string[] = []; const roots: string[] = [];
@@ -27,13 +26,6 @@ describe("agent subprocess security policy", () => {
PATH: "/usr/local/bin:/usr/bin:/bin", PATH: "/usr/local/bin:/usr/bin:/bin",
LANG: "C.UTF-8", LANG: "C.UTF-8",
CPH_BIN: "/usr/local/bin/cph", 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", DATABASE_URL: "postgresql://platform-secret",
FEISHU_APP_SECRET: "feishu-secret", FEISHU_APP_SECRET: "feishu-secret",
HUB_SESSION_SECRET: "session-secret", HUB_SESSION_SECRET: "session-secret",
@@ -46,16 +38,9 @@ describe("agent subprocess security policy", () => {
PATH: "/usr/local/bin:/usr/bin:/bin", PATH: "/usr/local/bin:/usr/bin:/bin",
LANG: "C.UTF-8", LANG: "C.UTF-8",
CPH_BIN: "/usr/local/bin/cph", 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_BASE_URL: "http://127.0.0.1:43123",
ANTHROPIC_AUTH_TOKEN: "run-proxy-capability", ANTHROPIC_AUTH_TOKEN: "run-proxy-capability",
ANTHROPIC_API_KEY: "", 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("DATABASE_URL");
expect(policy.env).not.toHaveProperty("FEISHU_APP_SECRET"); expect(policy.env).not.toHaveProperty("FEISHU_APP_SECRET");
@@ -76,10 +61,7 @@ describe("agent subprocess security policy", () => {
autoAllowBashIfSandboxed: true, autoAllowBashIfSandboxed: true,
allowUnsandboxedCommands: false, allowUnsandboxedCommands: false,
filesystem: { filesystem: {
allowWrite: expect.arrayContaining([ allowWrite: [canonicalWorkspace],
canonicalWorkspace,
"/srv/curriculum-project-hub/typst-packages/para-26071100",
]),
denyRead: ["/"], denyRead: ["/"],
allowRead: expect.arrayContaining([canonicalWorkspace, "/usr/bin"]), allowRead: expect.arrayContaining([canonicalWorkspace, "/usr/bin"]),
}, },
@@ -92,59 +74,6 @@ 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 () => { it("rejects provider environment keys outside the explicit protocol", async () => {
const { workspaceRoot, workspace } = await makeWorkspace(); const { workspaceRoot, workspace } = await makeWorkspace();
@@ -196,43 +125,6 @@ describe("agent subprocess security policy", () => {
})).rejects.toThrow("Agent temp path is too long for sandbox bridge sockets"); })).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 () => { it("rejects a project workspace whose real path escapes the configured workspace root", async () => {
const { root, workspaceRoot } = await makeWorkspace(); const { root, workspaceRoot } = await makeWorkspace();
const outside = join(root, "outside"); const outside = join(root, "outside");
-50
View File
@@ -1,50 +0,0 @@
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
@@ -1,101 +0,0 @@
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);
}
+12 -28
View File
@@ -5,8 +5,6 @@ import { Readable } from "node:stream";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { claudeSdkToolConfigForRole, cphHubMcpToolsForRole } from "../../src/agent/roleTools.js"; import { claudeSdkToolConfigForRole, cphHubMcpToolsForRole } from "../../src/agent/roleTools.js";
import type { FeishuRuntime } from "../../src/feishu/client.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"; import { downloadFeishuMessageResource } from "../../src/feishu/download.js";
const itOnLinux = process.platform === "linux" ? it : it.skip; const itOnLinux = process.platform === "linux" ? it : it.skip;
@@ -14,10 +12,7 @@ const itOnLinux = process.platform === "linux" ? it : it.skip;
describe("Feishu message resource download", () => { describe("Feishu message resource download", () => {
it("exposes the download tool to default and explicitly configured roles", () => { it("exposes the download tool to default and explicitly configured roles", () => {
expect(cphHubMcpToolsForRole(undefined)).toContain("feishu_download_resource"); expect(cphHubMcpToolsForRole(undefined)).toContain("feishu_download_resource");
expect(cphHubMcpToolsForRole(["feishu_download_resource"])).toEqual([ expect(cphHubMcpToolsForRole(["feishu_download_resource"])).toEqual(["feishu_download_resource"]);
"todo_write",
"feishu_download_resource",
]);
expect(claudeSdkToolConfigForRole(["feishu_download_resource"]).allowedTools).toEqual([ expect(claudeSdkToolConfigForRole(["feishu_download_resource"]).allowedTools).toEqual([
"mcp__cph_hub__feishu_download_resource", "mcp__cph_hub__feishu_download_resource",
]); ]);
@@ -29,12 +24,14 @@ describe("Feishu message resource download", () => {
await mkdir(workspaceDir); await mkdir(workspaceDir);
try { try {
const messageGet = vi.fn(async () => ({ data: { items: [{ chat_id: "chat-1" }] } })); const messageGet = vi.fn(async () => ({ data: { items: [{ chat_id: "chat-1" }] } }));
const messageResourceGet = vi.fn(); const messageResourceGet = vi.fn(async () => ({
getReadableStream: () => Readable.from([Buffer.from("image bytes")]),
}));
const rt = mockRuntime(messageGet, messageResourceGet); const rt = mockRuntime(messageGet, messageResourceGet);
const botCli = fakeBotCli();
const result = await downloadFeishuMessageResource( const result = await downloadFeishuMessageResource(
{ messageId: "message-1", fileKey: "img-key-1", resourceType: "image" }, { messageId: "message-1", fileKey: "img-key-1", resourceType: "image" },
{ boundChatId: "chat-1", workspaceRoot, workspaceDir, botCli }, { boundChatId: "chat-1", workspaceRoot, workspaceDir },
rt, rt,
); );
@@ -44,7 +41,10 @@ describe("Feishu message resource download", () => {
); );
expect(result.path).toMatch(/\.png$/); expect(result.path).toMatch(/\.png$/);
await expect(readFile(result.path, "utf8")).resolves.toBe("image bytes"); await expect(readFile(result.path, "utf8")).resolves.toBe("image bytes");
expect(messageResourceGet).not.toHaveBeenCalled(); expect(messageResourceGet).toHaveBeenCalledWith({
params: { type: "image" },
path: { message_id: "message-1", file_key: "img-key-1" },
});
} finally { } finally {
await rm(workspaceRoot, { recursive: true, force: true }); await rm(workspaceRoot, { recursive: true, force: true });
} }
@@ -57,14 +57,10 @@ describe("Feishu message resource download", () => {
await expect(downloadFeishuMessageResource( await expect(downloadFeishuMessageResource(
{ messageId: "message-other", fileKey: "img-key-other", resourceType: "image" }, { 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, rt,
)).rejects.toThrow("current project's bound chat"); )).rejects.toThrow("current project's bound chat");
expect(messageResourceGet).not.toHaveBeenCalled();
}); });
}); });
@@ -93,18 +89,6 @@ 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 { function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
} }
@@ -1,169 +0,0 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { FeishuRuntime } from "../../src/feishu/client.js";
import {
findMarkdownImagesOutsideCode,
maskMarkdownImagesForStreaming,
materializeAnswerSegments,
uploadMessageImage,
} from "../../src/feishu/outboundImages.js";
import { buildAgentCard } from "../../src/feishu/card/builder.js";
const temps: string[] = [];
afterEach(async () => {
await Promise.all(temps.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
describe("outbound markdown image parsing", () => {
it("finds image refs outside fenced code blocks", () => {
const text = [
"See diagram:",
"![plot](https://cdn.example.com/a.png)",
"",
"```md",
"![not-this](https://cdn.example.com/b.png)",
"```",
"![local](assets/x.png \"title\")",
].join("\n");
const refs = findMarkdownImagesOutsideCode(text);
expect(refs.map((ref) => ref.src)).toEqual([
"https://cdn.example.com/a.png",
"assets/x.png",
]);
});
it("masks image urls for streaming cards", () => {
expect(maskMarkdownImagesForStreaming("before ![alt text](https://x/y.png) after")).toBe(
"before alt text after",
);
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", () => {
it("uploads remote and workspace images and builds card segments", async () => {
const workspaceRoot = await mkdtemp(join(tmpdir(), "cph-img-root-"));
temps.push(workspaceRoot);
const workspaceDir = join(workspaceRoot, "project");
await mkdir(join(workspaceDir, "assets"), { recursive: true });
await writeFile(
join(workspaceDir, "assets", "local.png"),
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
);
const imageCreate = vi
.fn()
.mockResolvedValueOnce({ image_key: "img_remote_1" })
.mockResolvedValueOnce({ image_key: "img_local_1" });
const rt = mockRuntime({ imageCreate });
const fetchImpl = vi.fn(async () =>
new Response(Buffer.from("remote-bytes"), {
status: 200,
headers: { "content-type": "image/png" },
}),
);
const text = "Intro\n\n![remote](https://cdn.example.com/r.png)\n\nAnd local ![local](assets/local.png)\nDone.";
const { segments, unresolved } = await materializeAnswerSegments(text, {
rt,
workspaceRoot,
workspaceDir,
fetchImpl: fetchImpl as unknown as typeof fetch,
});
expect(unresolved).toEqual([]);
expect(imageCreate).toHaveBeenCalledTimes(2);
expect(segments).toEqual([
{ type: "markdown", content: "Intro\n\n" },
{ type: "image", imgKey: "img_remote_1", alt: "remote" },
{ type: "markdown", content: "\n\nAnd local " },
{ type: "image", imgKey: "img_local_1", alt: "local" },
{ type: "markdown", content: "\nDone." },
]);
const card = buildAgentCard({
phase: "complete",
text: "",
contentSegments: segments,
reasoningText: undefined,
toolUseSteps: [],
toolUseElapsedMs: undefined,
isError: false,
interrupted: false,
runId: undefined,
});
expect(card.elements).toEqual(expect.arrayContaining([
expect.objectContaining({ tag: "img", img_key: "img_remote_1" }),
expect.objectContaining({ tag: "img", img_key: "img_local_1" }),
expect.objectContaining({ tag: "markdown", content: "Intro\n\n" }),
]));
});
it("rejects private remote hosts", async () => {
const imageCreate = vi.fn();
const rt = mockRuntime({ imageCreate });
const fetchImpl = vi.fn();
const { segments, unresolved } = await materializeAnswerSegments(
"![x](http://127.0.0.1/secret.png)",
{ rt, fetchImpl: fetchImpl as unknown as typeof fetch },
);
expect(fetchImpl).not.toHaveBeenCalled();
expect(imageCreate).not.toHaveBeenCalled();
expect(unresolved).toEqual(["http://127.0.0.1/secret.png"]);
expect(segments).toEqual([{ type: "markdown", content: "x" }]);
});
});
describe("uploadMessageImage", () => {
it("returns image_key from Feishu upload", async () => {
const imageCreate = vi.fn(async () => ({ data: { image_key: "img_nested" } }));
const rt = mockRuntime({ imageCreate });
await expect(uploadMessageImage(rt, Buffer.from("png"))).resolves.toBe("img_nested");
expect(imageCreate).toHaveBeenCalledWith({
data: { image_type: "message", image: Buffer.from("png") },
});
});
});
function mockRuntime(options: {
readonly imageCreate?: (payload: unknown) => Promise<unknown>;
}): FeishuRuntime {
return {
client: {
im: {
v1: {
image: {
create: options.imageCreate ?? vi.fn(),
},
message: {
create: vi.fn(),
reply: vi.fn(),
patch: vi.fn(),
},
},
},
} as unknown as FeishuRuntime["client"],
logger: {
warn: vi.fn(),
error: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
child: vi.fn(),
fatal: vi.fn(),
trace: vi.fn(),
silent: vi.fn(),
level: "info",
} as unknown as FeishuRuntime["logger"],
};
}
+1 -2
View File
@@ -52,7 +52,7 @@ describe("Feishu reactions", () => {
await expect(removeReaction(rt, "message-1", "reaction-1")).resolves.toBe(false); await expect(removeReaction(rt, "message-1", "reaction-1")).resolves.toBe(false);
}); });
it("adds Typing on start and replaces it with CheckMark on success", async () => { it("adds Typing on start and removes it on success", async () => {
const run = deferred<RunResult>(); const run = deferred<RunResult>();
const rt = mockRuntime(); const rt = mockRuntime();
const runAgent = vi.fn((req: RunRequest) => { const runAgent = vi.fn((req: RunRequest) => {
@@ -71,7 +71,6 @@ describe("Feishu reactions", () => {
expect(rt.reactionRequests).toEqual([ expect(rt.reactionRequests).toEqual([
{ kind: "add", messageId: "message-1", emoji: "Typing", reactionId: "reaction-1" }, { kind: "add", messageId: "message-1", emoji: "Typing", reactionId: "reaction-1" },
{ kind: "remove", messageId: "message-1", 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
@@ -1,58 +0,0 @@
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");
});
});

Some files were not shown because too many files have changed in this diff Show More