Compare commits

...

23 Commits

Author SHA1 Message Date
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
hongjr03 96e120e02c feat: add curated curriculum agent skills 2026-07-11 12:22:01 +08:00
hongjr03 1a892ccb54 chore: release hub 0.0.7 2026-07-11 02:42:35 +08:00
hongjr03 9d494f446f docs: clarify Feishu user profile permissions 2026-07-11 02:42:09 +08:00
hongjr03 5a65188b5d fix: allow arbitrary workspace file delivery 2026-07-11 02:41:14 +08:00
52 changed files with 3052 additions and 133 deletions
+4
View File
@@ -28,6 +28,10 @@
service identity、workspace、keyring 与 Feishu/provider connection;进程必须由 service identity、workspace、keyring 与 Feishu/provider connection;进程必须由
`HUB_SILO_ORGANIZATION_ID` fail-closed 绑定唯一 org,平台后台不开放。共享 SaaS `HUB_SILO_ORGANIZATION_ID` fail-closed 绑定唯一 org,平台后台不开放。共享 SaaS
控制面与 Docker adapter 后置(见 ADR-0025)。 控制面与 Docker adapter 后置(见 ADR-0025)。
- 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` 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. 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: Environment variables:
``` ```
ANTHROPIC_BASE_URL=https://openrouter.ai/api ANTHROPIC_BASE_URL=https://openrouter.ai/api
@@ -122,6 +122,16 @@ The boundary is enforced by the Claude Code SDK's built-in sandbox
- `settingSources: []` and strict MCP configuration prevent an untrusted - `settingSources: []` and strict MCP configuration prevent an untrusted
workspace or service-user config from widening tools, hooks, MCP servers, or workspace or service-user config from widening tools, hooks, MCP servers, or
sandbox paths. sandbox paths.
- 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). - Network: open (see Open Questions).
`bypassPermissions` is kept (headless server — no interactive prompts); the `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. 打开飞书开放平台开发者后台。 ## 1. 双方分别负责什么
2. 在目标企业下创建“企业自建应用”。
3. 应用名称可填写 `Educraft para-26071100`
4. 在“凭证与基础信息”记录:
- App ID(通常以 `cli_` 开头)
- App Secret
5. 添加并启用“机器人”能力。
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` ![凭证与基础信息页面;App Secret 默认以星号隐藏](assets/feishu-setup/01-credentials.png)
- 以应用身份发送消息(`im:message:send_as_bot`
- 获取消息内容,用于读取触发消息和线程上下文(`im:message:readonly`
- 获取与上传图片或文件资源(`im:resource`
- 添加、删除消息表情回复(`im:message.reactions:write_only`
- 获取用户基本信息(`contact:user.base:readonly`
如果飞书 API 调试台提示某个上述操作缺少更细粒度权限,请把提示截图交给平台部署人员,不要直接勾选通讯录全量读取或其他超出清单的权限 App ID 通常以 `cli_` 开头,可以写入交付单。App Secret 必须通过安全渠道单独发送。Bot Open ID 不需要管理员手工查找;部署程序会用 App ID/App Secret 调用 Bot Info API 获取并校验归属
## 3. 配置事件与卡片回调 ## 3. 开通最小权限
1. 进入“事件与回调” 进入“权限管理”,点击“开通权限”,搜索并申请以下应用身份权限。控制台中文名称可能调整,请优先核对 scope
2. 订阅方式选择“使用长连接接收事件”。
3. 添加事件 `im.message.receive_v1`(接收消息)。
4. 启用卡片交互回调 `card.action.trigger`,用于审批、中断运行和群聊建项目按钮。
5. 不需要填写公网 Event Callback URLHub 使用飞书长连接。
## 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 在“权限管理”页面点击“批量处理 → 导入”,粘贴以下 JSON 后确认。导入只会新增本次列出的权限,不会删除或影响应用已经申请、开通的其他权限。
https://para-26071100.educraft.paradigm-edu.net/auth/feishu/callback
```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. 创建应用版本并提交企业管理员审核 如果 API 调试台提示缺少更细粒度权限,请把错误提示和发生时间截图给部署人员。不要自行开通通讯录全量读取等超出本表的权限
2. 将应用可用范围至少包含试点 OWNER 和试点群成员。
3. 发布版本。
4. 将机器人加入准备试用的飞书群。
## 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 ```text
Organization: para-26071100 https://<organization-slug>.educraft.paradigm-edu.net/auth/feishu/callback
App ID: cli_...
App Secret: (安全渠道发送)
OWNER Open ID: ou_...
OWNER 显示名称:
OWNER Union ID: (可选)
试点群名称: (可选,便于验收)
``` ```
## 7. 验收动作 例如组织 slug 为 `example-school`
平台通知部署完成后: ```text
https://example-school.educraft.paradigm-edu.net/auth/feishu/callback
```
1. OWNER 打开 Host Console,完成飞书 OAuth 登录。 ![在安全设置中添加组织专属 OAuth 重定向 URL](assets/feishu-setup/05-security.png)
2. 在试点群中 @机器人发送一条纯文本消息
3. 如果群尚未绑定项目,机器人应返回项目创建/绑定卡片。
4. 创建项目后再次 @机器人,确认出现处理状态、流式卡片和最终回答。
5. 再测试一个小文件附件,以及运行中断按钮。
任何一步失败时,请保留发生时间、群名、消息截图和飞书 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 不提供开放注册、自助密钥管理或跨组织资源共享。
+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 # 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 The supervised alpha runs one Organization per named Silo. The supported host
has systemd, PostgreSQL, Node.js 24+, `pg_isready`, `runuser`, `setpriv`, has systemd, PostgreSQL, Node.js 24+, `pg_isready`, `runuser`, `setpriv`,
bubblewrap, `socat`, `pg_dump`, `tar`, `sha256sum`, and a compatible `cph`. bubblewrap, `socat`, `pg_dump`, `tar`, `sha256sum`, and a compatible `cph`.
@@ -54,6 +65,34 @@ Default state paths are:
/var/cache/cph-hub/org-a /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 ## Bootstrap the only Organization
Prepare a root-owned `0600` JSON file containing Prepare a root-owned `0600` JSON file containing
@@ -129,16 +168,24 @@ sudo INSTANCE_ID=org-a \
bash hub/deploy/backup_silo.sh 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 separate recovery set contains the keyring and environment. Both include
checksums; neither destination may be the live host's only disk. 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 ```sh
set -a; . /path/to/restored/platform.env; set +a 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 \ node hub/dist/deployment/restore-preflight.js \
--keyring-file /path/to/restored/secret-keyring.json --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 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}" BUSINESS_BACKUP_DIR="${BUSINESS_BACKUP_DIR:?BUSINESS_BACKUP_DIR required}"
RECOVERY_BACKUP_DIR="${RECOVERY_BACKUP_DIR:?RECOVERY_BACKUP_DIR required}" RECOVERY_BACKUP_DIR="${RECOVERY_BACKUP_DIR:?RECOVERY_BACKUP_DIR required}"
SERVICE_UNIT="cph-hub-$INSTANCE_ID.service" 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; } [ "$(id -u)" -eq 0 ] || { echo "backup must run as root" >&2; exit 1; }
umask 077 umask 077
@@ -37,12 +38,14 @@ set +a
: "${DATABASE_URL:?DATABASE_URL missing from ENV_FILE}" : "${DATABASE_URL:?DATABASE_URL missing from ENV_FILE}"
: "${HUB_PROJECT_WORKSPACE_ROOT:?HUB_PROJECT_WORKSPACE_ROOT 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 "$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" install -d -o root -g root -m 0700 "$BUSINESS_BACKUP_DIR" "$RECOVERY_BACKUP_DIR"
business_root="$(realpath -m "$BUSINESS_BACKUP_DIR")" business_root="$(realpath -m "$BUSINESS_BACKUP_DIR")"
recovery_root="$(realpath -m "$RECOVERY_BACKUP_DIR")" recovery_root="$(realpath -m "$RECOVERY_BACKUP_DIR")"
workspace_root="$(realpath -m "$HUB_PROJECT_WORKSPACE_ROOT")" workspace_root="$(realpath -m "$HUB_PROJECT_WORKSPACE_ROOT")"
secret_root="$(realpath -m "$(dirname "$KEYRING_FILE")")" secret_root="$(realpath -m "$(dirname "$KEYRING_FILE")")"
skill_root="$(realpath -m "$SKILL_STORE_ROOT")"
paths_overlap() { paths_overlap() {
local left="$1" right="$2" local left="$1" right="$2"
[ "$left" = "$right" ] || [[ "$left/" == "$right/"* ]] || [[ "$right/" == "$left/"* ]] [ "$left" = "$right" ] || [[ "$left/" == "$right/"* ]] || [[ "$right/" == "$left/"* ]]
@@ -58,6 +61,10 @@ for pair in \
exit 1 exit 1
fi fi
done 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)" stamp="$(date -u +%Y%m%dT%H%M%SZ)"
business="$business_root/$INSTANCE_ID-$stamp" business="$business_root/$INSTANCE_ID-$stamp"
recovery="$recovery_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" 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/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 "$KEYRING_FILE" "$recovery/secret-keyring.json"
cp --preserve=mode,ownership,timestamps "$ENV_FILE" "$recovery/platform.env" cp --preserve=mode,ownership,timestamps "$ENV_FILE" "$recovery/platform.env"
chmod 0600 "$recovery/secret-keyring.json" "$recovery/platform.env" chmod 0600 "$recovery/secret-keyring.json" "$recovery/platform.env"
( (
cd "$business" cd "$business"
sha256sum database.dump workspaces.tar > SHA256SUMS sha256sum database.dump workspaces.tar agent-skills.tar > SHA256SUMS
) )
( (
cd "$recovery" cd "$recovery"
+1
View File
@@ -18,6 +18,7 @@ EnvironmentFile=__ENV_FILE__
Environment=HOME=__SERVICE_HOME__ Environment=HOME=__SERVICE_HOME__
Environment=XDG_STATE_HOME=__STATE_DIR__ Environment=XDG_STATE_HOME=__STATE_DIR__
Environment=XDG_CACHE_HOME=__CACHE_DIR__ Environment=XDG_CACHE_HOME=__CACHE_DIR__
Environment=HUB_SKILL_STORE_ROOT=__SKILL_STORE_ROOT__
Environment=PATH=__RUNTIME_PATH__ Environment=PATH=__RUNTIME_PATH__
# ADR-0024: the root-owned source remains unreadable by the service account; # ADR-0024: the root-owned source remains unreadable by the service account;
# systemd materializes a read-only per-unit credential at runtime. # 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}" SERVICE_HOME="${SERVICE_HOME:-/var/lib/cph-hub/$INSTANCE_ID/home}"
STATE_DIR="${STATE_DIR:-/var/lib/cph-hub/$INSTANCE_ID/state}" STATE_DIR="${STATE_DIR:-/var/lib/cph-hub/$INSTANCE_ID/state}"
CACHE_DIR="${CACHE_DIR:-/var/cache/cph-hub/$INSTANCE_ID}" 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)}" WORKSPACE_ROOT="${WORKSPACE_ROOT:?WORKSPACE_ROOT required (use a short per-Silo path such as /w/997)}"
HOST="${HOST:-127.0.0.1}" HOST="${HOST:-127.0.0.1}"
PORT="${PORT:?PORT is required and must be unique on the host}" PORT="${PORT:?PORT is required and must be unique on the host}"
@@ -105,6 +106,7 @@ for pair in \
"SERVICE_HOME:$SERVICE_HOME" \ "SERVICE_HOME:$SERVICE_HOME" \
"STATE_DIR:$STATE_DIR" \ "STATE_DIR:$STATE_DIR" \
"CACHE_DIR:$CACHE_DIR" \ "CACHE_DIR:$CACHE_DIR" \
"SKILL_STORE_ROOT:$SKILL_STORE_ROOT" \
"WORKSPACE_ROOT:$WORKSPACE_ROOT" \ "WORKSPACE_ROOT:$WORKSPACE_ROOT" \
"ENV_FILE:$ENV_FILE" \ "ENV_FILE:$ENV_FILE" \
"KEYRING_FILE:$KEYRING_FILE" \ "KEYRING_FILE:$KEYRING_FILE" \
@@ -281,6 +283,7 @@ provision_directory() {
provision_directory "$SERVICE_HOME" provision_directory "$SERVICE_HOME"
provision_directory "$STATE_DIR" provision_directory "$STATE_DIR"
provision_directory "$CACHE_DIR" provision_directory "$CACHE_DIR"
provision_directory "$SKILL_STORE_ROOT"
provision_directory "$WORKSPACE_ROOT" provision_directory "$WORKSPACE_ROOT"
# Resolve every provisioned path again and verify uid/gid/mode before writing # 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|__SERVICE_HOME__|$SERVICE_HOME|g" \
-e "s|__STATE_DIR__|$STATE_DIR|g" \ -e "s|__STATE_DIR__|$STATE_DIR|g" \
-e "s|__CACHE_DIR__|$CACHE_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|__WORKSPACE_ROOT__|$WORKSPACE_ROOT|g" \
-e "s|__HUB_DIR__|$HUB_DIR|g" \ -e "s|__HUB_DIR__|$HUB_DIR|g" \
-e "s|__ENV_FILE__|$ENV_FILE|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 daemon-reload
systemctl enable "$SERVICE_UNIT" systemctl enable "$SERVICE_UNIT"
echo "[install] installed $SERVICE_UNIT for $SERVICE_USER:$SERVICE_GROUP" 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" 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", "name": "@paradigm/hub",
"version": "0.0.6", "version": "0.0.20",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@paradigm/hub", "name": "@paradigm/hub",
"version": "0.0.6", "version": "0.0.20",
"dependencies": { "dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.202", "@anthropic-ai/claude-agent-sdk": "^0.3.202",
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@paradigm/hub", "name": "@paradigm/hub",
"version": "0.0.6", "version": "0.0.20",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "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: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", "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", "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:bootstrap": "node dist/deployment/bootstrap-silo-cli.js",
"silo:restore-preflight": "node dist/deployment/restore-preflight.js", "silo:restore-preflight": "node dist/deployment/restore-preflight.js",
"deploy": "bash deploy/deploy_platform.sh", "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[] externalDirectoryConnections ExternalDirectoryConnection[]
providerConnections OrganizationProviderConnection[] providerConnections OrganizationProviderConnection[]
feishuApplicationConnection OrganizationFeishuApplicationConnection? feishuApplicationConnection OrganizationFeishuApplicationConnection?
agentSkills OrganizationAgentSkill[]
agentRoles OrganizationAgentRole[]
auditEntries AuditEntry[] @relation("organizationAudit") auditEntries AuditEntry[] @relation("organizationAudit")
@@index([status]) @@index([status])
@@ -78,6 +80,70 @@ enum OrganizationMemberRole {
MEMBER 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 /// ADR-0021: org-level project onboarding policy. Ordinary Feishu users can
/// create projects from unbound chats only when membersCanCreateProjects=true. /// create projects from unbound chats only when membersCanCreateProjects=true.
model OrganizationProjectSettings { model OrganizationProjectSettings {
+80 -11
View File
@@ -5,7 +5,7 @@ import { randomBytes } from "node:crypto";
import type { PrismaClient } from "@prisma/client"; import type { PrismaClient } from "@prisma/client";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { resolveActiveFeishuApplication } from "../../connections/feishuApplicationConnections.js"; 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 type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
import { import {
buildAuthorizeUrl, buildAuthorizeUrl,
@@ -164,16 +164,54 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute
const feishuUser = await exchangeCodeForUser(oauthConfig, code); const feishuUser = await exchangeCodeForUser(oauthConfig, code);
let userId: string; let userId: string;
if (statePayload.connectionId !== undefined && statePayload.organizationId !== undefined) { if (statePayload.connectionId !== undefined && statePayload.organizationId !== undefined) {
const identity = await upsertScopedFeishuIdentity(config.prisma, { const connectionId = statePayload.connectionId;
connectionId: statePayload.connectionId, const organizationId = statePayload.organizationId;
openId: feishuUser.openId, const identity = await config.prisma.$transaction(async (tx) => {
...(feishuUser.unionId !== undefined ? { unionId: feishuUser.unionId } : {}), const resolved = await upsertScopedFeishuIdentityInTransaction(tx, {
displayName: feishuUser.displayName, connectionId,
...(feishuUser.avatarUrl !== null ? { avatarUrl: feishuUser.avatarUrl } : {}), expectedOrganizationId: organizationId,
openId: feishuUser.openId,
...(feishuUser.unionId !== undefined ? { unionId: feishuUser.unionId } : {}),
displayName: feishuUser.displayName,
...(feishuUser.avatarUrl !== null ? { avatarUrl: feishuUser.avatarUrl } : {}),
});
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;
}); });
if (identity.organizationId !== statePayload.organizationId) {
throw new HttpError(400, "bad_request", "OAuth identity Organization scope mismatch");
}
userId = identity.userId; userId = identity.userId;
setSessionCookie(reply, config, { setSessionCookie(reply, config, {
userId, userId,
@@ -235,6 +273,34 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute
return reply.status(204).send(); 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) => { app.get("/api/me", async (request, reply) => {
try { try {
const auth = await requireSession(request, reply, guardDeps); const auth = await requireSession(request, reply, guardDeps);
@@ -363,10 +429,13 @@ async function resolvePostLoginRedirect(
revokedAt: null, revokedAt: null,
organization: { status: "ACTIVE" }, 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"; if (intended === null) return "/admin?error=not_an_active_org_member";
const orgRoot = `/admin/org/${intended.organization.slug}`; 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; return returnTo === orgRoot || returnTo.startsWith(`${orgRoot}/`) ? returnTo : orgRoot;
} }
if (returnTo !== "/admin" && returnTo.startsWith("/admin")) { 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;
}
+8
View File
@@ -40,6 +40,14 @@ export interface RoleEntry {
* Invalid names fail fast when settings are loaded or the run is set up. * Invalid names fail fast when settings are loaded or the run is set up.
*/ */
readonly tools?: readonly string[] | undefined; 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. */ /** A model the admin has enabled for use by the Hub. */
+29 -2
View File
@@ -29,10 +29,11 @@
* `workspace.ts` `confine()` path validator as a tool wrapper — the OS sandbox * `workspace.ts` `confine()` path validator as a tool wrapper — the OS sandbox
* is the mechanism, the contract pins the invariant. * is the mechanism, the contract pins the invariant.
*/ */
import { query, type HookCallback, type McpServerConfig, type SDKMessage, type SDKAssistantMessage, type SDKUserMessage, type SDKResultMessage, type SDKPartialAssistantMessage } from "@anthropic-ai/claude-agent-sdk"; import { query, type HookCallback, type McpServerConfig, type SDKMessage, type SDKAssistantMessage, type SDKUserMessage, type SDKResultMessage, type SDKPartialAssistantMessage, type SDKSystemMessage } from "@anthropic-ai/claude-agent-sdk";
import type { PrismaClient } from "@prisma/client"; import type { PrismaClient } from "@prisma/client";
import { claudeSdkToolConfigForRole } from "./roleTools.js"; import { claudeSdkToolConfigForRole } from "./roleTools.js";
import { createAgentSecurityPolicy } from "./security.js"; import { createAgentSecurityPolicy } from "./security.js";
import type { RoleSkillEntry } from "./models.js";
export interface ProjectContext { export interface ProjectContext {
readonly projectId: string; readonly projectId: string;
@@ -66,6 +67,7 @@ export interface RunRequest {
* means no tools. * means no tools.
*/ */
readonly tools?: readonly string[] | undefined; readonly tools?: readonly string[] | undefined;
readonly skills?: readonly RoleSkillEntry[] | undefined;
readonly mcpServers?: Record<string, McpServerConfig> | undefined; readonly mcpServers?: Record<string, McpServerConfig> | undefined;
readonly maxTurns?: number; readonly maxTurns?: number;
readonly runId: string; readonly runId: string;
@@ -91,6 +93,8 @@ export interface RunResult {
readonly costUsd?: number | undefined; readonly costUsd?: number | undefined;
readonly numTurns: number; readonly numTurns: number;
readonly sdkSessionId?: string | undefined; readonly sdkSessionId?: string | undefined;
/** Skill ids reported by the SDK init event, not merely requested options. */
readonly initializedSkillIds?: readonly string[] | undefined;
readonly error?: string; readonly error?: string;
} }
@@ -131,7 +135,9 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
let costUsd: number | undefined; let costUsd: number | undefined;
let numTurns = 0; let numTurns = 0;
let sdkSessionId: string | undefined; let sdkSessionId: string | undefined;
let initializedSkillIds: readonly string[] | undefined;
let error: string | undefined; let error: string | undefined;
let cleanupSecurity = async (): Promise<void> => {};
try { try {
await persistAgentMessage(req, "user", req.prompt); await persistAgentMessage(req, "user", req.prompt);
const toolConfig = claudeSdkToolConfigForRole(req.tools); const toolConfig = claudeSdkToolConfigForRole(req.tools);
@@ -140,15 +146,21 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
throw new Error("Agent run requires the configured workspace root"); throw new Error("Agent run requires the configured workspace root");
} }
const security = await createAgentSecurityPolicy({ const security = await createAgentSecurityPolicy({
runId: req.runId,
workspaceRoot, workspaceRoot,
workspaceDir: req.project.workspaceDir, workspaceDir: req.project.workspaceDir,
skills: req.skills,
providerProxyEnv: req.providerProxyEnv, providerProxyEnv: req.providerProxyEnv,
}); });
cleanupSecurity = security.cleanup;
const hasSkills = security.skillIds.length > 0;
type QueryOptions = NonNullable<Parameters<typeof query>[0]["options"]>; type QueryOptions = NonNullable<Parameters<typeof query>[0]["options"]>;
const options: QueryOptions = { const options: QueryOptions = {
cwd: security.cwd, 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], allowedTools: [...toolConfig.allowedTools],
maxTurns: cap, maxTurns: cap,
includePartialMessages: true, includePartialMessages: true,
@@ -166,6 +178,11 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
// The project workspace is untrusted input. Do not load user/project // The project workspace is untrusted input. Do not load user/project
// settings that could widen tools, hooks, MCP servers, or sandbox paths. // settings that could widen tools, hooks, MCP servers, or sandbox paths.
settingSources: [], settingSources: [],
settings: { disableBundledSkills: true },
...(hasSkills && security.skillPluginRoot !== undefined
? { plugins: [{ type: "local" as const, path: security.skillPluginRoot, skipMcpDiscovery: true }] }
: {}),
skills: [...security.skillIds],
strictMcpConfig: true, strictMcpConfig: true,
// Claude Code 2.1.202 can honor the per-call opt-out despite // Claude Code 2.1.202 can honor the per-call opt-out despite
// sandbox.allowUnsandboxedCommands=false. Enforce the invariant again at // sandbox.allowUnsandboxedCommands=false. Enforce the invariant again at
@@ -191,6 +208,12 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
for await (const message of conversation) { for await (const message of conversation) {
switch (message.type) { switch (message.type) {
case "system": {
if (message.subtype === "init") {
initializedSkillIds = [...(message as SDKSystemMessage).skills];
}
break;
}
case "stream_event": { case "stream_event": {
const evt = (message as SDKPartialAssistantMessage).event; const evt = (message as SDKPartialAssistantMessage).event;
if (evt.type === "content_block_delta" && evt.delta.type === "text_delta") { if (evt.type === "content_block_delta" && evt.delta.type === "text_delta") {
@@ -287,6 +310,7 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
...(costUsd !== undefined ? { costUsd } : {}), ...(costUsd !== undefined ? { costUsd } : {}),
numTurns, numTurns,
sdkSessionId, sdkSessionId,
...(initializedSkillIds !== undefined ? { initializedSkillIds } : {}),
...(error !== undefined ? { error } : {}), ...(error !== undefined ? { error } : {}),
}; };
} catch (e) { } catch (e) {
@@ -298,8 +322,11 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
...(costUsd !== undefined ? { costUsd } : {}), ...(costUsd !== undefined ? { costUsd } : {}),
numTurns, numTurns,
sdkSessionId, sdkSessionId,
...(initializedSkillIds !== undefined ? { initializedSkillIds } : {}),
...(aborted ? {} : { error: e instanceof Error ? e.message : String(e) }), ...(aborted ? {} : { error: e instanceof Error ? e.message : String(e) }),
}; };
} finally {
await cleanupSecurity();
} }
} }
+19 -1
View File
@@ -1,6 +1,8 @@
import { chmod, lstat, mkdir, realpath } from "node:fs/promises"; import { chmod, lstat, mkdir, realpath } from "node:fs/promises";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { isAbsolute, join, relative, resolve } from "node:path"; import { isAbsolute, join, relative, resolve } from "node:path";
import type { RoleSkillEntry } from "./models.js";
import { prepareRunSkillPlugin, readSkillStoreRoot } from "./skillStore.js";
const PROVIDER_ENV_KEYS = new Set([ const PROVIDER_ENV_KEYS = new Set([
"ANTHROPIC_BASE_URL", "ANTHROPIC_BASE_URL",
@@ -32,8 +34,10 @@ const SANDBOX_HIDDEN_ENV_KEYS = [
const MAX_AGENT_TMP_PREFIX_BYTES = 56; const MAX_AGENT_TMP_PREFIX_BYTES = 56;
export interface AgentSecurityInput { export interface AgentSecurityInput {
readonly runId: string;
readonly workspaceRoot: string; readonly workspaceRoot: string;
readonly workspaceDir: string; readonly workspaceDir: string;
readonly skills?: readonly RoleSkillEntry[] | undefined;
/** Run-scoped loopback proxy capability; customer provider secrets are forbidden here. */ /** Run-scoped loopback proxy capability; customer provider secrets are forbidden here. */
readonly providerProxyEnv?: Readonly<Record<string, string | undefined>> | undefined; readonly providerProxyEnv?: Readonly<Record<string, string | undefined>> | undefined;
readonly hostEnv?: Readonly<Record<string, string | undefined>> | undefined; readonly hostEnv?: Readonly<Record<string, string | undefined>> | undefined;
@@ -60,6 +64,9 @@ export interface AgentSecurityPolicy {
readonly cwd: string; readonly cwd: string;
readonly workspaceRoot: string; readonly workspaceRoot: string;
readonly env: Record<string, string | undefined>; readonly env: Record<string, string | undefined>;
readonly skillIds: readonly string[];
readonly skillPluginRoot?: string | undefined;
cleanup(): Promise<void>;
readonly sandbox: AgentSandboxPolicy; readonly sandbox: AgentSandboxPolicy;
} }
@@ -115,10 +122,21 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
const sensitiveReadPaths = hostSensitiveReadPaths(hostEnv); const sensitiveReadPaths = hostSensitiveReadPaths(hostEnv);
const runtimeReadPaths = hostRuntimeReadPaths(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 { return {
cwd: workspaceDir, cwd: workspaceDir,
workspaceRoot, workspaceRoot,
env, env,
skillIds: skillPlugin?.skillIds ?? [],
...(skillPlugin !== null ? { skillPluginRoot: skillPlugin.root } : {}),
cleanup: skillPlugin?.cleanup ?? (async () => {}),
sandbox: { sandbox: {
enabled: true, enabled: true,
failIfUnavailable: true, failIfUnavailable: true,
@@ -134,7 +152,7 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
// workspace plus the named system runtime needed to execute tools. // workspace plus the named system runtime needed to execute tools.
// SDK allowRead takes precedence over matching denyRead paths. // SDK allowRead takes precedence over matching denyRead paths.
denyRead: ["/"], denyRead: ["/"],
allowRead: [workspaceDir, ...runtimeReadPaths], allowRead: [workspaceDir, ...(skillPlugin !== null ? [skillPlugin.root] : []), ...runtimeReadPaths],
}, },
credentials: { credentials: {
files: sensitiveReadPaths.map((path) => ({ path, mode: "deny" as const })), 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(); const count = await tx.organization.count();
if (count !== 0) throw new Error(`Silo bootstrap requires an empty Organization set; found ${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.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({ await tx.organizationProjectSettings.create({
data: { organizationId: input.organization.id, membersCanCreateProjects: true }, data: { organizationId: input.organization.id, membersCanCreateProjects: true },
}); });
+20 -24
View File
@@ -1,4 +1,4 @@
import { extname, isAbsolute, join } from "node:path"; import { isAbsolute, join, relative, resolve, sep } from "node:path";
import { import {
WorkspaceFileBoundaryError, WorkspaceFileBoundaryError,
readWorkspaceFileNoFollow, readWorkspaceFileNoFollow,
@@ -10,39 +10,21 @@ export interface DeliverableFile {
readonly data: Buffer; readonly data: Buffer;
} }
const DELIVERABLE_EXTENSIONS = new Set([ const DELIVERABLE_CPH_DIRECTORY = "inbox";
".csv",
".doc",
".docx",
".gif",
".jpeg",
".jpg",
".md",
".pdf",
".png",
".ppt",
".pptx",
".svg",
".txt",
".typ",
".xls",
".xlsx",
".zip",
]);
export async function resolveDeliverableFile( export async function resolveDeliverableFile(
requestedPath: string, requestedPath: string,
workspaceRoot: string, workspaceRoot: string,
workspaceDir: string, workspaceDir: string,
maxBytes?: number,
): Promise<DeliverableFile | null> { ): Promise<DeliverableFile | null> {
const token = requestedPath.trim(); const token = requestedPath.trim();
if (token === "" || !DELIVERABLE_EXTENSIONS.has(extname(token).toLowerCase())) { if (token === "") return null;
return null;
}
for (const candidate of candidatePaths(token)) { for (const candidate of candidatePaths(token)) {
assertNotPlatformRuntimePath(candidate, workspaceDir);
try { try {
return await readWorkspaceFileNoFollow(workspaceRoot, workspaceDir, candidate); return await readWorkspaceFileNoFollow(workspaceRoot, workspaceDir, candidate, maxBytes);
} catch (error) { } catch (error) {
if (!(error instanceof WorkspaceFileBoundaryError) || error.reason !== "not_found") { if (!(error instanceof WorkspaceFileBoundaryError) || error.reason !== "not_found") {
throw error; throw error;
@@ -54,6 +36,20 @@ export async function resolveDeliverableFile(
return null; return null;
} }
function assertNotPlatformRuntimePath(candidate: string, workspaceDir: string): void {
const workspace = resolve(workspaceDir);
const target = isAbsolute(candidate) ? resolve(candidate) : resolve(workspace, candidate);
const rel = relative(workspace, target);
const components = rel.split(sep);
if (components[0] === ".cph" && components[1] !== DELIVERABLE_CPH_DIRECTORY) {
throw new WorkspaceFileBoundaryError(
`platform runtime files cannot be delivered: ${candidate}`,
candidate,
"boundary",
);
}
}
function candidatePaths(token: string): string[] { function candidatePaths(token: string): string[] {
if (isAbsolute(token)) return [token]; if (isAbsolute(token)) return [token];
const candidates = [token]; const candidates = [token];
+20 -5
View File
@@ -15,6 +15,7 @@ export interface FileDeliveryToolOptions {
readonly runId: string; readonly runId: string;
readonly workspaceRoot?: string | undefined; readonly workspaceRoot?: string | undefined;
readonly workspaceDir: string; readonly workspaceDir: string;
readonly maxFileBytes?: number | undefined;
readonly sendOptions?: SendMessageOptions | undefined; readonly sendOptions?: SendMessageOptions | undefined;
readonly approvalManager: ApprovalManager; readonly approvalManager: ApprovalManager;
readonly onDelivered?: (path: string) => void; readonly onDelivered?: (path: string) => void;
@@ -29,7 +30,7 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
tools.push( tools.push(
tool( tool(
"send_file", "send_file",
"Upload an existing file from the current project workspace to the current Feishu chat. The path must point to a concrete no-symlink file, for example build/student.pdf or README.md.", "Upload any existing regular file from the current project workspace to the current Feishu chat. The path must point to a concrete no-symlink file and must not be inside platform runtime directories.",
{ {
path: z.string().describe("Workspace-relative path, or an absolute path physically inside the current workspace."), path: z.string().describe("Workspace-relative path, or an absolute path physically inside the current workspace."),
name: z.string().optional().describe("Optional display filename. Defaults to the file's basename."), name: z.string().optional().describe("Optional display filename. Defaults to the file's basename."),
@@ -46,12 +47,18 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
content: [{ type: "text", text: "File delivery is unavailable because workspace isolation is not configured." }], content: [{ type: "text", text: "File delivery is unavailable because workspace isolation is not configured." }],
}; };
} }
if (options.maxFileBytes === undefined) {
throw new Error("Agent file delivery requires a configured maximum file size");
}
let file; let file;
try { try {
file = await resolveDeliverableFile(args.path, workspaceRoot, options.workspaceDir); file = await resolveDeliverableFile(args.path, workspaceRoot, options.workspaceDir, options.maxFileBytes);
} catch (error) { } catch (error) {
const boundaryViolation = error instanceof WorkspaceFileBoundaryError && error.reason === "boundary"; const boundaryViolation = error instanceof WorkspaceFileBoundaryError && error.reason === "boundary";
const log = boundaryViolation ? options.rt.logger.warn.bind(options.rt.logger) : options.rt.logger.error.bind(options.rt.logger); const limitViolation = error instanceof WorkspaceFileBoundaryError && error.reason === "limit";
const log = boundaryViolation || limitViolation
? options.rt.logger.warn.bind(options.rt.logger)
: options.rt.logger.error.bind(options.rt.logger);
log( log(
{ {
runId: options.runId, runId: options.runId,
@@ -61,12 +68,20 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M
}, },
boundaryViolation boundaryViolation
? "Agent file delivery refused by workspace boundary" ? "Agent file delivery refused by workspace boundary"
: "Agent file delivery failed during workspace file access", : limitViolation
? "Agent file delivery refused by file size limit"
: "Agent file delivery failed during workspace file access",
); );
if (limitViolation) {
return {
isError: true,
content: [{ type: "text", text: `File exceeds the configured delivery limit: ${args.path}` }],
};
}
if (!boundaryViolation) throw error; if (!boundaryViolation) throw error;
return { return {
isError: true, isError: true,
content: [{ type: "text", text: "File path is outside the current workspace or uses a symlink." }], content: [{ type: "text", text: "File path is outside the current workspace, belongs to platform runtime, or uses a symlink." }],
}; };
} }
if (file === null) { if (file === null) {
+11 -1
View File
@@ -391,10 +391,20 @@ function formatRoleSlashCommandHelp(role: RoleEntry): string {
if (role.defaultModel !== undefined) { if (role.defaultModel !== undefined) {
lines.push(`- 默认模型: ${role.defaultModel}`); 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"); 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 { function roleToolsDescription(role: RoleEntry): string {
if (role.tools === undefined) return "全部已注册工具"; if (role.tools === undefined) return "全部已注册工具";
if (role.tools.length === 0) return "无"; if (role.tools.length === 0) return "无";
+81 -11
View File
@@ -35,7 +35,6 @@ import type { RuntimeSettings } from "../settings/runtime.js";
import { currentLockRunId, releaseLock } from "../lock.js"; import { currentLockRunId, releaseLock } from "../lock.js";
import { createPermissionAuthorizer, type AuthorizationDecision, type PermissionAuthorizer } from "../permission.js"; import { createPermissionAuthorizer, type AuthorizationDecision, type PermissionAuthorizer } from "../permission.js";
import { writeAudit } from "../audit.js"; import { writeAudit } from "../audit.js";
import { formatRunCostLine } from "../agent/cost.js";
import { createAgentSdkStderrSink } from "../agent/diagnostics.js"; import { createAgentSdkStderrSink } from "../agent/diagnostics.js";
import { InactiveOrganizationError, lockActiveOrganization } from "../org/status.js"; import { InactiveOrganizationError, lockActiveOrganization } from "../org/status.js";
import { StreamingAgentCard } from "./card/streaming-card.js"; import { StreamingAgentCard } from "./card/streaming-card.js";
@@ -85,6 +84,10 @@ interface TriggerDeps {
readonly messageBatcherOptions?: MessageBatcherOptions | undefined; readonly messageBatcherOptions?: MessageBatcherOptions | undefined;
readonly triggerQueue?: TriggerQueue | undefined; readonly triggerQueue?: TriggerQueue | undefined;
readonly projectWorkspaceRoot: string; 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. */ /** Alpha Silo policy: never acknowledge work into the process-local queue. */
readonly rejectWhenBusy?: boolean | undefined; readonly rejectWhenBusy?: boolean | undefined;
readonly resourceLimits?: { readonly resourceLimits?: {
@@ -133,6 +136,9 @@ export interface TriggerHandler {
export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
const projectWorkspaceRoot = deps.projectWorkspaceRoot.trim(); const projectWorkspaceRoot = deps.projectWorkspaceRoot.trim();
if (projectWorkspaceRoot === "") throw new Error("projectWorkspaceRoot is required"); 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 authorizer = deps.authorizer ?? createPermissionAuthorizer(deps.prisma);
const runAgent = deps.runAgent ?? defaultRunAgent; const runAgent = deps.runAgent ?? defaultRunAgent;
const approvalManager = new ApprovalManager(); const approvalManager = new ApprovalManager();
@@ -405,6 +411,11 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
metadata: { metadata: {
roleId, roleId,
model, model,
requestedSkills: (role?.skills ?? []).map((skill) => ({
name: skill.name,
version: skill.version,
contentDigest: skill.contentDigest,
})),
prompt: agentPrompt.slice(0, 200), prompt: agentPrompt.slice(0, 200),
sender: senderMetadata, sender: senderMetadata,
feishuTriggerContext, feishuTriggerContext,
@@ -444,6 +455,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
runId: run.id, runId: run.id,
workspaceRoot: projectWorkspaceRoot, workspaceRoot: projectWorkspaceRoot,
workspaceDir: project.workspaceDir, workspaceDir: project.workspaceDir,
maxFileBytes: deps.resourceLimits?.maxBytesPerFile,
sendOptions, sendOptions,
approvalManager, approvalManager,
tools: cphHubMcpToolsForRole(roleTools), tools: cphHubMcpToolsForRole(roleTools),
@@ -479,6 +491,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
providerProxyEnv: { ...providerLease.sdkEnv }, providerProxyEnv: { ...providerLease.sdkEnv },
resumeSessionId: sessionMetadata.claudeSessionId, resumeSessionId: sessionMetadata.claudeSessionId,
tools: roleTools, tools: roleTools,
skills: role?.skills,
mcpServers: { cph_hub: fileDeliveryMcpServer }, mcpServers: { cph_hub: fileDeliveryMcpServer },
maxTurns: runPolicy.maxTurns, maxTurns: runPolicy.maxTurns,
runId: run.id, runId: run.id,
@@ -536,7 +549,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
: result.status === "failed" && result.error !== undefined : result.status === "failed" && result.error !== undefined
? `\u5904\u7406\u5931\u8D25: ${result.error}` ? `\u5904\u7406\u5931\u8D25: ${result.error}`
: result.text; : result.text;
await card.finish(finalText, { interrupted, footerText: formatRunCostLine(result.costUsd) }); await card.finish(finalText, { interrupted });
const metadataPatch = sessionMetadataPatch(result.sdkSessionId); const metadataPatch = sessionMetadataPatch(result.sdkSessionId);
if (metadataPatch !== null) { if (metadataPatch !== null) {
await deps.prisma.agentSession.update({ await deps.prisma.agentSession.update({
@@ -567,7 +580,11 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
runId: run.id, runId: run.id,
projectId, projectId,
action: "run.finished", action: "run.finished",
metadata: { status: result.status, deliveredFiles }, metadata: {
status: result.status,
deliveredFiles,
initializedSkills: [...(result.initializedSkillIds ?? [])],
},
}); });
await removeProcessingReaction(); await removeProcessingReaction();
}) })
@@ -925,6 +942,15 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
await sendText(rt, chatId, "无法识别发送者,拒绝触发。", sendOptionsForTriggerMessage(msg)); await sendText(rt, chatId, "无法识别发送者,拒绝触发。", sendOptionsForTriggerMessage(msg));
return; 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 actor = { feishuOpenId: senderOpenId, chatId };
const triggerDecision = await authorizer.can({ const triggerDecision = await authorizer.can({
actor, actor,
@@ -1072,17 +1098,33 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
readonly organizationName: string; readonly organizationName: string;
readonly role: "OWNER" | "ADMIN" | "MEMBER"; 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({ const identity = await deps.prisma.feishuUserIdentity.findFirst({
where: { where: {
openId: feishuOpenId, openId: feishuOpenId,
connection: { status: "ACTIVE", organization: { status: "ACTIVE" } }, connection: { status: "ACTIVE", organizationId: siloOrganization.id },
}, },
select: { select: {
user: { select: { organizationMemberships: { user: { select: { organizationMemberships: {
where: { where: {
revokedAt: null, revokedAt: null,
organizationId: siloOrganization.id,
organization: { status: "ACTIVE" }, organization: { status: "ACTIVE" },
}, },
select: { select: {
@@ -1098,19 +1140,34 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
where: { feishuOpenId }, where: { feishuOpenId },
select: { select: {
organizationMemberships: { 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 } } }, select: { role: true, organization: { select: { id: true, name: true } } },
orderBy: { createdAt: "asc" }, orderBy: { createdAt: "asc" },
}, },
}, },
}) })
: null); : null);
if (user === null) return { status: "error", message: "请先登录并加入组织后,再绑定项目。" }; if (user === null) {
if (user.organizationMemberships.length === 0) { const loginUrl = new URL(
return { status: "error", message: "你还不属于任何可用组织,请联系组织管理员。" }; `/auth/feishu/${encodeURIComponent(siloOrganization.slug)}`,
publicBaseUrl,
).toString();
return {
status: "error",
reason: "identity_missing",
message: `请先通过飞书登录并加入组织:${loginUrl}\n完成后返回群聊重试。`,
};
} }
if (user.organizationMemberships.length > 1) { if (user.organizationMemberships.length === 0) {
return { status: "error", message: "你属于多个组织。请先从组织后台选择项目绑定,或等待多组织选择入口。" }; return {
status: "error",
reason: "membership_missing",
message: "你尚未加入该组织,或成员资格已被移除。请联系组织管理员。",
};
} }
const membership = user.organizationMemberships[0]!; const membership = user.organizationMemberships[0]!;
return { return {
@@ -1173,6 +1230,19 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
return Object.assign(onMessage, { onCardAction, approvalManager }); 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 { interface SessionMetadata {
readonly claudeSessionId?: string | undefined; readonly claudeSessionId?: string | undefined;
} }
+2
View File
@@ -160,6 +160,8 @@ export async function startHub(): Promise<void> {
settings: runtimeSettings, settings: runtimeSettings,
logger: app.log, logger: app.log,
projectWorkspaceRoot, projectWorkspaceRoot,
publicBaseUrl,
siloOrganizationId: siloOrganization.id,
rejectWhenBusy: true, rejectWhenBusy: true,
resourceLimits: { maxFilesPerMessage, maxBytesPerFile }, resourceLimits: { maxFilesPerMessage, maxBytesPerFile },
allowLegacyFeishuIdentity: false, allowLegacyFeishuIdentity: false,
+36 -1
View File
@@ -37,6 +37,7 @@ export async function readWorkspaceFileNoFollow(
workspaceRoot: string, workspaceRoot: string,
workspaceDir: string, workspaceDir: string,
requestedPath: string, requestedPath: string,
maxBytes?: number,
): Promise<WorkspaceFileSnapshot> { ): Promise<WorkspaceFileSnapshot> {
const workspace = await canonicalWorkspace(workspaceRoot, workspaceDir); const workspace = await canonicalWorkspace(workspaceRoot, workspaceDir);
const components = fileComponents(workspace, requestedPath); const components = fileComponents(workspace, requestedPath);
@@ -53,8 +54,15 @@ export async function readWorkspaceFileNoFollow(
if (!metadata.isFile()) { if (!metadata.isFile()) {
throw new WorkspaceFileBoundaryError(`deliverable is not a regular file: ${requestedPath}`, requestedPath); throw new WorkspaceFileBoundaryError(`deliverable is not a regular file: ${requestedPath}`, requestedPath);
} }
if (maxBytes !== undefined && metadata.size > maxBytes) {
throw new WorkspaceFileBoundaryError(
`workspace file exceeds ${maxBytes} bytes: ${requestedPath}`,
requestedPath,
"limit",
);
}
await assertNameStillReferences(parent, name, metadata.dev, metadata.ino, requestedPath); await assertNameStillReferences(parent, name, metadata.dev, metadata.ino, requestedPath);
const data = await file.readFile(); const data = await readFileSnapshot(file, maxBytes, requestedPath);
result = { path: join(workspace, ...components), name, data }; result = { path: join(workspace, ...components), name, data };
} catch (error) { } catch (error) {
failure = boundaryError(error, requestedPath, "cannot read workspace file without following symlinks"); failure = boundaryError(error, requestedPath, "cannot read workspace file without following symlinks");
@@ -67,6 +75,33 @@ export async function readWorkspaceFileNoFollow(
return result!; return result!;
} }
async function readFileSnapshot(
file: FileHandle,
maxBytes: number | undefined,
requestedPath: string,
): Promise<Buffer> {
if (maxBytes === undefined) return file.readFile();
const chunks: Buffer[] = [];
let total = 0;
let position = 0;
while (true) {
const remainingWithSentinel = maxBytes - total + 1;
const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, remainingWithSentinel));
const { bytesRead } = await file.read(chunk, 0, chunk.length, position);
if (bytesRead === 0) return Buffer.concat(chunks, total);
total += bytesRead;
if (total > maxBytes) {
throw new WorkspaceFileBoundaryError(
`workspace file exceeds ${maxBytes} bytes: ${requestedPath}`,
requestedPath,
"limit",
);
}
chunks.push(chunk.subarray(0, bytesRead));
position += bytesRead;
}
}
/** /**
* Create one inbound file exclusively below the workspace and stream into its * Create one inbound file exclusively below the workspace and stream into its
* already-open descriptor. Linux uses /proc/self/fd-relative traversal so a * already-open descriptor. Linux uses /proc/self/fd-relative traversal so a
+87 -3
View File
@@ -1,5 +1,5 @@
import type { Prisma, PrismaClient } from "@prisma/client"; 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 { lockActiveOrganization } from "../org/status.js";
import { decryptStoredProviderCredential } from "../connections/providerConnections.js"; import { decryptStoredProviderCredential } from "../connections/providerConnections.js";
import { openProviderProxyLease, type AgentProviderLease, type ProviderUpstreamCredential } from "../connections/providerProxy.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> { async modelRegistry(scope?: RuntimeScope): Promise<ModelRegistry> {
return this.envSettings.modelRegistry(scope); 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> { 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( async function loadActiveProviderSecret(
tx: Prisma.TransactionClient, tx: Prisma.TransactionClient,
projectId: string, projectId: string,
+86 -4
View File
@@ -267,9 +267,6 @@ describe("admin auth + org API guards", () => {
openId: "ou_scoped_user", openId: "ou_scoped_user",
displayName: "Invited 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 seedTestOrganization("org_scoped_other", "scoped-other");
await prisma.organizationMembership.create({ await prisma.organizationMembership.create({
data: { organizationId: "org_scoped_other", userId: identity.userId, role: "ADMIN" }, 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.statusCode).toBe(200);
expect(me.json()).toMatchObject({ expect(me.json()).toMatchObject({
user: { id: identity.userId, displayName: "Scoped User" }, 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); expect((me.json() as { organizations: unknown[] }).organizations).toHaveLength(1);
const crossOrganization = await app.inject({ const crossOrganization = await app.inject({
@@ -331,6 +328,32 @@ describe("admin auth + org API guards", () => {
where: { id: identity.identityId }, where: { id: identity.identityId },
select: { unionId: true }, select: { unionId: true },
})).resolves.toEqual({ unionId: "on_scoped_union" }); })).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" }); await connections.disable({ organizationId: DEFAULT_ORG_ID, actorUserId: "scoped-owner" });
const revoked = await app.inject({ method: "GET", url: "/api/me", headers: { cookie: sessionCookie } }); 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 () => { it("unknown org slug returns 404 for admin", async () => {
await seedUser("u-admin", "ou_admin", "ADMIN"); await seedUser("u-admin", "ou_admin", "ADMIN");
const app = await buildApp(); 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 { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { runAgent, type StreamEvent } from "../../src/agent/runner.js"; import { runAgent, type StreamEvent } from "../../src/agent/runner.js";
import { importSkillDirectory } from "../../src/agent/skillStore.js";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
const originalEnv = new Map<string, string | undefined>(); 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 workspace = join(workspaceRoot, "a", `p_${nonce.slice(0, 8)}`);
const sibling = join(workspaceRoot, "b", `p_${nonce.slice(8, 16)}`); const sibling = join(workspaceRoot, "b", `p_${nonce.slice(8, 16)}`);
const serviceSecret = join(root, `s_${nonce.slice(16, 24)}`); 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([ await Promise.all([
mkdir(workspace, { recursive: true }), mkdir(workspace, { recursive: true }),
mkdir(sibling, { recursive: true }), mkdir(sibling, { recursive: true }),
@@ -62,6 +65,12 @@ describe("real Claude SDK sandbox boundary", () => {
writeFile(join(sibling, "secret.txt"), "sibling-secret\n"), writeFile(join(sibling, "secret.txt"), "sibling-secret\n"),
writeFile(serviceSecret, "platform-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");
// macOS tmpdir is reached through /var -> /private/var. Exercise the // macOS tmpdir is reached through /var -> /private/var. Exercise the
// sandbox with canonical paths, matching the canonical cwd returned by // sandbox with canonical paths, matching the canonical cwd returned by
// createAgentSecurityPolicy rather than relying on a host symlink alias. // createAgentSecurityPolicy rather than relying on a host symlink alias.
@@ -83,6 +92,7 @@ describe("real Claude SDK sandbox boundary", () => {
DATABASE_URL: "postgresql://platform-secret", DATABASE_URL: "postgresql://platform-secret",
FEISHU_APP_SECRET: "feishu-secret", FEISHU_APP_SECRET: "feishu-secret",
HUB_SESSION_SECRET: "session-secret", HUB_SESSION_SECRET: "session-secret",
HUB_SKILL_STORE_ROOT: skillStore,
}); });
const bashCommand = [ const bashCommand = [
@@ -130,6 +140,7 @@ describe("real Claude SDK sandbox boundary", () => {
ANTHROPIC_API_KEY: "", ANTHROPIC_API_KEY: "",
}, },
tools: ["bash"], tools: ["bash"],
skills: [{ name: "outline", version: "1", contentDigest: installedSkill.contentDigest }],
maxTurns: 3, maxTurns: 3,
runId: "sandbox-run", runId: "sandbox-run",
sessionId: "sandbox-session", sessionId: "sandbox-session",
@@ -146,6 +157,9 @@ describe("real Claude SDK sandbox boundary", () => {
[result.error, sdkStderr.join(""), JSON.stringify(streamEvents)].filter(Boolean).join("\n"), [result.error, sdkStderr.join(""), JSON.stringify(streamEvents)].filter(Boolean).join("\n"),
).toBe("completed"); ).toBe("completed");
expect(stub.requestCount()).toBeGreaterThanOrEqual(3); expect(stub.requestCount()).toBeGreaterThanOrEqual(3);
expect(new Set(result.initializedSkillIds)).toEqual(new Set([
"cph-runtime:outline",
]));
const toolResults = streamEvents.filter((event) => event.type === "tool-result"); const toolResults = streamEvents.filter((event) => event.type === "tool-result");
expect(toolResults).toHaveLength(2); expect(toolResults).toHaveLength(2);
const rejectedOptOut = toolResults[0]; const rejectedOptOut = toolResults[0];
+3
View File
@@ -35,6 +35,9 @@ export const prisma = new PrismaClient({
/** Truncate all tables before each test for isolation. */ /** Truncate all tables before each test for isolation. */
export async function resetDb(): Promise<void> { export async function resetDb(): Promise<void> {
const tables = [ const tables = [
"OrganizationAgentRoleSkill",
"OrganizationAgentRole",
"OrganizationAgentSkill",
"FeishuEventReceipt", "FeishuEventReceipt",
"FeishuUserIdentity", "FeishuUserIdentity",
"FeishuApplicationCredentialVersion", "FeishuApplicationCredentialVersion",
@@ -53,6 +53,14 @@ describe("Alpha Silo bootstrap", () => {
expect(await prisma.team.count({ where: { slug: "teachers", archivedAt: null } })).toBe(1); 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.teamMembership.count({ where: { revokedAt: null } })).toBe(1);
expect(await prisma.organizationProviderConnection.count({ where: { status: "ACTIVE" } })).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({ const persisted = JSON.stringify({
feishu: await prisma.feishuApplicationCredentialVersion.findMany(), feishu: await prisma.feishuApplicationCredentialVersion.findMany(),
+153 -4
View File
@@ -3,7 +3,15 @@ import { tmpdir } from "node:os";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { Readable } from "node:stream"; import { Readable } from "node:stream";
import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vitest"; 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 { InMemoryModelRegistry } from "../../src/agent/models.js";
import { createSlashCommandRegistry } from "../../src/feishu/slashCommands.js"; import { createSlashCommandRegistry } from "../../src/feishu/slashCommands.js";
import { makeTriggerHandler as makeProductionTriggerHandler, extractPrompt } from "../../src/feishu/trigger.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> { function makeTriggerHandler(deps: TestTriggerDeps): ReturnType<typeof makeProductionTriggerHandler> {
return makeProductionTriggerHandler({ return makeProductionTriggerHandler({
projectWorkspaceRoot: "/tmp", projectWorkspaceRoot: "/tmp",
publicBaseUrl: "https://educraft.example.test",
siloOrganizationId: DEFAULT_ORG_ID,
allowLegacyFeishuIdentity: true, allowLegacyFeishuIdentity: true,
...deps, ...deps,
}); });
@@ -158,7 +168,8 @@ describe("trigger full lifecycle (integration)", () => {
msgType: "interactive", msgType: "interactive",
replyInThread: undefined, 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).toHaveLength(1);
expect(runAgentCalls[0]?.providerProxyEnv).toMatchObject({ expect(runAgentCalls[0]?.providerProxyEnv).toMatchObject({
ANTHROPIC_BASE_URL: "http://127.0.0.1:12345", ANTHROPIC_BASE_URL: "http://127.0.0.1:12345",
@@ -386,6 +397,18 @@ describe("trigger full lifecycle (integration)", () => {
role: "EDIT", 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 } }); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
await trigger(makeEvent("chat-speaker", "@_user_1 Alice 的需求", "ou_alice"), rt); await trigger(makeEvent("chat-speaker", "@_user_1 Alice 的需求", "ou_alice"), rt);
@@ -626,6 +649,66 @@ describe("trigger full lifecycle (integration)", () => {
expect(runs).toHaveLength(0); 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)( it.each(["SUSPENDED", "ARCHIVED"] as const)(
"rejects triggers and resume commands when the organization is %s", "rejects triggers and resume commands when the organization is %s",
async (status) => { async (status) => {
@@ -922,18 +1005,62 @@ describe("trigger full lifecycle (integration)", () => {
await expect(readdir(join(workspaceRoot, ".cph-staging"))).resolves.toEqual([]); 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"); await seedProject("proj-4", "chat-4");
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
await trigger(makeEvent("chat-UNKNOWN", "@_user_1 写教案", "ou_unknown_user"), rt); 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); expect(rt.sentCards).toHaveLength(0);
const runs = await prisma.agentRun.findMany(); const runs = await prisma.agentRun.findMany();
expect(runs).toHaveLength(0); 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 () => { it("ignores messages without @bot mention", async () => {
await seedProject("proj-5", "chat-5"); await seedProject("proj-5", "chat-5");
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } }); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
@@ -1430,6 +1557,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> { async function tempWorkspaceRoot(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), "cph-trigger-onboarding-")); const root = await mkdtemp(join(tmpdir(), "cph-trigger-onboarding-"));
workspaceRoots.push(root); workspaceRoots.push(root);
+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 () => { it("passes only the run proxy capability and safe runtime variables and protects the capability from tools", async () => {
const { workspaceRoot, workspace } = await makeWorkspace(); const { workspaceRoot, workspace } = await makeWorkspace();
const policy = await createAgentSecurityPolicy({ const policy = await createAgentSecurityPolicy({
runId: "run-test",
workspaceRoot, workspaceRoot,
workspaceDir: workspace, workspaceDir: workspace,
providerProxyEnv: { providerProxyEnv: {
@@ -51,6 +52,8 @@ describe("agent subprocess security policy", () => {
expect(policy.env.TEMP).toBe(policy.env.TMPDIR); expect(policy.env.TEMP).toBe(policy.env.TMPDIR);
expect(policy.env.TMPDIR).toBe(join(canonicalWorkspace, ".cph", "t")); expect(policy.env.TMPDIR).toBe(join(canonicalWorkspace, ".cph", "t"));
expect(Buffer.byteLength(policy.env.TMPDIR!)).toBeLessThanOrEqual(56); expect(Buffer.byteLength(policy.env.TMPDIR!)).toBeLessThanOrEqual(56);
expect(policy.skillIds).toEqual([]);
expect(policy.skillPluginRoot).toBeUndefined();
expect(policy.sandbox).toMatchObject({ expect(policy.sandbox).toMatchObject({
enabled: true, enabled: true,
@@ -75,6 +78,7 @@ describe("agent subprocess security policy", () => {
const { workspaceRoot, workspace } = await makeWorkspace(); const { workspaceRoot, workspace } = await makeWorkspace();
await expect(createAgentSecurityPolicy({ await expect(createAgentSecurityPolicy({
runId: "run-test",
workspaceRoot, workspaceRoot,
workspaceDir: workspace, workspaceDir: workspace,
providerProxyEnv: { providerProxyEnv: {
@@ -88,6 +92,7 @@ describe("agent subprocess security policy", () => {
it("keeps every SDK temp variable on a short path inside the project workspace", async () => { it("keeps every SDK temp variable on a short path inside the project workspace", async () => {
const { workspaceRoot, workspace } = await makeWorkspace(); const { workspaceRoot, workspace } = await makeWorkspace();
const policy = await createAgentSecurityPolicy({ const policy = await createAgentSecurityPolicy({
runId: "run-test",
workspaceRoot, workspaceRoot,
workspaceDir: workspace, workspaceDir: workspace,
hostEnv: { PATH: "/usr/bin:/bin" }, hostEnv: { PATH: "/usr/bin:/bin" },
@@ -113,6 +118,7 @@ describe("agent subprocess security policy", () => {
await mkdir(workspace, { recursive: true }); await mkdir(workspace, { recursive: true });
await expect(createAgentSecurityPolicy({ await expect(createAgentSecurityPolicy({
runId: "run-test",
workspaceRoot, workspaceRoot,
workspaceDir: workspace, workspaceDir: workspace,
hostEnv: { PATH: "/usr/bin:/bin" }, hostEnv: { PATH: "/usr/bin:/bin" },
@@ -127,6 +133,7 @@ describe("agent subprocess security policy", () => {
await symlink(outside, linked); await symlink(outside, linked);
await expect(createAgentSecurityPolicy({ await expect(createAgentSecurityPolicy({
runId: "run-test",
workspaceRoot, workspaceRoot,
workspaceDir: linked, workspaceDir: linked,
providerProxyEnv: { ANTHROPIC_AUTH_TOKEN: "run-proxy-capability" }, providerProxyEnv: { ANTHROPIC_AUTH_TOKEN: "run-proxy-capability" },
@@ -142,6 +149,7 @@ describe("agent subprocess security policy", () => {
await symlink(sibling, linked); await symlink(sibling, linked);
await expect(createAgentSecurityPolicy({ await expect(createAgentSecurityPolicy({
runId: "run-test",
workspaceRoot, workspaceRoot,
workspaceDir: linked, workspaceDir: linked,
providerProxyEnv: { ANTHROPIC_AUTH_TOKEN: "run-proxy-capability" }, providerProxyEnv: { ANTHROPIC_AUTH_TOKEN: "run-proxy-capability" },
+2
View File
@@ -110,6 +110,8 @@ describe("Feishu approval cards", () => {
logger: silentLogger(), logger: silentLogger(),
authorizer: allowAllAuthorizer(), authorizer: allowAllAuthorizer(),
projectWorkspaceRoot: "/tmp", projectWorkspaceRoot: "/tmp",
publicBaseUrl: "https://educraft.example.test",
siloOrganizationId: "org_test_default",
runAgent: async () => ({ runAgent: async () => ({
status: "completed", status: "completed",
text: "", text: "",
+15
View File
@@ -103,6 +103,8 @@ async function triggerWithRunAgent(
logger: rt.logger, logger: rt.logger,
authorizer: allowAllAuthorizer(), authorizer: allowAllAuthorizer(),
projectWorkspaceRoot: "/tmp", projectWorkspaceRoot: "/tmp",
publicBaseUrl: "https://educraft.example.test",
siloOrganizationId: "org_test_default",
runAgent, runAgent,
messageBatcherOptions: { maxMessages: 1 }, messageBatcherOptions: { maxMessages: 1 },
}); });
@@ -254,6 +256,19 @@ function mockPrisma(): PrismaClient {
const session = { id: "session-1", metadata: {} }; const session = { id: "session-1", metadata: {} };
const client = { 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: { feishuEventReceipt: {
findUnique: vi.fn(async () => null), findUnique: vi.fn(async () => null),
create: vi.fn(async () => ({ id: "receipt-1" })), create: vi.fn(async () => ({ id: "receipt-1" })),
+60 -1
View File
@@ -34,6 +34,65 @@ describe("file delivery path resolution", () => {
} }
}); });
itOnLinux("accepts arbitrary extensions and extensionless workspace files", async () => {
const root = await makeRepo();
try {
const workspace = join(root, "examples", "TH-141");
await writeFile(join(workspace, "lesson.json"), "{\"ok\":true}\n");
await writeFile(join(workspace, "Makefile"), "all:\n\t@true\n");
await expect(resolveDeliverableFile("lesson.json", join(root, "examples"), workspace))
.resolves.toMatchObject({ name: "lesson.json" });
await expect(resolveDeliverableFile("Makefile", join(root, "examples"), workspace))
.resolves.toMatchObject({ name: "Makefile" });
} finally {
await rm(root, { recursive: true, force: true });
}
});
itOnLinux("allows the Feishu inbox but refuses other platform runtime files", async () => {
const root = await makeRepo();
try {
const workspace = join(root, "examples", "TH-141");
await mkdir(join(workspace, ".cph", "agent-runtime"), { recursive: true });
await mkdir(join(workspace, ".cph", "inbox"), { recursive: true });
await writeFile(join(workspace, ".cph", "agent-runtime", "session.jsonl"), "internal\n");
await writeFile(join(workspace, ".cph", "denials.log"), "internal\n");
await writeFile(join(workspace, ".cph", "inbox", "source.bin"), "source\n");
await expect(
resolveDeliverableFile(".cph/inbox/source.bin", join(root, "examples"), workspace),
).resolves.toMatchObject({ name: "source.bin" });
await expect(
resolveDeliverableFile(".cph/agent-runtime/session.jsonl", join(root, "examples"), workspace),
).rejects.toMatchObject({ reason: "boundary" });
await expect(
resolveDeliverableFile(".cph/denials.log", join(root, "examples"), workspace),
).rejects.toMatchObject({ reason: "boundary" });
} finally {
await rm(root, { recursive: true, force: true });
}
});
itOnLinux("refuses a file larger than the configured delivery limit", async () => {
const root = await makeRepo();
try {
const workspace = join(root, "examples", "TH-141");
await writeFile(join(workspace, "exact.bin"), Buffer.alloc(10));
await writeFile(join(workspace, "artifact.bin"), Buffer.alloc(11));
await expect(
resolveDeliverableFile("exact.bin", join(root, "examples"), workspace, 10),
).resolves.toMatchObject({ name: "exact.bin", data: Buffer.alloc(10) });
await expect(
resolveDeliverableFile("artifact.bin", join(root, "examples"), workspace, 10),
).rejects.toMatchObject({ reason: "limit" });
} finally {
await rm(root, { recursive: true, force: true });
}
});
itOnLinux("rejects a deliverable symlink even when its target exists", async () => { itOnLinux("rejects a deliverable symlink even when its target exists", async () => {
const root = await makeRepo(); const root = await makeRepo();
try { try {
@@ -50,7 +109,7 @@ describe("file delivery path resolution", () => {
} }
}); });
it("does not infer files from natural-language prompts", async () => { itOnLinux("does not infer files from natural-language prompts", async () => {
const root = await makeRepo(); const root = await makeRepo();
try { try {
const workspace = join(root, "examples", "TH-141"); const workspace = join(root, "examples", "TH-141");
+66 -3
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 { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { runAgent } from "../../src/agent/runner.js"; import { runAgent } from "../../src/agent/runner.js";
import { importSkillDirectory } from "../../src/agent/skillStore.js";
const queryMock = vi.hoisted(() => vi.fn()); const queryMock = vi.hoisted(() => vi.fn());
@@ -33,6 +34,16 @@ function resultMessage(sessionId: string, costUsd?: number) {
}; };
} }
function initMessage(skills: string[]) {
return {
type: "system",
subtype: "init",
skills,
tools: [],
plugins: [],
};
}
function messages(...items: unknown[]) { function messages(...items: unknown[]) {
return (async function* () { return (async function* () {
for (const item of items) yield item; for (const item of items) yield item;
@@ -66,7 +77,7 @@ describe("runAgent", () => {
workspaceRoot = await realpath(workspaceRoot); workspaceRoot = await realpath(workspaceRoot);
workspace = await realpath(workspace); workspace = await realpath(workspace);
previousSecrets = Object.fromEntries( 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]]),
); );
}); });
@@ -102,6 +113,8 @@ describe("runAgent", () => {
permissionMode: "bypassPermissions", permissionMode: "bypassPermissions",
allowDangerouslySkipPermissions: true, allowDangerouslySkipPermissions: true,
settingSources: [], settingSources: [],
settings: { disableBundledSkills: true },
skills: [],
strictMcpConfig: true, strictMcpConfig: true,
sandbox: expect.objectContaining({ sandbox: expect.objectContaining({
enabled: true, enabled: true,
@@ -134,6 +147,26 @@ describe("runAgent", () => {
expect(call?.options).not.toHaveProperty("resume"); expect(call?.options).not.toHaveProperty("resume");
}); });
it("returns the skills actually reported by SDK initialization", async () => {
queryMock.mockReturnValue(messages(
initMessage(["cph-runtime:outline"]),
assistantMessage("fresh"),
resultMessage("sdk-session-1"),
));
const result = await runAgent({
prompt: "写一个大纲",
model: undefined,
project: { projectId: "p", boundChatId: "c", workspaceRoot, workspaceDir: workspace },
systemPrompt: undefined,
runId: "run-1",
sessionId: "hub-session-1",
prisma: stubPrisma,
});
expect(result.initializedSkillIds).toEqual(["cph-runtime:outline"]);
});
it("maps role tool ids to the Claude SDK tool whitelist", async () => { it("maps role tool ids to the Claude SDK tool whitelist", async () => {
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1"))); queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));
@@ -156,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"))); queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1")));
await runAgent({ await runAgent({
@@ -178,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 () => { it("returns SDK-reported cost when present", async () => {
queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1", 0.0042))); 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;
}