Compare commits

...

21 Commits

Author SHA1 Message Date
hongjr03 53d372e29b fix: report untracked legacy symlinks 2026-07-11 23:37:25 +08:00
hongjr03 530fcdd2b7 feat: migrate legacy projects through binding search 2026-07-11 23:33:23 +08:00
hongjr03 53998d2651 fix: enable proxy use in new silos 2026-07-11 15:07:28 +08:00
hongjr03 5b55cf18a8 Revert "fix: accept SDK provider capability headers"
This reverts commit e7ad5580ec.
2026-07-11 15:06:48 +08:00
hongjr03 b0d691d53f Revert "fix: pass provider capability as API key"
This reverts commit f065f9f978.
2026-07-11 15:06:48 +08:00
hongjr03 1f48c5b707 Revert "fix: provide capability for both SDK auth modes"
This reverts commit ebf870249f.
2026-07-11 15:06:48 +08:00
hongjr03 12a2f3117f Revert "fix: preserve run provider capability"
This reverts commit 63c86322de.
2026-07-11 15:06:48 +08:00
hongjr03 2ee84d9543 Revert "fix: carry provider capability in dedicated header"
This reverts commit 3087132083.
2026-07-11 15:06:47 +08:00
hongjr03 3087132083 fix: carry provider capability in dedicated header 2026-07-11 15:02:51 +08:00
hongjr03 63c86322de fix: preserve run provider capability 2026-07-11 15:00:34 +08:00
hongjr03 ebf870249f fix: provide capability for both SDK auth modes 2026-07-11 14:59:00 +08:00
hongjr03 f065f9f978 fix: pass provider capability as API key 2026-07-11 14:57:22 +08:00
hongjr03 e7ad5580ec fix: accept SDK provider capability headers 2026-07-11 14:55:29 +08:00
hongjr03 19d942e812 feat: automate managed silo provisioning 2026-07-11 14:53:03 +08:00
hongjr03 e5e923dd34 feat: add repeatable alpha silo setup 2026-07-11 14:30:50 +08:00
hongjr03 df0b12e38b fix: hide per-run cost from Feishu replies 2026-07-11 14:11:59 +08:00
hongjr03 83ec835d4c fix: show Feishu OAuth completion page 2026-07-11 14:07:43 +08:00
hongjr03 6ed56ddfc8 feat: auto-join scoped Feishu OAuth users 2026-07-11 14:00:43 +08:00
hongjr03 3bf643ff4d fix: guide Feishu users through onboarding 2026-07-11 13:52:19 +08:00
hongjr03 d36b00bbec feat: make agent roles and skills dynamic 2026-07-11 12:55:05 +08:00
hongjr03 17c0536958 fix: enable only curated agent skills 2026-07-11 12:25:52 +08:00
73 changed files with 3738 additions and 1857 deletions
+4 -3
View File
@@ -28,9 +28,10 @@
service identity、workspace、keyring 与 Feishu/provider connection;进程必须由
`HUB_SILO_ORGANIZATION_ID` fail-closed 绑定唯一 org,平台后台不开放。共享 SaaS
控制面与 Docker adapter 后置(见 ADR-0025)。
- Agent skill 只来自 Hub release 内审核过的显式 allowlist,以 release-owned 只读 local
plugin 加载;`settingSources: []` 继续禁用项目/用户配置加载。不得把任意 workspace
`.claude` 配置或未审核 skill 变成运行时能力(见 ADR-0018)。
- Agent role 与 skill 是 Organization-scoped 动态运行配置:role 组合 model、system prompt、
tools 与已安装 skillskill 版本进入 content-addressed 持久存储,run 只读加载所选快照。
`settingSources: []` 继续禁用项目/用户配置加载,不得把任意 workspace `.claude` 配置变成
运行时能力(见 ADR-0018)。
## 纪律
@@ -37,6 +37,16 @@ that cursor is the `result.session_id`; store it in `AgentSession.metadata` as
tool surfaces can differ even when the underlying model is the same; `/draft`
and `/review` must not resume the same Claude runtime cursor by accident.
Role definitions are Organization-scoped runtime data. A role bundle selects
its default model, system prompt, tool allowlist and installed Agent skill
versions. PostgreSQL is authoritative for role composition and skill metadata;
skill bytes live in a content-addressed persistent store selected only by the
recorded SHA-256 digest. Updating a role or binding skills takes effect without
a Hub release or process restart. A change to the role's execution surface
(model, prompt, tools, selected skill content) archives its active sessions so
the next run cannot resume a provider context created under stale instructions;
label and ordering-only changes preserve conversational continuity.
Environment variables:
```
ANTHROPIC_BASE_URL=https://openrouter.ai/api
@@ -122,14 +122,16 @@ The boundary is enforced by the Claude Code SDK's built-in sandbox
- `settingSources: []` and strict MCP configuration prevent an untrusted
workspace or service-user config from widening tools, hooks, MCP servers, or
sandbox paths.
- Platform-curated Agent skills are immutable Hub release assets, loaded as a
programmatic local plugin from a release-owned path. The sandbox exposes that
path read-only, and the SDK receives only plugin-qualified allowlist names
through its `skills` option. Filesystem setting sources remain disabled, so a
project cannot register another skill or widen its tools through `.claude`
settings. Requested skill ids are recorded on `run.created`; SDK
initialization/results remain the authoritative evidence that loading
actually succeeded.
- Agent skills are Organization-scoped runtime configuration, not Hub release
assets. A controlled host-console installer imports each version into a
content-addressed persistent store and records its digest in PostgreSQL. A
role selects enabled Organization skills alongside its model, system prompt
and tool allowlist. Each run copies only those selected immutable versions
into a run-scoped plugin outside the project workspace; the sandbox exposes
that snapshot read-only and deletes it after the run. SDK-bundled skills and
filesystem setting sources remain disabled, so project `.claude` content
cannot register skills or widen tools. Requested skill versions are recorded
on `run.created`; SDK initialization remains authoritative loading evidence.
- Network: open (see Open Questions).
`bypassPermissions` is kept (headless server — no interactive prompts); the
Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

+192 -54
View File
@@ -1,81 +1,219 @@
# para-26071100 飞书应用配置清单
# Educraft 组织接入与飞书应用配置指南
本文供 `para-26071100` 的飞书企业管理员操作。不要把 App Secret 粘贴到群聊、工单或本文档中;请通过约定的安全渠道交给平台部署人员
本文供准备接入 Educraft Alpha Silo 的学校、教培机构和组织管理员使用。完成本文后,请把末尾的“部署信息交付单”交给 Educraft 部署人员;我们会为组织创建独立的服务账号、数据库、运行目录和域名入口
## 1. 创建企业自建应用
> **安全提醒:** App Secret、模型 Provider Token 属于密钥,禁止粘贴到飞书群、普通云文档、工单正文或截图中。请只通过双方约定的安全渠道传递。
1. 打开飞书开放平台开发者后台。
2. 在目标企业下创建“企业自建应用”。
3. 应用名称可填写 `Educraft para-26071100`
4. 在“凭证与基础信息”记录:
- App ID(通常以 `cli_` 开头)
- App Secret
5. 添加并启用“机器人”能力。
## 1. 双方分别负责什么
Bot Open ID 不需要管理员手工寻找。平台部署人员会使用 App ID/App Secret 调用飞书 Bot Info API 获取,并在 bootstrap 时校验它确实属于这一个应用。
| 角色 | 负责事项 |
| --- | --- |
| 组织管理员 | 创建企业自建应用、启用机器人、开通最小权限、配置事件、回调和 OAuth 重定向 URL、发布应用、提供 OWNER 身份 |
| Educraft 部署人员 | 分配组织 slug 和域名、部署独立 Silo、加密保存应用及模型密钥、初始化 OWNER、联调和验收 |
| 试点 OWNER | 把机器人加入试点群、创建或绑定项目、组织首轮验收 |
## 2. 开通权限
## 2. 创建企业自建应用
在“权限管理”中搜索并申请下列能力。飞书控制台的中文名称可能随版本调整;如控制台同时显示 scope,可优先核对括号中的 scope
1. 打开[飞书开放平台开发者后台](https://open.feishu.cn/app)
2. 在目标企业下点击“创建企业自建应用”。应用名称建议填写“Educraft + 组织简称”。
3. 进入“凭证与基础信息”,记录 App ID 和 App Secret。
4. 进入“添加应用能力”,添加并启用“机器人”。
- 接收群聊中 @ 机器人的消息(`im:message.group_at_msg:readonly`
- 以应用身份发送消息(`im:message:send_as_bot`
- 获取消息内容,用于读取触发消息和线程上下文(`im:message:readonly`
- 获取与上传图片或文件资源(`im:resource`
- 添加、删除消息表情回复(`im:message.reactions:write_only`
- 获取用户基本信息(`contact:user.base:readonly``contact:user.basic_profile:readonly`
![凭证与基础信息页面;App Secret 默认以星号隐藏](assets/feishu-setup/01-credentials.png)
如果飞书 API 调试台提示某个上述操作缺少更细粒度权限,请把提示截图交给平台部署人员,不要直接勾选通讯录全量读取或其他超出清单的权限
App ID 通常以 `cli_` 开头,可以写入交付单。App Secret 必须通过安全渠道单独发送。Bot Open ID 不需要管理员手工查找;部署程序会用 App ID/App Secret 调用 Bot Info API 获取并校验归属
## 3. 配置事件与卡片回调
## 3. 开通最小权限
1. 进入“事件与回调”
2. 订阅方式选择“使用长连接接收事件”。
3. 添加事件 `im.message.receive_v1`(接收消息)。
4. 启用卡片交互回调 `card.action.trigger`,用于审批、中断运行和群聊建项目按钮。
5. 不需要填写公网 Event Callback URLHub 使用飞书长连接。
进入“权限管理”,点击“开通权限”,搜索并申请以下应用身份权限。控制台中文名称可能调整,请优先核对 scope
## 4. 配置 OAuth 回调
| 用途 | Scope |
| --- | --- |
| 接收群聊中 @ 机器人的消息 | `im:message.group_at_msg:readonly` |
| 以应用身份发送消息 | `im:message:send_as_bot` |
| 读取触发消息和线程上下文 | `im:message:readonly` |
| 获取与上传图片或文件 | `im:resource` |
| 添加、删除消息表情回复 | `im:message.reactions:write_only` |
| 获取用户基本信息 | `contact:user.base:readonly` |
| 获取用户基本资料 | `contact:user.basic_profile:readonly` |
| 通过手机号或邮箱查询 OWNER Open ID | `contact:user.id:readonly` |
域名 DNS 和 TLS 生效后,在安全设置/重定向 URL 中添加:
### 批量导入权限(推荐)
```text
https://para-26071100.educraft.paradigm-edu.net/auth/feishu/callback
在“权限管理”页面点击“批量处理 → 导入”,粘贴以下 JSON 后确认。导入只会新增本次列出的权限,不会删除或影响应用已经申请、开通的其他权限。
```json
{
"scopes": {
"tenant": [
"im:message.group_at_msg:readonly",
"im:message:send_as_bot",
"im:message:readonly",
"im:resource",
"im:message.reactions:write_only",
"contact:user.base:readonly",
"contact:user.basic_profile:readonly",
"contact:user.id:readonly"
],
"user": []
}
}
```
该回调用于 OWNER 登录受控的 Host Console。飞书群机器人长连接本身不依赖这个 URL
Educraft 机器人以应用身份调用上述 API,因此这些 scope 全部放在 `tenant`,不要为了省事把相同权限重复放进 `user`
## 5. 发布并安装应用
![权限管理入口与已开通权限列表](assets/feishu-setup/02-permissions.png)
1. 创建应用版本并提交企业管理员审核
2. 将应用可用范围至少包含试点 OWNER 和试点群成员。
3. 发布版本。
4. 将机器人加入准备试用的飞书群。
如果 API 调试台提示缺少更细粒度权限,请把错误提示和发生时间截图给部署人员。不要自行开通通讯录全量读取等超出本表的权限
## 6. 获取首位 OWNER 身份
## 4. 配置事件与卡片回调
平台 bootstrap 需要 OWNER 的飞书 Open ID 和显示名称。可通过飞书 API 调试台的用户信息接口查询;Open ID 通常以 `ou_` 开头。Union ID 可选,不影响首次部署
进入“事件与回调”
请把以下结果通过安全渠道交给平台部署人员:
1. 在“事件配置”中将订阅方式设为“使用长连接接收事件”。
2. 添加事件“接收消息” `im.message.receive_v1`
3. 在“回调配置”中同样选择长连接。
4. 添加回调“卡片回传交互” `card.action.trigger`,用于审批、运行中断和项目创建/绑定按钮。
![长连接与消息事件配置](assets/feishu-setup/03-events.png)
![卡片交互回调配置](assets/feishu-setup/04-callbacks.png)
这里不需要填写公网 Event Callback URL。Educraft Hub 使用飞书官方 SDK 的长连接模式。
## 5. 配置用户 OAuth 重定向 URL(必需)
普通群成员首次使用前,需要通过飞书 OAuth 建立其在本应用下的用户身份。进入“安全设置 → 重定向 URL”,添加组织专属 callback
```text
Organization: para-26071100
App ID: cli_...
App Secret: (安全渠道发送)
OWNER Open ID: ou_...
OWNER 显示名称:
OWNER Union ID: (可选)
试点群名称: (可选,便于验收)
https://<organization-slug>.educraft.paradigm-edu.net/auth/feishu/callback
```
## 7. 验收动作
例如组织 slug 为 `example-school`
平台通知部署完成后:
```text
https://example-school.educraft.paradigm-edu.net/auth/feishu/callback
```
1. OWNER 打开 Host Console,完成飞书 OAuth 登录。
2. 在试点群中 @机器人发送一条纯文本消息
3. 如果群尚未绑定项目,机器人应返回项目创建/绑定卡片。
4. 创建项目后再次 @机器人,确认出现处理状态、流式卡片和最终回答。
5. 再测试一个小文件附件,以及运行中断按钮。
![在安全设置中添加组织专属 OAuth 重定向 URL](assets/feishu-setup/05-security.png)
任何一步失败时,请保留发生时间、群名、消息截图和飞书 request/log ID;不要在截图中包含 App Secret 或 Provider token
必须使用 Educraft 部署人员最终确认的 slug;不要直接照抄示例。该 URL 用于 OAuth 返回并创建应用作用域下的飞书用户身份,不代表当前已经开放组织管理台
组织专属 OAuth 同时完成身份建立和入组:首次成功登录的用户会自动成为当前 Organization 的 `MEMBER`,回到群聊即可使用。`OWNER``ADMIN` 仍只能由部署人员或管理员显式授予;曾被移除的成员重新登录不会自动恢复资格。
## 6. 发布并安装应用
1. 进入“版本管理与发布”,点击“创建版本”。
2. 将应用可用范围至少覆盖试点 OWNER 和试点群成员。
3. 提交企业管理员审核并发布。
4. 发布成功后,将机器人加入准备试用的群。
![版本管理与发布页面](assets/feishu-setup/06-publish.png)
仅保存开发配置但未发布时,新增权限、事件和可用范围通常不会对试点用户生效。
## 7. 获取首位 OWNER 身份
首次部署必须指定一位组织 OWNER。OWNER 是 Educraft 组织内的初始管理员,不等同于飞书应用所有者;两者可以是同一个人,也可以不同。部署所需的 Open ID 必须由本次创建的企业自建应用查询,因为同一用户在不同应用下的 Open ID 不同,不能复用其他应用查到的值。
### 7.1 确认 OWNER
先确认哪一位企业成员将担任 OWNER。记录其飞书显示名称,并准备在飞书的成员选择器中按姓名找到本人。若企业内有同名成员,选择前须通过部门等信息核对身份。
### 7.2 开通查询权限和数据范围
确认应用已开通上文列出的用户基本信息和用户 ID 权限。如果使用上文的批量导入 JSON,这些权限已包含在内。
应用的通讯录数据范围还必须覆盖这位 OWNER。最小做法是把 OWNER 加入应用可用范围;不需要为此开放全企业通讯录。
### 7.3 在官方接口页面获取 Open ID
1. 打开飞书开放平台的[“获取单个用户信息”接口页面](https://open.feishu.cn/document/server-docs/contact-v3/user/get)。如果使用带 `appId` 参数的页面链接,可以直接进入对应应用;本文不提供固定 App ID,请在页面顶部选择本组织刚创建的企业自建应用,并核对 App ID 与交付单一致。
2. 找到路径参数 `user_id`,点击参数输入框旁的“获取”。
3. 在成员选择器中找到并选择 OWNER;如有同名成员,依据部门等信息确认本人。
4. ID 类型选择 `open_id`。将选择器返回的值填入 `user_id`,并保持查询参数 `user_id_type=open_id`
5. 以应用身份(`tenant_access_token`)调用接口,核对成功响应中 `data.user.name` 与 OWNER 本人一致。
6. 复制完整的 `data.user.open_id` 交给 Educraft 部署人员。Open ID 通常以 `ou_` 开头。
参数旁的“获取”是飞书文档调试台提供的成员选择功能,不是要求管理员预先知道 Open ID。不要复用其他应用查到的 Open ID;同一用户在不同应用下的 Open ID 不同。若无法选择成员或接口调用失败,依次检查:页面当前选择的 App ID、应用可用范围和通讯录数据范围是否覆盖 OWNER、用户基本信息与用户 ID 权限是否已开通并随应用版本发布。
### 7.4 核对并交付
交付前完成以下检查:
- 返回用户的姓名与 OWNER 本人一致;
- Open ID 来自本次组织的这一个 App ID;
- Open ID 完整复制,没有空格或省略号;
- 显示名称使用组织希望在 Educraft 中展示的姓名;
- Union ID 不是必填项,查不到可以留空。
最终向部署人员提供:
```text
OWNER Open IDou_...
OWNER 显示名称:
OWNER Union ID:(可选)
用于查询的 App IDcli_...
```
Open ID 和显示名称可以放在普通交付单中;不要把 App Secret 一起粘贴进去。
## 8. 部署信息交付单
请复制下面的模板填写。标注“安全渠道”的字段不要与普通字段放在同一条群消息或云文档中。
```text
【组织信息】
组织正式名称:
组织简称:
期望 organization slug:(小写字母、数字和连字符,例如 example-school
期望机器人显示名称:
【飞书应用】
App IDcli_...
App Secret:(通过安全渠道单独发送)
应用已发布:是 / 否
机器人能力已启用:是 / 否
消息事件和卡片回调已配置:是 / 否
OAuth 重定向 URL 已配置:是 / 否
【首位 OWNER】
OWNER Open IDou_...
OWNER 显示名称:
OWNER Union ID:(可选)
【试点范围】
试点群名称:(可选,用于验收定位)
初始 Team 名称:(可选;没有 Team 不影响首次部署)
预计试用人数:
【模型配置】
Provider 名称:(例如 OpenRouter
Provider Base URL
Provider Token:(通过安全渠道单独发送)
启用的模型 ID
```
Educraft 部署人员收到信息后,会回传最终 organization slug、访问域名、部署窗口和验收时间。若期望 slug 已被占用或不符合命名规则,会在部署前协调调整。
## 9. 上线验收
部署人员通知服务就绪后,由 OWNER 完成:
1. OWNER 在试点群中 @机器人发送一条纯文本消息
2. 如果群尚未绑定项目,确认机器人返回项目创建/绑定卡片。
3. 创建项目后再次 @机器人,确认出现处理状态、流式卡片和最终回答。
4. 选择一位非 OWNER 试点成员完成 OAuth 登录,确认其自动以 MEMBER 身份加入组织。
5. 该成员在同一群中 @机器人,确认能够进入已绑定项目。
6. 测试一个小文件附件、一次运行中断,以及一个需要生成文档的任务。
出现问题时,请保留发生时间、群名、消息截图和飞书 request/log ID。截图前确认其中不包含 App Secret、Provider Token 或其他密钥。
## 10. Alpha 阶段边界
- 每个组织运行在独立的系统用户、服务实例、数据库和持久化目录中。
- 组织的 role、system prompt、tools 和 skills 是运行时配置,不需要跟随版本发布。
- 同一项目同一时间只执行一个任务,避免并发修改同一个 workspace;组织级并发上限由部署配置决定。
- 当前由 Educraft 人工创建组织、OWNER、Provider Connection 和初始 Team,并通过服务器上的受控管理命令运维;组织管理台尚未开放。
- 非 OWNER 试点成员通过组织专属 OAuth 首次登录后自动成为 MEMBEROWNER/ADMIN 提权和被移除成员的恢复仍需人工操作。
- Alpha 不提供开放注册、自助密钥管理或跨组织资源共享。
@@ -1,5 +0,0 @@
{
"name": "cph-curated",
"description": "Reviewed curriculum-production skills shipped with the Curriculum Project Hub.",
"version": "0.0.1"
}
@@ -1,72 +0,0 @@
---
name: data-processing-spec
description: 物理竞赛实验「数据处理」的两套作答规范——超严格版与考试版。当用户要出实验数据处理题、或要求题目答案/解析"按考试版写""按超严格版写""按严格规范作答",或问"什么是考试版/严格版""不确定度取几位""不确定度怎么修约""连算代入哪个值""拟合要不要算 B 类"等数据处理口径问题时使用。出题与批改时据此确定唯一口径。
---
# 数据处理作答规范(超严格版 / 考试版)
物理竞赛实验数据处理里,有效数字取位、不确定度修约、连算代入、拟合是否计 B 类等环节
**各家做法不一致**。为避免"同一份数据出现多个都对的答案",本课程把这些争议点各拍板成
两套自洽的口径:
| 版本 | 用途 | 一句话特征 |
|------|------|-----------|
| **超严格版** | 严格训练 | 每一步贴近误差理论最规范做法,接受较繁的计算量 |
| **考试版** | 考试 / 日常训练 | 在规范前提下简化计算,贴近竞赛复赛阅卷习惯 |
## 怎么用这个 skill
1. **先确定版本。** 用户出题或批改时通常会说明"按考试版"还是"按超严格版"。
- 用户明确指定 → 用该版。
- 用户没指定 → **必须先问**用户要哪一版,不要自己默认。两版在四处刻意不同,
选错会给出末位不同的答案。
2. **读对应规范全文,再动手。** 选定版本后,完整阅读对应文件,按其中每一条口径生成
题目答案 / 解析 / 评分点:
- 超严格版 → [strict-spec.md](strict-spec.md)
- 考试版 → [exam-spec.md](exam-spec.md)
3. **全程只认一版。** 一道题(含所有小问)自始至终用同一版口径,不得中途混用。
4. **需要解释"为什么有两版""某争议点各方怎么做"时** → 读 [disputes.md](disputes.md)
(中立罗列各方做法与依据,不拍板)。
## 两版差异一览(仅这四处不同)
下面四项是两版**唯一的区别**;其余约定两版完全一致(见下一节)。出题/批改时重点核对这四项。
| 争议环节 | 超严格版 | 考试版 |
|----------|----------|--------|
| **不确定度取几位有效数字** | 首位为 1/2/3 取 2 位,其余取 1 位(A2) | 一律取 1 位(A1) |
| **不确定度的修约方向** | 只进不舍(偏保守,代表:北大) | 四舍六入五凑偶(代表:中科大、第 42 届复赛) |
| **多小问连算代入哪个值** | 代入前一问**未修约的真实值**,仅终值修约 | 代入前一问**已修约的填空值**,接受逐问舍入 |
| **线性拟合不确定度** | A 类 + B 类合成(需算 `u_Bk = u_By / √Σ(xix̄)²` | 只算 A 类(`u_k = σ_k` |
> 测量值(中心值)的修约:**两版都用四舍六入五凑偶**——这一条不是差异项。
## 两版共同约定(不随版本变化)
- **A 类不确定度**:取平均值的实验标准差 `u_A = √[Σ(xix̄)² / (n(n1))]`**不做 t 因子修正**。
- **B 类不确定度**`u_B = Δ仪 / √3`(仪器误差限按均匀分布折算)。合成 `u = √(u_A² + u_B²)`
- **单次测量**:不假设 A 类不确定度为无穷大,**直接以仪器误差限估算**该次测量不确定度
(取 `u = Δ仪 / √3`)。出处:实验指导书"杨氏模量"实验对单次测量量的处理;措辞以本组
实际指导书为准。
- **有效数字总原则**:测量值位数必须与不确定度对齐——不确定度精确到哪一位,测量值就写到哪一位。
- **线性拟合 A 类**:斜率相对不确定度 `σ_k / k = √[ (1/(n2)) · (1/γ² 1) ]`(γ 为相关系数)。
## 出题/批改自检清单
确定版本后,逐项对照所选规范,确保答案在这些点上口径一致:
- [ ] A 类是否用了"不做 t 修正"的标准差公式
- [ ] B 类是否 `Δ仪/√3`;单次测量是否用仪器误差限
- [ ] 不确定度取了几位(A2 还是 A1)—— **按版本**
- [ ] 不确定度末位修约方向(只进不舍 / 四舍六入五凑偶)—— **按版本**
- [ ] 测量值是否与不确定度对齐、是否用四舍六入五凑偶
- [ ] 多小问连算代入的是真实值还是修约值 —— **按版本**
- [ ] 线性拟合是否计 B 类 —— **按版本**
- [ ] 全卷是否始终只用了这一版口径
## 配套 PDF 源码
`scripts/` 下保留了两版规范与争议点讨论的 Typst 源码,仅作为内容参考。当前 Educraft Agent
运行时不提供独立 `typst` 命令,不要尝试直接编译这些脚本,也不要安装运行时依赖。用户需要
成品 PDF 时,明确说明当前能力边界;若内容要进入课程工程,应按 `lesson-project` 的 cph
0.0.2 结构落地并使用 `cph check/build`
@@ -1,51 +0,0 @@
# 数据处理争议点(中立罗列,不拍板)
本文件解释"为什么会有超严格版 / 考试版两套口径"——每个环节各家做法不一致,本课程把它们各
拍板成两版。这里**只中立罗列各方做法与依据**,不评对错。需要给学生/教练讲清来龙去脉时引用。
## 共同约定(无争议前提)
- A 类不确定度:实验标准差,**不做 t 因子修正**。
- B 类不确定度:`u_B = Δ仪 / √3`(均匀分布)。
- 测量值修约:四舍六入五凑偶。
- 有效数字总原则:测量值位数跟着不确定度走(对齐)。
- 单次测量:以仪器误差限估算,不假设 A 类无穷大(出处:实验指导书"杨氏模量"部分)。
## 争议点 A:不确定度取几位有效数字
- **A1(考试版采用)**:一律 1 位。如 `0.034→0.03``0.12→0.1`
- **A2(超严格版采用)**:首位为 1/2/3 时取 2 位,其余取 1 位。如 `0.123→0.12``0.67→0.7`
- 分歧本质:修约不确定度本身引入的相对误差能容忍多大;A2 为压低该相对误差而保留 2 位。
## 争议点 B:有效数字"反向多取一位"变体
-`u=0.03`,再看测量值对齐位数字:≥3(如 1.87)正常对齐写 `(1.87±0.03)`;以 1/2/3 等更小
数起头(如 1.81)则允许测量值再多取一位、不确定度也反向多取一位 → `(1.812±0.034)`
- 与 A1/A2 不完全等价,是 A 的一个更细变体。本课程两版都未采用此变体(统一走 A1 或 A2),
列出仅供识别学生可能用到的写法。
## 争议点 C:不确定度本身如何修约
- **只进不舍(超严格版采用)**:末位一律进位,报告值偏保守。代表:北京大学。
- **四舍六入五凑偶(考试版采用)**:与测量值同一规则。代表:中国科学技术大学、第 42 届复赛。
- 提示:第 42 届全国中学生物理竞赛复赛对不确定度采用四舍六入五凑偶。
## 争议点 D:多小问连算代入哪个值
- **代入未修约真实值(超严格版采用)**:用完整精度中间量,仅终值修约;避免舍入误差传播,
误差理论上更规范。
- **代入已修约填空值(考试版采用)**:用前一问写出来的修约值;便于逐问复算、阅卷可追溯。
- 两者数值通常只差最后一两位,边界情形可能影响终值末位。
## 争议点 E:线性拟合是否计入 B 类
- A 类无争议:`σ_k/k = √[ (1/(n2))·(1/γ²−1) ]`
- **只算 A 类(考试版采用)**:直接 `u_k=σ_k`;相当多题目/教材实际只算 A 类,且常不说明理由。
- **A 类 + B 类合成(超严格版采用)**:把斜率写成 `k=Σci·yi``ci=(xix̄)/Σ(xjx̄)²`
`u_Bk = u_By / √(Σ(xix̄)²)`,再 `u_k=√(σ_k²+u_Bk²)`
- 为何常省略 B 类:点多、Σ(xi−x̄)² 大时 u_Bk 往往远小于 σ_k 被淹没——但这只是近似经验,非普遍成立。
## 速查对照
| 编号 | 争议内容 | 超严格版 | 考试版 |
|------|----------|----------|--------|
| A | 不确定度取几位 | 首位 1/2/3 取 2 位(A2 | 一律 1 位(A1 |
| C | 不确定度修约方向 | 只进不舍 | 四舍六入五凑偶 |
| D | 连算代入值 | 未修约真实值 | 已修约填空值 |
| E | 拟合是否计 B 类 | A 类 + B 类合成 | 只算 A 类 |
> B 项(反向多取一位变体)两版均不采用,故不在版本差异表内。
@@ -1,73 +0,0 @@
# 数据处理规范 · 考试版
> 用于**考试与日常训练**。在保证规范性的前提下**简化计算**(不确定度一律 1 位、拟合只算 A 类、
> 逐问代入修约值),贴近竞赛复赛阅卷习惯。评分以本规范为唯一口径。与超严格版在四处刻意不同
> (有效数字取位、不确定度修约方向、连算代入值、拟合是否计 B 类)——同一份数据两版可能给出
> 末位不同的答案,**全程只认本版,不可混用**。
## 共同约定(两版一致)
### A 类不确定度
多次测量,取平均值的实验标准差:
```
u_A = √[ Σ(xi x̄)² / (n(n1)) ]
```
- **不做 t 因子修正**,直接以上式为 u_A。
### B 类不确定度
- `u_B = Δ仪 / √3`(仪器误差限按均匀分布折算)。
- 合成:`u = √(u_A² + u_B²)`
### 单次测量
- 不假设 A 类不确定度为无穷大,**直接以仪器误差限估算**该次测量不确定度,取 `u = Δ仪 / √3`
- 出处批注:依据实验指导书"杨氏模量"实验对单次测量量的处理;措辞以本组实际指导书为准。
## 有效数字与修约(本版选定口径)
### 有效数字总原则
- 测量值(中心值)的位数**必须与不确定度对齐**:不确定度精确到哪一位,测量值就写到哪一位。
### 不确定度取几位有效数字 —— 统一 1 位
- **不确定度一律保留 1 位有效数字**(无论首位是几)。测量值随之对齐到该位。
- 示例:`u=0.123 → 0.1`,测量值 `1.8127 → 1.8`,记为 `(1.8 ± 0.1)``u=0.067 → 0.07`,对齐到该位。
### 测量值的修约 —— 四舍六入五凑偶
- 测量值采用"四舍六入五凑偶"(逢四舍、逢六入、逢五凑偶)。
### 不确定度的修约 —— 四舍六入五凑偶
- 不确定度也采用"四舍六入五凑偶",与测量值同一规则。
- 提示:第 42 届全国中学生物理竞赛复赛对不确定度即采用四舍六入五凑偶。本版选此口径以贴近
近年复赛阅卷习惯(代表:中科大、第 42 届复赛)。
## 多小问连算 —— 代入上一问修约后的结果
- 后一问用到前一问结果时,**代入前一问已修约、写进答题处的那个值**进行计算。
即接受每问修约带来的舍入误差,换取逐问可复算、便于阅卷。
- 示例:杨氏模量第 1 问报告 `d = 1.8 mm`;第 2 问算 E 时**直接代入 1.8 mm**(而非未修约的 1.8127…)。
## 线性拟合 —— 只算 A 类
`y = k x + b`
- **斜率只计 A 类不确定度,不计 B 类。** 斜率相对不确定度由相关系数 γ 给出:
```
σ_k / k = √[ (1/(n2)) · (1/γ² 1) ]
```
-`u_k = σ_k`,直接作为斜率不确定度上报。
- 说明:数据点多、Σ(xi−x̄)² 较大时拟合的 B 类分量通常远小于 A 类而可忽略,本版据此**只算 A 类**
以简化计算;如需完整合成请改用超严格版。
## 速查(考试版口径)
| 项目 | 本版做法 |
|------|----------|
| A 类不确定度 | 实验标准差,不做 t 修正 |
| B 类不确定度 | Δ仪 / √3 |
| 单次测量 | 以仪器误差限估算 |
| 有效数字 | 不确定度一律 1 位 |
| 测量值修约 | 四舍六入五凑偶 |
| 不确定度修约 | 四舍六入五凑偶 |
| 连算代入 | 代入上一问修约后的结果 |
| 线性拟合 | 只算 A 类 |
@@ -1,83 +0,0 @@
// 共享样式与语义框:两份规范(超严格版 / 考试版)共用
#let rule-color = rgb("#0b4f6c")
#let note-color = rgb("#6a4c00")
#let warn-color = rgb("#b3261e")
// 规范条目框(蓝色):本规范选定的做法
#let rule(body) = block(
width: 100%,
inset: 10pt,
radius: 4pt,
fill: rgb("#eaf2f6"),
stroke: (left: 3pt + rule-color),
body,
)
// 批注 / 出处框(黄色)
#let sidenote(body) = block(
width: 100%,
inset: 9pt,
radius: 4pt,
fill: rgb("#fbf6e8"),
stroke: (left: 3pt + note-color),
text(size: 9.5pt, body),
)
// 提醒框(红色)
#let warn(body) = block(
width: 100%,
inset: 9pt,
radius: 4pt,
fill: rgb("#fdeeec"),
stroke: (left: 3pt + warn-color),
text(size: 9.5pt, body),
)
// 例子框(灰色)
#let example(body) = block(
width: 100%,
inset: 9pt,
radius: 4pt,
fill: rgb("#f3f3f3"),
stroke: (left: 3pt + rgb("#999")),
text(size: 9.5pt, body),
)
// 全局配置 + 封面
#let conf(title: "", subtitle: "", badge: "", badge-color: rgb("#0b4f6c"), doc) = {
set document(title: title, author: "竞赛实验教研组")
set page(
paper: "a4",
margin: (top: 2.4cm, bottom: 2.4cm, left: 2.4cm, right: 2.4cm),
numbering: "1 / 1",
number-align: center,
)
set text(font: ("Noto Serif CJK SC",), size: 10.5pt, lang: "zh", region: "cn")
set par(justify: true, leading: 0.85em, first-line-indent: (amount: 2em, all: true))
show heading: set text(font: ("Noto Sans CJK SC",))
show heading: set block(above: 1.2em, below: 0.7em)
set heading(numbering: "1.1")
show math.equation: set text(font: "New Computer Modern Math")
// 封面
align(center)[
#v(3cm)
#box(
inset: (x: 12pt, y: 6pt),
radius: 6pt,
fill: badge-color,
text(font: ("Noto Sans CJK SC",), size: 13pt, weight: "bold", fill: white, badge),
)
#v(0.9cm)
#text(font: ("Noto Sans CJK SC",), size: 24pt, weight: "bold", title)
#v(0.5cm)
#text(size: 13pt, fill: rgb("#555"), subtitle)
#v(1.4cm)
#text(size: 11pt)[竞赛实验数据处理 · 评分口径规范]
#v(0.3cm)
#text(size: 10pt, fill: rgb("#777"))[供教练出题与学生研读使用]
]
pagebreak()
doc
}
@@ -1,311 +0,0 @@
// 物理竞赛中数据处理的争议点讨论
// 定位:争议点讨论为主,只中立罗列各方做法,不给本课程拍板结论。
#set document(title: "物理竞赛中数据处理的争议点讨论", author: "竞赛实验教研组")
// ---------- 字体与页面 ----------
#set page(
paper: "a4",
margin: (top: 2.4cm, bottom: 2.4cm, left: 2.4cm, right: 2.4cm),
numbering: "1 / 1",
number-align: center,
)
#set text(
font: ("Noto Serif CJK SC",),
size: 10.5pt,
lang: "zh",
region: "cn",
)
#set par(justify: true, leading: 0.85em, first-line-indent: (amount: 2em, all: true))
#show heading: set text(font: ("Noto Sans CJK SC",))
#show heading: set block(above: 1.2em, below: 0.7em)
#set heading(numbering: "1.1")
// 数学字体不指定 CJK,公式用默认 New Computer Modern Math
#show math.equation: set text(font: "New Computer Modern Math")
// ---------- 一些可复用的语义框 ----------
#let dispute-color = rgb("#b3261e")
#let calm-color = rgb("#1b5e20")
#let note-color = rgb("#6a4c00")
// 无争议约定框
#let agreed(body) = block(
width: 100%,
inset: 10pt,
radius: 4pt,
fill: rgb("#eef6ee"),
stroke: (left: 3pt + calm-color),
body,
)
// 争议点框
#let dispute(body) = block(
width: 100%,
inset: 10pt,
radius: 4pt,
fill: rgb("#fdeeec"),
stroke: (left: 3pt + dispute-color),
body,
)
// 批注 / 出处框
#let sidenote(body) = block(
width: 100%,
inset: 9pt,
radius: 4pt,
fill: rgb("#fbf6e8"),
stroke: (left: 3pt + note-color),
text(size: 9.5pt, body),
)
// 各方做法的小标签
#let school(name) = box(
inset: (x: 5pt, y: 1.5pt),
radius: 3pt,
fill: rgb("#e8eef7"),
text(size: 9pt, weight: "bold", name),
)
// ============================================================
// 封面
// ============================================================
#align(center)[
#v(3.2cm)
#text(font: ("Noto Sans CJK SC",), size: 24pt, weight: "bold")[
物理竞赛中数据处理的\
争议点讨论
]
#v(0.6cm)
#text(size: 13pt, fill: rgb("#555"))[—— 不确定度、有效数字与拟合的多种规范对照 ——]
#v(1.4cm)
#text(size: 11pt)[竞赛实验数据处理 · 教研与教学参考]
#v(0.4cm)
#text(size: 10pt, fill: rgb("#777"))[供教练备课与学生研读使用]
]
#pagebreak()
// ============================================================
// 阅读说明
// ============================================================
= 这份文档怎么读
本文档的目的,是把竞赛实验数据处理中那些"两种甚至多种做法都在流传、却没有统一答案"的地方一次性摆清楚。它*不是*一份判定对错的评分标准,而是一份*争议点对照表*
- 凡是本领域已有共识、几乎不会引起争论的内容,归入 #text(fill: calm-color)[*"约定"*](绿色框),作为后续讨论的共同前提;
- 凡是各家(教材、命题、竞赛习惯)做法不一致的地方,单列为 #text(fill: dispute-color)[*"争议点"*](红色框),并尽量中立地列出每一方的做法与其依据;
- 个别需要交代来源或加以提醒的内容,用#text(fill: note-color)[*批注框*](黄色框)标出。
#sidenote[
*关于"中立"。* 本文档对每个争议点*只罗列、不拍板*。哪一套规则作为本课程或某次测验的评分口径,由教练在使用时另行约定并提前告知学生——这一点务必在出题或考试前说清楚,否则同一份数据会出现多个"都对"的答案。
]
// ============================================================
// 第一部分:共同约定
// ============================================================
= 共同约定(基本无争议)
下面几条在我们的处理体系里是稳定的前提,先固定下来,后面讨论争议时不再反复。
== A 类不确定度
多次测量下,A 类不确定度按样本标准差给出(贝塞尔公式给出的实验标准差,再除以 $sqrt(n)$ 得到平均值的标准不确定度):
$ u_A = s(overline(x)) = sqrt(frac(sum_(i=1)^n (x_i - overline(x))^2, n(n-1))) $
#agreed[
*约定 1:A 类不确定度不做 $t$ 因子(学生 $t$ 分布)修正。* 即直接以上式作为 $u_A$,不再乘以与测量次数有关的 $t$ 因子或包含因子。这是本体系的固定口径。
]
== B 类不确定度(多次测量)
B 类不确定度由仪器误差限 $Delta_"仪"$ 给出,按均匀分布折算:
$ u_B = frac(Delta_"仪", sqrt(3)) $
#agreed[
*约定 2B 类不确定度 $= Delta_"仪" \/ sqrt(3)$。* 这里取 $sqrt(3)$ 对应仪器误差在 $plus.minus Delta_"仪"$ 区间内服从均匀分布的假设。
]
合成不确定度按方和根:$u = sqrt(u_A^2 + u_B^2)$
== 测量值的修约方式
#agreed[
*约定 3:测量值(中心值)一律采用"四舍六入五凑偶"修约。* 即逢四舍、逢六入,逢五时看前一位凑成偶数。注意:这一条只针对*测量值*;不确定度本身怎么修约是有争议的(见 @sec:round-u)。
]
#agreed[
*约定 4:测量值的位数必须与不确定度对齐。* 不确定度精确到哪一位,测量值就写到哪一位,不多写也不少写。换言之,*有效数字跟着不确定度走*——这是整个有效数字问题的总原则。争议只在于"不确定度本身取几位"以及"末位怎么修约"。
]
// ============================================================
// 第二部分:单次测量
// ============================================================
= 单次测量的特别约定
多次测量时 A 类、B 类各司其职。但有时只做*单次测量*,此时不能简单地认为"没有重复测量,A 类不确定度就趋于无穷大、结果无法估计"。
#agreed[
*约定 5(单次测量):单次测量时,直接用仪器误差限来估算该次测量的不确定度*,即把 $Delta_"仪" \/ sqrt(3)$(或按所采用规范直接用 $Delta_"仪"$)作为这一次测量结果的不确定度,而*不*假设 A 类不确定度为无穷大。
]
#sidenote[
*出处批注。* 此约定的依据来自*实验指导书中"杨氏模量"实验*的相应章节——该实验对某些只测一次的量(如仪器读数类的单次量)即采用"以仪器误差限估算单次测量误差"的处理。使用本文档时,若所在教学体系的指导书版本不同,请以本组实际采用的指导书"杨氏模量"部分为准核对此条措辞。
]
// ============================================================
// 第三部分:争议点
// ============================================================
= 争议点
以下每一条都没有"唯一正确"的答案。请教练在使用前选定口径并告知学生。
== 争议点 A:不确定度取几位有效数字 <sec:u-digits>
总原则没有争议(约定 4:测量值跟着不确定度对齐)。争议在于*不确定度本身*保留几位有效数字。
#dispute[
*做法 A1:不确定度一律取 1 位有效数字。*
无论首位是几,不确定度都只写 1 位。例如 $u = 0.034 0.03$$u = 0.12 0.1$。对应测量值也只对齐到该位。
*做法 A2:首位为 1、2、3 时取 2 位有效数字,其余取 1 位。*
当不确定度首位较小(1、2、3)时,只留 1 位会带来较大的相对截断,故允许保留 2 位。例如 $u = 0.123 0.12$(首位 1,取 2 位),而 $u = 0.67 0.7$(首位 6,取 1 位)。
]
#sidenote[
A1 A2 的分歧本质,是"修约不确定度本身引入的相对误差能容忍多大"。A2 的"1/2/3 取两位"正是为压低这一相对误差而设。两套都很常见,命题时必须二选一并写明。
]
== 争议点 B:有效数字的"反向多取一位"变体 <sec:reverse-digit>
这是争议点 A 的一个更细的变体,单独列出,因为它对测量值写法的影响最直接。
#dispute[
*做法 B("看测量值末位决定是否多取一位"):*
设不确定度形如 $u = 0.03$。再看测量值在对齐位上的数字:
- 若该位数字 $>= 3$(如测量值 $= 1.87$,末位 7),则*正常对齐*,写成 $(1.87 plus.minus 0.03)$
- 若该位数字以 1、2、3 这类较小数字开头(如测量值 $= 1.81$),则*允许测量值再多取一位*,并*相应地让不确定度也反向多取一位*,写成 $(1.81 plus.minus 0.03) (1.812 plus.minus 0.034)$ 之类。
]
#sidenote[
做法 B 的动机与 A2 一致——都是为了在数值较小时避免过度修约损失精度,只不过 B 是"由测量值末位反推是否多留一位,并让不确定度跟着多留一位"。它与 A1/A2 不完全等价,使用时要明确到底以哪条为准,避免学生在同一题里混用三套规则。
]
== 争议点 C:不确定度本身如何修约 <sec:round-u>
测量值用四舍六入五凑偶已是约定(约定 3)。但*不确定度*的修约方向有分歧。
#dispute[
*做法 C1(只进不舍 / 向上取整):* 不确定度修约时一律*只进不舍*,即末位无论被舍去的部分是多少都进位,使报告的不确定度偏保守(偏大)。
#v(0.3em)
代表口径:#school[北京大学]
*做法 C2(四舍六入五凑偶):* 不确定度与测量值一样,采用四舍六入五凑偶修约。
#v(0.3em)
代表口径:#school[中国科学技术大学] #school[ 42 届物理竞赛复赛]
]
#sidenote[
*特别提示:* 42 届全国中学生物理竞赛复赛对不确定度采用的是*四舍六入五凑偶*(即做法 C2)。若以贴近近年竞赛复赛阅卷习惯为目标,这一点值得在教学时强调;但日常训练里两种都可能遇到,仍以"出题时声明口径"为准。
]
== 争议点 D:多小问连算时,代入哪一个值 <sec:carry-value>
一道大题常有多个小问,前一问的结果会被后一问用到。典型如杨氏模量:第 1 问先求直径 $d$(含不确定度),第 2 问再用 $d$ 求杨氏模量 $E$。问题是:算 $E$ 时代入哪个 $d$
#dispute[
*做法 D1(代入未修约的"真实值"):* 用第 1 问计算过程中得到的*完整精度的 $d$*(小数点后很多位、未做修约)代入后续计算,最后只在终值处统一修约。
#v(0.3em)
依据:修约只应在*最终报告*时进行;中途代入修约值会引入*舍入误差*并逐级传播。从误差理论看这是更规范的做法。
*做法 D2(代入第 1 问已修约的填空值):* 用第 1 *答题卡上已经修约、写进横线里的那个 $d$*(如 $d = 1.81 "mm"$)代入后续计算。
#v(0.3em)
依据:答题与阅卷的可追溯性——后一问的结果应当能由前一问"写出来的答案"复现;某些阅卷口径据此判分。
]
#sidenote[
D1 是误差理论上更干净的做法(避免人为舍入误差累积),D2 则更贴合"按填写值逐问复算"的阅卷便利。两者在数值上通常只差最后一两位,但在边界情形可能影响终值修约后的末位。出题时应明确要求学生采用哪一种,并保持全卷一致。
]
== 争议点 E:线性拟合是否计入 B 类不确定度 <sec:fit>
线性拟合 $y = k x + b$ 中,斜率 $k$ *A 类*不确定度有成熟公式,无争议;争议在于*要不要再算 B 类并合成*
=== A 类(无争议部分)
斜率的 A 类相对不确定度可由相关系数 $gamma$ 表示:
$ frac(sigma_k, k) = sqrt(frac(1, n-2) (frac(1, gamma^2) - 1)) $
其中 $gamma$ 为线性相关系数,$n$ 为数据点个数。这是线性拟合 A 类不确定度的常用表达,本身不引起争议。
#agreed[
*无争议:* 线性拟合斜率的 A 类不确定度套用上面的 $sigma_k \/ k$(用 $gamma$ 表示)公式。
]
=== 争议:要不要再加 B 类
#dispute[
*做法 E1(只算 A 类,不计 B 类):* 直接以拟合给出的 $sigma_k$ 作为斜率不确定度,不再考虑各测量点仪器误差带来的 B 类分量。
#v(0.3em)
现状:*相当多的题目与教材实际上只算 A 类*,而且常常*没有把"为什么忽略 B 类"说清楚*——这正是混乱的来源。
*做法 E2(A 类与 B 类合成):* 认为每个测量点都带有 B 类不确定度,应推导出斜率的 B 类分量后与 A 类方和根合成。
#v(0.3em)
现状:原则上更完整,但*少见教材给出现成公式*,需要自行推导(见下)。
]
=== 线性拟合 B 类不确定度的推导(供采用 E2 时参考)
考虑最小二乘斜率的标准表达
$ k = frac(sum_(i) (x_i - overline(x))(y_i - overline(y)), sum_(i) (x_i - overline(x))^2) = frac(sum_i (x_i - overline(x)) y_i, sum_i (x_i - overline(x))^2). $
$k$ 看成各 $y_i$ 的线性组合 $k = sum_i c_i y_i$,其中权重
$ c_i = frac(x_i - overline(x), sum_j (x_j - overline(x))^2). $
若每个 $y_i$ 带有相互独立的 B 类不确定度 $u_(B,y)$(由纵轴量的仪器误差限给出,$u_(B,y) = Delta_("仪",y) \/ sqrt(3)$,且各点近似相同),按不确定度传播:
$ u_(B,k) = sqrt(sum_i c_i^2 u_(B,y)^2) = u_(B,y) sqrt(sum_i c_i^2) = frac(u_(B,y), sqrt(sum_i (x_i - overline(x))^2)). $
*斜率的 B 类不确定度等于纵轴单点 B 类不确定度,除以自变量的"离差平方和的平方根"* $sqrt(sum_i (x_i-overline(x))^2)$
如横轴量 $x$ 的仪器误差也不可忽略,可类似地把它折算到 $y$ 方向(乘以斜率 $k$)后并入 $u_(B,y)$;此处从略。最终斜率的合成不确定度为
$ u_k = sqrt(sigma_k^2 + u_(B,k)^2). $
#sidenote[
*为什么会有 E1 这种"只算 A 类"的现状?* 当数据点较多、且离差平方和 $sum_i (x_i-overline(x))^2$ 较大时,$u_(B,k) = u_(B,y) \/ sqrt(sum_i (x_i-overline(x))^2)$ 往往远小于 A 类的 $sigma_k$,于是 B 类被"淹没"而省略。但这只是*近似成立的经验*,并非普遍正确——所以是否计入 B 类、以及是否声明忽略理由,仍是一个需要出题时明确的争议点。
]
// ============================================================
// 速查表
// ============================================================
#pagebreak()
= 争议点速查表
#table(
columns: (auto, 1fr, 1fr),
inset: 8pt,
align: (left + horizon, left, left),
stroke: 0.5pt + rgb("#cccccc"),
fill: (_, row) => if row == 0 { rgb("#e8eef7") } else { white },
table.header(
[*编号*], [*争议内容*], [*主要分歧 / 代表口径*],
),
[A], [不确定度取几位有效数字], [A1 一律 1 位 / A2 首位为 1·2·3 时取 2 ],
[B], [有效数字"反向多取一位"变体], [测量值末位 ≥3 正常对齐;以 1·2·3 起更小时,测量值与不确定度同时多取一位],
[C], [不确定度本身如何修约], [C1 只进不舍(北大) / C2 四舍六入五凑偶(中科大、第 42 届复赛)],
[D], [多小问连算代入哪个值], [D1 代入未修约真实值(误差理论更规范) / D2 代入第一问已修约的填空值(便于复算阅卷)],
[E], [线性拟合是否计入 B ], [E1 只算 A 类(常见但常不说明理由) / E2 A 类与 B 类合成(需自行推导 $u_(B,k)=u_(B,y)\/sqrt(sum (x_i-overline(x))^2)$],
)
#v(0.6em)
#sidenote[
*使用建议:* 每次出题或测验前,针对表中 AE 各项各选定一种口径,连同"A 类不做 $t$ 修正""$u_B=Delta_"仪"\/sqrt(3)$""单次测量以仪器误差限估算"等约定一并写在卷首说明里。口径一旦公布,全卷保持一致,避免同一份数据出现多个"都对"的答案。
]
@@ -1,115 +0,0 @@
#import "conf.typ": conf, rule, sidenote, warn, example
#show: conf.with(
title: "数据处理规范\n考试版",
subtitle: "—— 贴近竞赛阅卷习惯、计算量适中的实用口径 ——",
badge: "考试版",
badge-color: rgb("#0b4f6c"),
)
= 规范定位
本规范用于*考试与日常训练*场景,在保证规范性的前提下*简化计算*(不确定度一律 1 位、拟合只算 A 类、逐问代入修约值),贴近竞赛复赛的阅卷习惯。学生应严格按本规范作答;评分以本规范为唯一口径。
#warn[
本规范与《超严格版》在四处刻意不同(有效数字取位、不确定度修约方向、连算代入值、拟合是否计 B 类)。同一份数据在两版下可能给出末位不同的答案,*出题与作答务必只认其中一版*,不可混用。
]
= 共同约定(两版一致)
== A 类不确定度
多次测量,A 类不确定度取平均值的实验标准差:
$ u_A = sqrt(frac(sum_(i=1)^n (x_i - overline(x))^2, n(n-1))) $
#rule[*A 类不确定度不做 $t$ 因子修正*,直接以上式为 $u_A$]
== B 类不确定度
#rule[*B 类不确定度 $u_B = Delta_"仪" \/ sqrt(3)$*(仪器误差限按均匀分布折算)。]
合成:$u = sqrt(u_A^2 + u_B^2)$
== 单次测量
#rule[
*单次测量*时,不假设 A 类不确定度为无穷大,*直接以仪器误差限估算该次测量的不确定度*(取 $u = Delta_"仪" \/ sqrt(3)$)。
]
#sidenote[
*出处批注:* 此条依据实验指导书"杨氏模量"实验对单次测量量的处理。使用时以本组实际采用的指导书"杨氏模量"部分为准核对措辞。
]
= 有效数字与修约(本版选定口径)
== 有效数字总原则
#rule[*测量值(中心值)的位数必须与不确定度对齐*:不确定度精确到哪一位,测量值就写到哪一位。]
== 不确定度取几位有效数字 —— 统一 1 位
#rule[
*不确定度一律保留 1 位有效数字*(无论首位是几)。测量值随之对齐到该位。
]
#example[
$u = 0.123 0.1$,测量值 $1.8127 1.8$,记为 $(1.8 plus.minus 0.1)$ $u = 0.067 0.07$,对齐到该位。
]
== 测量值的修约 —— 四舍六入五凑偶
#rule[*测量值采用"四舍六入五凑偶"修约*(逢四舍、逢六入、逢五凑偶)。]
== 不确定度的修约 —— 四舍六入五凑偶
#rule[
*不确定度也采用"四舍六入五凑偶"修约*,与测量值同一规则。
]
#sidenote[
*提示:* 42 届全国中学生物理竞赛复赛对不确定度即采用四舍六入五凑偶。本版选此口径以贴近近年复赛阅卷习惯(代表:中科大、第 42 届复赛)。
]
= 多小问连算 —— 代入上一问修约后的结果
#rule[
大题分多小问、后一问要用到前一问结果时,*代入前一问已修约、写进答题处的那个值*进行计算。即*接受每一问修约带来的舍入误差*,换取逐问可复算、便于阅卷。
]
#example[
杨氏模量:第 1 问报告 $d = 1.8 "mm"$。第 2 问算 $E$ *直接代入 $d = 1.8 "mm"$*(而非未修约的 $1.8127...$)。
]
= 线性拟合 —— 只算 A 类
$y = k x + b$
#rule[
*线性拟合斜率只计 A 类不确定度,不计 B 类。* 斜率相对不确定度由相关系数 $gamma$ 给出:
$ frac(sigma_k, k) = sqrt(frac(1, n-2) (frac(1, gamma^2) - 1)). $
$u_k = sigma_k$,直接作为斜率不确定度上报。
]
#sidenote[
当数据点较多、自变量离差平方和 $sum_i (x_i-overline(x))^2$ 较大时,拟合的 B 类分量通常远小于 A 类而可忽略。本版据此*只算 A 类*以简化计算;如需完整合成请改用《超严格版》。
]
= 速查(考试版口径)
#table(
columns: (auto, 1fr),
inset: 8pt,
align: (left + horizon, left),
stroke: 0.5pt + rgb("#cccccc"),
fill: (_, row) => if row == 0 { rgb("#e3edf2") } else { white },
table.header([*项目*], [*本版做法*]),
[A 类不确定度], [实验标准差,不做 $t$ 修正],
[B 类不确定度], [$Delta_"仪" \/ sqrt(3)$],
[单次测量], [以仪器误差限估算],
[有效数字], [不确定度一律 1 ],
[测量值修约], [四舍六入五凑偶],
[不确定度修约], [四舍六入五凑偶],
[连算代入], [代入上一问修约后的结果],
[线性拟合], [只算 A ],
)
@@ -1,130 +0,0 @@
#import "conf.typ": conf, rule, sidenote, warn, example
#show: conf.with(
title: "数据处理规范\n超严格版",
subtitle: "—— 每一步都按误差理论最规范的方法处理 ——",
badge: "超严格版",
badge-color: rgb("#7a1f1f"),
)
= 规范定位
本规范用于*严格训练*场景,目标是让每一步都贴近误差理论上最规范的做法,*接受较繁的计算量以换取处理的严谨性*。学生应严格按本规范作答;评分以本规范为唯一口径。
#warn[
本规范与《考试版》在四处刻意不同(有效数字取位、不确定度修约方向、连算代入值、拟合是否计 B 类)。同一份数据在两版下可能给出末位不同的答案,*出题与作答务必只认其中一版*,不可混用。
]
= 共同约定(两版一致)
== A 类不确定度
多次测量,A 类不确定度取平均值的实验标准差:
$ u_A = sqrt(frac(sum_(i=1)^n (x_i - overline(x))^2, n(n-1))) $
#rule[*A 类不确定度不做 $t$ 因子修正*,直接以上式为 $u_A$]
== B 类不确定度
#rule[*B 类不确定度 $u_B = Delta_"仪" \/ sqrt(3)$*(仪器误差限按均匀分布折算)。]
合成:$u = sqrt(u_A^2 + u_B^2)$
== 单次测量
#rule[
*单次测量*时,不假设 A 类不确定度为无穷大,*直接以仪器误差限估算该次测量的不确定度*(取 $u = Delta_"仪" \/ sqrt(3)$)。
]
#sidenote[
*出处批注:* 此条依据实验指导书"杨氏模量"实验对单次测量量的处理。使用时以本组实际采用的指导书"杨氏模量"部分为准核对措辞。
]
= 有效数字与修约(本版选定口径)
== 有效数字总原则
#rule[*测量值(中心值)的位数必须与不确定度对齐*:不确定度精确到哪一位,测量值就写到哪一位。]
== 不确定度取几位有效数字 —— 采用 A2
#rule[
*不确定度首位为 1、2、3 时保留 2 位有效数字;首位为 4\~9 时保留 1 位。*
]
#example[
$u = 0.123 0.12$(首位 1,取 2 位); $u = 0.067 0.07$(首位 6,取 1 位); $u = 0.28 0.28$(首位 2,取 2 位)。
]
== 测量值的修约 —— 四舍六入五凑偶
#rule[*测量值一律采用"四舍六入五凑偶"修约*(逢四舍、逢六入、逢五凑偶)。]
== 不确定度的修约 —— 只进不舍
#rule[
*不确定度修约时一律"只进不舍"*:在保留位之后只要有非零数字(乃至向上保守),末位即进位,使报告的不确定度偏保守(偏大)。
]
#example[
$u = 0.121 0.13$(保留 2 位,末位进 1); $u = 0.341 0.4$(保留 1 位,进位)。
]
#sidenote[
"只进不舍"是较保守的口径(代表:北京大学)。它确保报告的不确定度不会因修约而偏小。
]
= 多小问连算 —— 代入未修约真实值
#rule[
大题分多小问、后一问要用到前一问结果时,*一律代入前一问计算所得的完整精度数值(未修约的"真实值")*,仅在每问*最终报告*时按上面的规则修约。中途不得代入已修约的填空值。
]
#example[
杨氏模量:第 1 问算得 $d = 1.8127... "mm"$(报告时修约为 $1.81 "mm"$)。第 2 问算 $E$ *代入 $d = 1.8127...$*,而非 $1.81$,避免逐级累积舍入误差。
]
= 线性拟合 —— A 类与 B 类合成
$y = k x + b$
== A 类
斜率 A 类相对不确定度由相关系数 $gamma$ 表示:
$ frac(sigma_k, k) = sqrt(frac(1, n-2) (frac(1, gamma^2) - 1)) $
== B 类(本版必须计入)
把斜率写成各 $y_i$ 的线性组合 $k = sum_i c_i y_i$,权重 $c_i = (x_i - overline(x)) \/ sum_j (x_j - overline(x))^2$。设各点纵轴 B 类不确定度近似相同、为 $u_(B,y) = Delta_("仪",y)\/sqrt(3)$,按传播:
$ u_(B,k) = u_(B,y) sqrt(sum_i c_i^2) = frac(u_(B,y), sqrt(sum_i (x_i - overline(x))^2)). $
#rule[
*斜率不确定度取 A 类与 B 类的方和根*
$ u_k = sqrt(sigma_k^2 + u_(B,k)^2), wide u_(B,k) = frac(u_(B,y), sqrt(sum_i (x_i - overline(x))^2)). $
]
#sidenote[
若横轴量 $x$ 的仪器误差不可忽略,可乘以斜率 $k$ 折算到 $y$ 方向后并入 $u_(B,y)$。当数据点多、$sum_i (x_i-overline(x))^2$ 大时 $u_(B,k)$ 往往很小,但本版*不因此省略*,一律计入。
]
= 速查(超严格版口径)
#table(
columns: (auto, 1fr),
inset: 8pt,
align: (left + horizon, left),
stroke: 0.5pt + rgb("#cccccc"),
fill: (_, row) => if row == 0 { rgb("#f0e6e6") } else { white },
table.header([*项目*], [*本版做法*]),
[A 类不确定度], [实验标准差,不做 $t$ 修正],
[B 类不确定度], [$Delta_"仪" \/ sqrt(3)$],
[单次测量], [以仪器误差限估算],
[有效数字], [首位 1/2/3 2 位,其余 1 位(A2],
[测量值修约], [四舍六入五凑偶],
[不确定度修约], [只进不舍],
[连算代入], [代入未修约真实值],
[线性拟合], [A + B 类合成],
)
@@ -1,85 +0,0 @@
# 数据处理规范 · 超严格版
> 用于**严格训练**。目标:每一步贴近误差理论上最规范的做法,**接受较繁的计算量以换取严谨性**。
> 评分以本规范为唯一口径。与考试版在四处刻意不同(有效数字取位、不确定度修约方向、连算代入值、
> 拟合是否计 B 类)——同一份数据两版可能给出末位不同的答案,**全程只认本版,不可混用**。
## 共同约定(两版一致)
### A 类不确定度
多次测量,取平均值的实验标准差:
```
u_A = √[ Σ(xi x̄)² / (n(n1)) ]
```
- **不做 t 因子修正**,直接以上式为 u_A。
### B 类不确定度
- `u_B = Δ仪 / √3`(仪器误差限按均匀分布折算)。
- 合成:`u = √(u_A² + u_B²)`
### 单次测量
- 不假设 A 类不确定度为无穷大,**直接以仪器误差限估算**该次测量不确定度,取 `u = Δ仪 / √3`
- 出处批注:依据实验指导书"杨氏模量"实验对单次测量量的处理;措辞以本组实际指导书为准。
## 有效数字与修约(本版选定口径)
### 有效数字总原则
- 测量值(中心值)的位数**必须与不确定度对齐**:不确定度精确到哪一位,测量值就写到哪一位。
### 不确定度取几位有效数字 —— 采用 A2
- **首位为 1、2、3 时保留 2 位有效数字;首位为 4~9 时保留 1 位。**
- 示例:`u=0.123 → 0.12`(首位 1,取 2 位);`u=0.067 → 0.07`(首位 6,取 1 位);`u=0.28 → 0.28`(首位 2,取 2 位)。
### 测量值的修约 —— 四舍六入五凑偶
- 测量值一律采用"四舍六入五凑偶"(逢四舍、逢六入、逢五凑偶)。
### 不确定度的修约 —— 只进不舍
- 不确定度修约时一律**只进不舍**:保留位之后只要有非零数字即向上进位,使报告值偏保守(偏大)。
- 示例:`u=0.121 → 0.13`(保留 2 位,进位);`u=0.341 → 0.4`(保留 1 位,进位)。
- 说明:"只进不舍"是较保守口径(代表:北京大学),确保报告的不确定度不因修约而偏小。
## 多小问连算 —— 代入未修约真实值
- 后一问用到前一问结果时,**一律代入前一问计算所得的完整精度数值(未修约的真实值)**,
仅在每问**最终报告**时修约。中途不得代入已修约的填空值。
- 示例:杨氏模量第 1 问算得 `d = 1.8127… mm`(报告修约为 `1.81 mm`);第 2 问算 E 时
**代入 1.8127…**,而非 1.81,避免逐级累积舍入误差。
## 线性拟合 —— A 类与 B 类合成
`y = k x + b`
### A 类
斜率 A 类相对不确定度由相关系数 γ 表示:
```
σ_k / k = √[ (1/(n2)) · (1/γ² 1) ]
```
### B 类(本版必须计入)
把斜率写成各 yi 的线性组合 `k = Σ ci·yi`,权重 `ci = (xi x̄) / Σ(xj x̄)²`
设各点纵轴 B 类不确定度近似相同 `u_By = Δ仪,y / √3`,按传播:
```
u_Bk = u_By · √(Σ ci²) = u_By / √( Σ(xi x̄)² )
```
### 斜率不确定度(本版上报值)
```
u_k = √( σ_k² + u_Bk² ), u_Bk = u_By / √( Σ(xi x̄)² )
```
- 若横轴量 x 的仪器误差不可忽略,可乘以斜率 k 折算到 y 方向后并入 u_By。
- 即使数据点多、Σ(xi−x̄)² 大致使 u_Bk 很小,本版**也不省略**,一律计入。
## 速查(超严格版口径)
| 项目 | 本版做法 |
|------|----------|
| A 类不确定度 | 实验标准差,不做 t 修正 |
| B 类不确定度 | Δ仪 / √3 |
| 单次测量 | 以仪器误差限估算 |
| 有效数字 | 首位 1/2/3 取 2 位,其余 1 位(A2 |
| 测量值修约 | 四舍六入五凑偶 |
| 不确定度修约 | 只进不舍 |
| 连算代入 | 代入未修约真实值 |
| 线性拟合 | A 类 + B 类合成 |
@@ -1,24 +0,0 @@
---
name: lesson-project
description: 把项目根目录 outline.md 落成符合 cph 0.0.2 的结构化讲义工程,并用 cph check/build 验证和生成教师版、学生版 PDF。
---
# 把 outline.md 落成 cph 0.0.2 工程
只在当前项目 workspace 内工作。先完整阅读 `outline.md`,再依次阅读本 skill 的 `structure.md``templates.md``workflow.md``writing-style.md`
## 不可违反的边界
- 当前唯一工程清单是 `manifest.toml`,版本契约是 `.cph-version`;不要创建旧格式 `project.toml``info.toml` 或根 `main.typ`
- element 只允许 `segment``lemma``example``sop`,字段以 `structure.md` 为准。
- 不生成 commentary、hint、answer、instruction、handout、summary 等 cph 0.0.2 不支持的字段。
- 忠实于 outline;缺题面、公式或关键结论时询问用户,不擅自补写。
- 使用 `cph check .` 验证结构,使用 `cph build . --target student``cph build . --target teacher` 构建;不要直接调用 `typst compile`
- 任一命令失败都保留完整错误并修复根因,不删除内容来糊绿。
## 完成标准
1. `cph check .` 为 0 errors。
2. 两个 `cph build` 命令退出码为 0。
3. 产物位于 `build/student.pdf``build/teacher.pdf`
4. 简报列出落地的 element、仍需用户补充的内容和两份 PDF 路径。
@@ -1,252 +0,0 @@
# 写得好的样例片段
本文件从两份现行讲义里抽取代表性片段,按 element 类型分类。看这些片段是为了对齐"写出来
就该是这样"的标准。文风、连贯性、推导风、归宿判断都靠这些样例校准——[writing-style.md]
讲方法论,本文件给出对应方法论的具体落地。
样例出处:
- EM-131 保角变换法(学生版讲义)
- 简正模(第 19 章)
---
## segment:物理引入的范例
样例摘自简正模 §19.1.1 动能的表示。
> 要考察一个多自由度体系在平衡位置附近的小振动,一种普适的方法是写出体系的动能和势能
> 然后代入拉格朗日方程。这里我们假设体系的广义坐标的为 $q_1, q_2, \dots, q_n$,那么动能
> 一定可以写为
>
> $$T = \frac{1}{2}\sum_{i,j} f_{i,j}(q_1, q_2, \dots, q_n)\,\dot q_i \dot q_j .$$
>
> 其中 $f_{i,j}$ 是一个关于广义坐标的函数。例如当我们选择极坐标系描述二维空间中的运动
> 的时候,有
>
> $$T = \tfrac{1}{2} m\dot r^2 + \tfrac{1}{2} m r^2 \dot\theta^2 ,$$
>
> 可见 $\dot\theta^2$ 对应的 $f$ 为 $mr^2$。考虑到这里我们考虑的振动是在平衡位置附近的
> 小振动,广义坐标的导数 $\dot q_i$ 是小量,而在振动过程中 $f$ 的改变是一阶的,因此如
> 果仅仅保留到二阶小量,我们可以将上式改写为 ……(接下来矩阵化、对角化)
**为什么写得好**:第一句直接给出物理设置(多自由度小振动 + 普适方法)。引入一般动能形式
之后立刻举一个最简单的极坐标例子让公式落地,然后顺着"小振动→二阶小量"的物理逻辑推进到
矩阵化。整段没有"接下来要做的是""本节的核心是""为后面 X 节铺垫"这类编排话——下一步是
什么由物理决定,不需要预告。
---
## segment:概念串联的范例
样例摘自保角变换 §1.2 复势的定义。
> 考虑一个二维静电场问题,电势 $\varphi(x,y)$ 满足拉普拉斯方程。由上一节的讨论,必然
> 存在一个共轭调和函数 $\psi(x,y)$,使得 $\varphi$ 和 $\psi$ 共同构成一个解析函数
>
> $$W(z) = \varphi(x,y) + \mathrm{i}\psi(x,y),$$
>
> 称为复势。其中 $\varphi$ 为电势,$\psi$ 为流函数,电通量则正比于两条流线的流函数差值。
> 等势线 $\varphi = \text{const}$ 与电力线 $\psi = \text{const}$ 处处正交,这与式 (3)
> 的几何意义完全吻合。
>
> 从复势中提取电场只需要做一次求导。对上式求导得到
>
> $$\frac{\mathrm{d}W}{\mathrm{d}z} = \frac{\partial\varphi}{\partial x} + \mathrm{i}\frac{\partial\psi}{\partial x} = -E_x + \mathrm{i}E_y,$$
>
> 其中最后一步利用了 $E_x = -\partial\varphi/\partial x$ 以及式 (3) 给出的 $\partial\psi/\partial x = -\partial\varphi/\partial y = E_y$。
**为什么写得好**:用"由上一节的讨论""这与式 (3) 的几何意义完全吻合""利用了式 (3)"三次
回引前文,每一次都是物理推导中真正用到了前文结论。回引方式简洁、点到为止,不展开复述。
对比之下,错误的回引是"还记得我们在第 X 节讲的那个图吗,这里就是它的回扣"。
---
## lemma stmt:简洁陈述的范例
样例摘自保角变换 §1.1 末,柯西-黎曼条件的引出。
> 设复变量 $z = x + \mathrm{i}y$,考虑复变函数 $f(z) = u(x,y) + \mathrm{i}v(x,y)$
> 其中 $u$ 和 $v$ 是两个实值函数。我们要求 $f$ 的导数在复平面上处处存在且与求导方向
> 无关。沿实轴方向求导给出 ……,而沿虚轴方向求导给出 ……,两个表达式的实部和虚部分别
> 相等,立即得到柯西-黎曼条件
>
> $$\frac{\partial u}{\partial x} = \frac{\partial v}{\partial y}, \qquad \frac{\partial u}{\partial y} = -\frac{\partial v}{\partial x}.$$
>
> 满足此式的函数称为解析函数。从此式可以读出一个重要的几何性质:$u$ 的梯度与 $v$ 的
> 梯度正交。这意味着 $u = \text{const}$ 与 $v = \text{const}$ 两族曲线处处正交。
**为什么写得好**:定理陈述(柯西-黎曼条件)由前面的物理设置自然推出,给出公式之后用一
两句话陈述它的几何含义。整段没有任何"这是核心定理""务必掌握""非常重要"的元评论,几何
含义陈述本身就是对定理意义的最好说明。
---
## lemma proof:纯推导的范例
样例摘自简正模 §19.1.3,证明 $\frac{\partial}{\partial q_i}\bigl(\tfrac{1}{2}\boldsymbol{q}^\mathrm{T}\boldsymbol{M}\boldsymbol{q}\bigr)\boldsymbol{e}_i = \boldsymbol{M}\boldsymbol{q}$。
> 将被求导的式子展开,为
>
> $$\tfrac{1}{2}\boldsymbol{q}^\mathrm{T}\boldsymbol{M}\boldsymbol{q} = \sum_{i,j}\tfrac{1}{2} m_{ij} q_i q_j = \sum_i \sum_j \tfrac{1}{2} m_{ij} q_i q_j .$$
>
> 考察其中与 $q_i$ 有关的部分,有可能是第一个求和取 $i$,可能是第二个求和取 $i$,也可
> 能是两个求和都取 $i$,把这三类相加为
>
> $$\sum_{j\neq i}\tfrac{1}{2} m_{ij} q_i q_j + \sum_{j\neq i}\tfrac{1}{2} m_{ji} q_j q_i + \tfrac{1}{2} m_{ii} q_i^2 .$$
>
> 代回原式得到
>
> $$\text{left side} = \frac{\partial}{\partial q_i}\Bigl[\sum_{j\neq i}\tfrac{1}{2} m_{ij} q_i q_j + \sum_{j\neq i}\tfrac{1}{2} m_{ji} q_j q_i + \tfrac{1}{2} m_{ii} q_i^2\Bigr]\boldsymbol{e}_i$$
> $$= \sum_{j\neq i}\bigl[\tfrac{1}{2} m_{ij} q_j + \tfrac{1}{2} m_{ji} q_j\bigr]\boldsymbol{e}_i + m_{ii} q_i \boldsymbol{e}_i$$
> $$= \sum_j m_{ij} q_j \boldsymbol{e}_i = \boldsymbol{M}\boldsymbol{q} ,$$
>
> 倒数第二个等号利用了 $\boldsymbol{M}$ 作为对称矩阵的性质。
**为什么写得好**:整段就是一连串公式加最短衔接词——"展开为""考察……部分""相加为""代回
得到""利用了……的性质"。没有"我们要做的第一步是……""现在我们考虑……""注意到这一步非常
关键……"这类讲解语言。推导自身的逻辑就是叙事,不需要再多一层元叙述。
---
## lemma proof:含分步推导的范例
样例摘自简正模 §19.1.1 末段(动能对角化的几步推进)。
> 显然我们可以适当分配交叉项使得 $\boldsymbol{M}$ 是一个对称矩阵,这意味着它可对角化。
> 令 $\boldsymbol{M}$ 的对角化形式为
>
> $$\boldsymbol{M} = \boldsymbol{P}\boldsymbol{\Lambda}\boldsymbol{P}^{-1} .$$
>
> 此时动能可以改写为
>
> $$T = \dot{\boldsymbol{q}}^\mathrm{T} \boldsymbol{P}\boldsymbol{\Lambda}\boldsymbol{P}^{-1} \dot{\boldsymbol{q}} .$$
>
> 定义新的广义坐标
>
> $$\boldsymbol{q}^* = \boldsymbol{P}^{-1} \boldsymbol{q} ,$$
>
> 又由于 $\boldsymbol{P}^{-1}$ 的每一行都是 $\boldsymbol{M}$ 的本征矢量 $\boldsymbol{x}_i$
> 也可以得到新广义坐标的各个分量为
>
> $$q_i^* = \boldsymbol{x}_i \cdot \boldsymbol{q}_i .$$
>
> 若令 $\boldsymbol{M}$ 的本征值为 $m_i$,则可以将动能写为不含广义坐标交叉项的形式,即
>
> $$T = \sum_i \tfrac{1}{2} m_i (\dot q_i^*)^2 .$$
**为什么写得好**:每一步都是一行"陈述 + 公式",陈述部分极短("令 $\boldsymbol{M}$ 的
对角化形式为""定义新的广义坐标""若令 $\boldsymbol{M}$ 的本征值为 $m_i$"),公式紧跟。
六个公式块用五个衔接句串起来,每个衔接句平均不到 10 字。
---
## example problem:题面紧凑的范例
样例摘自保角变换 EM131.14、EM131.18。
> 例 EM131.14:空间中有两个半径分别为 $R_1$ 和 $R_2$ 的一大一小两个圆柱,其中心间距
> 为 $D$,试在 $D < R_2 - R_1$ 的条件下计算两个圆柱之间的电容。
> 例 EM131.18:有一个半长轴为 $A$、短半轴为 $B$ 的无限长导体椭圆柱,将其置于沿长轴方
> 向的均匀外电场 $E_0$ 中,试求椭圆柱外的电势分布和表面电荷密度。
**为什么写得好**:题面只给"物理设置 + 所求量"两件事,参数齐全、约束条件齐全。没有"为了
练习……""下面这道题考察……""请同学们仔细思考"等元描述。
---
## example solution:纯推导的范例
样例摘自简正模例题 19.4。
> 解:不论通过对角化矩阵还是加减消元都可以很容易得到简正坐标为
>
> $$\xi_{1,2} = x_1 \pm x_2 .$$
**为什么写得好**:solution 可以很短——所求量直接由前面建立的方法得到的话,给出结果即可,
不必为了凑字数把方法再讲一遍。"不论通过对角化矩阵还是加减消元"这句话指明可走的路径,
然后立刻给结果。
---
## example solution:分步推导的范例
样例摘自简正模例题 19.5(含约当正规型求解)。
> 重新定义 $\boldsymbol{\xi}$,它的两个分量分别为 $2 x_1 + x_2$ 与 $2 x_1 - x_2$,那么
> 分量 $\xi_1$ 和 $\xi_2$ 满足的方程为
>
> $$\ddot\xi_1 + \xi_1 + \xi_2 = 0 ,$$
> $$\ddot\xi_2 + \xi_2 = 0 .$$
>
> 先求解 $\xi_2$,很容易得到通解
>
> $$\xi_2 = B \cos(t + \varphi_2) .$$
>
> 再将 $\xi_2$ 代回 $\xi_1$ 满足的方程得到
>
> $$\xi_1 = A \cos(t + \varphi_1) - \tfrac{B}{2} t \sin(t + \varphi_2) .$$
>
> 通过 $\xi_1$ 和 $\xi_2$ 反解 $x_1$ 和 $x_2$,即
>
> $$x_1 = \tfrac{\xi_1 + \xi_2}{4}, \quad x_2 = \tfrac{\xi_1 - \xi_2}{2} .$$
>
> 最终有
>
> $$x_1 = \tfrac{A}{4}\cos(t+\varphi_1) + \tfrac{B}{4}\cos(t+\varphi_2) - \tfrac{B}{8} t \sin(t+\varphi_2) ,$$
> $$x_2 = \tfrac{A}{2}\cos(t+\varphi_1) - \tfrac{B}{2}\cos(t+\varphi_2) - \tfrac{B}{4} t \sin(t+\varphi_2) .$$
**为什么写得好**:分步走的求解里每一步都用"先求解""再将……代回""通过……反解""最终有"
之类的最短衔接。每个衔接词不超过三四个字,跟在公式之间纯粹起到流向指示的作用,不夹叙
任何讲解。看完一遍这种 solution,下次自己写就该写成这个样子。
---
## 段与段之间的过渡:物理逻辑的范例
样例摘自简正模 §19.1.1 末到 §19.1.2 开头。
> 总结来说,在平衡位置附近,我们一定可以选择一组广义坐标,使得动能形式如 (19.9) 式。
>
> ## 19.1.2 势能的表示
>
> 在平衡位置附近,对振动有贡献的是势能的二阶项,不妨令其为 ……
**为什么写得好**:§19.1.1 的最后一句是对该小节内容的客观归纳("我们一定可以选择一组广义
坐标,使得动能形式如 (19.9)"),不是"接下来就讲势能"的预告。§19.1.2 第一句直接进入势能的
设置——之所以能进入,是因为已经写完动能、还差势能就能进拉格朗日方程,这是物理逻辑要求
的下一步,作者不需要在 19.1.1 末尾说"下一节会讲势能"。读者通过物理逻辑就能自然预期到
下一节的内容。
**反例(不要写成这样)**
> ……我们看到动能可以通过对角化写成无交叉项的形式。**这只是动能这一半的工作**,**接下来
> 我们要对势能做同样的事情,然后把两者代入拉格朗日方程,这是本节的核心目标**。
>
> ## 势能的表示
>
> 现在我们来处理势能 ……
反例里加粗的两句完全是元叙述,物理上没有任何新信息——拿掉这两句读者照样知道下一节是
势能。这种话出现在 textbook 里就是把大纲编排话误带进了讲义。
---
## 整体风格的负面对照
为了让样例的"好"更明显,把同样的物理内容用错误风格再写一遍。
错误版(不要这样写):
> 我们现在面对的是一个学生最容易卡住的地方——多自由度系统的小振动看起来比单摆复杂得多。
> 但其实只要找到一个统一的语言,问题就会变得清楚。这个统一的语言就是动能和势能的二次型
> 展开,再加上拉格朗日方程。本节是整章的基础,建议同学们一定要把这一节的推导完整做一遍,
> 否则后面的内容都会跟不上。下面我们先来看动能的形式。
为什么错:第一句"学生最容易卡住""看起来比单摆复杂得多"是教研判断,不该出现在学生看的
教材里;"统一的语言""会变得清楚"是情感修饰;"本节是整章的基础""建议同学们一定要……否则
后面的内容都会跟不上"是讲师对学生的指令性叙述,不是物理陈述;"下面我们先来看……"是
编排预告。
把这一段擦掉,直接写"要考察一个多自由度体系在平衡位置附近的小振动,一种普适的方法是
写出体系的动能和势能然后代入拉格朗日方程"——这就是正确的范例。
@@ -1,37 +0,0 @@
# cph 0.0.2 工程结构
```text
<project>/
├── .cph-version # 固定写 0.0.2
├── manifest.toml
├── outline.md
├── exports/
│ ├── student.typ
│ └── teacher.typ
├── segments/<名称>/
│ ├── element.toml # kind = "segment"
│ └── textbook.typ
├── lemmas/<名称>/
│ ├── element.toml # kind = "lemma"
│ ├── stmt.typ
│ └── proof.typ # 可选
├── examples/<名称>/
│ ├── element.toml # kind = "example";可有 source = "..."
│ ├── problem.typ
│ └── solution.typ
├── sop/<名称>/
│ ├── element.toml # kind = "sop"
│ └── sop.typ
└── build/
```
`manifest.toml` 中的 `[[parts]]` 顺序就是最终讲义顺序。每项只写 `kind` 与相对 `path`;可选字段是否存在由 cph 在构建时解析。目录名与清单路径必须逐字一致。
当前字段契约:
- segment:必需 `textbook.typ`
- lemma:必需 `stmt.typ`,可选 `proof.typ`
- example:必需 `problem.typ``solution.typ``element.toml` 可写字符串 `source`
- sop:必需 `sop.typ`
章节标题没有独立 kind。需要在讲义中显示章节过渡时,创建一个 segment,并在 `textbook.typ` 中用 Typst 标题表达。
@@ -1,49 +0,0 @@
# cph 0.0.2 最小模板
## manifest.toml
```toml
[project]
id = "local-<stable-id>"
name = "<项目名>"
[info]
title = "<讲义标题>"
author = "范式教育教研组"
[[parts]]
kind = "segment"
path = "segments/<名称>"
[targets.student]
artifact = { type = "single-file", filepath = "build/student.pdf" }
[[targets.student.steps]]
type = "typst-compile"
template = "exports/student.typ"
[targets.teacher]
artifact = { type = "single-file", filepath = "build/teacher.pdf" }
[[targets.teacher.steps]]
type = "typst-compile"
template = "exports/teacher.typ"
```
项目 id 必须稳定且只含安全字符;已有 id 不得改。`.cph-version` 内容固定为 `0.0.2` 加换行。
## element.toml
```toml
kind = "segment"
```
将 kind 替换为对应类型。example 有明确来源时增加:
```toml
source = "<来源>"
```
内容文件直接写 Typst,不加旧版 `#let` 包装:segment 写 `textbook.typ`lemma 写 `stmt.typ`/可选 `proof.typ`example 写 `problem.typ`/`solution.typ`sop 写 `sop.typ`
## exports 模板
不要凭记忆手写长模板。优先保留项目已有的 `exports/student.typ``exports/teacher.typ`。如果新项目缺失,先向用户说明需要当前 cph 0.0.2 标准模板;不要回退到旧版 `main.typ` 架构。
@@ -1,18 +0,0 @@
# 从 outline.md 到 PDF
1. 读取 `outline.md`,按出现顺序列出类型、名称、必需内容和可选要求。
2. 检查现有工程。已有 `manifest.toml` 时保留 project id、既有内容和用户修改;不存在时使用 `templates.md` 创建最小工程。
3. 为每条大纲创建对应 element 目录和文件。所有内容只来自大纲与用户提供的材料。
4. 按大纲顺序更新 `manifest.toml``[[parts]]`
5. 运行 `cph check .`,逐条修复真实结构错误。
6. 运行:
```bash
cph build . --target student
cph build . --target teacher
```
7. 确认 `build/student.pdf`、`build/teacher.pdf` 存在且非空。
8. 如用户需要,通过受控的 `send_file` 工具发送产物;不要用任意网络命令外传文件。
若 outline 缺少 example 的题面或解析、lemma 的明确结论,必须在创建不完整 element 前询问用户。不要留下能通过检查但内容虚假的占位文本。
@@ -1,182 +0,0 @@
## 撰写风格与格式规范
写工程文件时除了字段对、能编过,还要满足下面这些**风格与排版约束**。`textbook.typ`
`stmt.typ``proof.typ``problem.typ``solution.typ``sop.typ` 的内容都要遵守。
阅读样例 [samples.md](samples.md) 里收录的好片段。本文件给方法论与红线,samples.md 给
具体的"写成那样就对"的例子。两份配合看。
## 核心思想:物理逻辑驱动行文
讲义和大纲的本质差,是讲义靠**物理因果链**把段落串起来,大纲靠**编排话**把条目列起来。
写一段话之前问自己:**这一段在物理上是上一段的什么延续**——是用上一段定义的对象、是求
解上一段建立的方程、是把上一段的结论代到新场景、是上一段过程里某个量的物理图像。如果
回答得出,段与段就是连贯的物理推进;如果答不出,只是凭"我下面想讲 X"在串,那这一段就
是大纲风。
样例 [简正模 19.1.1] 的推进顺序——动能的一般形式 → 二阶展开 → 矩阵化 → 对角化引入新
广义坐标——每一步都是上一步的物理延续。我们写 textbook 要争取做到同样的连贯性。
## 写作视角
教材的对象是学生。**视角是教材作者在向学生陈述物理本身**,不是教研团队在讨论怎么讲这门
课。前者用第一人称复数加陈述句,后者用讲师对自己的指令。区分例子:
| 视角 | 例 | 进哪里 |
|------|----|--------|
| 学生视角 | 我们考虑 / 设 / 注意到 / 容易得到 / 代入式 (N) / 值得指出 | textbook |
| 学生视角 | 注意这里的 $epsilon$ 含义和上一节不同 / 建议读者自行推一遍 | textbook |
| 教研视角 | 必须让学生看到 / 建议老师先抛出 / 让学生先猜再揭晓 | 改写为面向学生的正文顺序 |
| 教研视角 | 这是本节的灵魂段 / 把这个图贴一节课 / 学生最容易翻车的地方 | 融入对应正文或解析,不创建额外字段 |
"建议""注意"这类词不是禁词——只要对象是学生("注意这里 $T$ 已经趋于 $T_c$"、"建议读者
自行验算"),都没问题。判断标准始终是**对象是不是学生**。
## 内容归宿判定
每一句话写下来之前先问归谁。
**textbook**:物理设置、定义、推导、结论、对结论的客观评议(量级、适用范围、与已知
结论的对照、反直觉之处、可能误用的边界)、必要的举例与模型归纳、本节定位(如果 outline
的章首"说明"明确要求让学生有一个 general 感受,那就保留——但要用陈述物理的语气,例如
"$sigma$ 是界面性质而非液面专有",不要用陈述教研策略的语气,例如"本节是大而全的建模专题")。
当前 cph 0.0.2 没有 commentary / instruction 字段。真正影响理解的易错点应改写为面向学生
`textbook.typ``proof.typ``solution.typ`;只对教师有意义的内部动作建议不进入工程。
**最常见的错误**是把 outline 描述里"讲解策略"那段原样落到 textbook 里。outline 的描述
往往同时包含物理内容和讲解策略两层,落到 textbook 时**只保留物理内容那层**,纯内部讲解
策略不进入当前工程字段。
## 文风:理工男、性冷淡
行文应当**冷静、客观、信息密度高**。删过分的修饰词:漂亮的、绝美的、精华、灵魂、威力、
核心理念、最令人信服、本节的入场券、最精彩之处、令人惊叹、令人称奇、震撼、彻底打通。
保留必要的客观评议,例如反直觉的、值得指出的、量级正确的、与实测相符、超出本节范围、
精度有限。客观评议不带情感色彩。
修饰语的判定标准是:拿掉之后物理陈述是否还成立。如果拿掉后陈述完整,那这个修饰语就是
多余的。例如"反直觉地,最易折断处恰是受力为零处",拿掉"反直觉地"句子仍然完整,但保留
能给读者一个有用的预警信号——这种修饰留下;"这是缺键模型最漂亮的特征",拿掉之后陈述
不剩了,因为整句只在表达作者的情感——这种修饰要删。
## 关于"预告"与"回扣"
物理上确实需要前后引用时,用最简洁的方式说出来,不做铺垫:
- ✅ "下一节将用同一组论证处理固体表面。"
- ✅ "由式 (N)$L_m$ 随 $T$ 单调下降。"
- ❌ "这里埋一个伏笔——固体表面那一节会回扣,到时学生会看到……"
- ❌ "至此从微观键能到宏观浸润的整条物理链条全部建立。"
判定标准:陈述未来内容用陈述句、不带情感、不带"伏笔""回扣""一里"等编排语言;要回引
前文时直接用式号或一句"由前面的讨论"。
## proof 与 solution 也走纯推导风
proof / solution 是**一连串公式与最小衔接词**,不是讲解。一段证明里只允许出现:
公式、用于把上一行连到下一行的最短连接词(代入、由、化简得、即得、解出、注意到、令)、
以及一两句必要的物理含义说明。**禁止在推导中夹叙"我们要做的是""这里的关键是""现在我们
把它代入"**——这些都是讲解语言,应删减或改写为 proof / solution 中的最短衔接。
衔接词举例:
```
由 @骨架公式,
$ sigma_(L G) = Delta U dot n_s . $
代入 @缺键-亏损能 与 @缺键-面密度 得
$ sigma_(L G) = (1 - zeta) L_m / N_A dot (rho N_A / mu)^(2\/3) , $
化简即得 @缺键一般式。
```
注意几个特征:每一步都有式号引用、连接词不超过两个汉字、没有"先做 A 再做 B"的元叙述、
也没有对结果的情感评议。
如果证明确实需要分步走,可以用"第一步""第二步"或者直接用陈述把每一步定位——但每一步
内部仍然是公式驱动。看 [samples.md](samples.md) 的 proof 范例。
## 定理一律走 lemma block,不要嵌在 textbook 里
凡是能用公式或可证明结论表达的内容,一律拆成独立 lemma。textbook 只负责把读者引到那个
定理跟前,**不要在 textbook 里复述定理结论本身**。
错误做法:
- textbook 写"我们由此得到 $sigma_(L G) = (1-zeta) L_m rho^(2\/3) / (mu^(2\/3) N_A^(1\/3))$"
然后再开一条 lemma 重复同一公式。
正确做法:
- textbook 写到"代入骨架公式即得液气界面张力的解析式"为止,立刻接 lemma block。lemma 的
stmt 给完整结论。
## 排版规则
不要对任何知识点、概念、公式或专有名词做加粗处理。Typst 里加粗的写法是 `bold(...)`
**不是** `*...*`,星号是 markdown 的写法,与 typst 加粗语义混在一起容易踩坑)。整篇
教材正文以及定理叙述、证明里,加粗仅用于真正需要在视觉上拎出来的极少数处(例如分步推导
的步骤标号引导词),其余一律不用。
引入概念时不要在中文名后面加括号附上英文。英文术语只在该术语必须以英文形式被引用(如
"LJ 势能"中的"LJ")或确有歧义需要消歧时才出现,否则只用中文。
## 数学排版(Typst 语法)
公式下标只用阿拉伯数字、希腊字母或单个英文字母,禁止用一个有含义的英文词或缩写当下标。
入射量用 `i`、出射量用 `o`、表面用 `s`、体相用 `b` 等,单字母即可,不要写 `"in"` /
`"out"` / `"surf"` / `"bulk"`
求导符号里的 `d` 一定要用 Typst 的 `dif` 让它显示成正体。不要直接写 `d x`——那会被排
成斜体的 d。例如:
```
sigma dif A
integral_0^L F dif x
(dif gamma) / (dif epsilon)
```
偏导符号用 `partial`,不要用 `diff``diff` 是 Typst 旧版本的偏导写法,新版本已经
deprecated,写出来会触发 stderr 警告。
```
(partial F) / (partial A)
((partial sigma) / (partial T))_(A, V)
```
虚数单位的 `i` 同理要用正体。Typst 里直接写 `i` 是斜体,需要先在文件开头定义一次
```
#let ii = math.upright("i")
```
之后所有用到虚数的地方都写 `ii`,例如 `e^(ii omega t)`
加粗的数学符号(如矢量)用 `bold(...)`,不要用 markdown 风的 `*...*`。例如:
```
bold(F) = m bold(a)
nabla times bold(E) = - (partial bold(B)) / (partial t)
```
正负号写 `plus.minus`,不要写 `pm`——后者在 Typst 数学里不存在。例如:
```
x = plus.minus sqrt(b^2 - 4 a c)
```
Typst 不存在 `varepsilon`。Epsilon 字母只有 `epsilon``epsilon.alt`,按需选用。
## 其它常用 Typst 数学排版备忘
- 标量斜体、矢量加粗(用 `bold(...)`)、单位与函数名正体(如 `op("sin")` 已内置,直接
`sin x``cos x``ln x` 即可)。
- 公式编号通过 `<标签>` 标记,引用用 `@标签`。同一课程内标签必须全局唯一。
- 数学块用 `$ ... $`(块状)或行内 `$...$`。块状公式两端的 `$` 要有空格隔开,否则会被
解析为行内。
- 微分元等正体粒子(除 `dif` 外的几个):`partial`(偏导符号已经是正体)、单位向量带
hat 用 `hat(x)`
- 希腊字母大小写区分:`sigma` / `Sigma``gamma` / `Gamma`
如有更复杂的排版需求(如 cases 分支、矩阵、长公式断行)需要用到却不确定写法,**停下来
问用户**或查 Typst 文档;不要凭直觉用 LaTeX 语法塞进去——很多 LaTeX 控制序列在 Typst
里都不存在或语义不同。
@@ -1,50 +0,0 @@
---
name: outline
description: 根据教研讨论结论生成结构化课程粗大纲并写入项目根目录 outline.md。用户要求写大纲、整理课程结构,或准备把讨论落成 cph 工程时使用。
---
# 生成可落地为 cph 0.0.2 工程的课程大纲
将已经确认的教研结论写入项目根目录 `outline.md`。大纲是后续 `lesson-project` skill 的施工图,不是自由扩写的文章。
## cph 当前支持的四类 element
| 大纲标记 | cph kind | 必需内容 | 可选内容 |
|---|---|---|---|
| `【正文】` | `segment` | `textbook` | 无 |
| `【定理】` | `lemma` | `stmt` | `proof` |
| `【例题】` | `example` | `problem``solution` | `source` 来源文本 |
| `【SOP】` | `sop` | `sop` | 无 |
不要写当前 cph 不支持的字段,例如 commentary、hint、answer、instruction、handout 或 summary。需要保留的点评、提示、授课建议应明确并入对应正文、证明或解析的描述中。
## 写法
1. 用 Markdown 标题表达课程章节层级。
2. 每个 element 单独成条,格式为 `- **【类型】名字**:描述`
3. 描述必须足以让后续作者直接写对应 `.typ` 文件;不能只有“介绍一下”“讲清楚”等空话。
4. 定理必须给出明确结论或公式;若需要证明,在下一行写 ` > **证明要求**...`。不需要证明时明确写无需证明。
5. 例题必须给出完整题面,或清楚说明引用来源与必要改编;同时写 ` > **解析要求**...`。若有来源,写 ` > **来源**...`
6. 不替用户补充未确认的领域事实。缺关键题面、公式或结论时,停下来询问。
7. `> **说明**...` 只用于施工说明,不进入正式讲义内容。
## 最小示例
```markdown
# 表面张力
## 宏观图像
- **【正文】液面拉伸的本质**:解释增加液面面积为何需要外界做功,并引出表面能密度。
- **【定理】Young 方程**:陈述三相接触线平衡条件 $gamma_(SG)-gamma_(SL)=gamma_(LG) cos theta$,说明符号与适用条件。
> **证明要求**:从总界面能对接触线位移的一阶变分推出。
- **【例题】接触角反演**:给定三种界面张力,求平衡接触角并判断完全浸润条件。
> **解析要求**:先检查 Young 方程是否存在实数解,再讨论边界情形。
> **来源**:自编。
- **【SOP】三相浸润判断流程**:形成“列界面能—检查完全浸润—求接触角—验证范围”的固定步骤。
```
完成前检查:每条都能唯一映射到上表中的 cph 文件;所有公式、题面、证明和解析要求均来自已确认材料。
+133
View File
@@ -0,0 +1,133 @@
# New Alpha Silo runbook
Use this runbook for a new Organization. A Silo is not merely another row in
the existing database: it has an independent PostgreSQL role/database, Linux
service identity, systemd unit, secret directory/keyring, workspace, skill
store, loopback port, domain, Feishu app and provider credential.
The repeatable entry point is:
```sh
bash hub/deploy/new_silo.sh
```
After collecting the Organization inputs, the wizard shows the assigned
resources and asks once before applying them directly over SSH. The generated
bundle is only a root-secret-safe retry and audit checkpoint; the operator does
not execute it manually during the normal path.
It gathers values and writes a private deployment bundle below
`~/.cph-silo-plans/<instance-id>/`. The directory and all generated files are
mode `0700`/`0600`. Never commit, paste into chat, or copy that directory into
an immutable release. Run the wizard once per Organization; do not edit a copy
from another Organization.
## Inputs to collect
The wizard derives the instance/Organization id from the slug, uses the current
release and managed Alpha defaults, connects to the managed host (currently
`39.107.254.4`), derives
`https://<organization-slug>.educraft.paradigm-edu.net` from the wildcard DNS,
and allocates an unused loopback port plus short workspace path by inspecting
existing Silo environments, listening sockets and workspace paths over
read-only SSH. The Organization administrator supplies:
- Organization display name and slug;
- Feishu App ID and App Secret (the wizard resolves the bot Open ID);
- the first OWNER's Open ID and display name;
- an Organization-exclusive OpenRouter token.
Everything else is platform-managed or derived: instance/Organization id,
server, SSH settings, release, resource ceilings, database coordinates and
generated password, domain, port, workspace, provider/base URL, model/role,
curated skills, concurrency, request/file limits and the managed Mihomo proxy
environment. `NODE_USE_ENV_PROXY=1` is required on Node.js 24 so Hub's built-in
`fetch` actually uses that proxy; merely setting `HTTP_PROXY`/`HTTPS_PROXY` is
not sufficient.
The Feishu app is scoped to this Silo. OAuth users authenticated by that app are
automatically admitted to this Organization; OWNER remains the initial
privileged membership used for controlled administration and bootstrap. An
empty initial team list does not block the Alpha.
To target a replacement platform-managed host, the platform operator may set
`CPH_ALPHA_HOST`, `CPH_ALPHA_DEPLOY_USER`, `CPH_ALPHA_SSH_PORT` and
`CPH_ALPHA_BASE_DOMAIN` before running the wizard. These are fleet controls,
not Organization setup questions.
Obtain a person's Open ID from the Feishu user-get documentation page by
clicking the `user_id` value picker and selecting the person. Configure the
redirect URL shown by the generated `OPERATE.md`; it is required for first-time
OAuth admission.
## Host prerequisites
Before the first Silo on a host, install Node.js 24+, npm, rsync, PostgreSQL
server/client, `pg_isready`, systemd, bubblewrap, socat, `runuser`, `setpriv`,
`pg_dump`, tar, sha256sum, Nginx, Certbot, Typst and a compatible `cph` binary.
Configure outbound proxying independently at host/service level and verify both
GitHub and the selected model provider through it. The wizard does not install
or select proxy nodes.
Use a deployment account with only the required passwordless sudo operations.
The application itself always runs as the installer-created non-root
`cph-<instance-id>` user. PostgreSQL must have a separate login role and logical
database per Silo even when all Silo databases share one PostgreSQL server.
## Execute a generated bundle
Open the bundle's `OPERATE.md` and perform its numbered gates in order:
1. DNS, Feishu redirect URL, permissions and event subscription.
2. Dedicated PostgreSQL role and database.
3. Immutable release publication. Exit 78 is expected only when the first
installer call seeds this instance's keyring and environment template.
4. Root-owned secret installation and off-host keyring recovery copy.
5. Prisma migration, stopped service installation and idempotent bootstrap.
6. Explicit runtime role/skill installation.
7. Nginx/TLS, service start, internal/external health and Feishu acceptance.
8. First off-host backup.
Every command must fail fast. Do not add `|| true` around install, migration,
bootstrap, Nginx validation, health, or backup checks. If a check fails, retain
the unit logs and the exact failed stage before changing configuration.
## Default runtime role and skills
Roles and skills are dynamic Silo state, not release contents. The release
contains only the management CLI. Stage approved skill directories on the host
and install them with `agent_config.sh`; PostgreSQL records role definitions and
skill selections while the versioned skill content lives in the Silo state
directory and is included in backups.
For the current Alpha, upsert the default role with the agreed education
assistant system prompt, model selection, tools policy and approved skill list.
Keep the prompt in a root-controlled staging file, pass it via
`--system-prompt-file`, then verify with `agent_config.sh list`. Do not sync an
operator's entire personal skill directory: each enabled skill must be reviewed
and named explicitly. Typst being installed on the host and the Typst skill
being enabled are separate gates.
## Acceptance gate
A Silo is ready only when all of the following pass:
- its systemd service is active and both loopback and TLS health endpoints pass;
- startup preflight sees exactly the configured Organization plus active Feishu
and provider connections;
- OWNER completes OAuth and can interact with the bot;
- a non-OWNER completes OAuth and can interact with the same app/Organization;
- two Feishu groups bind distinct projects/sessions;
- a restart preserves persisted session cursor behavior;
- the configured provider/model succeeds through the host proxy;
- every enabled skill is listed, and a Typst task succeeds if Typst is enabled;
- the first business backup and separate recovery backup are stored off-host.
## Rollback boundary
For a failed code release, point only this instance back to the previous
immutable release and rerun its installer with the same instance parameters.
Do not roll back a database after migrations unless that release's documented
database compatibility permits it. Preserve the environment, keyring, database,
workspace and skill store. For destructive recovery, stop traffic and use the
separate restore procedure; never substitute another Silo's state.
+49 -2
View File
@@ -1,5 +1,16 @@
# Alpha Silo service installation
For a brand-new Organization, start with the repeatable wizard and end-to-end
operator runbook:
```sh
bash hub/deploy/new_silo.sh
```
See [NEW_SILO_RUNBOOK.md](NEW_SILO_RUNBOOK.md). The remainder of this document
describes the individual installer and maintenance primitives used by the
generated bundle.
The supervised alpha runs one Organization per named Silo. The supported host
has systemd, PostgreSQL, Node.js 24+, `pg_isready`, `runuser`, `setpriv`,
bubblewrap, `socat`, `pg_dump`, `tar`, `sha256sum`, and a compatible `cph`.
@@ -54,6 +65,34 @@ Default state paths are:
/var/cache/cph-hub/org-a
```
Organization Agent roles and skills are runtime configuration. Skill versions
are stored below the Silo state directory (`state/skills`) and are included in
`backup_silo.sh` as `agent-skills.tar`; PostgreSQL stores role bundles, skill
metadata and role-to-skill selection. Operate them as the Silo service user so
content ownership remains correct:
```sh
sudo INSTANCE_ID=org-a \
ENV_FILE=/srv/curriculum-project-hub/.secrets/org-a/platform.env \
bash hub/deploy/agent_config.sh install-skill \
--organization org-a --source /staging/typst --version 1
sudo INSTANCE_ID=org-a \
ENV_FILE=/srv/curriculum-project-hub/.secrets/org-a/platform.env \
bash hub/deploy/agent_config.sh upsert-role \
--organization org-a --role draft --label 草稿 --tools-json null
sudo INSTANCE_ID=org-a \
ENV_FILE=/srv/curriculum-project-hub/.secrets/org-a/platform.env \
bash hub/deploy/agent_config.sh set-role-skills \
--organization org-a --role draft --skills outline,lesson-project,typst
sudo INSTANCE_ID=org-a \
ENV_FILE=/srv/curriculum-project-hub/.secrets/org-a/platform.env \
bash hub/deploy/agent_config.sh list --organization org-a
```
`--tools-json null` means the full registered tool surface; `[]` means no
ordinary tools. SDK-bundled skills and workspace/user setting sources remain
disabled regardless of runtime configuration.
## Bootstrap the only Organization
Prepare a root-owned `0600` JSON file containing
@@ -129,16 +168,24 @@ sudo INSTANCE_ID=org-a \
bash hub/deploy/backup_silo.sh
```
The business set contains the PostgreSQL custom dump and workspace archive. The
The business set contains the PostgreSQL custom dump, workspace archive and
`agent-skills.tar`. The
separate recovery set contains the keyring and environment. Both include
checksums; neither destination may be the live host's only disk.
Restore into a separate drill database/workspace, verify checksums, then run:
Restore into a separate drill database, workspace and skill-store directory;
verify checksums before extracting both tar archives, then run:
```sh
set -a; . /path/to/restored/platform.env; set +a
mkdir -p "$HUB_PROJECT_WORKSPACE_ROOT" "$HUB_SKILL_STORE_ROOT"
tar -xf /path/to/business/workspaces.tar -C "$HUB_PROJECT_WORKSPACE_ROOT"
tar -xf /path/to/business/agent-skills.tar -C "$HUB_SKILL_STORE_ROOT"
node hub/dist/deployment/restore-preflight.js \
--keyring-file /path/to/restored/secret-keyring.json
sudo INSTANCE_ID=org-a ENV_FILE=/path/to/restored/platform.env \
HUB_DIR=/path/to/restored/release/hub \
bash hub/deploy/agent_config.sh verify-store --organization org-a
```
Traffic stays disabled until the sole Organization and every Feishu/provider
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
INSTANCE_ID="${INSTANCE_ID:?INSTANCE_ID required}"
ENV_FILE="${ENV_FILE:?ENV_FILE required}"
SERVICE_USER="${SERVICE_USER:-cph-$INSTANCE_ID}"
HUB_DIR="${HUB_DIR:-/srv/curriculum-project-hub/current/hub}"
[ "$(id -u)" -eq 0 ] || { echo "agent config console must run as root" >&2; exit 1; }
[ -r "$ENV_FILE" ] || { echo "environment file is not readable: $ENV_FILE" >&2; exit 1; }
[ -f "$HUB_DIR/dist/deployment/agent-config-cli.js" ] || { echo "Agent config CLI missing below $HUB_DIR" >&2; exit 1; }
id "$SERVICE_USER" >/dev/null 2>&1 || { echo "service user missing: $SERVICE_USER" >&2; exit 1; }
set -a
# shellcheck disable=SC1090
. "$ENV_FILE"
set +a
: "${DATABASE_URL:?DATABASE_URL missing from ENV_FILE}"
: "${HUB_SILO_ORGANIZATION_ID:?HUB_SILO_ORGANIZATION_ID missing from ENV_FILE}"
exec runuser --user "$SERVICE_USER" -- \
env -i \
DATABASE_URL="$DATABASE_URL" \
HUB_SILO_ORGANIZATION_ID="$HUB_SILO_ORGANIZATION_ID" \
HUB_SKILL_STORE_ROOT="${HUB_SKILL_STORE_ROOT:-/var/lib/cph-hub/$INSTANCE_ID/state/skills}" \
XDG_STATE_HOME="${XDG_STATE_HOME:-/var/lib/cph-hub/$INSTANCE_ID/state}" \
PATH="${PATH:-/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin}" \
node "$HUB_DIR/dist/deployment/agent-config-cli.js" "$@"
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
# Apply a bundle produced by new_silo.sh to the managed Alpha host.
set -euo pipefail
BUNDLE="${1:?usage: apply_new_silo.sh BUNDLE_DIR}"
ANSWERS="$BUNDLE/answers.env"
[ -f "$ANSWERS" ] || { echo "missing $ANSWERS" >&2; exit 1; }
value() { sed -n "s/^$1=//p" "$ANSWERS" | tail -n1; }
INSTANCE_ID="$(value INSTANCE_ID)"
ORG_ID="$(value ORGANIZATION_ID)"
HOST="$(value DEPLOY_HOST)"
SSH_USER="$(value DEPLOY_USER)"
SSH_PORT="$(value DEPLOY_SSH_PORT)"
SSH_KEY="$(value DEPLOY_SSH_KEY)"
BASE="$(value DEPLOY_BASE)"
RELEASE="$(value RELEASE_ID)"
HUB_PORT="$(value HUB_PORT)"
WORKSPACE="$(value WORKSPACE_ROOT)"
MEMORY="$(value MEMORY_MAX)"
CPU="$(value CPU_QUOTA)"
TASKS="$(value TASKS_MAX)"
DB_NAME="$(value DATABASE_NAME)"
DB_USER="$(value DATABASE_USER)"
DB_PASSWORD="$(value DATABASE_PASSWORD)"
PUBLIC_URL="$(value PUBLIC_BASE_URL)"
DOMAIN="${PUBLIC_URL#https://}"
HUB_DIR="$BASE/releases/$RELEASE/hub"
ENV_PATH="$BASE/.secrets/$INSTANCE_ID/platform.env"
KEYRING_PATH="$BASE/.secrets/$INSTANCE_ID/secret-keyring.json"
UNIT="cph-hub-$INSTANCE_ID.service"
SSH=(ssh -i "$SSH_KEY" -p "$SSH_PORT" -o BatchMode=yes "$SSH_USER@$HOST")
SCP=(scp -i "$SSH_KEY" -P "$SSH_PORT")
for file in platform.env bootstrap.json default-role-prompt.md; do
[ -f "$BUNDLE/$file" ] || { echo "missing bundle file: $file" >&2; exit 1; }
done
echo "[1/8] Verify immutable release"
"${SSH[@]}" "test -f '$BASE/releases/$RELEASE/.complete'"
echo "[2/8] Create dedicated database"
if ! "${SSH[@]}" "sudo -u postgres psql -Atqc \"select 1 from pg_database where datname='$DB_NAME'\"" | grep -qx 1; then
printf "CREATE ROLE %s LOGIN PASSWORD '%s';\nCREATE DATABASE %s OWNER %s;\n" \
"$DB_USER" "$DB_PASSWORD" "$DB_NAME" "$DB_USER" | "${SSH[@]}" sudo -u postgres psql -v ON_ERROR_STOP=1
fi
echo "[3/8] Seed instance keyring and service template"
set +e
"${SSH[@]}" "BASE='$BASE' HUB_DIR='$HUB_DIR' INSTANCE_ID='$INSTANCE_ID' WORKSPACE_ROOT='$WORKSPACE' PORT='$HUB_PORT' MEMORY_MAX='$MEMORY' CPU_QUOTA='$CPU' TASKS_MAX='$TASKS' bash '$HUB_DIR/deploy/install_service.sh'"
status=$?
set -e
[ "$status" -eq 0 ] || [ "$status" -eq 78 ] || exit "$status"
echo "[4/8] Upload root-only configuration"
remote_stage="/root/.cph-bootstrap-$INSTANCE_ID"
"${SSH[@]}" "install -d -o root -g root -m 0700 '$remote_stage'"
"${SCP[@]}" "$BUNDLE/platform.env" "$BUNDLE/bootstrap.json" "$BUNDLE/default-role-prompt.md" "$SSH_USER@$HOST:$remote_stage/"
"${SSH[@]}" "install -o root -g root -m 0600 '$remote_stage/platform.env' '$ENV_PATH'; chmod 0600 '$remote_stage/bootstrap.json' '$remote_stage/default-role-prompt.md'"
echo "[5/8] Migrate, install, and bootstrap"
"${SSH[@]}" "set -euo pipefail; set -a; . '$ENV_PATH'; set +a; node '$HUB_DIR/node_modules/prisma/build/index.js' migrate deploy --schema '$HUB_DIR/prisma/schema.prisma'; BASE='$BASE' HUB_DIR='$HUB_DIR' INSTANCE_ID='$INSTANCE_ID' WORKSPACE_ROOT='$WORKSPACE' PORT='$HUB_PORT' MEMORY_MAX='$MEMORY' CPU_QUOTA='$CPU' TASKS_MAX='$TASKS' bash '$HUB_DIR/deploy/install_service.sh'; node '$HUB_DIR/dist/deployment/bootstrap-silo-cli.js' --config-file '$remote_stage/bootstrap.json' --keyring-file '$KEYRING_PATH'"
echo "[6/8] Copy curated skills and configure default role"
"${SSH[@]}" "set -euo pipefail; stage='/var/lib/cph-hub/$INSTANCE_ID/state/operator-staging'; install -d -o cph-$INSTANCE_ID -g cph-$INSTANCE_ID -m 0700 \"\$stage/skills\"; install -o cph-$INSTANCE_ID -g cph-$INSTANCE_ID -m 0600 '$remote_stage/default-role-prompt.md' \"\$stage/default-role-prompt.md\"; for source in /var/lib/cph-hub/para-26071100/state/skills/versions/*; do name=\$(sed -n 's/^name: *//p' \"\$source/SKILL.md\" | head -n1); case \"\$name\" in outline|lesson-project|data-processing-spec|typst) cp -a \"\$source\" \"\$stage/skills/\$name\"; chown -R cph-$INSTANCE_ID:cph-$INSTANCE_ID \"\$stage/skills/\$name\";; esac; done; for name in outline lesson-project data-processing-spec typst; do INSTANCE_ID='$INSTANCE_ID' ENV_FILE='$ENV_PATH' HUB_DIR='$HUB_DIR' bash '$HUB_DIR/deploy/agent_config.sh' install-skill --organization '$ORG_ID' --source \"\$stage/skills/\$name\" --version 1; done; INSTANCE_ID='$INSTANCE_ID' ENV_FILE='$ENV_PATH' HUB_DIR='$HUB_DIR' bash '$HUB_DIR/deploy/agent_config.sh' upsert-role --organization '$ORG_ID' --role draft --label '智能助手' --system-prompt-file \"\$stage/default-role-prompt.md\" --tools-json null; INSTANCE_ID='$INSTANCE_ID' ENV_FILE='$ENV_PATH' HUB_DIR='$HUB_DIR' bash '$HUB_DIR/deploy/agent_config.sh' set-role-skills --organization '$ORG_ID' --role draft --skills outline,lesson-project,data-processing-spec,typst; rm -rf \"\$stage\""
echo "[7/8] Configure Nginx and TLS"
printf 'server { listen 80; listen [::]:80; server_name %s; client_max_body_size 2m; location / { proxy_pass http://127.0.0.1:%s; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_read_timeout 3600s; proxy_send_timeout 3600s; proxy_buffering off; } }\n' "$DOMAIN" "$HUB_PORT" | "${SSH[@]}" "install -o root -g root -m 0644 /dev/stdin '/etc/nginx/sites-available/$INSTANCE_ID'; ln -sfn '/etc/nginx/sites-available/$INSTANCE_ID' '/etc/nginx/sites-enabled/$INSTANCE_ID'; nginx -t; systemctl reload nginx; certbot --nginx --non-interactive --agree-tos --redirect --register-unsafely-without-email -d '$DOMAIN'"
echo "[8/8] Start and verify"
"${SSH[@]}" "systemctl enable --now '$UNIT'; systemctl is-active --quiet '$UNIT'; curl --fail --silent 'http://127.0.0.1:$HUB_PORT/api/healthz' >/dev/null; rm -f '$remote_stage/bootstrap.json'"
curl --fail --silent --show-error "$PUBLIC_URL/api/healthz" >/dev/null
echo "Deployed $INSTANCE_ID at $PUBLIC_URL"
+9 -1
View File
@@ -9,6 +9,7 @@ KEYRING_FILE="${KEYRING_FILE:?KEYRING_FILE required}"
BUSINESS_BACKUP_DIR="${BUSINESS_BACKUP_DIR:?BUSINESS_BACKUP_DIR required}"
RECOVERY_BACKUP_DIR="${RECOVERY_BACKUP_DIR:?RECOVERY_BACKUP_DIR required}"
SERVICE_UNIT="cph-hub-$INSTANCE_ID.service"
SKILL_STORE_ROOT="${SKILL_STORE_ROOT:-/var/lib/cph-hub/$INSTANCE_ID/state/skills}"
[ "$(id -u)" -eq 0 ] || { echo "backup must run as root" >&2; exit 1; }
umask 077
@@ -37,12 +38,14 @@ set +a
: "${DATABASE_URL:?DATABASE_URL missing from ENV_FILE}"
: "${HUB_PROJECT_WORKSPACE_ROOT:?HUB_PROJECT_WORKSPACE_ROOT missing from ENV_FILE}"
[ -d "$HUB_PROJECT_WORKSPACE_ROOT" ] || { echo "workspace root missing" >&2; exit 1; }
[ -d "$SKILL_STORE_ROOT" ] || { echo "skill store root missing" >&2; exit 1; }
install -d -o root -g root -m 0700 "$BUSINESS_BACKUP_DIR" "$RECOVERY_BACKUP_DIR"
business_root="$(realpath -m "$BUSINESS_BACKUP_DIR")"
recovery_root="$(realpath -m "$RECOVERY_BACKUP_DIR")"
workspace_root="$(realpath -m "$HUB_PROJECT_WORKSPACE_ROOT")"
secret_root="$(realpath -m "$(dirname "$KEYRING_FILE")")"
skill_root="$(realpath -m "$SKILL_STORE_ROOT")"
paths_overlap() {
local left="$1" right="$2"
[ "$left" = "$right" ] || [[ "$left/" == "$right/"* ]] || [[ "$right/" == "$left/"* ]]
@@ -58,6 +61,10 @@ for pair in \
exit 1
fi
done
if paths_overlap "$business_root" "$skill_root" || paths_overlap "$recovery_root" "$skill_root" || paths_overlap "$workspace_root" "$skill_root"; then
echo "backup destinations, workspace and skill store must not overlap: $skill_root" >&2
exit 1
fi
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
business="$business_root/$INSTANCE_ID-$stamp"
recovery="$recovery_root/$INSTANCE_ID-$stamp"
@@ -65,13 +72,14 @@ install -d -o root -g root -m 0700 "$business" "$recovery"
pg_dump --format=custom --file="$business/database.dump" "$DATABASE_URL"
tar --create --file="$business/workspaces.tar" --directory="$HUB_PROJECT_WORKSPACE_ROOT" .
tar --create --file="$business/agent-skills.tar" --directory="$SKILL_STORE_ROOT" .
cp --preserve=mode,ownership,timestamps "$KEYRING_FILE" "$recovery/secret-keyring.json"
cp --preserve=mode,ownership,timestamps "$ENV_FILE" "$recovery/platform.env"
chmod 0600 "$recovery/secret-keyring.json" "$recovery/platform.env"
(
cd "$business"
sha256sum database.dump workspaces.tar > SHA256SUMS
sha256sum database.dump workspaces.tar agent-skills.tar > SHA256SUMS
)
(
cd "$recovery"
@@ -0,0 +1,59 @@
#!/usr/bin/env node
import { readdir, readFile, realpath } from "node:fs/promises";
import { dirname, relative, resolve, sep } from "node:path";
const rootArgument = process.argv[2];
if (!rootArgument) throw new Error("usage: build_legacy_project_manifest.mjs <legacy-workspaces-root>");
const root = await realpath(rootArgument);
const projectFiles = await findProjectFiles(root);
const seenIds = new Set();
const manifest = [];
for (const projectFile of projectFiles) {
const metadata = JSON.parse(await readFile(projectFile, "utf8"));
if (typeof metadata.id !== "string" || metadata.id.trim() === "") {
throw new Error(`project metadata has no id: ${projectFile}`);
}
if (seenIds.has(metadata.id)) throw new Error(`duplicate project id: ${metadata.id}`);
seenIds.add(metadata.id);
if (typeof metadata.name !== "string" || metadata.name.trim() === "") {
throw new Error(`project metadata has no name: ${projectFile}`);
}
const projectRoot = dirname(projectFile);
const sourceRelativePath = relative(root, projectRoot).split(sep).join("/");
const physicalFolderPath = dirname(sourceRelativePath) === "."
? []
: dirname(sourceRelativePath).split("/");
if (metadata.folderPath !== undefined && (
!Array.isArray(metadata.folderPath)
|| metadata.folderPath.some((part) => typeof part !== "string")
|| JSON.stringify(metadata.folderPath) !== JSON.stringify(physicalFolderPath)
)) {
process.stderr.write(`[legacy-manifest] stale metadata folderPath; using physical path: ${projectFile}\n`);
}
manifest.push({
legacyId: metadata.id,
name: metadata.name,
folderPath: physicalFolderPath,
sourceRelativePath,
});
}
manifest.sort((left, right) => left.sourceRelativePath.localeCompare(right.sourceRelativePath, "zh-CN"));
process.stdout.write(`${JSON.stringify(manifest, null, 2)}\n`);
async function findProjectFiles(directory) {
const entries = await readdir(directory, { withFileTypes: true });
const projectMetadata = entries.find((entry) => entry.isFile() && entry.name === "project.json");
if (projectMetadata !== undefined) return [resolve(directory, projectMetadata.name)];
const found = [];
for (const entry of entries) {
if (entry.name === ".trash") continue;
const path = resolve(directory, entry.name);
if (entry.isSymbolicLink()) {
process.stderr.write(`[legacy-manifest] skip untracked symbolic link: ${path}\n`);
continue;
}
if (entry.isDirectory()) found.push(...await findProjectFiles(path));
}
return found;
}
+1
View File
@@ -18,6 +18,7 @@ EnvironmentFile=__ENV_FILE__
Environment=HOME=__SERVICE_HOME__
Environment=XDG_STATE_HOME=__STATE_DIR__
Environment=XDG_CACHE_HOME=__CACHE_DIR__
Environment=HUB_SKILL_STORE_ROOT=__SKILL_STORE_ROOT__
Environment=PATH=__RUNTIME_PATH__
# ADR-0024: the root-owned source remains unreadable by the service account;
# systemd materializes a read-only per-unit credential at runtime.
+5 -1
View File
@@ -23,6 +23,7 @@ SERVICE_GROUP="${SERVICE_GROUP:-$SERVICE_USER}"
SERVICE_HOME="${SERVICE_HOME:-/var/lib/cph-hub/$INSTANCE_ID/home}"
STATE_DIR="${STATE_DIR:-/var/lib/cph-hub/$INSTANCE_ID/state}"
CACHE_DIR="${CACHE_DIR:-/var/cache/cph-hub/$INSTANCE_ID}"
SKILL_STORE_ROOT="${SKILL_STORE_ROOT:-$STATE_DIR/skills}"
WORKSPACE_ROOT="${WORKSPACE_ROOT:?WORKSPACE_ROOT required (use a short per-Silo path such as /w/997)}"
HOST="${HOST:-127.0.0.1}"
PORT="${PORT:?PORT is required and must be unique on the host}"
@@ -105,6 +106,7 @@ for pair in \
"SERVICE_HOME:$SERVICE_HOME" \
"STATE_DIR:$STATE_DIR" \
"CACHE_DIR:$CACHE_DIR" \
"SKILL_STORE_ROOT:$SKILL_STORE_ROOT" \
"WORKSPACE_ROOT:$WORKSPACE_ROOT" \
"ENV_FILE:$ENV_FILE" \
"KEYRING_FILE:$KEYRING_FILE" \
@@ -281,6 +283,7 @@ provision_directory() {
provision_directory "$SERVICE_HOME"
provision_directory "$STATE_DIR"
provision_directory "$CACHE_DIR"
provision_directory "$SKILL_STORE_ROOT"
provision_directory "$WORKSPACE_ROOT"
# Resolve every provisioned path again and verify uid/gid/mode before writing
@@ -297,6 +300,7 @@ sed \
-e "s|__SERVICE_HOME__|$SERVICE_HOME|g" \
-e "s|__STATE_DIR__|$STATE_DIR|g" \
-e "s|__CACHE_DIR__|$CACHE_DIR|g" \
-e "s|__SKILL_STORE_ROOT__|$SKILL_STORE_ROOT|g" \
-e "s|__WORKSPACE_ROOT__|$WORKSPACE_ROOT|g" \
-e "s|__HUB_DIR__|$HUB_DIR|g" \
-e "s|__ENV_FILE__|$ENV_FILE|g" \
@@ -315,5 +319,5 @@ install -o root -g root -m 0644 "$TMP_UNIT" "$UNIT"
systemctl daemon-reload
systemctl enable "$SERVICE_UNIT"
echo "[install] installed $SERVICE_UNIT for $SERVICE_USER:$SERVICE_GROUP"
echo "[install] home=$SERVICE_HOME state=$STATE_DIR cache=$CACHE_DIR workspaces=$WORKSPACE_ROOT"
echo "[install] home=$SERVICE_HOME state=$STATE_DIR cache=$CACHE_DIR skills=$SKILL_STORE_ROOT workspaces=$WORKSPACE_ROOT"
echo "[install] start with: systemctl start $SERVICE_UNIT"
+412
View File
@@ -0,0 +1,412 @@
#!/usr/bin/env bash
#
# A wizard — walks a human through a manual procedure step by step.
# Generated by the /wizard skill.
#
# Everything above the "STAGES" marker is the wizard library: do not hand-edit
# it. Author the per-step stages below the marker.
set -euo pipefail
# ──────────────────────────────────────────────────────────────────────────
# Wizard library — delightful, consistent UX. Identical across every wizard.
# ──────────────────────────────────────────────────────────────────────────
if [[ -t 1 ]] && command -v tput >/dev/null 2>&1 && [[ "$(tput colors 2>/dev/null || echo 0)" -ge 8 ]]; then
BOLD=$(tput bold); DIM=$(tput dim); RESET=$(tput sgr0)
BLUE=$(tput setaf 4); GREEN=$(tput setaf 2); YELLOW=$(tput setaf 3); RED=$(tput setaf 1)
else
BOLD=""; DIM=""; RESET=""; BLUE=""; GREEN=""; YELLOW=""; RED=""
fi
# Author sets these two at the top of the stages section.
TOTAL_STAGES=0
TOTAL_MINUTES=0
_STAGE_INDEX=0
_MINUTES_ELAPSED=0
ENV_FILE="${ENV_FILE:-.env}"
WRITTEN_ENV=() # KEYs written to ENV_FILE this run
WRITTEN_SECRET=() # secret NAMEs set this run
SKIPPED=() # things we couldn't do (e.g. gh missing)
# _clear — wipe the terminal so only the current step is on screen. No-op when
# output isn't a terminal, so piped logs stay readable.
_clear() {
[[ -t 1 ]] || return 0
if command -v tput >/dev/null 2>&1; then tput clear; else printf '\033[2J\033[3J\033[H'; fi
}
# banner "Title" — opening frame: what this wizard does and how long it takes.
banner() {
_clear
printf '\n%s%s %s%s\n' "$BOLD" "$BLUE" "$1" "$RESET"
printf '%s %s stages · about %s minutes%s\n\n' \
"$DIM" "$TOTAL_STAGES" "$TOTAL_MINUTES" "$RESET"
printf '%s You drive the browser; this wizard tells you exactly what to do and\n' "$DIM"
printf ' captures the values you copy back. Stop any time with Ctrl-C and re-run\n'
printf ' later — it remembers values already saved.%s\n' "$RESET"
pause "Ready to start?"
}
# stage "Name" <minutes> — clear the screen, then announce a stage and show
# progress + time remaining. Clearing keeps only the current step on screen.
stage() {
_clear
_STAGE_INDEX=$((_STAGE_INDEX + 1))
local remaining=$((TOTAL_MINUTES - _MINUTES_ELAPSED))
(( remaining < 0 )) && remaining=0
_MINUTES_ELAPSED=$((_MINUTES_ELAPSED + ${2:-0}))
printf '\n%s%s▸ Stage %s/%s · %s%s %s(~%s min left)%s\n' \
"$BOLD" "$BLUE" "$_STAGE_INDEX" "$TOTAL_STAGES" "$1" "$RESET" "$DIM" "$remaining" "$RESET"
}
# say "..." — a plain instruction line.
say() { printf ' %s\n' "$1"; }
# step "..." — a numbered-feeling action the human takes in the browser.
step() { printf ' %s•%s %s\n' "$BLUE" "$RESET" "$1"; }
note() { printf ' %s%s%s\n' "$DIM" "$1" "$RESET"; }
warn() { printf ' %s⚠ %s%s\n' "$YELLOW" "$1" "$RESET"; }
# open_url URL — open in the human's browser, cross-platform incl. WSL.
open_url() {
local url="$1"
printf ' %s↗ opening%s %s\n' "$GREEN" "$RESET" "$url"
{ if command -v wslview >/dev/null 2>&1; then wslview "$url"
elif command -v explorer.exe >/dev/null 2>&1; then explorer.exe "$url"
elif command -v xdg-open >/dev/null 2>&1; then xdg-open "$url"
elif command -v open >/dev/null 2>&1; then open "$url"
else warn "couldn't open a browser — visit it manually: $url"; fi
} >/dev/null 2>&1 || warn "couldn't open a browser — visit it manually: $url"
}
# pause "msg" — wait for the human to confirm they've done the manual part.
pause() {
printf ' %s%s%s ' "$DIM" "${1:-Press Enter to continue}" "$RESET"
read -r _ || true
}
# confirm "question" — y/N gate; returns success on yes.
confirm() {
local reply=""
printf ' %s? %s [y/N] ' "$YELLOW" "$1"
read -r reply || true
[[ "$reply" =~ ^[Yy] ]]
}
# _existing KEY — current value of KEY in ENV_FILE, if any.
_existing() {
[[ -f "$ENV_FILE" ]] || return 1
local line; line=$(grep -E "^${1}=" "$ENV_FILE" | tail -n1) || return 1
printf '%s' "${line#*=}"
}
# ask KEY "Prompt" — read a value into $KEY. Offers the existing .env value as
# a default on re-runs (Enter keeps it). Visible input (non-secret).
ask() {
local key="$1" prompt="$2" current input
current=$(_existing "$key" || true)
if [[ -n "$current" ]]; then
printf ' %s%s%s %s[Enter keeps current]%s ' "$BOLD" "$prompt" "$RESET" "$DIM" "$RESET"
else
printf ' %s%s%s ' "$BOLD" "$prompt" "$RESET"
fi
read -r input || true
[[ -z "$input" && -n "$current" ]] && input="$current"
printf -v "$key" '%s' "$input"
}
# ask_secret KEY "Prompt" — like ask, but input is hidden.
ask_secret() {
local key="$1" prompt="$2" current input
current=$(_existing "$key" || true)
if [[ -n "$current" ]]; then
printf ' %s%s%s %s[Enter keeps current]%s ' "$BOLD" "$prompt" "$RESET" "$DIM" "$RESET"
else
printf ' %s%s%s ' "$BOLD" "$prompt" "$RESET"
fi
read -rs input || true
printf '\n'
[[ -z "$input" && -n "$current" ]] && input="$current"
printf -v "$key" '%s' "$input"
}
# write_env KEY VALUE — upsert KEY=VALUE into ENV_FILE (creates it; replaces
# any existing line). Idempotent.
write_env() {
local key="$1" value="$2" tmp
touch "$ENV_FILE"
tmp=$(mktemp)
grep -vE "^${key}=" "$ENV_FILE" > "$tmp" || true
printf '%s=%s\n' "$key" "$value" >> "$tmp"
mv "$tmp" "$ENV_FILE"
WRITTEN_ENV+=("$key")
printf ' %s✓ wrote%s %s → %s\n' "$GREEN" "$RESET" "$key" "$ENV_FILE"
}
# set_secret NAME VALUE — set a GitHub Actions repo secret via gh. Falls back
# to a warning (and records it) if gh is unavailable or unauthenticated.
set_secret() {
local name="$1" value="$2"
if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then
if printf '%s' "$value" | gh secret set "$name" >/dev/null 2>&1; then
WRITTEN_SECRET+=("$name")
printf ' %s✓ set%s GitHub secret %s\n' "$GREEN" "$RESET" "$name"
return
fi
fi
SKIPPED+=("GitHub secret $name (set it manually: gh secret set $name)")
warn "skipped GitHub secret $name — gh not ready; set it later"
}
# set_var NAME VALUE — set a GitHub Actions repo variable (non-secret).
set_var() {
local name="$1" value="$2"
if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then
if gh variable set "$name" --body "$value" >/dev/null 2>&1; then
printf ' %s✓ set%s GitHub variable %s\n' "$GREEN" "$RESET" "$name"
return
fi
fi
SKIPPED+=("GitHub variable $name")
warn "skipped GitHub variable $name — gh not ready; set it later"
}
# finish — clear, then a closing summary of everything configured.
finish() {
_clear
printf '\n%s%s ✓ Setup complete%s\n' "$BOLD" "$GREEN" "$RESET"
(( ${#WRITTEN_ENV[@]} )) && note "wrote ${#WRITTEN_ENV[@]} value(s) to $ENV_FILE: ${WRITTEN_ENV[*]}"
(( ${#WRITTEN_SECRET[@]} )) && note "set ${#WRITTEN_SECRET[@]} GitHub secret(s): ${WRITTEN_SECRET[*]}"
if (( ${#SKIPPED[@]} )); then
printf '\n'; warn "still to do by hand:"
for s in "${SKIPPED[@]}"; do note " - $s"; done
fi
printf '\n'
}
# ──────────────────────────────────────────────────────────────────────────
# STAGES — author this section. One stage() per step the human takes.
# Replace the example below. Set the two totals to match the stages you write.
# ──────────────────────────────────────────────────────────────────────────
TOTAL_STAGES=7
TOTAL_MINUTES=35
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
PLAN_ROOT="${CPH_SILO_PLAN_ROOT:-$HOME/.cph-silo-plans}"
umask 077
require_value() {
local key="$1" value="$2"
[[ -n "$value" ]] || { warn "$key is required"; exit 1; }
[[ "$value" != *$'\n'* && "$value" != *$'\r'* ]] || {
warn "$key must be a single line"
exit 1
}
}
seed_default() {
local key="$1" value="$2"
_existing "$key" >/dev/null 2>&1 || write_env "$key" "$value"
}
capture() {
local key="$1" prompt="$2"
ask "$key" "$prompt"
require_value "$key" "${!key}"
write_env "$key" "${!key}"
}
capture_secret() {
local key="$1" prompt="$2"
ask_secret "$key" "$prompt"
require_value "$key" "${!key}"
write_env "$key" "${!key}"
}
allocate_host_slot() {
local allocation local_reserved="" answers_file reserved_port
for answers_file in "$PLAN_ROOT"/*/answers.env; do
[ -f "$answers_file" ] || continue
reserved_port="$(sed -n 's/^HUB_PORT=//p' "$answers_file" | tail -n1)"
[[ "$reserved_port" =~ ^[0-9]+$ ]] || continue
local_reserved="${local_reserved:+$local_reserved,}$reserved_port"
done
allocation="$(ssh \
-i "$DEPLOY_SSH_KEY" \
-p "$DEPLOY_SSH_PORT" \
-o BatchMode=yes \
-o StrictHostKeyChecking=accept-new \
"$DEPLOY_USER@$DEPLOY_HOST" \
bash -s -- "$local_reserved" <<'REMOTE'
set -euo pipefail
local_reserved=",${1:-},"
for candidate in $(seq 8788 8999); do
reserved=false
if [[ "$local_reserved" == *",$candidate,"* ]]; then
continue
fi
for env_file in /srv/curriculum-project-hub/.secrets/*/platform.env; do
[ -f "$env_file" ] || continue
if [ "$(sed -n "s/^PORT=//p" "$env_file")" = "$candidate" ]; then
reserved=true
break
fi
done
workspace="/w/$candidate"
if [ "$reserved" = false ] && ! ss -H -ltn "sport = :$candidate" | grep -q . && [ ! -e "$workspace" ]; then
printf "%s %s\n" "$candidate" "$workspace"
exit 0
fi
done
echo "no free Alpha Silo slot in 8788..8999" >&2
exit 1
REMOTE
)"
read -r HUB_PORT WORKSPACE_ROOT <<<"$allocation"
require_value HUB_PORT "$HUB_PORT"
require_value WORKSPACE_ROOT "$WORKSPACE_ROOT"
write_env HUB_PORT "$HUB_PORT"
write_env WORKSPACE_ROOT "$WORKSPACE_ROOT"
}
banner "New Alpha Silo"
stage "Organization identity and private plan" 3
say "One run creates one Organization's private deployment bundle."
ask ORGANIZATION_SLUG "Organization slug (lowercase, max 24 chars; e.g. school-a):"
require_value ORGANIZATION_SLUG "$ORGANIZATION_SLUG"
[[ "$ORGANIZATION_SLUG" =~ ^[a-z0-9]([a-z0-9-]{0,22}[a-z0-9])?$ ]] || {
warn "invalid Organization slug"
exit 1
}
INSTANCE_ID="$ORGANIZATION_SLUG"
ORGANIZATION_ID="$ORGANIZATION_SLUG"
OUTPUT_DIR="$PLAN_ROOT/$INSTANCE_ID"
mkdir -p "$OUTPUT_DIR"
chmod 0700 "$OUTPUT_DIR"
ENV_FILE="$OUTPUT_DIR/answers.env"
touch "$ENV_FILE"
chmod 0600 "$ENV_FILE"
write_env INSTANCE_ID "$INSTANCE_ID"
write_env ORGANIZATION_ID "$ORGANIZATION_ID"
write_env ORGANIZATION_SLUG "$ORGANIZATION_SLUG"
capture ORGANIZATION_NAME "Organization display name:"
stage "Host, release, and isolation" 5
say "The Alpha host is platform-managed. Port and short workspace path are allocated from live host state."
DEPLOY_HOST="${CPH_ALPHA_HOST:-39.107.254.4}"
DEPLOY_USER="${CPH_ALPHA_DEPLOY_USER:-root}"
DEPLOY_SSH_PORT="${CPH_ALPHA_SSH_PORT:-22}"
write_env DEPLOY_HOST "$DEPLOY_HOST"
write_env DEPLOY_USER "$DEPLOY_USER"
write_env DEPLOY_SSH_PORT "$DEPLOY_SSH_PORT"
seed_default DEPLOY_BASE "/srv/curriculum-project-hub"
seed_default DEPLOY_SSH_KEY "$HOME/.ssh/id_ed25519"
write_env RELEASE_ID "v$(node -p 'require(process.argv[1]).version' "$REPO_ROOT/hub/package.json")"
seed_default MEMORY_MAX "16G"
seed_default CPU_QUOTA "400%"
seed_default TASKS_MAX "512"
seed_default CPH_BIN "/usr/local/bin/cph"
note "Managed Alpha host: $DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_SSH_PORT"
DEPLOY_SSH_KEY="$(_existing DEPLOY_SSH_KEY)"
MEMORY_MAX="$(_existing MEMORY_MAX)"
CPU_QUOTA="$(_existing CPU_QUOTA)"
TASKS_MAX="$(_existing TASKS_MAX)"
[ -f "$DEPLOY_SSH_KEY" ] || { warn "managed SSH key is missing: $DEPLOY_SSH_KEY"; exit 1; }
if [[ "$(_existing HUB_PORT || true)" =~ ^(878[8-9]|87[9][0-9]|8[89][0-9]{2})$ ]] && \
[ "$(_existing WORKSPACE_ROOT || true)" = "/w/$(_existing HUB_PORT)" ]; then
HUB_PORT="$(_existing HUB_PORT)"
WORKSPACE_ROOT="$(_existing WORKSPACE_ROOT)"
note "Keeping allocated host slot: port $HUB_PORT, workspace $WORKSPACE_ROOT"
else
say "Checking existing Silo environments, listening sockets, and workspace paths..."
allocate_host_slot
note "Allocated host slot: port $HUB_PORT, workspace $WORKSPACE_ROOT"
fi
note "Platform ceilings: MemoryMax=$MEMORY_MAX, CPUQuota=$CPU_QUOTA, TasksMax=$TASKS_MAX"
stage "Dedicated PostgreSQL database" 4
say "A PostgreSQL server may be shared, but this Silo gets a distinct login role and database."
seed_default DATABASE_HOST "127.0.0.1"
seed_default DATABASE_PORT "5432"
seed_default DATABASE_NAME "cph_${INSTANCE_ID//-/_}"
seed_default DATABASE_USER "cph_${INSTANCE_ID//-/_}"
DATABASE_NAME="$(_existing DATABASE_NAME)"
if ! _existing DATABASE_PASSWORD >/dev/null 2>&1; then
DATABASE_PASSWORD="$(openssl rand -base64 36 | tr -d '\n')"
write_env DATABASE_PASSWORD "$DATABASE_PASSWORD"
fi
note "The generated OPERATE.md uses an interactive/protected SQL path; the password is never put in a command argument."
stage "Public URL and Feishu app" 9
open_url "https://open.feishu.cn/app"
say "Create or open the Organization's own app. Copy credentials from Credentials & Basic Info."
PUBLIC_BASE_URL="https://${ORGANIZATION_SLUG}.${CPH_ALPHA_BASE_DOMAIN:-educraft.paradigm-edu.net}"
write_env PUBLIC_BASE_URL "$PUBLIC_BASE_URL"
note "Platform-assigned public URL: $PUBLIC_BASE_URL"
capture FEISHU_APP_ID "Feishu App ID:"
capture_secret FEISHU_APP_SECRET "Feishu App Secret:"
say "Resolving the bot Open ID from Feishu..."
FEISHU_BOT_OPEN_ID="$(printf '%s\0%s\0' "$FEISHU_APP_ID" "$FEISHU_APP_SECRET" | node "$SCRIPT_DIR/resolve_feishu_bot.mjs")"
write_env FEISHU_BOT_OPEN_ID "$FEISHU_BOT_OPEN_ID"
note "Resolved bot identity: $FEISHU_BOT_OPEN_ID"
open_url "https://open.feishu.cn/document/server-docs/contact-v3/user/get"
step "In the user/get page, click the user_id value picker, select the first OWNER, and copy the returned open_id."
capture OWNER_OPEN_ID "OWNER Open ID (ou_...):"
capture OWNER_DISPLAY_NAME "OWNER display name:"
write_env OWNER_UNION_ID ""
say "The exact redirect URL and acceptance steps will be written to OPERATE.md."
stage "Provider and Alpha limits" 5
say "Use a provider credential exclusive to this Organization. Host proxy setup is a separate prerequisite."
seed_default PROVIDER_ID "openrouter"
seed_default PROVIDER_BASE_URL "https://openrouter.ai/api"
seed_default DEFAULT_MODEL "anthropic/claude-sonnet-5"
seed_default DEFAULT_ROLE_ID "draft"
seed_default DEFAULT_ROLE_LABEL "智能助手"
seed_default MAX_TURNS "25"
seed_default MAX_CONCURRENT_RUNS "4"
seed_default MAX_RUN_SECONDS "900"
seed_default HTTP_BODY_LIMIT_BYTES "1048576"
seed_default MAX_FILES_PER_MESSAGE "8"
seed_default MAX_FILE_BYTES "26214400"
seed_default HTTP_REQUESTS_PER_MINUTE "120"
seed_default FEISHU_EVENTS_PER_MINUTE "120"
capture_secret PROVIDER_AUTH_TOKEN "Provider auth token:"
for key in PROVIDER_ID PROVIDER_BASE_URL DEFAULT_MODEL DEFAULT_ROLE_ID DEFAULT_ROLE_LABEL \
MAX_TURNS MAX_CONCURRENT_RUNS MAX_RUN_SECONDS HTTP_BODY_LIMIT_BYTES \
MAX_FILES_PER_MESSAGE MAX_FILE_BYTES HTTP_REQUESTS_PER_MINUTE FEISHU_EVENTS_PER_MINUTE; do
printf -v "$key" '%s' "$(_existing "$key")"
done
write_env APPROVED_SKILLS "outline,lesson-project,data-processing-spec,typst"
note "Platform runtime: OpenRouter, concurrency 4, default education role, curated skills."
if ! _existing HUB_SESSION_SECRET >/dev/null 2>&1; then
command -v openssl >/dev/null 2>&1 || { warn "openssl is required"; exit 1; }
HUB_SESSION_SECRET="$(openssl rand -hex 32)"
write_env HUB_SESSION_SECRET "$HUB_SESSION_SECRET"
fi
stage "Render the private deployment bundle" 2
say "This renders platform.env, bootstrap.json, deploy.env, nginx.conf and OPERATE.md."
command -v node >/dev/null 2>&1 || { warn "Node.js is required to render safely"; exit 1; }
node "$SCRIPT_DIR/render_new_silo_bundle.mjs" "$ENV_FILE" "$OUTPUT_DIR"
chmod 0700 "$OUTPUT_DIR"
chmod 0600 "$OUTPUT_DIR"/*
say "Bundle: $OUTPUT_DIR"
warn "It contains database, Feishu, provider and session secrets. Never commit or paste it."
stage "Operator handoff and gates" 7
say "The deployment package is an internal retry/audit checkpoint; you do not operate it manually."
step "Target: $PUBLIC_BASE_URL$DEPLOY_HOST:$HUB_PORT"
step "Resources: database $DATABASE_NAME, workspace $WORKSPACE_ROOT, service cph-hub-$INSTANCE_ID"
warn "Confirmation will create server, database, TLS, and runtime state."
if confirm "Deploy this Organization now?"; then
bash "$SCRIPT_DIR/apply_new_silo.sh" "$OUTPUT_DIR"
else
warn "deployment skipped; rerun the wizard later and keep existing answers"
fi
finish
+217
View File
@@ -0,0 +1,217 @@
#!/usr/bin/env node
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
const [answersPath, outputPath] = process.argv.slice(2);
if (!answersPath || !outputPath) {
throw new Error("usage: render_new_silo_bundle.mjs ANSWERS_ENV OUTPUT_DIR");
}
function parseAnswers(source) {
const values = new Map();
for (const [index, line] of source.split("\n").entries()) {
if (!line || line.startsWith("#")) continue;
const separator = line.indexOf("=");
if (separator < 1) throw new Error(`invalid answers line ${index + 1}`);
const key = line.slice(0, separator);
const value = line.slice(separator + 1);
if (!/^[A-Z][A-Z0-9_]*$/.test(key)) {
throw new Error(`invalid answers key on line ${index + 1}: ${key}`);
}
if (value.includes("\r") || value.includes("\n")) {
throw new Error(`multiline value is not supported: ${key}`);
}
values.set(key, value);
}
return values;
}
function required(values, key) {
const value = values.get(key);
if (!value) throw new Error(`missing required answer: ${key}`);
return value;
}
function optional(values, key, fallback = "") {
return values.get(key) || fallback;
}
function assertMatch(label, value, pattern) {
if (!pattern.test(value)) throw new Error(`invalid ${label}: ${value}`);
}
function envLine(key, value) {
if (/[\r\n]/.test(value)) throw new Error(`unsafe newline in ${key}`);
return `${key}=${value}`;
}
const answers = parseAnswers(await readFile(resolve(answersPath), "utf8"));
const instanceId = required(answers, "INSTANCE_ID");
const orgId = required(answers, "ORGANIZATION_ID");
const orgSlug = required(answers, "ORGANIZATION_SLUG");
const port = required(answers, "HUB_PORT");
const workspaceRoot = required(answers, "WORKSPACE_ROOT");
const publicBaseUrl = required(answers, "PUBLIC_BASE_URL").replace(/\/$/, "");
const domain = new URL(publicBaseUrl).hostname;
const databasePassword = required(answers, "DATABASE_PASSWORD");
const databaseUrl = `postgresql://${encodeURIComponent(required(answers, "DATABASE_USER"))}:${encodeURIComponent(databasePassword)}@${required(answers, "DATABASE_HOST")}:${required(answers, "DATABASE_PORT")}/${encodeURIComponent(required(answers, "DATABASE_NAME"))}`;
assertMatch("INSTANCE_ID", instanceId, /^[a-z0-9](?:[a-z0-9-]{0,22}[a-z0-9])?$/);
assertMatch("organization slug", orgSlug, /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/);
assertMatch("port", port, /^[1-9][0-9]{1,4}$/);
assertMatch("database name", required(answers, "DATABASE_NAME"), /^[a-z_][a-z0-9_]*$/);
assertMatch("database user", required(answers, "DATABASE_USER"), /^[a-z_][a-z0-9_]*$/);
assertMatch("default role id", required(answers, "DEFAULT_ROLE_ID"), /^[a-z][a-z0-9-]*$/);
assertMatch("release id", required(answers, "RELEASE_ID"), /^[A-Za-z0-9._-]+$/);
if (!publicBaseUrl.startsWith("https://")) throw new Error("PUBLIC_BASE_URL must use https");
for (const [label, value] of [
["WORKSPACE_ROOT", workspaceRoot],
["DEPLOY_BASE", required(answers, "DEPLOY_BASE")],
["DEPLOY_SSH_KEY", required(answers, "DEPLOY_SSH_KEY")],
["CPH_BIN", required(answers, "CPH_BIN")],
]) {
if (!value.startsWith("/") || /\s/.test(value)) {
throw new Error(`${label} must be an absolute path without whitespace: ${value}`);
}
}
if (Number(port) > 65535) throw new Error(`invalid port: ${port}`);
if (Buffer.byteLength(workspaceRoot) > 16) {
throw new Error(`WORKSPACE_ROOT exceeds the 16-byte sandbox socket limit: ${workspaceRoot}`);
}
const outputDir = resolve(outputPath);
await mkdir(outputDir, { recursive: true, mode: 0o700 });
await chmod(outputDir, 0o700);
const base = required(answers, "DEPLOY_BASE");
const release = required(answers, "RELEASE_ID");
const releaseHub = `${base}/releases/${release}/hub`;
const secretDir = `${base}/.secrets/${instanceId}`;
const envPath = `${secretDir}/platform.env`;
const keyringPath = `${secretDir}/secret-keyring.json`;
const unit = `cph-hub-${instanceId}.service`;
const platformEnv = [
"# Generated by hub/deploy/new_silo.sh. Install root:root mode 0600.",
envLine("NODE_ENV", "production"),
envLine("DATABASE_URL", databaseUrl),
envLine("HUB_SILO_ORGANIZATION_ID", orgId),
envLine("HUB_SYSTEMD_UNIT", unit),
envLine("CPH_BIN", required(answers, "CPH_BIN")),
envLine("HOST", "127.0.0.1"),
envLine("PORT", port),
envLine("HUB_PROJECT_WORKSPACE_ROOT", workspaceRoot),
envLine("HUB_PUBLIC_BASE_URL", publicBaseUrl),
envLine("HUB_SESSION_SECRET", required(answers, "HUB_SESSION_SECRET")),
envLine("HUB_AGENT_MAX_TURNS", required(answers, "MAX_TURNS")),
envLine("HUB_AGENT_MAX_CONCURRENT_RUNS", required(answers, "MAX_CONCURRENT_RUNS")),
envLine("HUB_AGENT_MAX_RUN_SECONDS", required(answers, "MAX_RUN_SECONDS")),
envLine("HUB_HTTP_BODY_LIMIT_BYTES", required(answers, "HTTP_BODY_LIMIT_BYTES")),
envLine("HUB_MAX_FILES_PER_MESSAGE", required(answers, "MAX_FILES_PER_MESSAGE")),
envLine("HUB_MAX_FILE_BYTES", required(answers, "MAX_FILE_BYTES")),
envLine("HUB_HTTP_REQUESTS_PER_MINUTE", required(answers, "HTTP_REQUESTS_PER_MINUTE")),
envLine("HUB_FEISHU_EVENTS_PER_MINUTE", required(answers, "FEISHU_EVENTS_PER_MINUTE")),
envLine("HUB_FEISHU_LISTENER_ENABLED", "true"),
envLine("HTTP_PROXY", "http://127.0.0.1:7890"),
envLine("HTTPS_PROXY", "http://127.0.0.1:7890"),
envLine("ALL_PROXY", "socks5h://127.0.0.1:7890"),
envLine("NO_PROXY", "127.0.0.1,localhost,::1"),
envLine("NODE_USE_ENV_PROXY", "1"),
envLine("ANTHROPIC_DEFAULT_SONNET_MODEL", "anthropic/claude-sonnet-5"),
envLine("CPH_SANDBOX_EXTRA_DENY_READ", `${envPath}:${keyringPath}`),
"",
].join("\n");
const bootstrap = {
organization: {
id: orgId,
slug: orgSlug,
name: required(answers, "ORGANIZATION_NAME"),
},
owner: {
openId: required(answers, "OWNER_OPEN_ID"),
displayName: required(answers, "OWNER_DISPLAY_NAME"),
},
feishu: {
appId: required(answers, "FEISHU_APP_ID"),
appSecret: required(answers, "FEISHU_APP_SECRET"),
botOpenId: required(answers, "FEISHU_BOT_OPEN_ID"),
},
provider: {
providerId: required(answers, "PROVIDER_ID"),
baseUrl: required(answers, "PROVIDER_BASE_URL"),
authToken: required(answers, "PROVIDER_AUTH_TOKEN"),
},
teams: [],
};
const ownerUnionId = optional(answers, "OWNER_UNION_ID");
if (ownerUnionId) bootstrap.owner.unionId = ownerUnionId;
const deployEnv = [
"# Source this file locally before deploy_platform.sh (contains no app/provider secrets).",
envLine("PLATFORM_DEPLOY_HOST", required(answers, "DEPLOY_HOST")),
envLine("PLATFORM_DEPLOY_SSH_KEY", required(answers, "DEPLOY_SSH_KEY")),
envLine("PLATFORM_DEPLOY_USER", required(answers, "DEPLOY_USER")),
envLine("PLATFORM_DEPLOY_PORT", required(answers, "DEPLOY_SSH_PORT")),
envLine("PLATFORM_DEPLOY_HUB_PORT", port),
envLine("PLATFORM_DEPLOY_BASE", base),
envLine("PLATFORM_DEPLOY_RELEASE", release),
envLine("PLATFORM_DEPLOY_INSTANCE", instanceId),
envLine("PLATFORM_DEPLOY_WORKSPACE_ROOT", workspaceRoot),
envLine("PLATFORM_DEPLOY_MEMORY_MAX", required(answers, "MEMORY_MAX")),
envLine("PLATFORM_DEPLOY_CPU_QUOTA", required(answers, "CPU_QUOTA")),
envLine("PLATFORM_DEPLOY_TASKS_MAX", required(answers, "TASKS_MAX")),
envLine("PLATFORM_DEPLOY_HEALTH_URL", `http://127.0.0.1:${port}/api/healthz`),
"",
].join("\n");
const nginx = `# Install as /etc/nginx/conf.d/${instanceId}.conf after obtaining TLS certificates.\nserver {\n listen 80;\n server_name ${domain};\n return 301 https://$host$request_uri;\n}\n\nserver {\n listen 443 ssl http2;\n server_name ${domain};\n\n ssl_certificate /etc/letsencrypt/live/${domain}/fullchain.pem;\n ssl_certificate_key /etc/letsencrypt/live/${domain}/privkey.pem;\n\n location / {\n proxy_pass http://127.0.0.1:${port};\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto https;\n }\n}\n`;
const defaultRolePrompt = `你是一位教育机构智能助手,服务于学校和教培机构的日常管理与协作。
## 核心能力
**教务管理**:熟悉排课调课、班级管理、课时统计、师资调度等常见管理流程。
**教研支持**:理解课程设计、教案编写、例题/变式题、定理证明、随堂练习、阶段性测验、知识点拆解、大纲对标等教学业务概念。
**数字化操作**:熟练使用shell命令行进行文件管理、批量处理、脚本编写、文本处理(grep/sed/awk等)、自动化任务;能协助处理Markdown、Typst等文档、表格数据、格式转换等。
## 行为准则
- **实事求是**:不确定时明确说明,能力边界外的任务如实告知,不为完成目标而编造信息或勉强输出
- **简洁优先**:直接给出答案或方案,省略铺垫和客套
- **按需展开**:仅在问题复杂或用户明确要求时提供详细说明
- **结构清晰**:多步骤内容使用编号,便于飞书阅读
- **可操作性**:涉及操作时给出具体命令或步骤
保持专业、高效,像一位熟悉业务的同事。
`;
const operate = `# ${instanceId} deployment commands\n\nThis file contains no application/provider secrets. Run commands deliberately; do not source answers.env.\n\n## 1. DNS and Feishu\n\n- Point \`${domain}\` to \`${required(answers, "DEPLOY_HOST")}\`.\n- In the Feishu app, configure redirect URL: \`${publicBaseUrl}/auth/feishu/callback\`.\n- Enable the required bot/message/contact permissions and event subscription described in the customer setup document.\n\n## 2. Database (run as the PostgreSQL administrator)\n\nCreate one role and one database for this Silo. The password is in \`answers.env\`; use an interactive client or a protected SQL file, never a command-line argument.\n\n\`\`\`sql\nCREATE ROLE ${required(answers, "DATABASE_USER")} LOGIN PASSWORD '<copy DATABASE_PASSWORD from answers.env>';\nCREATE DATABASE ${required(answers, "DATABASE_NAME")} OWNER ${required(answers, "DATABASE_USER")};\n\`\`\`\n\n## 3. Publish the immutable release\n\nThe normal deploy script seeds the first-instance secrets and exits 78. That exit is expected only on this first pass.\n\n\`\`\`sh\nset -a; . ./deploy.env; set +a\nbash hub/deploy/deploy_platform.sh\n\`\`\`\n\n## 4. Install the generated secrets\n\nCopy \`platform.env\` and \`bootstrap.json\` to the server through a protected channel. On the server:\n\n\`\`\`sh\ninstall -d -o root -g root -m 0700 '${secretDir}'\ninstall -o root -g root -m 0600 platform.env '${envPath}'\ninstall -o root -g root -m 0600 bootstrap.json '/root/${instanceId}-bootstrap.json'\n# Copy ${keyringPath} to separate off-host recovery storage before continuing.\n\`\`\`\n\n## 5. Migrate, install the stopped service, and bootstrap\n\n\`\`\`sh\nset -a; . '${envPath}'; set +a\nnode '${releaseHub}/node_modules/prisma/build/index.js' migrate deploy --schema '${releaseHub}/prisma/schema.prisma'\nBASE='${base}' HUB_DIR='${releaseHub}' INSTANCE_ID='${instanceId}' WORKSPACE_ROOT='${workspaceRoot}' PORT='${port}' MEMORY_MAX='${required(answers, "MEMORY_MAX")}' CPU_QUOTA='${required(answers, "CPU_QUOTA")}' TASKS_MAX='${required(answers, "TASKS_MAX")}' bash '${releaseHub}/deploy/install_service.sh'\nnode '${releaseHub}/dist/deployment/bootstrap-silo-cli.js' --config-file '/root/${instanceId}-bootstrap.json' --keyring-file '${keyringPath}'\nrm -f '/root/${instanceId}-bootstrap.json'\n\`\`\`\n\n## 6. Runtime role and skills\n\nRuntime role/skill configuration is intentionally separate from the release. Follow NEW_SILO_RUNBOOK.md, staging each approved skill directory outside the immutable release, then use \`agent_config.sh\`. Do not silently copy a local personal skill collection.\n\n## 7. Nginx, start, and acceptance\n\nInstall \`nginx.conf\`, run \`nginx -t\`, reload Nginx, then:\n\n\`\`\`sh\nsystemctl start '${unit}'\nsystemctl is-active '${unit}'\ncurl --fail --silent --show-error 'http://127.0.0.1:${port}/api/healthz'\ncurl --fail --silent --show-error '${publicBaseUrl}/api/healthz'\njournalctl -u '${unit}' --since '-10 min' --no-pager\n\`\`\`\n\nAcceptance requires: Feishu OAuth completes, OWNER and a non-OWNER can @bot, a second group creates a separate project/session, Typst works when that skill is enabled, and restart preserves session cursor. Then take the first off-host backup.\n\n## 8. Later releases\n\nAfter the instance exists, source \`deploy.env\` with the new release id and run \`deploy_platform.sh\`. Never reuse another org's database, secret directory, workspace root, port, service identity, domain, Feishu app, or provider credential.\n`;
const selectedSkills = optional(answers, "APPROVED_SKILLS");
const roleInstructions = `
## Appendix: default runtime role
Copy \`default-role-prompt.md\` to \`/root/${instanceId}-default-role-prompt.md\`.
After installing each reviewed skill with \`agent_config.sh install-skill\`, run:
\`\`\`sh
INSTANCE_ID=${JSON.stringify(instanceId)} ENV_FILE=${JSON.stringify(envPath)} HUB_DIR=${JSON.stringify(releaseHub)} bash ${JSON.stringify(`${releaseHub}/deploy/agent_config.sh`)} upsert-role --organization ${JSON.stringify(orgId)} --role ${JSON.stringify(required(answers, "DEFAULT_ROLE_ID"))} --label ${JSON.stringify(required(answers, "DEFAULT_ROLE_LABEL"))} --model ${JSON.stringify(required(answers, "DEFAULT_MODEL"))} --system-prompt-file ${JSON.stringify(`/root/${instanceId}-default-role-prompt.md`)} --tools-json null
${selectedSkills ? `INSTANCE_ID=${JSON.stringify(instanceId)} ENV_FILE=${JSON.stringify(envPath)} HUB_DIR=${JSON.stringify(releaseHub)} bash ${JSON.stringify(`${releaseHub}/deploy/agent_config.sh`)} set-role-skills --organization ${JSON.stringify(orgId)} --role ${JSON.stringify(required(answers, "DEFAULT_ROLE_ID"))} --skills ${JSON.stringify(selectedSkills)}
` : "# No skills selected in the wizard; set-role-skills remains an explicit operator step.\n"}INSTANCE_ID=${JSON.stringify(instanceId)} ENV_FILE=${JSON.stringify(envPath)} HUB_DIR=${JSON.stringify(releaseHub)} bash ${JSON.stringify(`${releaseHub}/deploy/agent_config.sh`)} list --organization ${JSON.stringify(orgId)}
\`\`\`
Changing a role's model, system prompt, tools or skill selection archives that
role's existing sessions by design. Finish this setup before inviting Alpha users.
`;
async function privateWrite(name, contents) {
const path = resolve(outputDir, name);
await writeFile(path, contents, { encoding: "utf8", mode: 0o600 });
await chmod(path, 0o600);
}
await privateWrite("platform.env", platformEnv);
await privateWrite("bootstrap.json", `${JSON.stringify(bootstrap, null, 2)}\n`);
await privateWrite("deploy.env", deployEnv);
await privateWrite("nginx.conf", nginx);
await privateWrite("default-role-prompt.md", defaultRolePrompt);
await privateWrite("OPERATE.md", `${operate}${roleInstructions}`);
console.log(`Rendered private Silo bundle: ${outputDir}`);
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env node
const chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
const [appId, appSecret] = Buffer.concat(chunks).toString("utf8").split("\0");
if (!appId || !appSecret) throw new Error("Feishu App ID and App Secret are required on stdin");
const tokenResponse = await fetch("https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", {
method: "POST",
headers: { "content-type": "application/json; charset=utf-8" },
body: JSON.stringify({ app_id: appId, app_secret: appSecret }),
});
if (!tokenResponse.ok) throw new Error(`Feishu token request failed: HTTP ${tokenResponse.status}`);
const tokenPayload = await tokenResponse.json();
if (tokenPayload.code !== 0 || typeof tokenPayload.tenant_access_token !== "string") {
throw new Error(`Feishu token request failed: ${JSON.stringify(tokenPayload)}`);
}
const botResponse = await fetch("https://open.feishu.cn/open-apis/bot/v3/info", {
headers: { authorization: `Bearer ${tokenPayload.tenant_access_token}` },
});
if (!botResponse.ok) throw new Error(`Feishu bot info request failed: HTTP ${botResponse.status}`);
const botPayload = await botResponse.json();
if (botPayload.code !== 0 || typeof botPayload.bot?.open_id !== "string" || !botPayload.bot.open_id) {
throw new Error(`Feishu bot info request failed: ${JSON.stringify(botPayload)}`);
}
process.stdout.write(botPayload.bot.open_id);
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@paradigm/hub",
"version": "0.0.8",
"version": "0.0.22",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@paradigm/hub",
"version": "0.0.8",
"version": "0.0.22",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.202",
"@fastify/cookie": "^11.0.2",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@paradigm/hub",
"version": "0.0.8",
"version": "0.0.22",
"private": true,
"type": "module",
"engines": {
@@ -38,6 +38,7 @@
"prisma:validate": "DATABASE_URL=${DATABASE_URL:-postgresql://stub:stub@127.0.0.1:5432/stub} prisma validate --schema prisma/schema.prisma",
"prisma:migrate": "DATABASE_URL=${DATABASE_URL:-postgresql://paradigm:paradigm@127.0.0.1:5432/paradigm} prisma migrate deploy --schema prisma/schema.prisma",
"secrets:rotate-kek": "node dist/deployment/rotate-secret-kek.js",
"agent-config": "node dist/deployment/agent-config-cli.js",
"silo:bootstrap": "node dist/deployment/bootstrap-silo-cli.js",
"silo:restore-preflight": "node dist/deployment/restore-preflight.js",
"deploy": "bash deploy/deploy_platform.sh",
@@ -0,0 +1,71 @@
-- ADR-0017: Organization-scoped runtime role bundles and content-addressed
-- skills. Composite foreign keys make cross-Organization role/skill bindings
-- structurally impossible.
CREATE TABLE "OrganizationAgentSkill" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"version" TEXT NOT NULL,
"description" TEXT,
"contentDigest" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"disabledAt" TIMESTAMP(3),
CONSTRAINT "OrganizationAgentSkill_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "OrganizationAgentRole" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"label" TEXT NOT NULL,
"defaultModel" TEXT,
"systemPrompt" TEXT,
"tools" JSONB,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"disabledAt" TIMESTAMP(3),
CONSTRAINT "OrganizationAgentRole_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "OrganizationAgentRoleSkill" (
"organizationId" TEXT NOT NULL,
"agentRoleId" TEXT NOT NULL,
"agentSkillId" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "OrganizationAgentRoleSkill_pkey" PRIMARY KEY ("organizationId", "agentRoleId", "agentSkillId")
);
CREATE UNIQUE INDEX "OrganizationAgentSkill_organizationId_name_key" ON "OrganizationAgentSkill"("organizationId", "name");
CREATE UNIQUE INDEX "OrganizationAgentSkill_organizationId_id_key" ON "OrganizationAgentSkill"("organizationId", "id");
CREATE INDEX "OrganizationAgentSkill_organizationId_disabledAt_idx" ON "OrganizationAgentSkill"("organizationId", "disabledAt");
CREATE INDEX "OrganizationAgentSkill_contentDigest_idx" ON "OrganizationAgentSkill"("contentDigest");
CREATE UNIQUE INDEX "OrganizationAgentRole_organizationId_roleId_key" ON "OrganizationAgentRole"("organizationId", "roleId");
CREATE UNIQUE INDEX "OrganizationAgentRole_organizationId_id_key" ON "OrganizationAgentRole"("organizationId", "id");
CREATE INDEX "OrganizationAgentRole_organizationId_disabledAt_sortOrder_idx" ON "OrganizationAgentRole"("organizationId", "disabledAt", "sortOrder");
CREATE INDEX "OrganizationAgentRoleSkill_organizationId_agentRoleId_sortOrder_idx" ON "OrganizationAgentRoleSkill"("organizationId", "agentRoleId", "sortOrder");
CREATE INDEX "OrganizationAgentRoleSkill_organizationId_agentSkillId_idx" ON "OrganizationAgentRoleSkill"("organizationId", "agentSkillId");
ALTER TABLE "OrganizationAgentSkill" ADD CONSTRAINT "OrganizationAgentSkill_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "OrganizationAgentRole" ADD CONSTRAINT "OrganizationAgentRole_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "OrganizationAgentRoleSkill" ADD CONSTRAINT "OrganizationAgentRoleSkill_organizationId_agentRoleId_fkey"
FOREIGN KEY ("organizationId", "agentRoleId") REFERENCES "OrganizationAgentRole"("organizationId", "id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "OrganizationAgentRoleSkill" ADD CONSTRAINT "OrganizationAgentRoleSkill_organizationId_agentSkillId_fkey"
FOREIGN KEY ("organizationId", "agentSkillId") REFERENCES "OrganizationAgentSkill"("organizationId", "id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Preserve current alpha behavior while moving role definitions into data.
INSERT INTO "OrganizationAgentRole" (
"id", "organizationId", "roleId", "label", "sortOrder", "updatedAt"
)
SELECT "id" || ':agent-role:draft', "id", 'draft', '草稿', 10, CURRENT_TIMESTAMP
FROM "Organization";
INSERT INTO "OrganizationAgentRole" (
"id", "organizationId", "roleId", "label", "sortOrder", "updatedAt"
)
SELECT "id" || ':agent-role:review', "id", 'review', '审校', 20, CURRENT_TIMESTAMP
FROM "Organization";
+66
View File
@@ -43,6 +43,8 @@ model Organization {
externalDirectoryConnections ExternalDirectoryConnection[]
providerConnections OrganizationProviderConnection[]
feishuApplicationConnection OrganizationFeishuApplicationConnection?
agentSkills OrganizationAgentSkill[]
agentRoles OrganizationAgentRole[]
auditEntries AuditEntry[] @relation("organizationAudit")
@@index([status])
@@ -78,6 +80,70 @@ enum OrganizationMemberRole {
MEMBER
}
/// Organization-scoped, content-addressed Agent skill registration. The DB is
/// the runtime registry; `contentDigest` selects an immutable directory below
/// the platform-controlled skill store and is never interpreted as a path.
model OrganizationAgentSkill {
id String @id @default(cuid())
organizationId String
name String
version String
description String?
contentDigest String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
disabledAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
roleBindings OrganizationAgentRoleSkill[]
@@unique([organizationId, name])
@@unique([organizationId, id])
@@index([organizationId, disabledAt])
@@index([contentDigest])
}
/// ADR-0017 runtime role bundle. Roles are Organization-owned data rather than
/// a code enum: model, system prompt, tool allowlist and skill selection change
/// without a Hub release or process restart.
model OrganizationAgentRole {
id String @id @default(cuid())
organizationId String
roleId String
label String
defaultModel String?
systemPrompt String?
tools Json?
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
disabledAt DateTime?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
skillBindings OrganizationAgentRoleSkill[]
@@unique([organizationId, roleId])
@@unique([organizationId, id])
@@index([organizationId, disabledAt, sortOrder])
}
/// Same-Organization join enforced by both composite foreign keys. `sortOrder`
/// gives stable skill listing and prompt discovery order for a role bundle.
model OrganizationAgentRoleSkill {
organizationId String
agentRoleId String
agentSkillId String
sortOrder Int @default(0)
createdAt DateTime @default(now())
role OrganizationAgentRole @relation(fields: [organizationId, agentRoleId], references: [organizationId, id], onDelete: Cascade)
skill OrganizationAgentSkill @relation(fields: [organizationId, agentSkillId], references: [organizationId, id], onDelete: Cascade)
@@id([organizationId, agentRoleId, agentSkillId])
@@index([organizationId, agentRoleId, sortOrder])
@@index([organizationId, agentSkillId])
}
/// ADR-0021: org-level project onboarding policy. Ordinary Feishu users can
/// create projects from unbound chats only when membersCanCreateProjects=true.
model OrganizationProjectSettings {
+75 -6
View File
@@ -5,7 +5,7 @@ import { randomBytes } from "node:crypto";
import type { PrismaClient } from "@prisma/client";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { resolveActiveFeishuApplication } from "../../connections/feishuApplicationConnections.js";
import { upsertScopedFeishuIdentity } from "../../feishu/identityNamespace.js";
import { upsertScopedFeishuIdentityInTransaction } from "../../feishu/identityNamespace.js";
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
import {
buildAuthorizeUrl,
@@ -164,16 +164,54 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute
const feishuUser = await exchangeCodeForUser(oauthConfig, code);
let userId: string;
if (statePayload.connectionId !== undefined && statePayload.organizationId !== undefined) {
const identity = await upsertScopedFeishuIdentity(config.prisma, {
connectionId: statePayload.connectionId,
const connectionId = statePayload.connectionId;
const organizationId = statePayload.organizationId;
const identity = await config.prisma.$transaction(async (tx) => {
const resolved = await upsertScopedFeishuIdentityInTransaction(tx, {
connectionId,
expectedOrganizationId: organizationId,
openId: feishuUser.openId,
...(feishuUser.unionId !== undefined ? { unionId: feishuUser.unionId } : {}),
displayName: feishuUser.displayName,
...(feishuUser.avatarUrl !== null ? { avatarUrl: feishuUser.avatarUrl } : {}),
});
if (identity.organizationId !== statePayload.organizationId) {
throw new HttpError(400, "bad_request", "OAuth identity Organization scope mismatch");
const activeMembership = await tx.organizationMembership.findFirst({
where: {
organizationId,
userId: resolved.userId,
revokedAt: null,
},
select: { id: true },
});
if (activeMembership === null) {
const revokedMembership = await tx.organizationMembership.findFirst({
where: {
organizationId,
userId: resolved.userId,
revokedAt: { not: null },
},
select: { id: true },
});
if (revokedMembership === null) {
await tx.organizationMembership.create({
data: {
organizationId,
userId: resolved.userId,
role: "MEMBER",
},
});
await tx.auditEntry.create({
data: {
organizationId,
actorUserId: resolved.userId,
action: "organization_member.oauth_auto_joined",
metadata: { connectionId: resolved.connectionId, role: "MEMBER" },
},
});
}
}
return resolved;
});
userId = identity.userId;
setSessionCookie(reply, config, {
userId,
@@ -235,6 +273,34 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute
return reply.status(204).send();
});
app.get("/auth/feishu/complete", async (request, reply) => {
const query = request.query as { org?: string };
const organizationName = typeof query.org === "string" && query.org.trim() !== ""
? query.org.trim()
: "当前组织";
return reply.type("text/html").send(`<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Educraft 登录成功</title>
<style>
body{font-family:system-ui,sans-serif;display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0;background:#f6f7f9;color:#1a1a1a}
.card{background:#fff;padding:2rem 2.5rem;border-radius:12px;box-shadow:0 8px 24px rgba(0,0,0,.08);max-width:25rem;text-align:center}
.ok{font-size:3rem;margin:0 0 .5rem}.hint{color:#646a73;line-height:1.6}
</style>
</head>
<body>
<main class="card">
<p class="ok">✅</p>
<h1>登录并加入组织成功</h1>
<p>你已加入 ${escapeHtml(organizationName)}。</p>
<p class="hint">现在可以关闭本页面,返回飞书群再次 @机器人继续使用。</p>
</main>
</body>
</html>`);
});
app.get("/api/me", async (request, reply) => {
try {
const auth = await requireSession(request, reply, guardDeps);
@@ -363,10 +429,13 @@ async function resolvePostLoginRedirect(
revokedAt: null,
organization: { status: "ACTIVE" },
},
select: { organization: { select: { slug: true } } },
select: { organization: { select: { slug: true, name: true } } },
});
if (intended === null) return "/admin?error=not_an_active_org_member";
const orgRoot = `/admin/org/${intended.organization.slug}`;
if (returnTo === "/admin") {
return `/auth/feishu/complete?org=${encodeURIComponent(intended.organization.name)}`;
}
return returnTo === orgRoot || returnTo.startsWith(`${orgRoot}/`) ? returnTo : orgRoot;
}
if (returnTo !== "/admin" && returnTo.startsWith("/admin")) {
+260
View File
@@ -0,0 +1,260 @@
import type { PrismaClient } from "@prisma/client";
import { Prisma } from "@prisma/client";
import { assertSupportedRoleTools } from "./roleTools.js";
import { importSkillDirectory } from "./skillStore.js";
const ROLE_ID_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
/**
* Deep module for controlled host-console Agent configuration. It owns the
* filesystem/DB ordering, Organization checks and role-skill composition so
* callers never manipulate registry rows or content paths independently.
*/
export class OrganizationAgentConfiguration {
constructor(
private readonly prisma: PrismaClient,
private readonly skillStoreRoot: string,
) {}
async installSkill(input: {
readonly organizationId: string;
readonly sourceDir: string;
readonly version: string;
}): Promise<{ readonly id: string; readonly name: string; readonly contentDigest: string }> {
await this.requireActiveOrganization(input.organizationId);
const version = nonEmpty(input.version, "skill version");
const imported = await importSkillDirectory({
sourceDir: input.sourceDir,
storeRoot: this.skillStoreRoot,
});
return this.prisma.$transaction(async (tx) => {
const previous = await tx.organizationAgentSkill.findUnique({
where: { organizationId_name: { organizationId: input.organizationId, name: imported.name } },
select: { contentDigest: true },
});
const skill = await tx.organizationAgentSkill.upsert({
where: {
organizationId_name: {
organizationId: input.organizationId,
name: imported.name,
},
},
create: {
organizationId: input.organizationId,
name: imported.name,
version,
description: imported.description ?? null,
contentDigest: imported.contentDigest,
},
update: {
version,
description: imported.description ?? null,
contentDigest: imported.contentDigest,
disabledAt: null,
},
select: {
id: true,
name: true,
contentDigest: true,
roleBindings: { select: { role: { select: { roleId: true } } } },
},
});
if (previous !== null && previous.contentDigest !== skill.contentDigest) {
await archiveRoleSessions(
tx,
input.organizationId,
skill.roleBindings.map((binding) => binding.role.roleId),
);
}
await tx.auditEntry.create({
data: {
organizationId: input.organizationId,
action: "agent_skill.installed",
metadata: {
name: skill.name,
version,
contentDigest: skill.contentDigest,
},
},
});
return { id: skill.id, name: skill.name, contentDigest: skill.contentDigest };
});
}
async upsertRole(input: {
readonly organizationId: string;
readonly roleId: string;
readonly label: string;
readonly defaultModel?: string | null | undefined;
readonly systemPrompt?: string | null | undefined;
readonly tools?: readonly string[] | null | undefined;
readonly sortOrder?: number | undefined;
}): Promise<{ readonly id: string; readonly roleId: string }> {
await this.requireActiveOrganization(input.organizationId);
if (!ROLE_ID_PATTERN.test(input.roleId)) throw new Error(`invalid role id: ${input.roleId}`);
const label = nonEmpty(input.label, "role label");
if (input.tools !== undefined && input.tools !== null) assertSupportedRoleTools([...input.tools]);
const sortOrder = input.sortOrder ?? 0;
if (!Number.isSafeInteger(sortOrder)) throw new Error("role sortOrder must be an integer");
const createTools = input.tools === undefined || input.tools === null
? Prisma.DbNull
: [...input.tools];
const updateTools = input.tools === undefined
? undefined
: input.tools === null
? Prisma.DbNull
: [...input.tools];
return this.prisma.$transaction(async (tx) => {
const previous = await tx.organizationAgentRole.findUnique({
where: { organizationId_roleId: { organizationId: input.organizationId, roleId: input.roleId } },
select: { defaultModel: true, systemPrompt: true, tools: true },
});
const role = await tx.organizationAgentRole.upsert({
where: {
organizationId_roleId: {
organizationId: input.organizationId,
roleId: input.roleId,
},
},
create: {
organizationId: input.organizationId,
roleId: input.roleId,
label,
defaultModel: normalizeOptionalText(input.defaultModel),
systemPrompt: normalizeOptionalText(input.systemPrompt),
tools: createTools,
sortOrder,
},
update: {
label,
...(input.defaultModel !== undefined ? { defaultModel: normalizeOptionalText(input.defaultModel) } : {}),
...(input.systemPrompt !== undefined ? { systemPrompt: normalizeOptionalText(input.systemPrompt) } : {}),
...(updateTools !== undefined ? { tools: updateTools } : {}),
sortOrder,
disabledAt: null,
},
select: { id: true, roleId: true },
});
const executionSurfaceChanged = previous !== null && (
(input.defaultModel !== undefined && normalizeOptionalText(input.defaultModel) !== previous.defaultModel) ||
(input.systemPrompt !== undefined && normalizeOptionalText(input.systemPrompt) !== previous.systemPrompt) ||
(input.tools !== undefined && JSON.stringify(input.tools) !== JSON.stringify(previous.tools))
);
if (executionSurfaceChanged) await archiveRoleSessions(tx, input.organizationId, [input.roleId]);
await tx.auditEntry.create({
data: {
organizationId: input.organizationId,
action: "agent_role.upserted",
metadata: {
roleId: input.roleId,
label,
defaultModel: input.defaultModel === undefined ? "unchanged" : normalizeOptionalText(input.defaultModel),
systemPromptConfigured: input.systemPrompt === undefined
? "unchanged"
: normalizeOptionalText(input.systemPrompt) !== null,
tools: input.tools === undefined ? "unchanged" : input.tools === null ? "all" : [...input.tools],
sortOrder,
},
},
});
return role;
});
}
async setRoleSkills(input: {
readonly organizationId: string;
readonly roleId: string;
readonly skillNames: readonly string[];
}): Promise<void> {
const uniqueNames = new Set(input.skillNames);
if (uniqueNames.size !== input.skillNames.length) throw new Error("role skill names must be unique");
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}`);
}
const skills = await tx.organizationAgentSkill.findMany({
where: {
organizationId: input.organizationId,
name: { in: [...input.skillNames] },
disabledAt: null,
},
select: { id: true, name: true },
});
if (skills.length !== input.skillNames.length) {
const found = new Set(skills.map((skill) => skill.name));
const missing = input.skillNames.filter((name) => !found.has(name));
throw new Error(`active skills not found in organization: ${missing.join(", ")}`);
}
const byName = new Map(skills.map((skill) => [skill.name, skill.id]));
await tx.organizationAgentRoleSkill.deleteMany({
where: { organizationId: input.organizationId, agentRoleId: role.id },
});
if (input.skillNames.length > 0) {
await tx.organizationAgentRoleSkill.createMany({
data: input.skillNames.map((name, index) => ({
organizationId: input.organizationId,
agentRoleId: role.id,
agentSkillId: byName.get(name)!,
sortOrder: index,
})),
});
}
await archiveRoleSessions(tx, input.organizationId, [input.roleId]);
await tx.auditEntry.create({
data: {
organizationId: input.organizationId,
action: "agent_role.skills_set",
metadata: { roleId: input.roleId, skillNames: [...input.skillNames] },
},
});
});
}
private async requireActiveOrganization(organizationId: string): Promise<void> {
const organization = await this.prisma.organization.findUnique({
where: { id: organizationId },
select: { status: true },
});
if (organization === null) throw new Error(`organization not found: ${organizationId}`);
if (organization.status !== "ACTIVE") {
throw new Error(`organization ${organizationId} is ${organization.status}`);
}
}
}
async function archiveRoleSessions(
tx: Prisma.TransactionClient,
organizationId: string,
roleIds: readonly string[],
): Promise<void> {
if (roleIds.length === 0) return;
await tx.agentSession.updateMany({
where: {
roleId: { in: [...new Set(roleIds)] },
archivedAt: null,
project: { organizationId },
},
data: { archivedAt: new Date() },
});
}
function nonEmpty(value: string, label: string): string {
const normalized = value.trim();
if (normalized === "") throw new Error(`${label} is required`);
return normalized;
}
function normalizeOptionalText(value: string | null | undefined): string | null {
if (value === undefined || value === null) return null;
const normalized = value.trim();
return normalized === "" ? null : normalized;
}
-87
View File
@@ -1,87 +0,0 @@
import { lstat, readFile, readdir } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
export const CURATED_SKILL_PLUGIN_NAME = "cph-curated";
export const CURATED_SKILL_NAMES = [
"outline",
"lesson-project",
"data-processing-spec",
] as const;
export const CURATED_SKILL_IDS = CURATED_SKILL_NAMES.map(
(name) => `${CURATED_SKILL_PLUGIN_NAME}:${name}`,
);
export interface CuratedSkillPlugin {
readonly root: string;
readonly skillIds: readonly string[];
}
/** Validate the immutable, release-owned plugin before exposing it read-only. */
export async function validateCuratedSkillPlugin(
root = curatedSkillPluginRoot(),
): Promise<CuratedSkillPlugin> {
await assertExactDirectoryEntries(root, [".claude-plugin", "skills"], "");
await assertExactDirectoryEntries(join(root, ".claude-plugin"), ["plugin.json"], ".claude-plugin");
await assertExactDirectoryEntries(join(root, "skills"), CURATED_SKILL_NAMES, "skills");
const pluginManifest = join(root, ".claude-plugin", "plugin.json");
let pluginName: unknown;
try {
const stat = await lstat(pluginManifest);
if (!stat.isFile()) throw new Error(`not a regular file: ${pluginManifest}`);
pluginName = JSON.parse(await readFile(pluginManifest, "utf8")).name;
} catch (error) {
throw new Error(`curated skill plugin manifest invalid: ${pluginManifest}`, { cause: error });
}
if (pluginName !== CURATED_SKILL_PLUGIN_NAME) {
throw new Error(`curated skill plugin name mismatch: expected ${CURATED_SKILL_PLUGIN_NAME}, got ${String(pluginName)}`);
}
for (const name of CURATED_SKILL_NAMES) {
const manifest = join(root, "skills", name, "SKILL.md");
try {
const stat = await lstat(manifest);
if (!stat.isFile()) throw new Error(`not a regular file: ${manifest}`);
const contents = await readFile(manifest, "utf8");
const declaredName = /^name:\s*['"]?([^'"\r\n]+)['"]?\s*$/m.exec(contents)?.[1]?.trim();
if (declaredName !== name) {
throw new Error(`curated skill manifest name mismatch: expected ${name}, got ${declaredName ?? "missing"}`);
}
} catch (error) {
if (error instanceof Error && error.message.startsWith("curated skill manifest name mismatch:")) throw error;
throw new Error(`curated skill source missing: ${manifest}`, { cause: error });
}
}
return { root, skillIds: CURATED_SKILL_IDS };
}
export function curatedSkillPluginRoot(): string {
return fileURLToPath(new URL("../../curated-skills-plugin/", import.meta.url));
}
async function assertExactDirectoryEntries(
directory: string,
allowedNames: readonly string[],
relativeDirectory: string,
): Promise<void> {
let entries;
try {
entries = await readdir(directory, { withFileTypes: true });
} catch (error) {
throw new Error(`curated plugin directory missing: ${directory}`, { cause: error });
}
const allowed = new Set(allowedNames);
for (const entry of entries) {
if (!allowed.has(entry.name)) {
const relativePath = relativeDirectory === "" ? entry.name : `${relativeDirectory}/${entry.name}`;
throw new Error(`unexpected curated plugin entry: ${relativePath}`);
}
}
for (const name of allowedNames) {
if (!entries.some((entry) => entry.name === name)) {
const relativePath = relativeDirectory === "" ? name : `${relativeDirectory}/${name}`;
throw new Error(`curated plugin entry missing: ${relativePath}`);
}
}
}
+8
View File
@@ -40,6 +40,14 @@ export interface RoleEntry {
* Invalid names fail fast when settings are loaded or the run is set up.
*/
readonly tools?: readonly string[] | undefined;
/** Immutable skill versions selected by this role at runtime. */
readonly skills?: readonly RoleSkillEntry[] | undefined;
}
export interface RoleSkillEntry {
readonly name: string;
readonly version: string;
readonly contentDigest: string;
}
/** A model the admin has enabled for use by the Hub. */
+16 -2
View File
@@ -33,6 +33,7 @@ import { query, type HookCallback, type McpServerConfig, type SDKMessage, type S
import type { PrismaClient } from "@prisma/client";
import { claudeSdkToolConfigForRole } from "./roleTools.js";
import { createAgentSecurityPolicy } from "./security.js";
import type { RoleSkillEntry } from "./models.js";
export interface ProjectContext {
readonly projectId: string;
@@ -66,6 +67,7 @@ export interface RunRequest {
* means no tools.
*/
readonly tools?: readonly string[] | undefined;
readonly skills?: readonly RoleSkillEntry[] | undefined;
readonly mcpServers?: Record<string, McpServerConfig> | undefined;
readonly maxTurns?: number;
readonly runId: string;
@@ -135,6 +137,7 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
let sdkSessionId: string | undefined;
let initializedSkillIds: readonly string[] | undefined;
let error: string | undefined;
let cleanupSecurity = async (): Promise<void> => {};
try {
await persistAgentMessage(req, "user", req.prompt);
const toolConfig = claudeSdkToolConfigForRole(req.tools);
@@ -143,15 +146,21 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
throw new Error("Agent run requires the configured workspace root");
}
const security = await createAgentSecurityPolicy({
runId: req.runId,
workspaceRoot,
workspaceDir: req.project.workspaceDir,
skills: req.skills,
providerProxyEnv: req.providerProxyEnv,
});
cleanupSecurity = security.cleanup;
const hasSkills = security.skillIds.length > 0;
type QueryOptions = NonNullable<Parameters<typeof query>[0]["options"]>;
const options: QueryOptions = {
cwd: security.cwd,
tools: [...toolConfig.tools],
// `skills` controls discovery/allowlisting, but an explicit `tools`
// list still has to expose the Skill dispatcher itself.
tools: [...toolConfig.tools, ...(hasSkills ? ["Skill"] : [])],
allowedTools: [...toolConfig.allowedTools],
maxTurns: cap,
includePartialMessages: true,
@@ -169,7 +178,10 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
// The project workspace is untrusted input. Do not load user/project
// settings that could widen tools, hooks, MCP servers, or sandbox paths.
settingSources: [],
plugins: [{ type: "local", path: security.skillPluginRoot, skipMcpDiscovery: true }],
settings: { disableBundledSkills: true },
...(hasSkills && security.skillPluginRoot !== undefined
? { plugins: [{ type: "local" as const, path: security.skillPluginRoot, skipMcpDiscovery: true }] }
: {}),
skills: [...security.skillIds],
strictMcpConfig: true,
// Claude Code 2.1.202 can honor the per-call opt-out despite
@@ -313,6 +325,8 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
...(initializedSkillIds !== undefined ? { initializedSkillIds } : {}),
...(aborted ? {} : { error: e instanceof Error ? e.message : String(e) }),
};
} finally {
await cleanupSecurity();
}
}
+18 -6
View File
@@ -1,7 +1,8 @@
import { chmod, lstat, mkdir, realpath } from "node:fs/promises";
import { homedir } from "node:os";
import { isAbsolute, join, relative, resolve } from "node:path";
import { validateCuratedSkillPlugin } from "./curatedSkills.js";
import type { RoleSkillEntry } from "./models.js";
import { prepareRunSkillPlugin, readSkillStoreRoot } from "./skillStore.js";
const PROVIDER_ENV_KEYS = new Set([
"ANTHROPIC_BASE_URL",
@@ -33,8 +34,10 @@ const SANDBOX_HIDDEN_ENV_KEYS = [
const MAX_AGENT_TMP_PREFIX_BYTES = 56;
export interface AgentSecurityInput {
readonly runId: string;
readonly workspaceRoot: string;
readonly workspaceDir: string;
readonly skills?: readonly RoleSkillEntry[] | undefined;
/** Run-scoped loopback proxy capability; customer provider secrets are forbidden here. */
readonly providerProxyEnv?: Readonly<Record<string, string | undefined>> | undefined;
readonly hostEnv?: Readonly<Record<string, string | undefined>> | undefined;
@@ -62,7 +65,8 @@ export interface AgentSecurityPolicy {
readonly workspaceRoot: string;
readonly env: Record<string, string | undefined>;
readonly skillIds: readonly string[];
readonly skillPluginRoot: string;
readonly skillPluginRoot?: string | undefined;
cleanup(): Promise<void>;
readonly sandbox: AgentSandboxPolicy;
}
@@ -82,7 +86,6 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
const agentState = await ensureDirectoryTree(runtimeRoot, ["state"]);
const agentTmp = await ensureDirectoryTree(cphRoot, ["t"]);
assertShortAgentTemp(agentTmp);
const skillPlugin = await validateCuratedSkillPlugin();
const path = hostEnv["PATH"]?.trim();
if (path === undefined || path === "") {
@@ -119,12 +122,21 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
const sensitiveReadPaths = hostSensitiveReadPaths(hostEnv);
const runtimeReadPaths = hostRuntimeReadPaths(hostEnv);
const selectedSkills = input.skills ?? [];
const skillPlugin = selectedSkills.length === 0
? null
: await prepareRunSkillPlugin({
storeRoot: readSkillStoreRoot(hostEnv),
runId: input.runId,
skills: selectedSkills,
});
return {
cwd: workspaceDir,
workspaceRoot,
env,
skillIds: skillPlugin.skillIds,
skillPluginRoot: skillPlugin.root,
skillIds: skillPlugin?.skillIds ?? [],
...(skillPlugin !== null ? { skillPluginRoot: skillPlugin.root } : {}),
cleanup: skillPlugin?.cleanup ?? (async () => {}),
sandbox: {
enabled: true,
failIfUnavailable: true,
@@ -140,7 +152,7 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
// workspace plus the named system runtime needed to execute tools.
// SDK allowRead takes precedence over matching denyRead paths.
denyRead: ["/"],
allowRead: [workspaceDir, skillPlugin.root, ...runtimeReadPaths],
allowRead: [workspaceDir, ...(skillPlugin !== null ? [skillPlugin.root] : []), ...runtimeReadPaths],
},
credentials: {
files: sensitiveReadPaths.map((path) => ({ path, mode: "deny" as const })),
+217
View File
@@ -0,0 +1,217 @@
import { createHash, randomUUID } from "node:crypto";
import {
cp,
lstat,
mkdir,
readFile,
readdir,
rename,
rm,
writeFile,
} from "node:fs/promises";
import { join, relative, resolve } from "node:path";
import type { RoleSkillEntry } from "./models.js";
const MAX_SKILL_FILES = 512;
const MAX_SKILL_BYTES = 16 * 1024 * 1024;
const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
const DIGEST_PATTERN = /^[a-f0-9]{64}$/;
const RUNTIME_PLUGIN_NAME = "cph-runtime";
export interface ImportedSkillContent {
readonly name: string;
readonly description: string | undefined;
readonly contentDigest: string;
}
export interface RunSkillPlugin {
readonly root: string;
readonly skillIds: readonly string[];
cleanup(): Promise<void>;
}
export async function importSkillDirectory(input: {
readonly sourceDir: string;
readonly storeRoot: string;
}): Promise<ImportedSkillContent> {
const source = await inspectSkillDirectory(input.sourceDir);
const versionsRoot = join(input.storeRoot, "versions");
await mkdir(versionsRoot, { recursive: true, mode: 0o750 });
const destination = join(versionsRoot, source.contentDigest);
try {
const existing = await inspectSkillDirectory(destination);
if (existing.contentDigest !== source.contentDigest || existing.name !== source.name) {
throw new Error(`stored skill content digest mismatch: ${source.name}`);
}
return source;
} catch (error) {
if (!isMissingPath(error)) throw error;
}
const temporary = join(versionsRoot, `.tmp-${randomUUID()}`);
try {
await cp(input.sourceDir, temporary, { recursive: true, force: false, errorOnExist: true });
const copied = await inspectSkillDirectory(temporary);
if (copied.contentDigest !== source.contentDigest || copied.name !== source.name) {
throw new Error(`skill changed while importing: ${source.name}`);
}
await rename(temporary, destination);
} catch (error) {
await rm(temporary, { recursive: true, force: true });
if (isDestinationExists(error)) {
const existing = await inspectSkillDirectory(destination);
if (existing.contentDigest === source.contentDigest && existing.name === source.name) return source;
}
throw error;
}
return source;
}
export async function prepareRunSkillPlugin(input: {
readonly storeRoot: string;
readonly runId: string;
readonly skills: readonly RoleSkillEntry[];
}): Promise<RunSkillPlugin | null> {
if (input.skills.length === 0) return null;
const names = new Set<string>();
for (const skill of input.skills) {
requireSkillName(skill.name);
if (!DIGEST_PATTERN.test(skill.contentDigest)) {
throw new Error(`skill ${skill.name} has invalid content digest`);
}
if (names.has(skill.name)) throw new Error(`duplicate role skill: ${skill.name}`);
names.add(skill.name);
}
const runtimeRoot = join(input.storeRoot, "runtime");
await mkdir(runtimeRoot, { recursive: true, mode: 0o750 });
const pluginRoot = join(runtimeRoot, `run-${randomUUID()}`);
try {
await mkdir(join(pluginRoot, ".claude-plugin"), { recursive: true, mode: 0o750 });
await mkdir(join(pluginRoot, "skills"), { recursive: true, mode: 0o750 });
await writeFile(
join(pluginRoot, ".claude-plugin", "plugin.json"),
`${JSON.stringify({
name: RUNTIME_PLUGIN_NAME,
description: `Runtime skill snapshot for ${input.runId}`,
version: "1",
}, null, 2)}\n`,
{ mode: 0o640 },
);
for (const skill of input.skills) {
const sourceDir = join(input.storeRoot, "versions", skill.contentDigest);
const stored = await inspectSkillDirectory(sourceDir);
if (stored.contentDigest !== skill.contentDigest) {
throw new Error(`skill ${skill.name} content digest mismatch`);
}
if (stored.name !== skill.name) {
throw new Error(`skill name mismatch: expected ${skill.name}, got ${stored.name}`);
}
await cp(sourceDir, join(pluginRoot, "skills", skill.name), {
recursive: true,
force: false,
errorOnExist: true,
});
}
} catch (error) {
await rm(pluginRoot, { recursive: true, force: true });
throw error;
}
return {
root: pluginRoot,
skillIds: input.skills.map((skill) => `${RUNTIME_PLUGIN_NAME}:${skill.name}`),
async cleanup() {
await rm(pluginRoot, { recursive: true, force: true });
},
};
}
export function readSkillStoreRoot(env: Readonly<Record<string, string | undefined>> = process.env): string {
const configured = env["HUB_SKILL_STORE_ROOT"]?.trim();
if (configured !== undefined && configured !== "") return resolve(configured);
const stateRoot = env["XDG_STATE_HOME"]?.trim();
if (stateRoot === undefined || stateRoot === "") {
throw new Error("HUB_SKILL_STORE_ROOT or XDG_STATE_HOME is required");
}
return resolve(stateRoot, "skills");
}
export async function verifyStoredSkill(input: {
readonly storeRoot: string;
readonly name: string;
readonly contentDigest: string;
}): Promise<void> {
requireSkillName(input.name);
if (!DIGEST_PATTERN.test(input.contentDigest)) {
throw new Error(`skill ${input.name} has invalid content digest`);
}
const stored = await inspectSkillDirectory(join(input.storeRoot, "versions", input.contentDigest));
if (stored.name !== input.name || stored.contentDigest !== input.contentDigest) {
throw new Error(`stored skill verification failed: ${input.name}`);
}
}
async function inspectSkillDirectory(directory: string): Promise<ImportedSkillContent> {
const root = resolve(directory);
const rootStat = await lstat(root);
if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
throw new Error(`skill root must be a real directory: ${root}`);
}
const files: Array<{ readonly path: string; readonly bytes: Buffer }> = [];
await walk(root, root, files);
if (files.length > MAX_SKILL_FILES) throw new Error(`skill has too many files: ${files.length}`);
const totalBytes = files.reduce((sum, file) => sum + file.bytes.byteLength, 0);
if (totalBytes > MAX_SKILL_BYTES) throw new Error(`skill is too large: ${totalBytes} bytes`);
const manifest = files.find((file) => file.path === "SKILL.md");
if (manifest === undefined) throw new Error(`skill manifest missing: ${join(root, "SKILL.md")}`);
const frontmatter = manifest.bytes.toString("utf8");
const name = /^name:\s*['"]?([^'"\r\n]+)['"]?\s*$/m.exec(frontmatter)?.[1]?.trim();
if (name === undefined) throw new Error("skill manifest name missing");
requireSkillName(name);
const description = /^description:\s*['"]?([^'"\r\n]+)['"]?\s*$/m.exec(frontmatter)?.[1]?.trim();
const hash = createHash("sha256");
for (const file of files.sort((left, right) => left.path.localeCompare(right.path))) {
hash.update(`${Buffer.byteLength(file.path)}:`);
hash.update(file.path);
hash.update(`${file.bytes.byteLength}:`);
hash.update(file.bytes);
}
return { name, description, contentDigest: hash.digest("hex") };
}
async function walk(
root: string,
directory: string,
files: Array<{ readonly path: string; readonly bytes: Buffer }>,
): Promise<void> {
const entries = await readdir(directory, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(directory, entry.name);
if (entry.isSymbolicLink()) throw new Error(`skill symlink is forbidden: ${fullPath}`);
if (entry.isDirectory()) {
await walk(root, fullPath, files);
continue;
}
if (!entry.isFile()) throw new Error(`skill contains unsupported filesystem entry: ${fullPath}`);
const relativePath = relative(root, fullPath);
files.push({ path: relativePath, bytes: await readFile(fullPath) });
if (files.length > MAX_SKILL_FILES) throw new Error(`skill has too many files: ${files.length}`);
}
}
function requireSkillName(name: string): void {
if (!SKILL_NAME_PATTERN.test(name)) throw new Error(`invalid skill name: ${name}`);
}
function isMissingPath(error: unknown): boolean {
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
}
function isDestinationExists(error: unknown): boolean {
return typeof error === "object" && error !== null && "code" in error &&
(error.code === "EEXIST" || error.code === "ENOTEMPTY");
}
+157
View File
@@ -0,0 +1,157 @@
import { readFile } from "node:fs/promises";
import { prisma } from "../db.js";
import { OrganizationAgentConfiguration } from "../agent/configuration.js";
import { readSkillStoreRoot, verifyStoredSkill } from "../agent/skillStore.js";
import { readSiloOrganizationId } from "./silo.js";
async function main(argv: readonly string[]): Promise<void> {
const [command, ...args] = argv;
if (command === undefined || command === "help" || command === "--help") {
printHelp();
return;
}
const options = parseOptions(args);
const organizationId = required(options, "organization");
const siloOrganizationId = readSiloOrganizationId();
if (organizationId !== siloOrganizationId) {
throw new Error(`Silo Agent configuration is restricted to ${siloOrganizationId}`);
}
const configuration = new OrganizationAgentConfiguration(prisma, readSkillStoreRoot());
switch (command) {
case "install-skill": {
const installed = await configuration.installSkill({
organizationId,
sourceDir: required(options, "source"),
version: required(options, "version"),
});
console.log(JSON.stringify(installed));
return;
}
case "upsert-role": {
const systemPromptFile = options.get("system-prompt-file");
const toolsJson = options.get("tools-json");
const tools = toolsJson === undefined
? undefined
: toolsJson === "null"
? null
: parseTools(toolsJson);
const sortOrderRaw = options.get("sort-order");
const role = await configuration.upsertRole({
organizationId,
roleId: required(options, "role"),
label: required(options, "label"),
...(options.has("model") ? { defaultModel: options.get("model") ?? null } : {}),
...(systemPromptFile !== undefined
? { systemPrompt: await readFile(systemPromptFile, "utf8") }
: {}),
...(tools !== undefined ? { tools } : {}),
...(sortOrderRaw !== undefined ? { sortOrder: integer(sortOrderRaw, "sort-order") } : {}),
});
console.log(JSON.stringify(role));
return;
}
case "set-role-skills": {
const skills = required(options, "skills").split(",").map((name) => name.trim()).filter(Boolean);
await configuration.setRoleSkills({
organizationId,
roleId: required(options, "role"),
skillNames: skills,
});
console.log(JSON.stringify({ roleId: required(options, "role"), skills }));
return;
}
case "list": {
const roles = await prisma.organizationAgentRole.findMany({
where: { organizationId },
orderBy: [{ sortOrder: "asc" }, { roleId: "asc" }],
include: {
skillBindings: {
orderBy: [{ sortOrder: "asc" }, { agentSkillId: "asc" }],
include: { skill: { select: { name: true, version: true, disabledAt: true } } },
},
},
});
console.log(JSON.stringify(roles.map((role) => ({
roleId: role.roleId,
label: role.label,
defaultModel: role.defaultModel,
systemPromptConfigured: role.systemPrompt !== null,
tools: role.tools,
disabled: role.disabledAt !== null,
skills: role.skillBindings.map((binding) => ({
name: binding.skill.name,
version: binding.skill.version,
disabled: binding.skill.disabledAt !== null,
})),
})), null, 2));
return;
}
case "verify-store": {
const skills = await prisma.organizationAgentSkill.findMany({
where: { organizationId, disabledAt: null },
select: { name: true, contentDigest: true },
});
for (const skill of skills) {
await verifyStoredSkill({ storeRoot: readSkillStoreRoot(), ...skill });
}
console.log(JSON.stringify({ verifiedSkills: skills.length }));
return;
}
default:
throw new Error(`unknown Agent configuration command: ${command}`);
}
}
function parseOptions(args: readonly string[]): Map<string, string> {
const options = new Map<string, string>();
for (let index = 0; index < args.length; index += 2) {
const flag = args[index];
const value = args[index + 1];
if (flag === undefined || !flag.startsWith("--") || value === undefined) {
throw new Error(`expected --name value, got: ${args.slice(index).join(" ")}`);
}
const name = flag.slice(2);
if (options.has(name)) throw new Error(`duplicate option: --${name}`);
options.set(name, value);
}
return options;
}
function required(options: ReadonlyMap<string, string>, name: string): string {
const value = options.get(name)?.trim();
if (value === undefined || value === "") throw new Error(`--${name} is required`);
return value;
}
function parseTools(raw: string): readonly string[] {
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed) || parsed.some((value) => typeof value !== "string")) {
throw new Error("--tools-json must be null or a JSON string array");
}
return parsed;
}
function integer(raw: string, name: string): number {
const value = Number(raw);
if (!Number.isSafeInteger(value)) throw new Error(`--${name} must be an integer`);
return value;
}
function printHelp(): void {
console.log(`Usage:
agent-config install-skill --organization ORG --source DIR --version VERSION
agent-config upsert-role --organization ORG --role ID --label LABEL [--model MODEL] [--system-prompt-file FILE] [--tools-json JSON] [--sort-order N]
agent-config set-role-skills --organization ORG --role ID --skills name,name
agent-config list --organization ORG
agent-config verify-store --organization ORG`);
}
main(process.argv.slice(2))
.catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
});
+16
View File
@@ -226,6 +226,22 @@ async function initializeSilo(
const count = await tx.organization.count();
if (count !== 0) throw new Error(`Silo bootstrap requires an empty Organization set; found ${count}`);
await tx.organization.create({ data: { ...input.organization } });
await tx.organizationAgentRole.createMany({
data: [
{
organizationId: input.organization.id,
roleId: "draft",
label: "草稿",
sortOrder: 10,
},
{
organizationId: input.organization.id,
roleId: "review",
label: "审校",
sortOrder: 20,
},
],
});
await tx.organizationProjectSettings.create({
data: { organizationId: input.organization.id, membersCanCreateProjects: true },
});
@@ -0,0 +1,48 @@
import { readFile } from "node:fs/promises";
import { prisma } from "../db.js";
import { importLegacyProjects, type LegacyProjectManifestEntry } from "./legacyProjectImport.js";
import { readSiloOrganizationId } from "./silo.js";
async function main(argv: readonly string[]): Promise<void> {
const options = parseOptions(argv);
const organizationId = readSiloOrganizationId();
const manifest = JSON.parse(await readFile(required(options, "manifest"), "utf8")) as unknown;
if (!Array.isArray(manifest)) throw new Error("legacy import manifest must be a JSON array");
const state = await importLegacyProjects({
prisma,
organizationId,
actorFeishuOpenId: required(options, "actor-open-id"),
workspaceRoot: required(options, "workspace-root"),
sourceRoot: required(options, "source-root"),
stateFile: required(options, "state-file"),
projects: manifest as LegacyProjectManifestEntry[],
onProgress: (message) => console.error(`[legacy-import] ${message}`),
});
console.log(JSON.stringify({ imported: Object.keys(state.projects).length }));
}
function parseOptions(args: readonly string[]): Map<string, string> {
const options = new Map<string, string>();
for (let index = 0; index < args.length; index += 2) {
const flag = args[index];
const value = args[index + 1];
if (flag === undefined || !flag.startsWith("--") || value === undefined) {
throw new Error(`expected --name value, got: ${args.slice(index).join(" ")}`);
}
options.set(flag.slice(2), value);
}
return options;
}
function required(options: ReadonlyMap<string, string>, name: string): string {
const value = options.get(name)?.trim();
if (!value) throw new Error(`--${name} is required`);
return value;
}
main(process.argv.slice(2))
.catch((error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
})
.finally(async () => prisma.$disconnect());
+369
View File
@@ -0,0 +1,369 @@
import { createHash } from "node:crypto";
import { cp, lstat, mkdir, readFile, readdir, realpath, rename, rm, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import type { PrismaClient } from "@prisma/client";
import { createFolder, createProjectFromOrgAdmin } from "../projectOnboarding.js";
export interface LegacyProjectManifestEntry {
readonly legacyId: string;
readonly name: string;
readonly folderPath: readonly string[];
readonly sourceRelativePath: string;
}
export interface LegacyProjectImportState {
readonly version: 1;
readonly projects: Readonly<Record<string, {
readonly status: "PENDING" | "COMPLETED";
readonly projectId: string;
readonly workspaceDir: string;
readonly importedAt: string;
readonly sourceRelativePath: string;
}>>;
}
export async function importLegacyProjects(input: {
readonly prisma: PrismaClient;
readonly organizationId: string;
readonly actorFeishuOpenId: string;
readonly workspaceRoot: string;
readonly sourceRoot: string;
readonly stateFile: string;
readonly projects: readonly LegacyProjectManifestEntry[];
readonly onProgress?: (message: string) => void;
}): Promise<LegacyProjectImportState> {
const sourceRoot = await realpath(input.sourceRoot);
const state = await readState(input.stateFile);
const projects = { ...state.projects };
const seen = new Set<string>();
for (const entry of input.projects) {
validateEntry(entry);
if (seen.has(entry.legacyId)) throw new Error(`duplicate legacy project id: ${entry.legacyId}`);
seen.add(entry.legacyId);
const sourceDir = await confinedSourceDir(sourceRoot, entry.sourceRelativePath);
const projectId = importedProjectId(input.organizationId, entry.legacyId);
const existing = await input.prisma.project.findUnique({ where: { id: projectId } });
const recorded = projects[entry.legacyId];
if (recorded !== undefined && (recorded.projectId !== projectId || recorded.sourceRelativePath !== entry.sourceRelativePath)) {
throw new Error(`legacy import state identity conflict: ${entry.legacyId}`);
}
if (recorded?.status === "COMPLETED" && existing === null) {
throw new Error(`legacy import state references missing project: ${entry.legacyId} -> ${recorded.projectId}`);
}
if (existing !== null) {
if (existing.organizationId !== input.organizationId || (recorded !== undefined && recorded.projectId !== existing.id)) {
throw new Error(`legacy import identity conflict: ${entry.legacyId} -> ${existing.id}`);
}
if (await hasCompletionMarker(existing.workspaceDir, entry)) {
await ensureImportAudit(input.prisma, existing.id, entry);
projects[entry.legacyId] = {
status: "COMPLETED",
projectId: existing.id,
workspaceDir: existing.workspaceDir,
importedAt: recorded?.importedAt || new Date().toISOString(),
sourceRelativePath: entry.sourceRelativePath,
};
await writeState(input.stateFile, { version: 1, projects });
input.onProgress?.(`skip ${entry.legacyId}: recovered completed import`);
continue;
}
if (recorded?.status !== "PENDING") {
throw new Error(`refusing to remove legacy project without a matching pending record: ${entry.legacyId}`);
}
input.onProgress?.(`recover ${entry.legacyId}: remove incomplete target`);
await removeIncompleteTarget(input.prisma, existing.id, existing.workspaceDir, input.workspaceRoot);
}
projects[entry.legacyId] = {
status: "PENDING",
projectId,
workspaceDir: "",
importedAt: "",
sourceRelativePath: entry.sourceRelativePath,
};
await writeState(input.stateFile, { version: 1, projects });
const folderId = await ensureFolderPath(input.prisma, input.organizationId, ["旧教学资产", ...entry.folderPath]);
input.onProgress?.(`import ${entry.legacyId}: ${entry.name}`);
const created = await createProjectFromOrgAdmin(input.prisma, {
organizationId: input.organizationId,
actorFeishuOpenId: input.actorFeishuOpenId,
name: entry.name,
workspaceRoot: input.workspaceRoot,
folderId,
projectId,
});
try {
await copyLegacyProject(sourceDir, created.workspaceDir, entry);
} catch (error) {
try {
await removeIncompleteTarget(input.prisma, created.projectId, created.workspaceDir, input.workspaceRoot);
delete projects[entry.legacyId];
await writeState(input.stateFile, { version: 1, projects });
} catch (cleanupError) {
throw new AggregateError(
[error, cleanupError],
`legacy project import and target cleanup failed: ${entry.legacyId}`,
);
}
throw new Error(`legacy project import failed: ${entry.legacyId}: ${errorMessage(error)}`, { cause: error });
}
await ensureImportAudit(input.prisma, created.projectId, entry);
projects[entry.legacyId] = {
status: "COMPLETED",
projectId: created.projectId,
workspaceDir: created.workspaceDir,
importedAt: new Date().toISOString(),
sourceRelativePath: entry.sourceRelativePath,
};
await writeState(input.stateFile, { version: 1, projects });
}
return { version: 1, projects };
}
async function ensureFolderPath(
prisma: PrismaClient,
organizationId: string,
parts: readonly string[],
): Promise<string> {
let parentId: string | undefined;
for (const name of parts) {
const existing = await prisma.folder.findFirst({
where: { organizationId, parentId: parentId ?? null, name, archivedAt: null },
select: { id: true },
});
if (existing !== null) {
parentId = existing.id;
continue;
}
const created = await createFolder(prisma, {
organizationId,
name,
...(parentId !== undefined ? { parentId } : {}),
});
parentId = created.id;
}
if (parentId === undefined) throw new Error("legacy import folder path is empty");
return parentId;
}
async function copyLegacyProject(
sourceDir: string,
workspaceDir: string,
entry: LegacyProjectManifestEntry,
): Promise<void> {
const sourceWorkspace = join(sourceDir, "workspace");
await assertNoSymlinks(sourceWorkspace);
const names = await readdir(sourceWorkspace);
for (const name of names) {
if (name === ".claude" || name === ".cph") continue;
await cp(join(sourceWorkspace, name), join(workspaceDir, name), {
recursive: true,
force: false,
errorOnExist: true,
preserveTimestamps: true,
filter: (source) => {
const parts = relative(sourceWorkspace, source).split(sep);
return !parts.includes(".claude") && !parts.includes(".cph");
},
});
}
const legacyDir = join(workspaceDir, ".legacy-source");
await mkdir(legacyDir, { mode: 0o750 });
await cp(join(sourceDir, "project.json"), join(legacyDir, "project.json"), {
force: false,
errorOnExist: true,
preserveTimestamps: true,
});
const rawDir = join(sourceDir, "_raw");
await assertNoSymlinks(rawDir).catch((error: unknown) => {
if (isMissing(error)) return;
throw error;
});
await cp(rawDir, join(legacyDir, "raw"), {
recursive: true,
force: false,
errorOnExist: true,
preserveTimestamps: true,
}).catch((error: unknown) => {
if (isMissing(error)) return;
throw error;
});
await writeFile(join(legacyDir, "migration.json"), `${JSON.stringify({
source: "teaching-material-host-service",
legacyProjectId: entry.legacyId,
legacyPath: entry.sourceRelativePath,
migratedAt: new Date().toISOString(),
}, null, 2)}\n`, { mode: 0o640 });
}
function importedProjectId(organizationId: string, legacyId: string): string {
const digest = createHash("sha256")
.update("teaching-material-host-service\0")
.update(organizationId)
.update("\0")
.update(legacyId)
.digest("hex")
.slice(0, 32);
return `legacy_${digest}`;
}
async function hasCompletionMarker(
workspaceDir: string,
entry: LegacyProjectManifestEntry,
): Promise<boolean> {
try {
const marker = JSON.parse(await readFile(join(workspaceDir, ".legacy-source", "migration.json"), "utf8")) as unknown;
return typeof marker === "object" && marker !== null
&& "legacyProjectId" in marker && marker.legacyProjectId === entry.legacyId
&& "legacyPath" in marker && marker.legacyPath === entry.sourceRelativePath;
} catch (error) {
if (isMissing(error)) return false;
if (error instanceof SyntaxError) {
throw new Error(`invalid legacy completion marker: ${workspaceDir}`, { cause: error });
}
throw error;
}
}
async function ensureImportAudit(
prisma: PrismaClient,
projectId: string,
entry: LegacyProjectManifestEntry,
): Promise<void> {
const existing = await prisma.auditEntry.findFirst({
where: { projectId, action: "legacy_project.imported" },
select: { id: true },
});
if (existing !== null) return;
await prisma.auditEntry.create({
data: {
projectId,
action: "legacy_project.imported",
metadata: {
source: "teaching-material-host-service",
legacyProjectId: entry.legacyId,
legacyPath: entry.sourceRelativePath,
},
},
});
}
async function removeIncompleteTarget(
prisma: PrismaClient,
projectId: string,
workspaceDir: string,
workspaceRoot: string,
): Promise<void> {
await assertConfinedExistingPath(workspaceRoot, workspaceDir);
const failures: unknown[] = [];
try {
await prisma.project.delete({ where: { id: projectId } });
} catch (error) {
failures.push(error);
}
try {
await rm(workspaceDir, { recursive: true, force: true });
} catch (error) {
failures.push(error);
}
if (failures.length > 0) throw new AggregateError(failures, `failed to remove incomplete legacy target: ${projectId}`);
}
async function assertConfinedExistingPath(root: string, path: string): Promise<void> {
const trustedRoot = await realpath(root);
const candidate = await realpath(path);
const rel = relative(trustedRoot, candidate);
if (rel === "" || rel === ".." || rel.startsWith("../") || isAbsolute(rel)) {
throw new Error(`refusing to remove path outside workspace root: ${path}`);
}
}
async function assertNoSymlinks(path: string): Promise<void> {
const metadata = await lstat(path);
if (metadata.isSymbolicLink()) throw new Error(`legacy import rejects symbolic link: ${path}`);
if (!metadata.isDirectory()) return;
for (const entry of await readdir(path)) await assertNoSymlinks(join(path, entry));
}
async function confinedSourceDir(sourceRoot: string, relativePath: string): Promise<string> {
const candidate = await realpath(resolve(sourceRoot, relativePath));
const rel = relative(sourceRoot, candidate);
if (rel === "" || rel === ".." || rel.startsWith("../") || isAbsolute(rel)) {
throw new Error(`legacy source path escapes source root: ${relativePath}`);
}
return candidate;
}
function validateEntry(entry: LegacyProjectManifestEntry): void {
if (typeof entry !== "object" || entry === null) throw new Error("invalid legacy project entry");
if (typeof entry.legacyId !== "string") throw new Error("legacy project id must be a string");
if (!/^[A-Za-z0-9_-]+$/.test(entry.legacyId)) throw new Error(`invalid legacy project id: ${entry.legacyId}`);
if (typeof entry.name !== "string") throw new Error(`legacy project name must be a string: ${entry.legacyId}`);
if (entry.name.trim() === "") throw new Error(`legacy project name is empty: ${entry.legacyId}`);
if (typeof entry.sourceRelativePath !== "string") {
throw new Error(`legacy source path must be a string: ${entry.legacyId}`);
}
if (entry.sourceRelativePath === "" || resolve("/", entry.sourceRelativePath) === "/") {
throw new Error(`invalid legacy source path: ${entry.legacyId}`);
}
if (!Array.isArray(entry.folderPath)) throw new Error(`legacy folder path must be an array: ${entry.legacyId}`);
for (const part of entry.folderPath) {
if (typeof part !== "string" || part.trim() === "" || part === "." || part === ".." || part.includes("/") || part.includes("\\")) {
throw new Error(`invalid legacy folder part for ${entry.legacyId}: ${part}`);
}
}
}
async function readState(path: string): Promise<LegacyProjectImportState> {
try {
const parsed = JSON.parse(await readFile(path, "utf8")) as LegacyProjectImportState;
if (parsed.version !== 1 || typeof parsed.projects !== "object" || parsed.projects === null) {
throw new Error(`invalid legacy import state: ${path}`);
}
for (const [legacyId, record] of Object.entries(parsed.projects)) validateStateRecord(path, legacyId, record);
return parsed;
} catch (error) {
if (isMissing(error)) return { version: 1, projects: {} };
throw error;
}
}
function validateStateRecord(path: string, legacyId: string, record: unknown): void {
if (typeof record !== "object" || record === null || Array.isArray(record)) {
throw new Error(`invalid legacy import state record: ${path}#${legacyId}`);
}
const values = record as Record<string, unknown>;
const expectedKeys = ["importedAt", "projectId", "sourceRelativePath", "status", "workspaceDir"];
if (Object.keys(values).sort().join("\0") !== expectedKeys.join("\0")) {
throw new Error(`invalid legacy import state fields: ${path}#${legacyId}`);
}
if (values.status !== "PENDING" && values.status !== "COMPLETED") {
throw new Error(`invalid legacy import state status: ${path}#${legacyId}`);
}
for (const field of ["projectId", "workspaceDir", "importedAt", "sourceRelativePath"] as const) {
if (typeof values[field] !== "string") throw new Error(`invalid legacy import state ${field}: ${path}#${legacyId}`);
}
if (values.projectId === "" || values.sourceRelativePath === "") {
throw new Error(`invalid legacy import state identity: ${path}#${legacyId}`);
}
if (values.status === "PENDING" && (values.workspaceDir !== "" || values.importedAt !== "")) {
throw new Error(`invalid pending legacy import state: ${path}#${legacyId}`);
}
if (values.status === "COMPLETED" && (values.workspaceDir === "" || values.importedAt === "")) {
throw new Error(`invalid completed legacy import state: ${path}#${legacyId}`);
}
}
async function writeState(path: string, state: LegacyProjectImportState): Promise<void> {
await mkdir(dirname(path), { recursive: true, mode: 0o750 });
const temporary = `${path}.tmp`;
await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
await rename(temporary, path);
}
function isMissing(error: unknown): boolean {
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
+9 -1
View File
@@ -22,6 +22,7 @@ export function buildUnboundChatOnboardingCard(params: {
readonly folders: readonly OnboardingFolderOption[];
readonly projects: readonly OnboardingProjectOption[];
readonly canCreateProject: boolean;
readonly searchQuery?: string | undefined;
}): Record<string, unknown> {
const actions: unknown[] = [];
if (params.canCreateProject) {
@@ -64,7 +65,14 @@ export function buildUnboundChatOnboardingCard(params: {
`这个飞书群还没有绑定项目。`,
``,
`组织: **${escapeMarkdown(params.organizationName)}**`,
`可以选择 folder 新建项目并绑定到本群,或绑定你已经有管理权限的未绑定项目。`,
...(params.searchQuery === undefined || params.searchQuery === ""
? [`可以选择 folder 新建项目并绑定到本群,或绑定你已经有管理权限的未绑定项目。`]
: [
`项目搜索: **${escapeMarkdown(params.searchQuery)}**`,
params.projects.length === 0
? `没有匹配的未绑定项目。请 @bot 后换一个项目关键词。`
: `请选择匹配项目绑定到本群;如未找到,请 @bot 后换一个更具体的关键词。`,
]),
].join("\n"),
},
];
+11 -1
View File
@@ -391,10 +391,20 @@ function formatRoleSlashCommandHelp(role: RoleEntry): string {
if (role.defaultModel !== undefined) {
lines.push(`- 默认模型: ${role.defaultModel}`);
}
lines.push(`- 工具范围: ${roleToolsDescription(role)}`, "", `帮助: /help ${role.id} 或 /${role.id} help`);
lines.push(
`- 工具范围: ${roleToolsDescription(role)}`,
`- Skills: ${roleSkillsDescription(role)}`,
"",
`帮助: /help ${role.id} 或 /${role.id} help`,
);
return lines.join("\n");
}
function roleSkillsDescription(role: RoleEntry): string {
if (role.skills === undefined || role.skills.length === 0) return "无";
return role.skills.map((skill) => `${skill.name}@${skill.version}`).join(", ");
}
function roleToolsDescription(role: RoleEntry): string {
if (role.tools === undefined) return "全部已注册工具";
if (role.tools.length === 0) return "无";
+94 -14
View File
@@ -35,9 +35,7 @@ import type { RuntimeSettings } from "../settings/runtime.js";
import { currentLockRunId, releaseLock } from "../lock.js";
import { createPermissionAuthorizer, type AuthorizationDecision, type PermissionAuthorizer } from "../permission.js";
import { writeAudit } from "../audit.js";
import { formatRunCostLine } from "../agent/cost.js";
import { createAgentSdkStderrSink } from "../agent/diagnostics.js";
import { CURATED_SKILL_IDS } from "../agent/curatedSkills.js";
import { InactiveOrganizationError, lockActiveOrganization } from "../org/status.js";
import { StreamingAgentCard } from "./card/streaming-card.js";
import { createFileDeliveryMcpServer } from "./fileDeliveryTool.js";
@@ -86,6 +84,10 @@ interface TriggerDeps {
readonly messageBatcherOptions?: MessageBatcherOptions | undefined;
readonly triggerQueue?: TriggerQueue | undefined;
readonly projectWorkspaceRoot: string;
/** Public origin used to build the Silo Organization's Feishu OAuth entrypoint. */
readonly publicBaseUrl: string;
/** The single Organization this Alpha Silo is fail-closed to serve. */
readonly siloOrganizationId: string;
/** Alpha Silo policy: never acknowledge work into the process-local queue. */
readonly rejectWhenBusy?: boolean | undefined;
readonly resourceLimits?: {
@@ -134,6 +136,9 @@ export interface TriggerHandler {
export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
const projectWorkspaceRoot = deps.projectWorkspaceRoot.trim();
if (projectWorkspaceRoot === "") throw new Error("projectWorkspaceRoot is required");
const publicBaseUrl = parsePublicBaseUrl(deps.publicBaseUrl);
const siloOrganizationId = deps.siloOrganizationId.trim();
if (siloOrganizationId === "") throw new Error("siloOrganizationId is required");
const authorizer = deps.authorizer ?? createPermissionAuthorizer(deps.prisma);
const runAgent = deps.runAgent ?? defaultRunAgent;
const approvalManager = new ApprovalManager();
@@ -406,7 +411,11 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
metadata: {
roleId,
model,
requestedSkills: [...CURATED_SKILL_IDS],
requestedSkills: (role?.skills ?? []).map((skill) => ({
name: skill.name,
version: skill.version,
contentDigest: skill.contentDigest,
})),
prompt: agentPrompt.slice(0, 200),
sender: senderMetadata,
feishuTriggerContext,
@@ -482,6 +491,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
providerProxyEnv: { ...providerLease.sdkEnv },
resumeSessionId: sessionMetadata.claudeSessionId,
tools: roleTools,
skills: role?.skills,
mcpServers: { cph_hub: fileDeliveryMcpServer },
maxTurns: runPolicy.maxTurns,
runId: run.id,
@@ -539,7 +549,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
: result.status === "failed" && result.error !== undefined
? `\u5904\u7406\u5931\u8D25: ${result.error}`
: result.text;
await card.finish(finalText, { interrupted, footerText: formatRunCostLine(result.costUsd) });
await card.finish(finalText, { interrupted });
const metadataPatch = sessionMetadataPatch(result.sdkSessionId);
if (metadataPatch !== null) {
await deps.prisma.agentSession.update({
@@ -932,6 +942,15 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
await sendText(rt, chatId, "无法识别发送者,拒绝触发。", sendOptionsForTriggerMessage(msg));
return;
}
const organizationResolution = await resolveSingleActiveOrganizationForFeishuUser(senderOpenId);
if (organizationResolution.status === "error" && organizationResolution.reason !== "organization_unavailable") {
deps.logger.info(
{ projectId, senderOpenId, reason: organizationResolution.reason },
"feishu trigger: actor onboarding required before project authorization",
);
await sendText(rt, chatId, organizationResolution.message, sendOptionsForTriggerMessage(msg));
return;
}
const actor = { feishuOpenId: senderOpenId, chatId };
const triggerDecision = await authorizer.can({
actor,
@@ -1051,10 +1070,12 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
const settings = await ensureOrganizationProjectSettings(deps.prisma, organization.organizationId);
const canCreateProject = settings.membersCanCreateProjects || isOrgAdminRole(organization.role);
const searchQuery = (extractPrompt(msg) ?? "").trim().slice(0, 100);
const projects = await listBindableProjectsForActor({
organizationId: organization.organizationId,
actorFeishuOpenId: senderOpenId,
isOrgAdmin: isOrgAdminRole(organization.role),
searchQuery,
});
const folders = await listCreatableRootFolders(organization.organizationId);
@@ -1067,6 +1088,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
folders,
projects,
canCreateProject,
searchQuery,
}),
sendOptionsForTriggerMessage(msg),
);
@@ -1079,17 +1101,33 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
readonly organizationName: string;
readonly role: "OWNER" | "ADMIN" | "MEMBER";
}
| { readonly status: "error"; readonly message: string }
| {
readonly status: "error";
readonly reason: "identity_missing" | "membership_missing" | "organization_unavailable";
readonly message: string;
}
> {
const siloOrganization = await deps.prisma.organization.findFirst({
where: { id: siloOrganizationId, status: "ACTIVE" },
select: { id: true, slug: true },
});
if (siloOrganization === null) {
return {
status: "error",
reason: "organization_unavailable",
message: "组织当前不可用,请联系 Educraft 运维人员。",
};
}
const identity = await deps.prisma.feishuUserIdentity.findFirst({
where: {
openId: feishuOpenId,
connection: { status: "ACTIVE", organization: { status: "ACTIVE" } },
connection: { status: "ACTIVE", organizationId: siloOrganization.id },
},
select: {
user: { select: { organizationMemberships: {
where: {
revokedAt: null,
organizationId: siloOrganization.id,
organization: { status: "ACTIVE" },
},
select: {
@@ -1105,19 +1143,34 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
where: { feishuOpenId },
select: {
organizationMemberships: {
where: { revokedAt: null, organization: { status: "ACTIVE" } },
where: {
revokedAt: null,
organizationId: siloOrganization.id,
organization: { status: "ACTIVE" },
},
select: { role: true, organization: { select: { id: true, name: true } } },
orderBy: { createdAt: "asc" },
},
},
})
: null);
if (user === null) return { status: "error", message: "请先登录并加入组织后,再绑定项目。" };
if (user.organizationMemberships.length === 0) {
return { status: "error", message: "你还不属于任何可用组织,请联系组织管理员。" };
if (user === null) {
const loginUrl = new URL(
`/auth/feishu/${encodeURIComponent(siloOrganization.slug)}`,
publicBaseUrl,
).toString();
return {
status: "error",
reason: "identity_missing",
message: `请先通过飞书登录并加入组织:${loginUrl}\n完成后返回群聊重试。`,
};
}
if (user.organizationMemberships.length > 1) {
return { status: "error", message: "你属于多个组织。请先从组织后台选择项目绑定,或等待多组织选择入口。" };
if (user.organizationMemberships.length === 0) {
return {
status: "error",
reason: "membership_missing",
message: "你尚未加入该组织,或成员资格已被移除。请联系组织管理员。",
};
}
const membership = user.organizationMemberships[0]!;
return {
@@ -1132,22 +1185,32 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
readonly organizationId: string;
readonly actorFeishuOpenId: string;
readonly isOrgAdmin: boolean;
readonly searchQuery: string;
}): Promise<readonly OnboardingProjectOption[]> {
const allowed: OnboardingProjectOption[] = [];
let cursor: string | undefined;
while (allowed.length < 5) {
const candidates = await deps.prisma.project.findMany({
where: {
organizationId: input.organizationId,
archivedAt: null,
groupBindings: { none: { archivedAt: null } },
...(input.searchQuery === "" ? {} : {
OR: [
{ name: { contains: input.searchQuery, mode: "insensitive" } },
{ folder: { name: { contains: input.searchQuery, mode: "insensitive" } } },
],
}),
},
select: {
id: true,
name: true,
folder: { select: { name: true } },
},
orderBy: { updatedAt: "desc" },
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
take: 20,
...(cursor === undefined ? {} : { cursor: { id: cursor }, skip: 1 }),
});
const allowed: OnboardingProjectOption[] = [];
for (const project of candidates) {
if (!input.isOrgAdmin) {
const decision = await authorizer.can({
@@ -1164,6 +1227,10 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
});
if (allowed.length >= 5) break;
}
if (candidates.length < 20) break;
cursor = candidates.at(-1)?.id;
if (cursor === undefined) break;
}
return allowed;
}
@@ -1180,6 +1247,19 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
return Object.assign(onMessage, { onCardAction, approvalManager });
}
function parsePublicBaseUrl(raw: string): URL {
const value = raw.trim();
if (value === "") throw new Error("publicBaseUrl is required");
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error("publicBaseUrl must use http or https");
}
if (url.username !== "" || url.password !== "" || url.search !== "" || url.hash !== "" || url.pathname !== "/") {
throw new Error("publicBaseUrl must be an origin without credentials, path, query, or fragment");
}
return url;
}
interface SessionMetadata {
readonly claudeSessionId?: string | undefined;
}
+2
View File
@@ -160,6 +160,8 @@ export async function startHub(): Promise<void> {
settings: runtimeSettings,
logger: app.log,
projectWorkspaceRoot,
publicBaseUrl,
siloOrganizationId: siloOrganization.id,
rejectWhenBusy: true,
resourceLimits: { maxFilesPerMessage, maxBytesPerFile },
allowLegacyFeishuIdentity: false,
+5 -1
View File
@@ -27,6 +27,8 @@ export interface CreateOrgAdminProjectInput {
readonly workspaceRoot: string;
readonly folderId?: string | undefined;
readonly sortKey?: string | undefined;
/** Stable internal identifier for resumable imports; ordinary callers must omit it. */
readonly projectId?: string | undefined;
}
export interface CreateFeishuChatProjectInput {
@@ -147,6 +149,7 @@ export async function createProjectFromOrgAdmin(
workspaceRoot: input.workspaceRoot,
folderId: input.folderId,
sortKey: input.sortKey,
projectId: input.projectId,
chatId: undefined,
});
}
@@ -291,6 +294,7 @@ async function createManagedProject(
readonly workspaceRoot: string;
readonly folderId: string | undefined;
readonly sortKey?: string | undefined;
readonly projectId?: string | undefined;
readonly chatId: string | undefined;
},
): Promise<ProjectOnboardingResult> {
@@ -301,7 +305,7 @@ async function createManagedProject(
if (organization === null) throw new Error(`organization not found: ${input.organizationId}`);
requireActiveOrganizationStatus(organization.id, organization.status);
const projectId = createProjectId();
const projectId = input.projectId ?? createProjectId();
const workspaceDir = projectWorkspaceDir({
workspaceRoot: input.workspaceRoot,
organizationSlug: organization.slug,
+87 -3
View File
@@ -1,5 +1,5 @@
import type { Prisma, PrismaClient } from "@prisma/client";
import { InMemoryModelRegistry, type ModelRegistry } from "../agent/models.js";
import { InMemoryModelRegistry, type ModelRegistry, type RoleEntry, type RoleSkillEntry } from "../agent/models.js";
import { lockActiveOrganization } from "../org/status.js";
import { decryptStoredProviderCredential } from "../connections/providerConnections.js";
import { openProviderProxyLease, type AgentProviderLease, type ProviderUpstreamCredential } from "../connections/providerProxy.js";
@@ -141,8 +141,75 @@ export class DatabaseRuntimeSettings implements RuntimeSettings {
};
}
modelRegistry(scope?: RuntimeScope): Promise<ModelRegistry> {
return this.envSettings.modelRegistry(scope);
async modelRegistry(scope?: RuntimeScope): Promise<ModelRegistry> {
const projectId = scope?.projectId?.trim();
if (projectId === undefined || projectId === "") {
throw new Error("projectId is required to resolve Agent runtime configuration");
}
const project = await this.prisma.project.findUnique({
where: { id: projectId },
select: {
archivedAt: true,
organization: {
select: {
id: true,
status: true,
agentRoles: {
where: { disabledAt: null },
orderBy: [{ sortOrder: "asc" }, { roleId: "asc" }],
include: {
skillBindings: {
orderBy: [{ sortOrder: "asc" }, { agentSkillId: "asc" }],
include: { skill: true },
},
},
},
},
},
},
});
if (project === null || project.archivedAt !== null) {
throw new Error(`active project not found: ${projectId}`);
}
if (project.organization.status !== "ACTIVE") {
throw new Error(`organization ${project.organization.id} is ${project.organization.status}`);
}
if (project.organization.agentRoles.length === 0) {
throw new Error(`no active Agent roles configured for organization ${project.organization.id}`);
}
const defaults = await this.envSettings.modelRegistry(scope);
const enabledModels = new Set(defaults.listModels().map((model) => model.id));
const roles: RoleEntry[] = project.organization.agentRoles.map((role) => ({
id: role.roleId,
label: role.label,
defaultModel: validateRoleModel(role.roleId, role.defaultModel, enabledModels),
...(role.systemPrompt !== null ? { systemPrompt: role.systemPrompt } : {}),
...(role.tools !== null ? { tools: roleToolsFromJson(role.roleId, role.tools) } : {}),
skills: role.skillBindings.map((binding): RoleSkillEntry => {
if (binding.skill.disabledAt !== null) {
throw new Error(`role ${role.roleId} selects disabled skill ${binding.skill.name}`);
}
if (!/^[a-f0-9]{64}$/.test(binding.skill.contentDigest)) {
throw new Error(`skill ${binding.skill.name} has invalid content digest`);
}
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(binding.skill.name)) {
throw new Error(`skill has invalid name: ${binding.skill.name}`);
}
if (binding.skill.version.trim() === "") {
throw new Error(`skill ${binding.skill.name} has empty version`);
}
return {
name: binding.skill.name,
version: binding.skill.version,
contentDigest: binding.skill.contentDigest,
};
}),
}));
if (!roles.some((role) => role.id === "draft")) {
throw new Error(`default Agent role draft is not configured for organization ${project.organization.id}`);
}
return new InMemoryModelRegistry(defaults.listModels(), roles);
}
runPolicy(input: RunPolicyInput): Promise<RunPolicy> {
@@ -150,6 +217,23 @@ export class DatabaseRuntimeSettings implements RuntimeSettings {
}
}
function roleToolsFromJson(roleId: string, value: Prisma.JsonValue): readonly string[] {
if (!Array.isArray(value) || value.some((tool) => typeof tool !== "string")) {
throw new Error(`role ${roleId} tools must be a JSON string array`);
}
return value as string[];
}
function validateRoleModel(
roleId: string,
model: string | null,
enabledModels: ReadonlySet<string>,
): string | undefined {
if (model === null) return undefined;
if (!enabledModels.has(model)) throw new Error(`role ${roleId} selects unavailable model ${model}`);
return model;
}
async function loadActiveProviderSecret(
tx: Prisma.TransactionClient,
projectId: string,
+86 -4
View File
@@ -267,9 +267,6 @@ describe("admin auth + org API guards", () => {
openId: "ou_scoped_user",
displayName: "Invited User",
});
await prisma.organizationMembership.create({
data: { organizationId: DEFAULT_ORG_ID, userId: identity.userId, role: "ADMIN" },
});
await seedTestOrganization("org_scoped_other", "scoped-other");
await prisma.organizationMembership.create({
data: { organizationId: "org_scoped_other", userId: identity.userId, role: "ADMIN" },
@@ -317,7 +314,7 @@ describe("admin auth + org API guards", () => {
expect(me.statusCode).toBe(200);
expect(me.json()).toMatchObject({
user: { id: identity.userId, displayName: "Scoped User" },
organizations: [expect.objectContaining({ slug: "test-default", role: "ADMIN" })],
organizations: [expect.objectContaining({ slug: "test-default", role: "MEMBER" })],
});
expect((me.json() as { organizations: unknown[] }).organizations).toHaveLength(1);
const crossOrganization = await app.inject({
@@ -331,6 +328,32 @@ describe("admin auth + org API guards", () => {
where: { id: identity.identityId },
select: { unionId: true },
})).resolves.toEqual({ unionId: "on_scoped_union" });
await expect(prisma.auditEntry.count({
where: {
organizationId: DEFAULT_ORG_ID,
actorUserId: identity.userId,
action: "organization_member.oauth_auto_joined",
},
})).resolves.toBe(1);
const defaultStart = await app.inject({ method: "GET", url: "/auth/feishu/test-default" });
const defaultAuthorize = new URL(String(defaultStart.headers.location));
const defaultState = defaultAuthorize.searchParams.get("state");
expect(defaultState).not.toBeNull();
const defaultCallback = await app.inject({
method: "GET",
url: `/auth/feishu/callback?code=ok&state=${encodeURIComponent(defaultState!)}`,
headers: { cookie: cookiePair(defaultStart.headers["set-cookie"], OAUTH_STATE_COOKIE_NAME) },
});
expect(defaultCallback.statusCode).toBe(302);
expect(defaultCallback.headers.location).toBe(
"/auth/feishu/complete?org=Test%20Default%20Organization",
);
const complete = await app.inject({ method: "GET", url: defaultCallback.headers.location! });
expect(complete.statusCode).toBe(200);
expect(complete.headers["content-type"]).toContain("text/html");
expect(complete.body).toContain("登录并加入组织成功");
expect(complete.body).toContain("Test Default Organization");
await connections.disable({ organizationId: DEFAULT_ORG_ID, actorUserId: "scoped-owner" });
const revoked = await app.inject({ method: "GET", url: "/api/me", headers: { cookie: sessionCookie } });
@@ -340,6 +363,65 @@ describe("admin auth + org API guards", () => {
}
});
it("does not restore a revoked Organization membership during scoped OAuth", async () => {
await seedUser("revoked-owner", "legacy_revoked_owner", "OWNER");
const connections = new FeishuApplicationConnectionService(prisma, testSecretEnvelope, async () => {});
const connection = await connections.rotateCustomerApplication({
organizationId: DEFAULT_ORG_ID,
actorUserId: "revoked-owner",
appId: "cli_revoked_oauth",
appSecret: "revoked-oauth-secret",
botOpenId: "ou_revoked_bot",
});
const identity = await upsertScopedFeishuIdentity(prisma, {
connectionId: connection.id,
openId: "ou_revoked_user",
displayName: "Revoked User",
});
await prisma.organizationMembership.create({
data: {
organizationId: DEFAULT_ORG_ID,
userId: identity.userId,
role: "MEMBER",
revokedAt: new Date(),
},
});
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("/oauth/token")) {
return Response.json({ code: 0, access_token: "revoked-user-token" });
}
if (url.includes("/user_info")) {
return Response.json({
code: 0,
data: { open_id: "ou_revoked_user", name: "Revoked User" },
});
}
throw new Error(`unexpected ${url}`);
});
const app = await buildApp(fetchImpl as unknown as typeof fetch);
try {
const start = await app.inject({ method: "GET", url: "/auth/feishu/test-default" });
const authorize = new URL(String(start.headers.location));
const state = authorize.searchParams.get("state");
expect(state).not.toBeNull();
const callback = await app.inject({
method: "GET",
url: `/auth/feishu/callback?code=ok&state=${encodeURIComponent(state!)}`,
headers: { cookie: cookiePair(start.headers["set-cookie"], OAUTH_STATE_COOKIE_NAME) },
});
expect(callback.statusCode).toBe(302);
await expect(prisma.organizationMembership.count({
where: { organizationId: DEFAULT_ORG_ID, userId: identity.userId, revokedAt: null },
})).resolves.toBe(0);
await expect(prisma.auditEntry.count({
where: { action: "organization_member.oauth_auto_joined", actorUserId: identity.userId },
})).resolves.toBe(0);
} finally {
await app.close();
}
});
it("unknown org slug returns 404 for admin", async () => {
await seedUser("u-admin", "ou_admin", "ADMIN");
const app = await buildApp();
@@ -0,0 +1,90 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import { OrganizationAgentConfiguration } from "../../src/agent/configuration.js";
import { DEFAULT_ORG_ID, prisma, resetDb, seedTestOrganization } from "./helpers.js";
describe("Organization Agent configuration management", () => {
let root: string;
let configuration: OrganizationAgentConfiguration;
beforeEach(async () => {
await resetDb();
root = await mkdtemp(join(tmpdir(), "cph-agent-config-"));
configuration = new OrganizationAgentConfiguration(prisma, join(root, "store"));
});
afterAll(async () => {
await prisma.$disconnect();
});
it("installs versioned skills and selects them as part of a dynamic role bundle", async () => {
const typst = await makeSkill(root, "typst");
const outline = await makeSkill(root, "outline");
await configuration.installSkill({ organizationId: DEFAULT_ORG_ID, sourceDir: typst, version: "0.15.0" });
await configuration.installSkill({ organizationId: DEFAULT_ORG_ID, sourceDir: outline, version: "1" });
await configuration.upsertRole({
organizationId: DEFAULT_ORG_ID,
roleId: "draft",
label: "课程草稿",
defaultModel: "anthropic/claude-sonnet-5",
systemPrompt: "write carefully",
tools: ["read_file", "write_file", "cph_build"],
sortOrder: 10,
});
await prisma.project.create({
data: { id: "project-a", organizationId: DEFAULT_ORG_ID, name: "A", workspaceDir: "/tmp/a" },
});
await prisma.agentSession.create({
data: {
id: "session-old-role-config",
projectId: "project-a",
provider: "openrouter",
roleId: "draft",
model: "anthropic/claude-sonnet-5",
metadata: {},
},
});
await configuration.setRoleSkills({
organizationId: DEFAULT_ORG_ID,
roleId: "draft",
skillNames: ["outline", "typst"],
});
const role = await prisma.organizationAgentRole.findUniqueOrThrow({
where: { organizationId_roleId: { organizationId: DEFAULT_ORG_ID, roleId: "draft" } },
include: { skillBindings: { orderBy: { sortOrder: "asc" }, include: { skill: true } } },
});
expect(role).toMatchObject({ label: "课程草稿", systemPrompt: "write carefully" });
expect(role.tools).toEqual(["read_file", "write_file", "cph_build"]);
expect(role.skillBindings.map((binding) => binding.skill.name)).toEqual(["outline", "typst"]);
await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: "session-old-role-config" } }))
.resolves.toMatchObject({ archivedAt: expect.any(Date) });
});
it("rejects unknown, disabled and cross-Organization skills", async () => {
await seedTestOrganization("org_other", "other");
const typst = await makeSkill(root, "typst");
await configuration.installSkill({ organizationId: "org_other", sourceDir: typst, version: "1" });
await configuration.upsertRole({
organizationId: DEFAULT_ORG_ID,
roleId: "draft",
label: "Draft",
tools: [],
});
await expect(configuration.setRoleSkills({
organizationId: DEFAULT_ORG_ID,
roleId: "draft",
skillNames: ["typst"],
})).rejects.toThrow("active skills not found in organization");
});
async function makeSkill(parent: string, name: string): Promise<string> {
const source = join(parent, "sources", name);
await mkdir(source, { recursive: true });
await writeFile(join(source, "SKILL.md"), `---\nname: ${name}\ndescription: ${name} skill\n---\n# ${name}\n`);
return source;
}
});
@@ -0,0 +1,121 @@
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import { DatabaseRuntimeSettings } from "../../src/settings/runtime.js";
import { DEFAULT_ORG_ID, prisma, resetDb, seedTestOrganization, testSecretEnvelope } from "./helpers.js";
describe("Organization-scoped Agent runtime configuration", () => {
beforeEach(async () => {
await resetDb();
});
afterAll(async () => {
await prisma.$disconnect();
});
it("resolves role prompt, model, tools and skills from the project Organization", async () => {
await seedTestOrganization("org_other", "other");
await Promise.all([
prisma.project.create({
data: { id: "project-a", organizationId: DEFAULT_ORG_ID, name: "A", workspaceDir: "/tmp/a" },
}),
prisma.project.create({
data: { id: "project-b", organizationId: "org_other", name: "B", workspaceDir: "/tmp/b" },
}),
]);
const [skillA, skillB] = await Promise.all([
prisma.organizationAgentSkill.create({
data: {
id: "skill-a",
organizationId: DEFAULT_ORG_ID,
name: "typst",
version: "0.15.0",
contentDigest: "a".repeat(64),
},
}),
prisma.organizationAgentSkill.create({
data: {
id: "skill-b",
organizationId: "org_other",
name: "typst",
version: "other",
contentDigest: "b".repeat(64),
},
}),
]);
const [roleA, roleB] = await Promise.all([
prisma.organizationAgentRole.create({
data: {
id: "role-a",
organizationId: DEFAULT_ORG_ID,
roleId: "draft",
label: "A Draft",
defaultModel: "anthropic/claude-sonnet-5",
systemPrompt: "prompt-a",
tools: ["read_file", "cph_build"],
},
}),
prisma.organizationAgentRole.create({
data: {
id: "role-b",
organizationId: "org_other",
roleId: "draft",
label: "B Draft",
systemPrompt: "prompt-b",
tools: [],
},
}),
]);
await Promise.all([
prisma.organizationAgentRoleSkill.create({
data: { organizationId: DEFAULT_ORG_ID, agentRoleId: roleA.id, agentSkillId: skillA.id },
}),
prisma.organizationAgentRoleSkill.create({
data: { organizationId: "org_other", agentRoleId: roleB.id, agentSkillId: skillB.id },
}),
]);
const settings = new DatabaseRuntimeSettings(prisma, testSecretEnvelope, {});
const registryA = await settings.modelRegistry({ projectId: "project-a" });
const registryB = await settings.modelRegistry({ projectId: "project-b" });
expect(registryA.role("draft")).toMatchObject({
label: "A Draft",
systemPrompt: "prompt-a",
tools: ["read_file", "cph_build"],
skills: [{ name: "typst", version: "0.15.0", contentDigest: "a".repeat(64) }],
});
expect(registryB.role("draft")).toMatchObject({
label: "B Draft",
systemPrompt: "prompt-b",
tools: [],
skills: [{ name: "typst", version: "other", contentDigest: "b".repeat(64) }],
});
});
it("fails closed for missing scope and disabled role skills", async () => {
await prisma.project.create({
data: { id: "project-a", organizationId: DEFAULT_ORG_ID, name: "A", workspaceDir: "/tmp/a" },
});
const skill = await prisma.organizationAgentSkill.create({
data: {
id: "skill-disabled",
organizationId: DEFAULT_ORG_ID,
name: "typst",
version: "0.15.0",
contentDigest: "c".repeat(64),
disabledAt: new Date(),
},
});
const role = await prisma.organizationAgentRole.create({
data: { id: "role-a", organizationId: DEFAULT_ORG_ID, roleId: "draft", label: "Draft" },
});
await prisma.organizationAgentRoleSkill.create({
data: { organizationId: DEFAULT_ORG_ID, agentRoleId: role.id, agentSkillId: skill.id },
});
const settings = new DatabaseRuntimeSettings(prisma, testSecretEnvelope, {});
await expect(settings.modelRegistry()).rejects.toThrow("projectId is required");
await expect(settings.modelRegistry({ projectId: "project-a" })).rejects.toThrow(
"role draft selects disabled skill typst",
);
});
});
@@ -7,6 +7,7 @@ import { join } from "node:path";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
import { runAgent, type StreamEvent } from "../../src/agent/runner.js";
import { importSkillDirectory } from "../../src/agent/skillStore.js";
const execFileAsync = promisify(execFile);
const originalEnv = new Map<string, string | undefined>();
@@ -52,7 +53,9 @@ describe("real Claude SDK sandbox boundary", () => {
const workspace = join(workspaceRoot, "a", `p_${nonce.slice(0, 8)}`);
const sibling = join(workspaceRoot, "b", `p_${nonce.slice(8, 16)}`);
const serviceSecret = join(root, `s_${nonce.slice(16, 24)}`);
roots.push(workspace, sibling, serviceSecret);
const skillSource = join(root, `k_${nonce.slice(24, 28)}`);
const skillStore = join(root, `ks_${nonce.slice(28, 32)}`);
roots.push(workspace, sibling, serviceSecret, skillSource, skillStore);
await Promise.all([
mkdir(workspace, { recursive: true }),
mkdir(sibling, { recursive: true }),
@@ -62,6 +65,9 @@ describe("real Claude SDK sandbox boundary", () => {
writeFile(join(sibling, "secret.txt"), "sibling-secret\n"),
writeFile(serviceSecret, "platform-secret\n"),
]);
await mkdir(skillSource, { recursive: true });
await writeFile(join(skillSource, "SKILL.md"), "---\nname: outline\ndescription: Outline\n---\n");
const installedSkill = await importSkillDirectory({ sourceDir: skillSource, storeRoot: skillStore });
const untrustedSkill = join(workspace, ".claude", "skills", "untrusted");
await mkdir(untrustedSkill, { recursive: true });
await writeFile(join(untrustedSkill, "SKILL.md"), "---\nname: untrusted\ndescription: must never load\n---\n");
@@ -86,6 +92,7 @@ describe("real Claude SDK sandbox boundary", () => {
DATABASE_URL: "postgresql://platform-secret",
FEISHU_APP_SECRET: "feishu-secret",
HUB_SESSION_SECRET: "session-secret",
HUB_SKILL_STORE_ROOT: skillStore,
});
const bashCommand = [
@@ -133,6 +140,7 @@ describe("real Claude SDK sandbox boundary", () => {
ANTHROPIC_API_KEY: "",
},
tools: ["bash"],
skills: [{ name: "outline", version: "1", contentDigest: installedSkill.contentDigest }],
maxTurns: 3,
runId: "sandbox-run",
sessionId: "sandbox-session",
@@ -150,9 +158,7 @@ describe("real Claude SDK sandbox boundary", () => {
).toBe("completed");
expect(stub.requestCount()).toBeGreaterThanOrEqual(3);
expect(new Set(result.initializedSkillIds)).toEqual(new Set([
"cph-curated:outline",
"cph-curated:lesson-project",
"cph-curated:data-processing-spec",
"cph-runtime:outline",
]));
const toolResults = streamEvents.filter((event) => event.type === "tool-result");
expect(toolResults).toHaveLength(2);
+3
View File
@@ -35,6 +35,9 @@ export const prisma = new PrismaClient({
/** Truncate all tables before each test for isolation. */
export async function resetDb(): Promise<void> {
const tables = [
"OrganizationAgentRoleSkill",
"OrganizationAgentRole",
"OrganizationAgentSkill",
"FeishuEventReceipt",
"FeishuUserIdentity",
"FeishuApplicationCredentialVersion",
@@ -0,0 +1,185 @@
import { mkdir, mkdtemp, readFile, rm, symlink, unlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest";
import { importLegacyProjects } from "../../src/deployment/legacyProjectImport.js";
import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js";
const temporaryRoots: string[] = [];
describe("legacy teaching-material project import", () => {
beforeEach(async () => {
await resetDb();
await prisma.user.create({
data: {
id: "legacy-import-owner",
feishuOpenId: "ou_legacy_owner",
displayName: "Legacy Import Owner",
organizationMemberships: { create: { organizationId: DEFAULT_ORG_ID, role: "OWNER" } },
},
});
});
afterEach(async () => {
while (temporaryRoots.length > 0) {
const root = temporaryRoots.pop();
if (root !== undefined) await rm(root, { recursive: true, force: true });
}
});
afterAll(async () => prisma.$disconnect());
it("imports each legacy project as an unbound resumable project under its old folder path", async () => {
const sourceRoot = await temporaryRoot("cph-legacy-source-");
const workspaceRoot = await temporaryRoot("cph-legacy-target-");
const projectSource = join(sourceRoot, "物理", "M-243-牛顿力学");
await mkdir(join(projectSource, "workspace", "chapters"), { recursive: true });
await mkdir(join(projectSource, "workspace", ".claude"), { recursive: true });
await mkdir(join(projectSource, "workspace", "chapters", ".cph"), { recursive: true });
await mkdir(join(projectSource, "_raw"), { recursive: true });
await writeFile(join(projectSource, "workspace", "project.toml"), "title = \"牛顿力学\"\n");
await writeFile(join(projectSource, "workspace", "chapters", "lesson.typ"), "= 牛顿第二定律\n");
await writeFile(join(projectSource, "workspace", ".claude", "session.json"), "{}\n");
await writeFile(join(projectSource, "workspace", "chapters", ".cph", "runtime.json"), "{}\n");
await writeFile(join(projectSource, "project.json"), "{\"id\":\"M-243\"}\n");
await writeFile(join(projectSource, "_raw", "source.txt"), "legacy source\n");
const stateFile = join(workspaceRoot, "migration-state", "state.json");
const manifest = [{
legacyId: "M-243",
name: "牛顿力学",
folderPath: ["物理"],
sourceRelativePath: "物理/M-243-牛顿力学",
}];
const first = await importLegacyProjects({
prisma,
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_legacy_owner",
workspaceRoot,
sourceRoot,
stateFile,
projects: manifest,
});
const imported = first.projects["M-243"];
expect(imported).toBeDefined();
if (imported === undefined) throw new Error("missing imported project state");
const project = await prisma.project.findUniqueOrThrow({
where: { id: imported.projectId },
include: { folder: { include: { parent: true } }, groupBindings: true },
});
expect(project.name).toBe("牛顿力学");
expect(project.folder?.name).toBe("物理");
expect(project.folder?.parent?.name).toBe("旧教学资产");
expect(project.groupBindings).toEqual([]);
await expect(readFile(join(imported.workspaceDir, "chapters", "lesson.typ"), "utf8"))
.resolves.toBe("= 牛顿第二定律\n");
await expect(readFile(join(imported.workspaceDir, ".claude", "session.json"), "utf8"))
.rejects.toMatchObject({ code: "ENOENT" });
await expect(readFile(join(imported.workspaceDir, "chapters", ".cph", "runtime.json"), "utf8"))
.rejects.toMatchObject({ code: "ENOENT" });
await expect(readFile(join(imported.workspaceDir, ".legacy-source", "project.json"), "utf8"))
.resolves.toContain("M-243");
await expect(readFile(join(imported.workspaceDir, ".legacy-source", "raw", "source.txt"), "utf8"))
.resolves.toBe("legacy source\n");
await unlink(stateFile);
const second = await importLegacyProjects({
prisma,
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_legacy_owner",
workspaceRoot,
sourceRoot,
stateFile,
projects: manifest,
});
expect(second.projects["M-243"]?.projectId).toBe(imported.projectId);
await expect(prisma.project.count({ where: { organizationId: DEFAULT_ORG_ID } })).resolves.toBe(1);
expect(JSON.parse(await readFile(stateFile, "utf8"))).toMatchObject({
version: 1,
projects: { "M-243": { projectId: imported.projectId } },
});
await expect(prisma.auditEntry.count({
where: { projectId: imported.projectId, action: "legacy_project.imported" },
})).resolves.toBe(1);
await writeFile(join(imported.workspaceDir, ".legacy-source", "migration.json"), "not json\n");
await expect(importLegacyProjects({
prisma,
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_legacy_owner",
workspaceRoot,
sourceRoot,
stateFile,
projects: manifest,
})).rejects.toThrow(/invalid legacy completion marker/);
await expect(prisma.project.count({ where: { id: imported.projectId } })).resolves.toBe(1);
});
it("rejects symlinks instead of importing paths outside the staged project", async () => {
const sourceRoot = await temporaryRoot("cph-legacy-symlink-");
const workspaceRoot = await temporaryRoot("cph-legacy-target-");
const projectSource = join(sourceRoot, "legacy");
await mkdir(join(projectSource, "workspace"), { recursive: true });
await writeFile(join(projectSource, "project.json"), "{}\n");
await symlink("/etc/passwd", join(projectSource, "workspace", "outside"));
await expect(importLegacyProjects({
prisma,
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_legacy_owner",
workspaceRoot,
sourceRoot,
stateFile: join(workspaceRoot, "state.json"),
projects: [{ legacyId: "symlink", name: "Symlink", folderPath: [], sourceRelativePath: "legacy" }],
})).rejects.toThrow(/rejects symbolic link/);
await expect(prisma.project.count()).resolves.toBe(0);
});
it("rejects a manifest path that escapes the staged source root", async () => {
const parent = await temporaryRoot("cph-legacy-escape-");
const sourceRoot = join(parent, "source");
const outside = join(parent, "outside");
await mkdir(sourceRoot);
await mkdir(outside);
await expect(importLegacyProjects({
prisma,
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_legacy_owner",
workspaceRoot: await temporaryRoot("cph-legacy-target-"),
sourceRoot,
stateFile: join(parent, "state.json"),
projects: [{
legacyId: "escape",
name: "Escape",
folderPath: [],
sourceRelativePath: "../outside",
}],
})).rejects.toThrow(/escapes source root/);
});
it("rejects malformed resume state before creating a project", async () => {
const sourceRoot = await temporaryRoot("cph-legacy-state-source-");
const workspaceRoot = await temporaryRoot("cph-legacy-state-target-");
const stateFile = join(workspaceRoot, "state.json");
await writeFile(stateFile, JSON.stringify({ version: 1, projects: { broken: { status: "MAYBE" } } }));
await expect(importLegacyProjects({
prisma,
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_legacy_owner",
workspaceRoot,
sourceRoot,
stateFile,
projects: [],
})).rejects.toThrow(/invalid legacy import state fields/);
await expect(prisma.project.count()).resolves.toBe(0);
});
});
async function temporaryRoot(prefix: string): Promise<string> {
const root = await mkdtemp(join(tmpdir(), prefix));
temporaryRoots.push(root);
return root;
}
@@ -53,6 +53,14 @@ describe("Alpha Silo bootstrap", () => {
expect(await prisma.team.count({ where: { slug: "teachers", archivedAt: null } })).toBe(1);
expect(await prisma.teamMembership.count({ where: { revokedAt: null } })).toBe(1);
expect(await prisma.organizationProviderConnection.count({ where: { status: "ACTIVE" } })).toBe(1);
await expect(prisma.organizationAgentRole.findMany({
where: { organizationId: "org_alpha", disabledAt: null },
orderBy: { sortOrder: "asc" },
select: { roleId: true, label: true },
})).resolves.toEqual([
{ roleId: "draft", label: "草稿" },
{ roleId: "review", label: "审校" },
]);
const persisted = JSON.stringify({
feishu: await prisma.feishuApplicationCredentialVersion.findMany(),
+247 -4
View File
@@ -3,7 +3,15 @@ import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { Readable } from "node:stream";
import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vitest";
import { DEFAULT_ORG_ID, prisma, resetDb, mockFeishuRuntime, seedProject, silentLogger } from "./helpers.js";
import {
DEFAULT_ORG_ID,
prisma,
resetDb,
mockFeishuRuntime,
seedProject,
seedTestOrganization,
silentLogger,
} from "./helpers.js";
import { InMemoryModelRegistry } from "../../src/agent/models.js";
import { createSlashCommandRegistry } from "../../src/feishu/slashCommands.js";
import { makeTriggerHandler as makeProductionTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js";
@@ -24,6 +32,8 @@ type TestTriggerDeps = Omit<Parameters<typeof makeProductionTriggerHandler>[0],
function makeTriggerHandler(deps: TestTriggerDeps): ReturnType<typeof makeProductionTriggerHandler> {
return makeProductionTriggerHandler({
projectWorkspaceRoot: "/tmp",
publicBaseUrl: "https://educraft.example.test",
siloOrganizationId: DEFAULT_ORG_ID,
allowLegacyFeishuIdentity: true,
...deps,
});
@@ -158,7 +168,8 @@ describe("trigger full lifecycle (integration)", () => {
msgType: "interactive",
replyInThread: undefined,
});
expect(rt.sentTexts.some((text) => text.includes("mock response") && text.includes("本次成本: $0.0023"))).toBe(true);
expect(rt.sentTexts.some((text) => text.includes("mock response"))).toBe(true);
expect(rt.sentTexts.some((text) => text.includes("本次成本"))).toBe(false);
expect(runAgentCalls).toHaveLength(1);
expect(runAgentCalls[0]?.providerProxyEnv).toMatchObject({
ANTHROPIC_BASE_URL: "http://127.0.0.1:12345",
@@ -350,6 +361,100 @@ describe("trigger full lifecycle (integration)", () => {
expect(cardHeaderTitle(rt.sentPatches.at(-1))).toBe("已绑定项目");
});
it("uses unbound-chat mention text to search bindable projects", async () => {
await seedOnboardingUser("u-onboard-search", "ou_onboard_search", "OWNER");
const folder = await prisma.folder.create({
data: { organizationId: DEFAULT_ORG_ID, name: "物理竞赛" },
});
await prisma.project.createMany({
data: [
{
id: "p-search-newton",
organizationId: DEFAULT_ORG_ID,
folderId: folder.id,
name: "牛顿力学专题",
workspaceDir: join(await tempWorkspaceRoot(), "p-search-newton"),
},
{
id: "p-search-optics",
organizationId: DEFAULT_ORG_ID,
folderId: folder.id,
name: "几何光学专题",
workspaceDir: join(await tempWorkspaceRoot(), "p-search-optics"),
},
],
});
const trigger = makeTriggerHandler({
prisma,
settings,
logger: silentLogger,
runAgent,
projectWorkspaceRoot: await tempWorkspaceRoot(),
});
await trigger(makeEvent("chat-onboard-search", "@_user_1 牛顿", "ou_onboard_search"), rt);
expect(cardActionValues(rt.sentCards[0])).toContainEqual({
project_onboarding: {
action: "bind_project",
organization_id: DEFAULT_ORG_ID,
project_id: "p-search-newton",
},
});
expect(cardActionValues(rt.sentCards[0])).not.toContainEqual(expect.objectContaining({
project_onboarding: expect.objectContaining({ project_id: "p-search-optics" }),
}));
expect(JSON.stringify(rt.sentCards[0])).toContain("牛顿");
expect(runAgentCalls).toHaveLength(0);
});
it("continues searching past unauthorized matches for a manageable project", async () => {
await seedOnboardingUser("u-onboard-page", "ou_onboard_page", "MEMBER");
const workspaceRoot = await tempWorkspaceRoot();
await prisma.project.createMany({
data: [
...Array.from({ length: 20 }, (_, index) => ({
id: `z-search-denied-${String(index).padStart(2, "0")}`,
organizationId: DEFAULT_ORG_ID,
name: `迁移项目 ${index}`,
workspaceDir: join(workspaceRoot, `denied-${index}`),
})),
{
id: "a-search-allowed",
organizationId: DEFAULT_ORG_ID,
name: "迁移项目 可管理",
workspaceDir: join(workspaceRoot, "allowed"),
},
],
});
await prisma.permissionGrant.create({
data: {
resourceType: "PROJECT",
resourceId: "a-search-allowed",
principalType: "USER",
principalId: "ou_onboard_page",
role: "MANAGE",
},
});
const trigger = makeTriggerHandler({
prisma,
settings,
logger: silentLogger,
runAgent,
projectWorkspaceRoot: workspaceRoot,
});
await trigger(makeEvent("chat-onboard-page", "@_user_1 迁移", "ou_onboard_page"), rt);
expect(cardActionValues(rt.sentCards[0])).toContainEqual({
project_onboarding: {
action: "bind_project",
organization_id: DEFAULT_ORG_ID,
project_id: "a-search-allowed",
},
});
});
it("batches quick text messages from the same chat and sender into one run", async () => {
await seedProject("proj-1b", "chat-1b");
const trigger = makeTriggerHandler({
@@ -386,6 +491,18 @@ describe("trigger full lifecycle (integration)", () => {
role: "EDIT",
},
});
await prisma.user.createMany({
data: [
{ id: "u-speaker-alice", feishuOpenId: "ou_alice", displayName: "Alice" },
{ id: "u-speaker-bob", feishuOpenId: "ou_bob", displayName: "Bob" },
],
});
await prisma.organizationMembership.createMany({
data: [
{ organizationId: DEFAULT_ORG_ID, userId: "u-speaker-alice", role: "MEMBER" },
{ organizationId: DEFAULT_ORG_ID, userId: "u-speaker-bob", role: "MEMBER" },
],
});
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
await trigger(makeEvent("chat-speaker", "@_user_1 Alice 的需求", "ou_alice"), rt);
@@ -626,6 +743,66 @@ describe("trigger full lifecycle (integration)", () => {
expect(runs).toHaveLength(0);
});
it("gives an unknown user the scoped login URL in an already-bound chat", async () => {
await seedProject("proj-bound-unknown", "chat-bound-unknown");
const trigger = makeTriggerHandler({
prisma,
settings,
logger: silentLogger,
runAgent,
allowLegacyFeishuIdentity: false,
messageBatcherOptions: { maxMessages: 1 },
});
await trigger(makeEvent("chat-bound-unknown", "@_user_1 写教案", "ou_bound_unknown"), rt);
expect(rt.sentTexts).toContain(
"请先通过飞书登录并加入组织:https://educraft.example.test/auth/feishu/test-default\n" +
"完成后返回群聊重试。",
);
expect(rt.sentTexts).not.toContain("无权限触发。");
expect(runAgentCalls).toHaveLength(0);
});
it("tells a logged-in non-member to contact the administrator in an already-bound chat", async () => {
await seedProject("proj-bound-non-member", "chat-bound-non-member");
await seedScopedIdentityWithoutMembership("bound-non-member", "ou_bound_non_member");
const trigger = makeTriggerHandler({
prisma,
settings,
logger: silentLogger,
runAgent,
allowLegacyFeishuIdentity: false,
messageBatcherOptions: { maxMessages: 1 },
});
await trigger(makeEvent("chat-bound-non-member", "@_user_1 写教案", "ou_bound_non_member"), rt);
expect(rt.sentTexts).toContain(
"你尚未加入该组织,或成员资格已被移除。请联系组织管理员。",
);
expect(rt.sentTexts).not.toContain("无权限触发。");
expect(runAgentCalls).toHaveLength(0);
});
it("keeps generic project denial for an Organization member without project permission", async () => {
await seedProject("proj-bound-read-only", "chat-bound-read-only", { role: "READ" });
const trigger = makeTriggerHandler({
prisma,
settings,
logger: silentLogger,
runAgent,
messageBatcherOptions: { maxMessages: 1 },
});
await trigger(makeEvent("chat-bound-read-only", "@_user_1 写教案"), rt);
expect(rt.sentTexts).toContain("无权限触发。");
expect(rt.sentTexts.join("\n")).not.toContain("/auth/feishu/");
expect(rt.sentTexts.join("\n")).not.toContain("尚未加入该组织");
expect(runAgentCalls).toHaveLength(0);
});
it.each(["SUSPENDED", "ARCHIVED"] as const)(
"rejects triggers and resume commands when the organization is %s",
async (status) => {
@@ -922,18 +1099,62 @@ describe("trigger full lifecycle (integration)", () => {
await expect(readdir(join(workspaceRoot, ".cph-staging"))).resolves.toEqual([]);
});
it("does not create a run for unbound chats and asks unknown users to log in", async () => {
it("does not create a run for unbound chats and gives unknown users the scoped login URL", async () => {
await seedProject("proj-4", "chat-4");
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
await trigger(makeEvent("chat-UNKNOWN", "@_user_1 写教案", "ou_unknown_user"), rt);
expect(rt.sentTexts).toContain("请先登录并加入组织后,再绑定项目。");
expect(rt.sentTexts).toContain(
"请先通过飞书登录并加入组织:https://educraft.example.test/auth/feishu/test-default\n" +
"完成后返回群聊重试。",
);
expect(rt.sentCards).toHaveLength(0);
const runs = await prisma.agentRun.findMany();
expect(runs).toHaveLength(0);
});
it("distinguishes a scoped Feishu identity that has not joined the Silo Organization", async () => {
await seedScopedIdentityWithoutMembership("unbound-non-member", "ou_logged_in_not_member");
const trigger = makeTriggerHandler({
prisma,
settings,
logger: silentLogger,
runAgent,
allowLegacyFeishuIdentity: false,
messageBatcherOptions: { maxMessages: 1 },
});
await trigger(makeEvent("chat-unbound", "@_user_1 写教案", "ou_logged_in_not_member"), rt);
expect(rt.sentTexts).toContain(
"你尚未加入该组织,或成员资格已被移除。请联系组织管理员。",
);
expect(rt.sentTexts.join("\n")).not.toContain("/auth/feishu/");
await expect(prisma.agentRun.count()).resolves.toBe(0);
});
it("encodes the configured Organization slug in the OAuth login URL", async () => {
const encodedOrgId = "org_url_encoding";
await seedTestOrganization(encodedOrgId, "school east/数学?");
const trigger = makeTriggerHandler({
prisma,
settings,
logger: silentLogger,
runAgent,
siloOrganizationId: encodedOrgId,
publicBaseUrl: "https://school.example.test/",
messageBatcherOptions: { maxMessages: 1 },
});
await trigger(makeEvent("chat-unbound", "@_user_1 写教案", "ou_unknown_encoded"), rt);
expect(rt.sentTexts.join("\n")).toContain(
"https://school.example.test/auth/feishu/school%20east%2F%E6%95%B0%E5%AD%A6%3F",
);
expect(rt.sentTexts.join("\n")).not.toContain("school east/数学?");
});
it("ignores messages without @bot mention", async () => {
await seedProject("proj-5", "chat-5");
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
@@ -1430,6 +1651,28 @@ async function seedOnboardingUser(id: string, feishuOpenId: string, role: "OWNER
});
}
async function seedScopedIdentityWithoutMembership(id: string, openId: string): Promise<void> {
const connectionId = `feishu-connection-${id}`;
await prisma.organizationFeishuApplicationConnection.create({
data: {
id: connectionId,
organizationId: DEFAULT_ORG_ID,
appIdentityFingerprint: `fingerprint-${id}`,
status: "ACTIVE",
},
});
await prisma.user.create({
data: {
id: `user-${id}`,
displayName: "Logged in user",
feishuOpenId: `legacy-${openId}`,
feishuIdentities: {
create: { connectionId, openId },
},
},
});
}
async function tempWorkspaceRoot(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), "cph-trigger-onboarding-"));
workspaceRoots.push(root);
+8 -8
View File
@@ -14,6 +14,7 @@ describe("agent subprocess security policy", () => {
it("passes only the run proxy capability and safe runtime variables and protects the capability from tools", async () => {
const { workspaceRoot, workspace } = await makeWorkspace();
const policy = await createAgentSecurityPolicy({
runId: "run-test",
workspaceRoot,
workspaceDir: workspace,
providerProxyEnv: {
@@ -51,14 +52,8 @@ describe("agent subprocess security policy", () => {
expect(policy.env.TEMP).toBe(policy.env.TMPDIR);
expect(policy.env.TMPDIR).toBe(join(canonicalWorkspace, ".cph", "t"));
expect(Buffer.byteLength(policy.env.TMPDIR!)).toBeLessThanOrEqual(56);
expect(policy.skillIds).toEqual([
"cph-curated:outline",
"cph-curated:lesson-project",
"cph-curated:data-processing-spec",
]);
expect(policy.skillPluginRoot.startsWith(canonicalWorkspace)).toBe(false);
expect(policy.sandbox.filesystem.allowRead).toContain(policy.skillPluginRoot);
expect(policy.sandbox.filesystem.allowWrite).not.toContain(policy.skillPluginRoot);
expect(policy.skillIds).toEqual([]);
expect(policy.skillPluginRoot).toBeUndefined();
expect(policy.sandbox).toMatchObject({
enabled: true,
@@ -83,6 +78,7 @@ describe("agent subprocess security policy", () => {
const { workspaceRoot, workspace } = await makeWorkspace();
await expect(createAgentSecurityPolicy({
runId: "run-test",
workspaceRoot,
workspaceDir: workspace,
providerProxyEnv: {
@@ -96,6 +92,7 @@ describe("agent subprocess security policy", () => {
it("keeps every SDK temp variable on a short path inside the project workspace", async () => {
const { workspaceRoot, workspace } = await makeWorkspace();
const policy = await createAgentSecurityPolicy({
runId: "run-test",
workspaceRoot,
workspaceDir: workspace,
hostEnv: { PATH: "/usr/bin:/bin" },
@@ -121,6 +118,7 @@ describe("agent subprocess security policy", () => {
await mkdir(workspace, { recursive: true });
await expect(createAgentSecurityPolicy({
runId: "run-test",
workspaceRoot,
workspaceDir: workspace,
hostEnv: { PATH: "/usr/bin:/bin" },
@@ -135,6 +133,7 @@ describe("agent subprocess security policy", () => {
await symlink(outside, linked);
await expect(createAgentSecurityPolicy({
runId: "run-test",
workspaceRoot,
workspaceDir: linked,
providerProxyEnv: { ANTHROPIC_AUTH_TOKEN: "run-proxy-capability" },
@@ -150,6 +149,7 @@ describe("agent subprocess security policy", () => {
await symlink(sibling, linked);
await expect(createAgentSecurityPolicy({
runId: "run-test",
workspaceRoot,
workspaceDir: linked,
providerProxyEnv: { ANTHROPIC_AUTH_TOKEN: "run-proxy-capability" },
-71
View File
@@ -1,71 +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 } from "vitest";
import {
CURATED_SKILL_IDS,
CURATED_SKILL_NAMES,
CURATED_SKILL_PLUGIN_NAME,
validateCuratedSkillPlugin,
} from "../../src/agent/curatedSkills.js";
describe("validateCuratedSkillPlugin", () => {
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
it("accepts exactly the release-owned plugin and returns qualified skill ids", async () => {
const root = await skillPluginFixture();
await expect(validateCuratedSkillPlugin(root)).resolves.toEqual({
root,
skillIds: CURATED_SKILL_IDS,
});
});
it("fails closed when a curated skill is absent from the release", async () => {
const root = await skillPluginFixture();
await rm(join(root, "skills", "outline"), { recursive: true });
await expect(validateCuratedSkillPlugin(root)).rejects.toThrow(/curated plugin entry missing: skills\/outline/);
});
it("fails closed when a skill manifest name does not match the allowlist", async () => {
const root = await skillPluginFixture();
await writeFile(join(root, "skills", "outline", "SKILL.md"), "---\nname: other\n---\n");
await expect(validateCuratedSkillPlugin(root)).rejects.toThrow(/curated skill manifest name mismatch/);
});
it("rejects an extra skill directory", async () => {
const root = await skillPluginFixture();
await mkdir(join(root, "skills", "extra"));
await expect(validateCuratedSkillPlugin(root)).rejects.toThrow(/unexpected curated plugin entry: skills\/extra/);
});
it("rejects plugin capabilities outside the reviewed skill catalog", async () => {
const root = await skillPluginFixture();
await mkdir(join(root, "hooks"));
await expect(validateCuratedSkillPlugin(root)).rejects.toThrow(/unexpected curated plugin entry: hooks/);
});
async function skillPluginFixture(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), "cph-skills-"));
roots.push(root);
await mkdir(join(root, ".claude-plugin"), { recursive: true });
await writeFile(
join(root, ".claude-plugin", "plugin.json"),
JSON.stringify({ name: CURATED_SKILL_PLUGIN_NAME }),
);
for (const name of CURATED_SKILL_NAMES) {
const source = join(root, "skills", name);
await mkdir(source, { recursive: true });
await writeFile(join(source, "SKILL.md"), `---\nname: ${name}\n---\n# ${name}\n`);
}
return root;
}
});
+2
View File
@@ -110,6 +110,8 @@ describe("Feishu approval cards", () => {
logger: silentLogger(),
authorizer: allowAllAuthorizer(),
projectWorkspaceRoot: "/tmp",
publicBaseUrl: "https://educraft.example.test",
siloOrganizationId: "org_test_default",
runAgent: async () => ({
status: "completed",
text: "",
+15
View File
@@ -103,6 +103,8 @@ async function triggerWithRunAgent(
logger: rt.logger,
authorizer: allowAllAuthorizer(),
projectWorkspaceRoot: "/tmp",
publicBaseUrl: "https://educraft.example.test",
siloOrganizationId: "org_test_default",
runAgent,
messageBatcherOptions: { maxMessages: 1 },
});
@@ -254,6 +256,19 @@ function mockPrisma(): PrismaClient {
const session = { id: "session-1", metadata: {} };
const client = {
organization: {
findFirst: vi.fn(async () => ({ id: "org_test_default", slug: "test-default" })),
},
feishuUserIdentity: {
findFirst: vi.fn(async () => ({
user: {
organizationMemberships: [{
role: "OWNER",
organization: { id: "org_test_default", name: "Test Organization" },
}],
},
})),
},
feishuEventReceipt: {
findUnique: vi.fn(async () => null),
create: vi.fn(async () => ({ id: "receipt-1" })),
@@ -0,0 +1,63 @@
import { execFile } from "node:child_process";
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
const execute = promisify(execFile);
const temporaryRoots: string[] = [];
describe("legacy project manifest builder", () => {
afterEach(async () => {
while (temporaryRoots.length > 0) {
const root = temporaryRoots.pop();
if (root !== undefined) await rm(root, { recursive: true, force: true });
}
});
it("stops at a project root and excludes trash projects", async () => {
const root = await mkdtemp(join(tmpdir(), "cph-legacy-manifest-"));
temporaryRoots.push(root);
const projectRoot = join(root, "物理", "legacy__牛顿力学");
await mkdir(join(projectRoot, "workspace", "nested"), { recursive: true });
await mkdir(join(projectRoot, "_raw"), { recursive: true });
await mkdir(join(root, ".trash", "deleted"), { recursive: true });
await writeFile(join(projectRoot, "project.json"), JSON.stringify({
id: "legacy",
name: "牛顿力学",
folderPath: ["物理"],
}));
await writeFile(join(projectRoot, "workspace", "nested", "project.json"), "not metadata");
await writeFile(join(projectRoot, "_raw", "project.json"), "not metadata");
await writeFile(join(root, ".trash", "deleted", "project.json"), JSON.stringify({
id: "deleted",
name: "Deleted",
}));
const { stdout } = await execute(process.execPath, [
resolve("deploy/build_legacy_project_manifest.mjs"),
root,
]);
expect(JSON.parse(stdout)).toEqual([{
legacyId: "legacy",
name: "牛顿力学",
folderPath: ["物理"],
sourceRelativePath: "物理/legacy__牛顿力学",
}]);
});
it("reports untracked symlinked entries instead of silently omitting them", async () => {
const root = await mkdtemp(join(tmpdir(), "cph-legacy-manifest-link-"));
temporaryRoots.push(root);
await symlink("/tmp", join(root, "linked-project"));
const result = await execute(process.execPath, [
resolve("deploy/build_legacy_project_manifest.mjs"),
root,
]);
expect(result.stderr).toContain("skip untracked symbolic link");
expect(JSON.parse(result.stdout)).toEqual([]);
});
});
+38 -7
View File
@@ -1,8 +1,9 @@
import { mkdir, mkdtemp, realpath, rm } from "node:fs/promises";
import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { runAgent } from "../../src/agent/runner.js";
import { importSkillDirectory } from "../../src/agent/skillStore.js";
const queryMock = vi.hoisted(() => vi.fn());
@@ -76,7 +77,7 @@ describe("runAgent", () => {
workspaceRoot = await realpath(workspaceRoot);
workspace = await realpath(workspace);
previousSecrets = Object.fromEntries(
["DATABASE_URL", "FEISHU_APP_SECRET", "HUB_SESSION_SECRET"].map((name) => [name, process.env[name]]),
["DATABASE_URL", "FEISHU_APP_SECRET", "HUB_SESSION_SECRET", "HUB_SKILL_STORE_ROOT"].map((name) => [name, process.env[name]]),
);
});
@@ -112,8 +113,8 @@ describe("runAgent", () => {
permissionMode: "bypassPermissions",
allowDangerouslySkipPermissions: true,
settingSources: [],
plugins: [expect.objectContaining({ type: "local", skipMcpDiscovery: true })],
skills: ["cph-curated:outline", "cph-curated:lesson-project", "cph-curated:data-processing-spec"],
settings: { disableBundledSkills: true },
skills: [],
strictMcpConfig: true,
sandbox: expect.objectContaining({
enabled: true,
@@ -148,7 +149,7 @@ describe("runAgent", () => {
it("returns the skills actually reported by SDK initialization", async () => {
queryMock.mockReturnValue(messages(
initMessage(["cph-curated:outline"]),
initMessage(["cph-runtime:outline"]),
assistantMessage("fresh"),
resultMessage("sdk-session-1"),
));
@@ -163,7 +164,7 @@ describe("runAgent", () => {
prisma: stubPrisma,
});
expect(result.initializedSkillIds).toEqual(["cph-curated:outline"]);
expect(result.initializedSkillIds).toEqual(["cph-runtime:outline"]);
});
it("maps role tool ids to the Claude SDK tool whitelist", async () => {
@@ -188,7 +189,7 @@ describe("runAgent", () => {
});
});
it("disables all SDK tools for an empty role tool whitelist", async () => {
it("disables SDK tools for an empty role tool and skill selection", async () => {
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));
await runAgent({
@@ -210,6 +211,36 @@ describe("runAgent", () => {
});
});
it("loads only the dynamic skills selected by the role", async () => {
const source = join(root, "skill-source");
const storeRoot = join(root, "skill-store");
await mkdir(source);
await writeFile(join(source, "SKILL.md"), "---\nname: typst\ndescription: Typst\n---\n");
const installed = await importSkillDirectory({ sourceDir: source, storeRoot });
process.env["HUB_SKILL_STORE_ROOT"] = storeRoot;
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));
await runAgent({
prompt: "排版",
model: undefined,
project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
systemPrompt: undefined,
tools: [],
skills: [{ name: "typst", version: "0.15.0", contentDigest: installed.contentDigest }],
runId: "run-skill",
sessionId: "hub-session-1",
prisma: stubPrisma,
});
expect(queryMock.mock.calls[0]?.[0]).toMatchObject({
options: {
tools: ["Skill"],
plugins: [expect.objectContaining({ type: "local", skipMcpDiscovery: true })],
skills: ["cph-runtime:typst"],
},
});
});
it("returns SDK-reported cost when present", async () => {
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1", 0.0042)));
+71
View File
@@ -0,0 +1,71 @@
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { importSkillDirectory, prepareRunSkillPlugin } from "../../src/agent/skillStore.js";
describe("content-addressed Agent skill store", () => {
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
it("imports a skill into an immutable digest directory and materializes a selected run plugin", async () => {
const root = await makeRoot();
const source = await makeSkill(root, "typst", "Typst help");
const storeRoot = join(root, "store");
const installed = await importSkillDirectory({ sourceDir: source, storeRoot });
expect(installed).toMatchObject({ name: "typst", description: "Typst help" });
expect(installed.contentDigest).toMatch(/^[a-f0-9]{64}$/);
await expect(readFile(join(storeRoot, "versions", installed.contentDigest, "SKILL.md"), "utf8"))
.resolves.toContain("name: typst");
const plugin = await prepareRunSkillPlugin({
storeRoot,
runId: "run-1",
skills: [{ name: "typst", version: "0.15.0", contentDigest: installed.contentDigest }],
});
expect(plugin).not.toBeNull();
expect(plugin?.skillIds).toEqual(["cph-runtime:typst"]);
await expect(readFile(join(plugin!.root, "skills", "typst", "reference.md"), "utf8"))
.resolves.toBe("reference\n");
await plugin?.cleanup();
await expect(readFile(join(plugin!.root, ".claude-plugin", "plugin.json"), "utf8"))
.rejects.toMatchObject({ code: "ENOENT" });
});
it("rejects symlinks and detects content tampering before a run", async () => {
const root = await makeRoot();
const source = await makeSkill(root, "outline", "Outline");
await symlink(join(source, "reference.md"), join(source, "link.md"));
await expect(importSkillDirectory({ sourceDir: source, storeRoot: join(root, "store") }))
.rejects.toThrow(/symlink/);
await rm(join(source, "link.md"));
const storeRoot = join(root, "store");
const installed = await importSkillDirectory({ sourceDir: source, storeRoot });
await writeFile(join(storeRoot, "versions", installed.contentDigest, "reference.md"), "tampered\n");
await expect(prepareRunSkillPlugin({
storeRoot,
runId: "run-2",
skills: [{ name: "outline", version: "1", contentDigest: installed.contentDigest }],
})).rejects.toThrow(/content digest mismatch/);
});
async function makeRoot(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), "cph-skill-store-"));
roots.push(root);
return root;
}
});
async function makeSkill(root: string, name: string, description: string): Promise<string> {
const source = join(root, "source", name);
await mkdir(source, { recursive: true });
await writeFile(join(source, "SKILL.md"), `---\nname: ${name}\ndescription: ${description}\n---\n# ${name}\n`);
await writeFile(join(source, "reference.md"), "reference\n");
return source;
}