Compare commits

...

37 Commits

Author SHA1 Message Date
sjfhsjfh 3ebe4b754d refactor(spec): clean prose patterns across all modules
Remove filler/redundant patterns: 钉死/钉, 本模块, likec4 画不出/画得出,
臆造, 散文, 分歧点测试, 纯 plumbing, 恰好, 留白, 宪法第N条, 刻意.
No code definitions changed, only doc comments.
2026-07-13 11:26:23 +08:00
sjfhsjfh 3fa6a5a5a5 fix(spec): replace @Claude with @bot in Prelude and Run 2026-07-12 18:55:52 +08:00
sjfhsjfh be4260bcd0 refactor(spec): move AgentRole/Run/Memory/AgentSurface into System/Agent/ subdir 2026-07-12 18:43:11 +08:00
sjfhsjfh a4449f03c4 chore: ignore .env 2026-07-12 18:38:45 +08:00
sjfhsjfh 01bc20d25f feat(spec): add AgentRole, AgentSkill, RoleSkillBinding (ADR-0017/0018) 2026-07-12 18:38:17 +08:00
sjfhsjfh 38c3231190 refactor(spec): generalize Feishu to Connections
- Connections/Prelude.lean: ConnectionProvider 枚举 (当前仅飞书)
- Connections/Feishu.lean: FeishuAppBinding + FeishuProfile
- Connections.lean: ConnectionBinding/ConnectionProfile inductive
- Organization.feishu → connections: List ConnectionBinding
- User.feishu → connections: List ConnectionProfile
- 删除 FeishuConnection.lean
2026-07-12 15:54:16 +08:00
sjfhsjfh 63416e06ea refactor(spec): move FeishuProfile to FeishuConnection, clean docs
- FeishuProfile 从 User.lean 移到 FeishuConnection.lean
- User.lean 只留用户创建路径声明
- 清理所有 doc comment
2026-07-12 09:33:33 +08:00
sjfhsjfh 39bd2c9ff7 feat(spec): pin org-feishu app binding to 1:1
- FeishuConnection.lean: FeishuAppBinding (appId + appSecretEnvelope)
- Organization.feishu: Option FeishuAppBinding (Option 自带 1:1)
- 删除 FeishuConnectionId (不再需要游离类型)
- FeishuProfile 删除 connection 字段 (由 org 隐含)
2026-07-12 09:29:41 +08:00
sjfhsjfh e17e038232 feat(spec): add FeishuUserId to FeishuProfile
飞书 user_id 是租户内身份,换应用不变;open_id 是应用内身份,换应用即变。
两者都存:user_id 更稳定,open_id 是 API 调用句柄。
2026-07-12 09:26:13 +08:00
sjfhsjfh 678bc9f56c refactor(spec): tighten prose, replace jargon
- 角色格→角色体系 (3处)
- 租户根/tenant root→租户 (3处)
- 清理 Hierarchy/Organization/System 的 doc 注释
2026-07-12 09:23:39 +08:00
sjfhsjfh 3a50ed0ce2 feat(spec): add three-tier subject hierarchy and org role lattice
- Hierarchy.lean: Platform/Organization/User struct, 三层主体层级
- User.lean: FeishuProfile, 飞书身份是绑定不是本体
- User struct: id/displayName/passwordHash/feishu
- Organization.lean: OrganizationRole(owner/admin/member) + 成员管理规则
- Prelude.lean: UserId/FeishuOpenId/FeishuConnectionId

实现偏离: spec 钉 User 为独立实体, 实现 User.id 由飞书身份派生

lake build 35/35 全绿
2026-07-12 00:21:13 +08:00
hongjr03 53998d2651 fix: enable proxy use in new silos 2026-07-11 15:07:28 +08:00
hongjr03 5b55cf18a8 Revert "fix: accept SDK provider capability headers"
This reverts commit e7ad5580ec.
2026-07-11 15:06:48 +08:00
hongjr03 b0d691d53f Revert "fix: pass provider capability as API key"
This reverts commit f065f9f978.
2026-07-11 15:06:48 +08:00
hongjr03 1f48c5b707 Revert "fix: provide capability for both SDK auth modes"
This reverts commit ebf870249f.
2026-07-11 15:06:48 +08:00
hongjr03 12a2f3117f Revert "fix: preserve run provider capability"
This reverts commit 63c86322de.
2026-07-11 15:06:48 +08:00
hongjr03 2ee84d9543 Revert "fix: carry provider capability in dedicated header"
This reverts commit 3087132083.
2026-07-11 15:06:47 +08:00
hongjr03 3087132083 fix: carry provider capability in dedicated header 2026-07-11 15:02:51 +08:00
hongjr03 63c86322de fix: preserve run provider capability 2026-07-11 15:00:34 +08:00
hongjr03 ebf870249f fix: provide capability for both SDK auth modes 2026-07-11 14:59:00 +08:00
hongjr03 f065f9f978 fix: pass provider capability as API key 2026-07-11 14:57:22 +08:00
hongjr03 e7ad5580ec fix: accept SDK provider capability headers 2026-07-11 14:55:29 +08:00
hongjr03 19d942e812 feat: automate managed silo provisioning 2026-07-11 14:53:03 +08:00
hongjr03 e5e923dd34 feat: add repeatable alpha silo setup 2026-07-11 14:30:50 +08:00
hongjr03 df0b12e38b fix: hide per-run cost from Feishu replies 2026-07-11 14:11:59 +08:00
hongjr03 83ec835d4c fix: show Feishu OAuth completion page 2026-07-11 14:07:43 +08:00
hongjr03 6ed56ddfc8 feat: auto-join scoped Feishu OAuth users 2026-07-11 14:00:43 +08:00
hongjr03 3bf643ff4d fix: guide Feishu users through onboarding 2026-07-11 13:52:19 +08:00
hongjr03 d36b00bbec feat: make agent roles and skills dynamic 2026-07-11 12:55:05 +08:00
hongjr03 17c0536958 fix: enable only curated agent skills 2026-07-11 12:25:52 +08:00
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
hongjr03 7fcb57013e fix: restore bounded agent sandbox execution 2026-07-11 02:34:56 +08:00
hongjr03 035c264179 fix: keep agent sandbox sockets on short paths 2026-07-11 02:22:19 +08:00
hongjr03 38d9a9c6cb fix: bootstrap fresh alpha silo 2026-07-11 01:40:20 +08:00
92 changed files with 3729 additions and 346 deletions
+2 -2
View File
@@ -93,8 +93,8 @@ jobs:
- name: Prove real Claude SDK Bash sandbox boundary - name: Prove real Claude SDK Bash sandbox boundary
run: | run: |
sudo install -d -o "$(id -u)" -g "$(id -g)" -m 0700 /var/lib/cph-test sudo install -d -o "$(id -u)" -g "$(id -g)" -m 0700 /w/t
CPH_SANDBOX_TEST_ROOT=/var/lib/cph-test \ CPH_SANDBOX_TEST_ROOT=/w/t \
/usr/bin/setpriv --no-new-privs \ /usr/bin/setpriv --no-new-privs \
npx vitest run test/integration/agent-sandbox-linux.test.ts npx vitest run test/integration/agent-sandbox-linux.test.ts
+3
View File
@@ -11,6 +11,9 @@
# regenerable, not for VCS. The embedded engine mounts cph-render directly. # regenerable, not for VCS. The embedded engine mounts cph-render directly.
render/vendor/local-packages/ render/vendor/local-packages/
# Environment
.env
# Node (hub/ TS workspace and any future JS package) # Node (hub/ TS workspace and any future JS package)
node_modules/ node_modules/
+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
@@ -93,22 +93,26 @@ The boundary is enforced by the Claude Code SDK's built-in sandbox
its Bash subprocesses run sandboxed; if the sandbox can't start, `query()` its Bash subprocesses run sandboxed; if the sandbox can't start, `query()`
emits an error and exits rather than running unsandboxed. emits an error and exits rather than running unsandboxed.
- `sandbox.allowUnsandboxedCommands: false` — a tool cannot opt out with the - `sandbox.allowUnsandboxedCommands: false` — a tool cannot opt out with the
SDK's `dangerouslyDisableSandbox` input. SDK's `dangerouslyDisableSandbox` input. Claude Code 2.1.202 does not enforce
that option reliably, so a host-side `PreToolUse` hook also denies every
Bash request whose input explicitly sets `dangerouslyDisableSandbox: true`
before a process can start.
- `sandbox.filesystem.denyRead: ["/"]` with `allowRead` for the canonical - `sandbox.filesystem.denyRead: ["/"]` with `allowRead` for the canonical
current workspace and a small named system-runtime set — normal reads stay current workspace and a small named system-runtime set — normal reads stay
in the run's workspace while `/bin`, shared libraries, CA certificates, in the run's workspace while `/bin`, shared libraries, CA certificates,
fonts and the configured `cph` executable remain available as the external fonts and the configured `cph` executable remain available as the external
tool exception described above. tool exception described above.
- `sandbox.filesystem.allowWrite: [workspaceDir]` confines every write to the - `sandbox.filesystem.allowWrite: [workspaceDir]` confines persistent host
canonical ADR-0007 workspace. Config/cache/home stay beneath effects to the canonical ADR-0007 workspace. Config/cache/home stay beneath
`.cph/agent-runtime/`. `TMPDIR`, `TMP`, and `TEMP` use the absolute `.cph/agent-runtime/`; `TMPDIR`, `TMP`, `TEMP`, and `CLAUDE_CODE_TMPDIR` all
workspace-local `.cph/t/` path so tools remain anchored after `cd`; point at the workspace-local `.cph/t`. The workspace allocator uses stable
`CLAUDE_CODE_TMPDIR` uses the short relative `.cph/t` prefix, resolved from compact Organization/Project path segments, deployment requires a short
the canonical workspace cwd, because the SDK's socat bridge otherwise falls workspace root, and the canonical temp prefix fails fast above 56 bytes so
back to a host temp directory when its Unix-socket prefix is too long. the SDK can append randomized `socat` bridge socket names without exceeding
Root is denied for writes and only the canonical workspace is re-opened, so Linux `sockaddr_un.sun_path`. Bubblewrap shadows non-allowlisted host trees
`/tmp`, `/var/tmp`, sibling projects and every other host path are rejected. with disposable tmpfs mounts: a shell write there may succeed inside that
There is no writable scratch exception outside the project directory. private namespace, but it cannot mutate the corresponding host path. The
Linux proof checks host state after the sandbox exits.
- The SDK subprocess environment replaces rather than spreads `process.env`. - The SDK subprocess environment replaces rather than spreads `process.env`.
Only provider protocol variables and non-secret runtime variables cross the Only provider protocol variables and non-secret runtime variables cross the
boundary; database, Feishu and Hub session credentials never enter it. boundary; database, Feishu and Hub session credentials never enter it.
@@ -118,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

+219
View File
@@ -0,0 +1,219 @@
# Educraft 组织接入与飞书应用配置指南
本文供准备接入 Educraft Alpha Silo 的学校、教培机构和组织管理员使用。完成本文后,请把末尾的“部署信息交付单”交给 Educraft 部署人员;我们会为组织创建独立的服务账号、数据库、运行目录和域名入口。
> **安全提醒:** App Secret、模型 Provider Token 属于密钥,禁止粘贴到飞书群、普通云文档、工单正文或截图中。请只通过双方约定的安全渠道传递。
## 1. 双方分别负责什么
| 角色 | 负责事项 |
| --- | --- |
| 组织管理员 | 创建企业自建应用、启用机器人、开通最小权限、配置事件、回调和 OAuth 重定向 URL、发布应用、提供 OWNER 身份 |
| Educraft 部署人员 | 分配组织 slug 和域名、部署独立 Silo、加密保存应用及模型密钥、初始化 OWNER、联调和验收 |
| 试点 OWNER | 把机器人加入试点群、创建或绑定项目、组织首轮验收 |
## 2. 创建企业自建应用
1. 打开[飞书开放平台开发者后台](https://open.feishu.cn/app)。
2. 在目标企业下点击“创建企业自建应用”。应用名称建议填写“Educraft + 组织简称”。
3. 进入“凭证与基础信息”,记录 App ID 和 App Secret。
4. 进入“添加应用能力”,添加并启用“机器人”。
![凭证与基础信息页面;App Secret 默认以星号隐藏](assets/feishu-setup/01-credentials.png)
App ID 通常以 `cli_` 开头,可以写入交付单。App Secret 必须通过安全渠道单独发送。Bot Open ID 不需要管理员手工查找;部署程序会用 App ID/App Secret 调用 Bot Info API 获取并校验归属。
## 3. 开通最小权限
进入“权限管理”,点击“开通权限”,搜索并申请以下应用身份权限。控制台中文名称可能调整,请优先核对 scope。
| 用途 | 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` |
### 批量导入权限(推荐)
在“权限管理”页面点击“批量处理 → 导入”,粘贴以下 JSON 后确认。导入只会新增本次列出的权限,不会删除或影响应用已经申请、开通的其他权限。
```json
{
"scopes": {
"tenant": [
"im:message.group_at_msg:readonly",
"im:message:send_as_bot",
"im:message:readonly",
"im:resource",
"im:message.reactions:write_only",
"contact:user.base:readonly",
"contact:user.basic_profile:readonly",
"contact:user.id:readonly"
],
"user": []
}
}
```
Educraft 机器人以应用身份调用上述 API,因此这些 scope 全部放在 `tenant`,不要为了省事把相同权限重复放进 `user`
![权限管理入口与已开通权限列表](assets/feishu-setup/02-permissions.png)
如果 API 调试台提示缺少更细粒度权限,请把错误提示和发生时间截图给部署人员。不要自行开通通讯录全量读取等超出本表的权限。
## 4. 配置事件与卡片回调
进入“事件与回调”。
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
https://<organization-slug>.educraft.paradigm-edu.net/auth/feishu/callback
```
例如组织 slug 为 `example-school`
```text
https://example-school.educraft.paradigm-edu.net/auth/feishu/callback
```
![在安全设置中添加组织专属 OAuth 重定向 URL](assets/feishu-setup/05-security.png)
必须使用 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.
+51 -3
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`.
@@ -15,6 +26,7 @@ Application code lives in immutable versioned directories under
sudo BASE=/srv/curriculum-project-hub \ sudo BASE=/srv/curriculum-project-hub \
HUB_DIR=/srv/curriculum-project-hub/releases/<release-id>/hub \ HUB_DIR=/srv/curriculum-project-hub/releases/<release-id>/hub \
INSTANCE_ID=org-a \ INSTANCE_ID=org-a \
WORKSPACE_ROOT=/w/997 \
PORT=8788 \ PORT=8788 \
MEMORY_MAX=16G CPU_QUOTA=400% TASKS_MAX=512 \ MEMORY_MAX=16G CPU_QUOTA=400% TASKS_MAX=512 \
bash /srv/curriculum-project-hub/releases/<release-id>/hub/deploy/install_service.sh bash /srv/curriculum-project-hub/releases/<release-id>/hub/deploy/install_service.sh
@@ -49,10 +61,38 @@ Default state paths are:
```text ```text
/var/lib/cph-hub/org-a/home /var/lib/cph-hub/org-a/home
/var/lib/cph-hub/org-a/state /var/lib/cph-hub/org-a/state
/var/lib/cph-hub/org-a/workspaces /w/997
/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
@@ -128,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.
+4 -2
View File
@@ -10,6 +10,7 @@
# PLATFORM_DEPLOY_BASE optional, defaults to /srv/curriculum-project-hub # PLATFORM_DEPLOY_BASE optional, defaults to /srv/curriculum-project-hub
# PLATFORM_DEPLOY_RELEASE optional immutable release id, defaults to git HEAD # PLATFORM_DEPLOY_RELEASE optional immutable release id, defaults to git HEAD
# PLATFORM_DEPLOY_INSTANCE required, Silo instance id # PLATFORM_DEPLOY_INSTANCE required, Silo instance id
# PLATFORM_DEPLOY_WORKSPACE_ROOT required short per-Silo path (for example /w/997)
# PLATFORM_DEPLOY_MEMORY_MAX / CPU_QUOTA / TASKS_MAX required ceilings # PLATFORM_DEPLOY_MEMORY_MAX / CPU_QUOTA / TASKS_MAX required ceilings
# PLATFORM_DEPLOY_HEALTH_URL optional, defaults to http://127.0.0.1:8788/api/healthz # PLATFORM_DEPLOY_HEALTH_URL optional, defaults to http://127.0.0.1:8788/api/healthz
# The target host must have Node.js 24+, npm, rsync, PostgreSQL client tools # The target host must have Node.js 24+, npm, rsync, PostgreSQL client tools
@@ -31,6 +32,7 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
RELEASE_ID="${PLATFORM_DEPLOY_RELEASE:-$(git -C "$REPO_ROOT" rev-parse --verify HEAD)}" RELEASE_ID="${PLATFORM_DEPLOY_RELEASE:-$(git -C "$REPO_ROOT" rev-parse --verify HEAD)}"
[[ "$RELEASE_ID" =~ ^[A-Za-z0-9._-]+$ ]] || { echo "invalid PLATFORM_DEPLOY_RELEASE" >&2; exit 1; } [[ "$RELEASE_ID" =~ ^[A-Za-z0-9._-]+$ ]] || { echo "invalid PLATFORM_DEPLOY_RELEASE" >&2; exit 1; }
INSTANCE_ID="${PLATFORM_DEPLOY_INSTANCE:?PLATFORM_DEPLOY_INSTANCE required}" INSTANCE_ID="${PLATFORM_DEPLOY_INSTANCE:?PLATFORM_DEPLOY_INSTANCE required}"
WORKSPACE_ROOT="${PLATFORM_DEPLOY_WORKSPACE_ROOT:?PLATFORM_DEPLOY_WORKSPACE_ROOT required}"
SERVICE_UNIT="cph-hub-$INSTANCE_ID.service" SERVICE_UNIT="cph-hub-$INSTANCE_ID.service"
MEMORY_MAX="${PLATFORM_DEPLOY_MEMORY_MAX:?PLATFORM_DEPLOY_MEMORY_MAX required}" MEMORY_MAX="${PLATFORM_DEPLOY_MEMORY_MAX:?PLATFORM_DEPLOY_MEMORY_MAX required}"
CPU_QUOTA="${PLATFORM_DEPLOY_CPU_QUOTA:?PLATFORM_DEPLOY_CPU_QUOTA required}" CPU_QUOTA="${PLATFORM_DEPLOY_CPU_QUOTA:?PLATFORM_DEPLOY_CPU_QUOTA required}"
@@ -67,11 +69,11 @@ fi
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" " ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" "
set -euo pipefail set -euo pipefail
if [ \"\$(id -u)\" = \"0\" ]; then if [ \"\$(id -u)\" = \"0\" ]; then
BASE='$BASE' HUB_DIR='$HUB_DIR' INSTANCE_ID='$INSTANCE_ID' PORT='$HUB_PORT' MEMORY_MAX='$MEMORY_MAX' CPU_QUOTA='$CPU_QUOTA' TASKS_MAX='$TASKS_MAX' bash '$HUB_DIR/deploy/install_service.sh' BASE='$BASE' HUB_DIR='$HUB_DIR' INSTANCE_ID='$INSTANCE_ID' WORKSPACE_ROOT='$WORKSPACE_ROOT' PORT='$HUB_PORT' MEMORY_MAX='$MEMORY_MAX' CPU_QUOTA='$CPU_QUOTA' TASKS_MAX='$TASKS_MAX' bash '$HUB_DIR/deploy/install_service.sh'
systemctl restart '$SERVICE_UNIT' systemctl restart '$SERVICE_UNIT'
systemctl is-active --quiet '$SERVICE_UNIT' systemctl is-active --quiet '$SERVICE_UNIT'
else else
sudo -n BASE='$BASE' HUB_DIR='$HUB_DIR' INSTANCE_ID='$INSTANCE_ID' PORT='$HUB_PORT' MEMORY_MAX='$MEMORY_MAX' CPU_QUOTA='$CPU_QUOTA' TASKS_MAX='$TASKS_MAX' bash '$HUB_DIR/deploy/install_service.sh' sudo -n BASE='$BASE' HUB_DIR='$HUB_DIR' INSTANCE_ID='$INSTANCE_ID' WORKSPACE_ROOT='$WORKSPACE_ROOT' PORT='$HUB_PORT' MEMORY_MAX='$MEMORY_MAX' CPU_QUOTA='$CPU_QUOTA' TASKS_MAX='$TASKS_MAX' bash '$HUB_DIR/deploy/install_service.sh'
sudo -n systemctl restart '$SERVICE_UNIT' sudo -n systemctl restart '$SERVICE_UNIT'
sudo -n systemctl is-active --quiet '$SERVICE_UNIT' sudo -n systemctl is-active --quiet '$SERVICE_UNIT'
fi fi
+7 -2
View File
@@ -23,7 +23,8 @@ 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}"
WORKSPACE_ROOT="${WORKSPACE_ROOT:-/var/lib/cph-hub/$INSTANCE_ID/workspaces}" SKILL_STORE_ROOT="${SKILL_STORE_ROOT:-$STATE_DIR/skills}"
WORKSPACE_ROOT="${WORKSPACE_ROOT:?WORKSPACE_ROOT required (use a short per-Silo path such as /w/997)}"
HOST="${HOST:-127.0.0.1}" 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}"
ENV_FILE="${ENV_FILE:-$BASE/.secrets/$INSTANCE_ID/platform.env}" ENV_FILE="${ENV_FILE:-$BASE/.secrets/$INSTANCE_ID/platform.env}"
@@ -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" \
@@ -120,6 +122,7 @@ done
[[ "$MEMORY_MAX" =~ ^[1-9][0-9]*[KMGT]$ ]] || fail "MEMORY_MAX must be a systemd byte size such as 16G" [[ "$MEMORY_MAX" =~ ^[1-9][0-9]*[KMGT]$ ]] || fail "MEMORY_MAX must be a systemd byte size such as 16G"
[[ "$CPU_QUOTA" =~ ^[1-9][0-9]*%$ ]] || fail "CPU_QUOTA must be a positive percentage such as 400%" [[ "$CPU_QUOTA" =~ ^[1-9][0-9]*%$ ]] || fail "CPU_QUOTA must be a positive percentage such as 400%"
[[ "$TASKS_MAX" =~ ^[1-9][0-9]*$ ]] || fail "TASKS_MAX must be a positive integer" [[ "$TASKS_MAX" =~ ^[1-9][0-9]*$ ]] || fail "TASKS_MAX must be a positive integer"
[ "${#WORKSPACE_ROOT}" -le 16 ] || fail "WORKSPACE_ROOT must be at most 16 bytes for Agent sandbox sockets: $WORKSPACE_ROOT"
KEYRING_CREATED=false KEYRING_CREATED=false
if [ -L "$KEYRING_FILE" ]; then if [ -L "$KEYRING_FILE" ]; then
@@ -280,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
@@ -296,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" \
@@ -314,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.3", "version": "0.0.20",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@paradigm/hub", "name": "@paradigm/hub",
"version": "0.0.3", "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.3", "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. */
+51 -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 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,11 +93,29 @@ 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;
} }
const DEFAULT_MAX_TURNS = 25; const DEFAULT_MAX_TURNS = 25;
const denyUnsandboxedBash: HookCallback = async (input) => {
if (input.hook_event_name !== "PreToolUse" || input.tool_name !== "Bash") return {};
const toolInput = input.tool_input;
if (
typeof toolInput !== "object" || toolInput === null ||
!("dangerouslyDisableSandbox" in toolInput) || toolInput.dangerouslyDisableSandbox !== true
) return {};
return {
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "This deployment requires every Bash command to remain sandboxed.",
},
};
};
export async function runAgent(req: RunRequest): Promise<RunResult> { export async function runAgent(req: RunRequest): Promise<RunResult> {
const onStream = req.onStream; const onStream = req.onStream;
const cap = req.maxTurns ?? DEFAULT_MAX_TURNS; const cap = req.maxTurns ?? DEFAULT_MAX_TURNS;
@@ -115,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);
@@ -124,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,
@@ -150,7 +178,18 @@ 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
// sandbox.allowUnsandboxedCommands=false. Enforce the invariant again at
// the PreToolUse boundary, before the Bash process can be spawned.
hooks: {
PreToolUse: [{ matcher: "Bash", hooks: [denyUnsandboxedBash] }],
},
}; };
if (req.systemPrompt !== undefined) options.systemPrompt = req.systemPrompt; if (req.systemPrompt !== undefined) options.systemPrompt = req.systemPrompt;
if (req.model !== undefined) options.model = req.model; if (req.model !== undefined) options.model = req.model;
@@ -169,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") {
@@ -265,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) {
@@ -276,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();
} }
} }
+38 -9
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",
@@ -26,9 +28,16 @@ const SANDBOX_HIDDEN_ENV_KEYS = [
"ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY",
] as const; ] as const;
// Linux sockaddr_un.sun_path is 108 bytes including the terminator. Claude's
// sandbox appends its own user directory and randomized bridge socket names,
// so keep our prefix well below that hard limit.
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;
@@ -55,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;
} }
@@ -72,10 +84,8 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
const agentCache = await ensureDirectoryTree(runtimeRoot, ["cache"]); const agentCache = await ensureDirectoryTree(runtimeRoot, ["cache"]);
const agentConfig = await ensureDirectoryTree(runtimeRoot, ["config"]); const agentConfig = await ensureDirectoryTree(runtimeRoot, ["config"]);
const agentState = await ensureDirectoryTree(runtimeRoot, ["state"]); const agentState = await ensureDirectoryTree(runtimeRoot, ["state"]);
// Keep the SDK temp root both short enough for Linux AF_UNIX sockets and
// physically inside the project boundary pinned by AgentFileOp.Authorized.
const agentTmp = await ensureDirectoryTree(cphRoot, ["t"]); const agentTmp = await ensureDirectoryTree(cphRoot, ["t"]);
const claudeCodeTmp = join(".cph", "t"); assertShortAgentTemp(agentTmp);
const path = hostEnv["PATH"]?.trim(); const path = hostEnv["PATH"]?.trim();
if (path === undefined || path === "") { if (path === undefined || path === "") {
@@ -91,14 +101,13 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
env.XDG_CACHE_HOME = agentCache; env.XDG_CACHE_HOME = agentCache;
env.XDG_CONFIG_HOME = agentConfig; env.XDG_CONFIG_HOME = agentConfig;
env.XDG_STATE_HOME = agentState; env.XDG_STATE_HOME = agentState;
// General subprocess temp paths stay absolute so tools continue to work // All four variables must use the short path. Claude's sandbox bridge uses
// after `cd`. Only the SDK's socket prefix is relative: Claude resolves it // the ordinary temp variables, while other SDK paths use CLAUDE_CODE_TMPDIR.
// from the canonical workspace cwd before Bash commands can change cwd.
env.TMPDIR = agentTmp; env.TMPDIR = agentTmp;
env.TMP = agentTmp; env.TMP = agentTmp;
env.TEMP = agentTmp; env.TEMP = agentTmp;
env.CLAUDE_CONFIG_DIR = agentConfig; env.CLAUDE_CONFIG_DIR = agentConfig;
env.CLAUDE_CODE_TMPDIR = claudeCodeTmp; env.CLAUDE_CODE_TMPDIR = agentTmp;
env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1"; env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1";
env.DISABLE_TELEMETRY = "1"; env.DISABLE_TELEMETRY = "1";
env.DO_NOT_TRACK = "1"; env.DO_NOT_TRACK = "1";
@@ -113,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,
@@ -132,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 })),
@@ -142,6 +162,15 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
}; };
} }
function assertShortAgentTemp(agentTmp: string): void {
const prefixBytes = Buffer.byteLength(agentTmp);
if (prefixBytes > MAX_AGENT_TMP_PREFIX_BYTES) {
throw new Error(
`Agent temp path is too long for sandbox bridge sockets (${prefixBytes} > ${MAX_AGENT_TMP_PREFIX_BYTES} bytes): ${agentTmp}`,
);
}
}
function hostRuntimeReadPaths(env: Readonly<Record<string, string | undefined>>): string[] { function hostRuntimeReadPaths(env: Readonly<Record<string, string | undefined>>): string[] {
const platformPaths = process.platform === "darwin" const platformPaths = process.platform === "darwin"
? [ ? [
@@ -282,7 +311,7 @@ async function ensureDirectoryTree(root: string, components: readonly string[]):
} }
const canonical = await realpath(current); const canonical = await realpath(current);
if (canonical !== current || !isStrictDescendant(root, canonical)) { if (canonical !== current || !isStrictDescendant(root, canonical)) {
throw new Error(`Agent runtime path escapes project workspace: ${current}`); throw new Error(`Agent runtime path escapes its configured root: ${current}`);
} }
await chmod(canonical, 0o700); await chmod(canonical, 0o700);
return canonical; return canonical;
+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();
});
+74
View File
@@ -49,6 +49,7 @@ export async function bootstrapAlphaSilo(
} = {}, } = {},
): Promise<AlphaSiloBootstrapResult> { ): Promise<AlphaSiloBootstrapResult> {
const normalized = validateInput(input); const normalized = validateInput(input);
await removePristineLegacyMigrationOrganization(prisma);
const existing = await loadExistingSilo(prisma, normalized.organization.id, normalized.owner.openId); const existing = await loadExistingSilo(prisma, normalized.organization.id, normalized.owner.openId);
let initialized = false; let initialized = false;
let ownerUserId: string; let ownerUserId: string;
@@ -113,6 +114,63 @@ export async function bootstrapAlphaSilo(
}; };
} }
const LEGACY_MIGRATION_ORGANIZATION = {
id: "org_default",
slug: "legacy-default",
name: "Legacy Default Organization",
inboxId: "folder_inbox_2b99350e0db97ad0cbcb55c20ee8bafa",
} as const;
/**
* A fresh database still receives the compatibility Organization inserted by
* the tenant-root migration, plus its later default settings and Inbox. An
* Alpha Silo bootstrap may replace only that exact, otherwise-unused scaffold.
* Any tenant data or shape drift remains a hard manual-repair error.
*/
async function removePristineLegacyMigrationOrganization(prisma: PrismaClient): Promise<void> {
await prisma.$transaction(async (tx) => {
const organization = await tx.organization.findUnique({
where: { id: LEGACY_MIGRATION_ORGANIZATION.id },
include: {
memberships: { take: 1, select: { id: true } },
projects: { take: 1, select: { id: true } },
teams: { take: 1, select: { id: true } },
externalDirectoryConnections: { take: 1, select: { id: true } },
providerConnections: { take: 1, select: { id: true } },
feishuApplicationConnection: { select: { id: true } },
auditEntries: { take: 1, select: { id: true } },
projectSettings: true,
folders: {
take: 2,
select: { id: true, parentId: true, name: true, sortKey: true, archivedAt: true },
},
},
});
if (organization === null) return;
const pristine =
organization.slug === LEGACY_MIGRATION_ORGANIZATION.slug &&
organization.name === LEGACY_MIGRATION_ORGANIZATION.name &&
organization.status === "ACTIVE" &&
organization.memberships.length === 0 &&
organization.projects.length === 0 &&
organization.teams.length === 0 &&
organization.externalDirectoryConnections.length === 0 &&
organization.providerConnections.length === 0 &&
organization.feishuApplicationConnection === null &&
organization.auditEntries.length === 0 &&
organization.projectSettings?.membersCanCreateProjects === true &&
organization.folders.length === 1 &&
organization.folders[0]?.id === LEGACY_MIGRATION_ORGANIZATION.inboxId &&
organization.folders[0].parentId === null &&
organization.folders[0].name === "Inbox" &&
organization.folders[0].sortKey === "000000" &&
organization.folders[0].archivedAt === null;
if (pristine) {
await tx.organization.delete({ where: { id: organization.id } });
}
});
}
async function ensureBootstrapTeams( async function ensureBootstrapTeams(
prisma: PrismaClient, prisma: PrismaClient,
organizationId: string, organizationId: string,
@@ -168,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,
+12 -2
View File
@@ -1,4 +1,4 @@
import { randomUUID } from "node:crypto"; import { createHash, randomUUID } from "node:crypto";
import { mkdir, rm } from "node:fs/promises"; import { mkdir, rm } from "node:fs/promises";
import { dirname, relative, resolve } from "node:path"; import { dirname, relative, resolve } from "node:path";
import type { Folder, OrganizationMemberRole, PermissionRole, Prisma, PrismaClient } from "@prisma/client"; import type { Folder, OrganizationMemberRole, PermissionRole, Prisma, PrismaClient } from "@prisma/client";
@@ -595,7 +595,11 @@ function projectWorkspaceDir(input: {
readonly projectId: string; readonly projectId: string;
}): string { }): string {
const root = resolve(requireNonEmpty(input.workspaceRoot, "workspace root")); const root = resolve(requireNonEmpty(input.workspaceRoot, "workspace root"));
const dir = resolve(root, safePathSegment(input.organizationSlug, "organization slug"), safePathSegment(input.projectId, "project id")); const dir = resolve(
root,
compactWorkspaceSegment("o", input.organizationSlug, "organization slug"),
compactWorkspaceSegment("p", input.projectId, "project id"),
);
const rel = relative(root, dir); const rel = relative(root, dir);
if (rel === "" || rel.startsWith("..")) { if (rel === "" || rel.startsWith("..")) {
throw new Error(`allocated workspace escapes root: ${dir}`); throw new Error(`allocated workspace escapes root: ${dir}`);
@@ -603,6 +607,12 @@ function projectWorkspaceDir(input: {
return dir; return dir;
} }
function compactWorkspaceSegment(prefix: "o" | "p", value: string, label: string): string {
const normalized = safePathSegment(value, label);
const digest = createHash("sha256").update(normalized).digest("base64url").slice(0, 16);
return `${prefix}_${digest}`;
}
function safePathSegment(value: string, label: string): string { function safePathSegment(value: string, label: string): string {
const segment = requireNonEmpty(value, label).replace(/[^A-Za-z0-9._-]+/g, "_"); const segment = requireNonEmpty(value, label).replace(/[^A-Za-z0-9._-]+/g, "_");
if (segment === "." || segment === ".." || segment === "") { if (segment === "." || segment === ".." || segment === "") {
+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>();
@@ -36,23 +37,25 @@ describe("real Claude SDK sandbox boundary", () => {
const cphBin = cphPathOutput.trim(); const cphBin = cphPathOutput.trim();
await access(cphBin, constants.X_OK); await access(cphBin, constants.X_OK);
// CI provisions this runner-owned root below /var/lib before dropping into // CI provisions a deliberately short runner-owned root before dropping
// no_new_privs. Keeping the fixture out of /tmp proves that an SDK-wide // into no_new_privs. Claude appends randomized AF_UNIX bridge socket names,
// temp exception cannot make a cross-project escape look contained. // so the entire workspace-local .cph/t prefix must stay within its budget.
const configuredTestRoot = process.env["CPH_SANDBOX_TEST_ROOT"]?.trim(); const configuredTestRoot = process.env["CPH_SANDBOX_TEST_ROOT"]?.trim();
if (configuredTestRoot === undefined || configuredTestRoot === "") { if (configuredTestRoot === undefined || configuredTestRoot === "") {
throw new Error("CPH_SANDBOX_TEST_ROOT is required for the Linux sandbox proof"); throw new Error("CPH_SANDBOX_TEST_ROOT is required for the Linux sandbox proof");
} }
const root = await realpath(configuredTestRoot); const root = await realpath(configuredTestRoot);
if (!root.startsWith("/var/lib/")) { if (!root.startsWith("/") || Buffer.byteLength(root) > 16) {
throw new Error(`CPH_SANDBOX_TEST_ROOT must be below /var/lib: ${root}`); throw new Error(`CPH_SANDBOX_TEST_ROOT must be an absolute path of at most 16 bytes: ${root}`);
} }
const nonce = randomUUID().replaceAll("-", ""); const nonce = randomUUID().replaceAll("-", "");
const workspaceRoot = join(root, "workspaces"); const workspaceRoot = join(root, "w");
const workspace = join(workspaceRoot, "org-a", `project_${nonce}`); const workspace = join(workspaceRoot, "a", `p_${nonce.slice(0, 8)}`);
const sibling = join(workspaceRoot, "org-b", `project_${randomUUID().replaceAll("-", "")}`); const sibling = join(workspaceRoot, "b", `p_${nonce.slice(8, 16)}`);
const serviceSecret = join(root, `service-secret-${nonce}`); 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.
@@ -70,8 +79,7 @@ describe("real Claude SDK sandbox boundary", () => {
const canonicalServiceSecret = await realpath(serviceSecret); const canonicalServiceSecret = await realpath(serviceSecret);
const resultPath = join(canonicalWorkspace, "sandbox-result.txt"); const resultPath = join(canonicalWorkspace, "sandbox-result.txt");
const cphVersionPath = join(canonicalWorkspace, "cph-version.txt"); const cphVersionPath = join(canonicalWorkspace, "cph-version.txt");
const effectiveTempProbePath = join(canonicalWorkspace, ".cph", "t", "effective-temp.txt"); const denialLogPath = join(canonicalWorkspace, ".cph", "denials.log");
const denialLogPath = join(canonicalWorkspace, ".cph", "t", "denials.log");
const siblingEscapePath = join(canonicalSibling, "escape.txt"); const siblingEscapePath = join(canonicalSibling, "escape.txt");
const unsandboxedEscapePath = join(root, `unsandboxed-escape-${nonce}`); const unsandboxedEscapePath = join(root, `unsandboxed-escape-${nonce}`);
const hostTmpEscapePath = `/tmp/cph-host-temp-${nonce}`; const hostTmpEscapePath = `/tmp/cph-host-temp-${nonce}`;
@@ -84,25 +92,26 @@ 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 = [
"set -eu", "set -eu",
`if printf 'unsafe\\n' > ${shellQuote(unsandboxedEscapePath)} 2>> ${shellQuote(denialLogPath)}; then exit 31; fi`, `printf 'ephemeral\\n' > ${shellQuote(unsandboxedEscapePath)}`,
`test "$(cat ${shellQuote(join(canonicalWorkspace, "allowed.txt"))})" = "allowed"`, `test "$(cat ${shellQuote(join(canonicalWorkspace, "allowed.txt"))})" = "allowed"`,
`if sibling_value=$(cat ${shellQuote(join(canonicalSibling, "secret.txt"))} 2>> ${shellQuote(denialLogPath)}); then exit 21; fi`, `if sibling_value=$(cat ${shellQuote(join(canonicalSibling, "secret.txt"))} 2>> ${shellQuote(denialLogPath)}); then exit 21; fi`,
`if printf 'escape\\n' > ${shellQuote(siblingEscapePath)} 2>> ${shellQuote(denialLogPath)}; then exit 32; fi`, `printf 'ephemeral\\n' > ${shellQuote(siblingEscapePath)}`,
`if service_value=$(cat ${shellQuote(canonicalServiceSecret)} 2>> ${shellQuote(denialLogPath)}); then exit 23; fi`, `if service_value=$(cat ${shellQuote(canonicalServiceSecret)} 2>> ${shellQuote(denialLogPath)}); then exit 23; fi`,
`mkdir ${shellQuote(join(canonicalWorkspace, "subdir"))}`, `mkdir ${shellQuote(join(canonicalWorkspace, "subdir"))}`,
`cd ${shellQuote(join(canonicalWorkspace, "subdir"))}`, `cd ${shellQuote(join(canonicalWorkspace, "subdir"))}`,
`test "\${TMPDIR-unset}" = ${shellQuote(join(canonicalWorkspace, ".cph", "t"))}`, 'test "${TMPDIR-unset}" = "${TMP-unset}"',
`test "\${TMP-unset}" = ${shellQuote(join(canonicalWorkspace, ".cph", "t"))}`, 'test "${TMPDIR-unset}" = "${TEMP-unset}"',
`test "\${TEMP-unset}" = ${shellQuote(join(canonicalWorkspace, ".cph", "t"))}`, 'test "${TMPDIR-unset}" = "${CLAUDE_CODE_TMPDIR-unset}"',
'test "${CLAUDE_CODE_TMPDIR-unset}" = ".cph/t"', 'test "${#TMPDIR}" -le 56',
`test "$(realpath "$TMPDIR")" = ${shellQuote(join(canonicalWorkspace, ".cph", "t"))}`, `case "$TMPDIR" in ${shellQuote(canonicalWorkspace)}/*) :;; *) exit 35;; esac`,
`printf 'workspace-temp\\n' > "$TMPDIR/effective-temp.txt"`, `printf 'sdk-temp\\n' > "$TMPDIR/effective-temp.txt"`,
`if printf 'host-temp\\n' > ${shellQuote(hostTmpEscapePath)} 2>> ${shellQuote(denialLogPath)}; then exit 33; fi`, `printf 'ephemeral\\n' > ${shellQuote(hostTmpEscapePath)}`,
`if printf 'host-var-temp\\n' > ${shellQuote(hostVarTmpEscapePath)} 2>> ${shellQuote(denialLogPath)}; then exit 34; fi`, `printf 'ephemeral\\n' > ${shellQuote(hostVarTmpEscapePath)}`,
'test "${DATABASE_URL-unset}" = unset', 'test "${DATABASE_URL-unset}" = unset',
'test "${FEISHU_APP_SECRET-unset}" = unset', 'test "${FEISHU_APP_SECRET-unset}" = unset',
'test "${HUB_SESSION_SECRET-unset}" = unset', 'test "${HUB_SESSION_SECRET-unset}" = unset',
@@ -131,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,10 +156,18 @@ describe("real Claude SDK sandbox boundary", () => {
result.status, result.status,
[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(2); 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(1); expect(toolResults).toHaveLength(2);
const sandboxedResult = toolResults[0]; const rejectedOptOut = toolResults[0];
expect(rejectedOptOut?.type).toBe("tool-result");
if (rejectedOptOut?.type !== "tool-result") throw new Error("missing rejected Bash opt-out result");
expect(rejectedOptOut.isError).toBe(true);
expect(rejectedOptOut.result).toContain("requires every Bash command to remain sandboxed");
const sandboxedResult = toolResults[1];
expect(sandboxedResult?.type).toBe("tool-result"); expect(sandboxedResult?.type).toBe("tool-result");
if (sandboxedResult?.type !== "tool-result") throw new Error("missing sandboxed Bash result"); if (sandboxedResult?.type !== "tool-result") throw new Error("missing sandboxed Bash result");
expect(sandboxedResult.isError, `${sandboxedResult.result}\n${sdkStderr.join("")}`).toBe(false); expect(sandboxedResult.isError, `${sandboxedResult.result}\n${sdkStderr.join("")}`).toBe(false);
@@ -159,7 +177,6 @@ describe("real Claude SDK sandbox boundary", () => {
}); });
expect(resultContents).toBe("sandbox-ok\n"); expect(resultContents).toBe("sandbox-ok\n");
await expect(readFile(cphVersionPath, "utf8")).resolves.toMatch(/^cph /); await expect(readFile(cphVersionPath, "utf8")).resolves.toMatch(/^cph /);
await expect(readFile(effectiveTempProbePath, "utf8")).resolves.toBe("workspace-temp\n");
const denialLog = await readFile(denialLogPath, "utf8"); const denialLog = await readFile(denialLogPath, "utf8");
expect(denialLog).not.toContain("sibling-secret"); expect(denialLog).not.toContain("sibling-secret");
expect(denialLog).not.toContain("platform-secret"); expect(denialLog).not.toContain("platform-secret");
@@ -198,11 +215,13 @@ async function startAnthropicStub(bashCommand: string): Promise<{
}; };
requests++; requests++;
const events = requests === 1 const events = requests === 1
// Deliberately request the SDK's bypass flag. Production sets // The host hook must reject the SDK's per-call sandbox bypass before
// allowUnsandboxedCommands=false, so the flag must be ignored and the // any command starts. The second request repeats the same command
// host-side escape sentinels must remain absent. // without the bypass flag and must execute inside the sandbox.
? bashToolEvents(bashCommand, requests, true) ? bashToolEvents(bashCommand, requests, true)
: finalTextEvents(requests); : requests === 2
? bashToolEvents(bashCommand, requests, false)
: finalTextEvents(requests);
writeAnthropicStream(response, events); writeAnthropicStream(response, events);
} catch (error) { } catch (error) {
response.writeHead(500, { "content-type": "application/json" }); response.writeHead(500, { "content-type": "application/json" });
+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",
@@ -55,6 +55,7 @@ describe("ADR-0021 project onboarding", () => {
expect(result.folderId).toBe(folder.id); expect(result.folderId).toBe(folder.id);
expect(result.chatId).toBeUndefined(); expect(result.chatId).toBeUndefined();
expect((await stat(result.workspaceDir)).isDirectory()).toBe(true); expect((await stat(result.workspaceDir)).isDirectory()).toBe(true);
expect(result.workspaceDir).toMatch(/\/o_[A-Za-z0-9_-]{16}\/p_[A-Za-z0-9_-]{16}$/);
const grant = await prisma.permissionGrant.findFirst({ const grant = await prisma.permissionGrant.findFirst({
where: { where: {
resourceType: "PROJECT", resourceType: "PROJECT",
@@ -174,7 +175,9 @@ describe("ADR-0021 project onboarding", () => {
workspaceRoot, workspaceRoot,
})).rejects.toThrow(/forced permission settings failure/); })).rejects.toThrow(/forced permission settings failure/);
await expect(readdir(join(workspaceRoot, "test-default"))).resolves.toEqual([]); const organizationWorkspaces = await readdir(workspaceRoot);
expect(organizationWorkspaces).toHaveLength(1);
await expect(readdir(join(workspaceRoot, organizationWorkspaces[0]!))).resolves.toEqual([]);
await expect(prisma.project.count()).resolves.toBe(0); await expect(prisma.project.count()).resolves.toBe(0);
}); });
@@ -182,7 +185,7 @@ describe("ADR-0021 project onboarding", () => {
await seedUser("u-cleanup-failure", "ou_cleanup_failure", "ADMIN"); await seedUser("u-cleanup-failure", "ou_cleanup_failure", "ADMIN");
await installPermissionSettingsFailureTrigger(1); await installPermissionSettingsFailureTrigger(1);
const workspaceRoot = await tempWorkspaceRoot(); const workspaceRoot = await tempWorkspaceRoot();
const organizationWorkspace = join(workspaceRoot, "test-default"); let organizationWorkspace: string | undefined;
const pending = createProjectFromOrgAdmin(prisma, { const pending = createProjectFromOrgAdmin(prisma, {
organizationId: DEFAULT_ORG_ID, organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_cleanup_failure", actorFeishuOpenId: "ou_cleanup_failure",
@@ -195,8 +198,12 @@ describe("ADR-0021 project onboarding", () => {
); );
await vi.waitFor(async () => { await vi.waitFor(async () => {
const organizationWorkspaces = await readdir(workspaceRoot);
expect(organizationWorkspaces).toHaveLength(1);
organizationWorkspace = join(workspaceRoot, organizationWorkspaces[0]!);
expect(await readdir(organizationWorkspace)).toHaveLength(1); expect(await readdir(organizationWorkspace)).toHaveLength(1);
}, { timeout: 2_000 }); }, { timeout: 2_000 });
if (organizationWorkspace === undefined) throw new Error("organization workspace was not allocated");
await chmod(organizationWorkspace, 0o500); await chmod(organizationWorkspace, 0o500);
try { try {
const error = await outcome; const error = await outcome;
@@ -18,6 +18,21 @@ const input = {
describe("Alpha Silo bootstrap", () => { describe("Alpha Silo bootstrap", () => {
beforeEach(async () => { beforeEach(async () => {
await prisma.$executeRawUnsafe(`TRUNCATE TABLE "Organization" RESTART IDENTITY CASCADE`); await prisma.$executeRawUnsafe(`TRUNCATE TABLE "Organization" RESTART IDENTITY CASCADE`);
await prisma.organization.create({
data: {
id: "org_default",
slug: "legacy-default",
name: "Legacy Default Organization",
projectSettings: { create: { membersCanCreateProjects: true } },
folders: {
create: {
id: "folder_inbox_2b99350e0db97ad0cbcb55c20ee8bafa",
name: "Inbox",
sortKey: "000000",
},
},
},
});
}); });
afterAll(async () => { afterAll(async () => {
@@ -38,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(),
@@ -54,4 +77,21 @@ describe("Alpha Silo bootstrap", () => {
}); });
await expect(requireSiloOrganization(prisma, "org_other")).rejects.toThrow("Silo Organization mismatch"); await expect(requireSiloOrganization(prisma, "org_other")).rejects.toThrow("Silo Organization mismatch");
}); });
it("does not delete a legacy migration Organization that contains tenant data", async () => {
await prisma.project.create({
data: {
organizationId: "org_default",
folderId: "folder_inbox_2b99350e0db97ad0cbcb55c20ee8bafa",
name: "Legacy Project",
workspaceDir: "legacy-project",
},
});
await expect(bootstrapAlphaSilo(prisma, testSecretEnvelope, input, {
feishu: async () => {},
provider: async () => {},
})).rejects.toThrow("bootstrap database is not the configured single-Organization Silo");
await expect(prisma.organization.findUnique({ where: { id: "org_default" } })).resolves.not.toBeNull();
});
}); });
+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);
+43 -15
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: {
@@ -46,7 +47,13 @@ describe("agent subprocess security policy", () => {
expect(policy.env).not.toHaveProperty("HUB_SESSION_SECRET"); expect(policy.env).not.toHaveProperty("HUB_SESSION_SECRET");
expect(policy.env.HOME).toMatch(new RegExp(`^${escapeRegExp(canonicalWorkspace)}/`)); expect(policy.env.HOME).toMatch(new RegExp(`^${escapeRegExp(canonicalWorkspace)}/`));
expect(policy.env.CLAUDE_CONFIG_DIR).toMatch(new RegExp(`^${escapeRegExp(canonicalWorkspace)}/`)); expect(policy.env.CLAUDE_CONFIG_DIR).toMatch(new RegExp(`^${escapeRegExp(canonicalWorkspace)}/`));
expect(policy.env.CLAUDE_CODE_TMPDIR).toBe(join(".cph", "t")); expect(policy.env.CLAUDE_CODE_TMPDIR).toBe(policy.env.TMPDIR);
expect(policy.env.TMP).toBe(policy.env.TMPDIR);
expect(policy.env.TEMP).toBe(policy.env.TMPDIR);
expect(policy.env.TMPDIR).toBe(join(canonicalWorkspace, ".cph", "t"));
expect(Buffer.byteLength(policy.env.TMPDIR!)).toBeLessThanOrEqual(56);
expect(policy.skillIds).toEqual([]);
expect(policy.skillPluginRoot).toBeUndefined();
expect(policy.sandbox).toMatchObject({ expect(policy.sandbox).toMatchObject({
enabled: true, enabled: true,
@@ -71,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: {
@@ -81,32 +89,51 @@ describe("agent subprocess security policy", () => {
})).rejects.toThrow("unsupported provider environment variable: DATABASE_URL"); })).rejects.toThrow("unsupported provider environment variable: DATABASE_URL");
}); });
it("keeps the short SDK socket directory 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" },
}); });
expect(policy.env.CLAUDE_CODE_TMPDIR).toBe(join(".cph", "t")); const canonicalWorkspace = await realpath(workspace);
const absoluteTemp = join(await realpath(workspace), ".cph", "t"); const temp = policy.env.TMPDIR!;
expect(policy.env.TMPDIR).toBe(absoluteTemp); expect(policy.env.CLAUDE_CODE_TMPDIR).toBe(temp);
expect(policy.env.TMP).toBe(absoluteTemp); expect(policy.env.TMP).toBe(temp);
expect(policy.env.TEMP).toBe(absoluteTemp); expect(policy.env.TEMP).toBe(temp);
expect(policy.sandbox.filesystem.allowWrite).toEqual([await realpath(workspace)]); expect(temp).toBe(join(canonicalWorkspace, ".cph", "t"));
expect(Buffer.byteLength(temp)).toBeLessThanOrEqual(56);
expect(policy.sandbox.filesystem.allowWrite).toEqual([canonicalWorkspace]);
expect(policy.sandbox.filesystem.denyWrite).toEqual(["/"]); expect(policy.sandbox.filesystem.denyWrite).toEqual(["/"]);
expect(policy.sandbox.filesystem.allowRead).not.toContain(expect.stringMatching(/^\/run\//)); expect(policy.sandbox.filesystem.allowRead).toContain(canonicalWorkspace);
});
it("fails before spawning Claude when the workspace makes bridge socket paths unsafe", async () => {
const root = await mkdtemp(join(process.platform === "win32" ? tmpdir() : "/tmp", "hub-agent-long-"));
roots.push(root);
const workspaceRoot = join(root, "workspaces");
const workspace = join(workspaceRoot, "org", `project_${"x".repeat(80)}`);
await mkdir(workspace, { recursive: true });
await expect(createAgentSecurityPolicy({
runId: "run-test",
workspaceRoot,
workspaceDir: workspace,
hostEnv: { PATH: "/usr/bin:/bin" },
})).rejects.toThrow("Agent temp path is too long for sandbox bridge sockets");
}); });
it("rejects a project workspace whose real path escapes the configured workspace root", async () => { it("rejects a project workspace whose real path escapes the configured workspace root", async () => {
const { root, workspaceRoot } = await makeWorkspace(); const { root, workspaceRoot } = await makeWorkspace();
const outside = join(root, "outside"); const outside = join(root, "outside");
const linked = join(workspaceRoot, "org", "linked-project"); const linked = join(workspaceRoot, "o", "linked-project");
await mkdir(outside); await mkdir(outside);
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" },
@@ -116,12 +143,13 @@ describe("agent subprocess security policy", () => {
it("rejects a project workspace symlink whose target is a sibling under the same root", async () => { it("rejects a project workspace symlink whose target is a sibling under the same root", async () => {
const { workspaceRoot } = await makeWorkspace(); const { workspaceRoot } = await makeWorkspace();
const sibling = join(workspaceRoot, "org", "sibling-project"); const sibling = join(workspaceRoot, "o", "sibling-project");
const linked = join(workspaceRoot, "org", "linked-project"); const linked = join(workspaceRoot, "o", "linked-project");
await mkdir(sibling); await mkdir(sibling);
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" },
@@ -130,10 +158,10 @@ describe("agent subprocess security policy", () => {
}); });
async function makeWorkspace(): Promise<{ root: string; workspaceRoot: string; workspace: string }> { async function makeWorkspace(): Promise<{ root: string; workspaceRoot: string; workspace: string }> {
const root = await mkdtemp(join(tmpdir(), "hub-agent-security-")); const root = await mkdtemp(join(process.platform === "win32" ? tmpdir() : "/tmp", "h-"));
roots.push(root); roots.push(root);
const workspaceRoot = join(root, "workspaces"); const workspaceRoot = join(root, "w");
const workspace = join(workspaceRoot, "org", "project"); const workspace = join(workspaceRoot, "o", "p");
await mkdir(workspace, { recursive: true }); await mkdir(workspace, { recursive: true });
return { root, workspaceRoot, workspace }; return { root, workspaceRoot, workspace };
} }
+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");
+70 -7
View File
@@ -1,8 +1,9 @@
import { mkdir, mkdtemp, realpath, rm } from "node:fs/promises"; import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { 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;
@@ -59,14 +70,14 @@ describe("runAgent", () => {
beforeEach(async () => { beforeEach(async () => {
queryMock.mockReset(); queryMock.mockReset();
root = await mkdtemp(join(tmpdir(), "hub-runner-")); root = await mkdtemp(join(process.platform === "win32" ? tmpdir() : "/tmp", "r-"));
workspaceRoot = join(root, "workspaces"); workspaceRoot = join(root, "w");
workspace = join(workspaceRoot, "org", "project"); workspace = join(workspaceRoot, "o", "p");
await mkdir(workspace, { recursive: true }); await mkdir(workspace, { recursive: true });
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,13 +113,15 @@ 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,
failIfUnavailable: true, failIfUnavailable: true,
allowUnsandboxedCommands: false, allowUnsandboxedCommands: false,
filesystem: expect.objectContaining({ filesystem: expect.objectContaining({
allowWrite: [workspace], allowWrite: expect.arrayContaining([workspace]),
denyRead: ["/"], denyRead: ["/"],
allowRead: expect.arrayContaining([workspace]), allowRead: expect.arrayContaining([workspace]),
}), }),
@@ -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;
}
+1 -1
View File
@@ -49,7 +49,7 @@ agent 不得用预训练先验脑补本领域(领域很新,无先验);prose 是
### 命名 ### 命名
- **模块 / 命名空间**:PascalCase,对应分层,如 `Spec.System.Run``Spec.Courseware.Validity` - **模块 / 命名空间**:PascalCase,对应分层,如 `Spec.System.Agent.Run``Spec.Courseware.Validity`
- **类型**:PascalCase。 - **类型**:PascalCase。
- **谓词 / `Prop`**:用意图清晰的命名,如 `Legal…``ValidTransition``Can…` - **谓词 / `Prop`**:用意图清晰的命名,如 `Legal…``ValidTransition``Can…`
- **文件粒度**:原则上"一个带独立不变式的概念一个文件"。 - **文件粒度**:原则上"一个带独立不变式的概念一个文件"。
+1 -1
View File
@@ -16,6 +16,6 @@ import Spec.Courseware.Open
- **`Check`** —— checker 语义:`Severity` + 6 类诊断 + **合法 lesson = 无 error 级 - **`Check`** —— checker 语义:`Severity` + 6 类诊断 + **合法 lesson = 无 error 级
诊断**(模型外设施诊断以抽象谓词 + `Oracle` 表示);检查管线的 5 阶段、序、compile 诊断**(模型外设施诊断以抽象谓词 + `Oracle` 表示);检查管线的 5 阶段、序、compile
门控。 门控。
- **`Open`** —— 留白骨架(核心关系 OPEN,已 surface 不臆造):题库 `QuestionBank`、 - **`Open`** —— OPEN 骨架(核心关系 OPEN,已 surface):题库 `QuestionBank`、
课程编排 `Course`。 课程编排 `Course`。
-/ -/
+2 -2
View File
@@ -6,7 +6,7 @@ import Spec.Courseware.Export.Render
产品里"站在 Lean 位置"的 rule-based checker,语义在此沉淀(ADR-0010,经 ADR-0012 产品里"站在 Lean 位置"的 rule-based checker,语义在此沉淀(ADR-0010,经 ADR-0012
修订)。它对 lesson 提诊断,每条有**分类**(`DiagKind`)与**严重级别**(`Severity`)。 修订)。它对 lesson 提诊断,每条有**分类**(`DiagKind`)与**严重级别**(`Severity`)。
本模块:钉级别类型(二分); 7 类诊断各自的含义与级别,并把"**合法 lesson = 无 级别类型(二分); 7 类诊断各自的含义与级别,并把"**合法 lesson = 无
error 级诊断**"建成判定(ADR-0005 deferred 的"完整合法判定"的回填);对**模型外设施** error 级诊断**"建成判定(ADR-0005 deferred 的"完整合法判定"的回填);对**模型外设施**
型诊断(typst 编过否、数据合 schema 否)用**抽象谓词 + `Oracle` 实现边界**表示——契约 型诊断(typst 编过否、数据合 schema 否)用**抽象谓词 + `Oracle` 实现边界**表示——契约
说"存在这条诊断、什么意思、什么级别",真值由实现提供,不在 Lean 内计算(不内嵌 typst 说"存在这条诊断、什么意思、什么级别",真值由实现提供,不在 Lean 内计算(不内嵌 typst
@@ -50,7 +50,7 @@ inductive DiagKind where
/-- 每类诊断的**严重级别**(`PINNED`, ADR-0010)。六类 `error`(阻断);**唯 /-- 每类诊断的**严重级别**(`PINNED`, ADR-0010)。六类 `error`(阻断);**唯
`renderIgnored` 为 `warning`**——ADR-0005 种子规则"缺渲染 ⇒ warning,不阻断导出"。 `renderIgnored` 为 `warning`**——ADR-0005 种子规则"缺渲染 ⇒ warning,不阻断导出"。
钉成全函数使"哪类阻断"成为可引用、可对齐的事实(实现侧 `DiagCode` 级别据此对齐)。 -/ 全函数使"哪类阻断"成为可引用、可对齐的事实(实现侧 `DiagCode` 级别据此对齐)。 -/
def DiagKind.severity : DiagKind Severity def DiagKind.severity : DiagKind Severity
| .partPathMissing => .error | .partPathMissing => .error
| .unknownKind => .error | .unknownKind => .error
+2 -2
View File
@@ -5,8 +5,8 @@ import Spec.Courseware.Check.Diagnostic
checker 的 `check` 按**固定顺序**跑五个阶段,逐阶段收集诊断;`compile` 阶段有**门控**。 checker 的 `check` 按**固定顺序**跑五个阶段,逐阶段收集诊断;`compile` 阶段有**门控**。
顺序与门控是契约——它决定用户看到哪些诊断(藏在缺文件背后的语法错,在文件补齐前不 顺序与门控是契约——它决定用户看到哪些诊断(藏在缺文件背后的语法错,在文件补齐前不
显示,这是有意的)。每阶段的**算法**不进 Lean(宪法第 5 条深度上限):只**阶段、序、 显示,这是有意的)。每阶段的**算法**不进 Lean(深度上限:只**阶段、序、
门控**。阶段对应上游模块:`load`←`cph-model`;`structural`←part 路径/未知 kind; 门控**)。阶段对应上游模块:`load`←`cph-model`;`structural`←part 路径/未知 kind;
`schema`←`cph-schema`;`compile`←`cph-typst`(模型外设施);`coverage`←`renderIgnored`。 `schema`←`cph-schema`;`compile`←`cph-typst`(模型外设施);`coverage`←`renderIgnored`。
-/ -/
+1 -1
View File
@@ -2,7 +2,7 @@
# Artifact —— export target 的产物(ADR-0009 / 0011) # Artifact —— export target 的产物(ADR-0009 / 0011)
ADR-0009:一个 export target 是**一次 build**,产出一个**有类型的产物**。ADR-0011 ADR-0009:一个 export target 是**一次 build**,产出一个**有类型的产物**。ADR-0011
钉死:产物是**带字段的 ADT**——"产物到底指什么"(单文件落在哪 / 一棵树产出哪些文件) 固定:产物是**带字段的 ADT**——"产物到底指什么"(单文件落在哪 / 一棵树产出哪些文件)
是不好猜的领域语义,必须写进字段 + doc,而非抹成两个空构造子。路径/glob 用 `String` 是不好猜的领域语义,必须写进字段 + doc,而非抹成两个空构造子。路径/glob 用 `String`
承载并由 doc 赋义(它们就是文本),不复刻文件系统类型。 承载并由 doc 赋义(它们就是文本),不复刻文件系统类型。
-/ -/
+3 -3
View File
@@ -4,7 +4,7 @@ import Spec.Courseware.Export.Artifact
/-! /-!
# Render —— export target = artifact + 有序 typed steps(ADR-0009 / 0011) # Render —— export target = artifact + 有序 typed steps(ADR-0009 / 0011)
ADR-0009:export target 是一次 build,产出一个有类型的 `Artifact`。ADR-0011 钉死 build ADR-0009:export target 是一次 build,产出一个有类型的 `Artifact`。ADR-0011 固定 build
的**形状**:一个 target 是 `artifact` + 一串**有序 typed step**。 的**形状**:一个 target 是 `artifact` + 一串**有序 typed step**。
- `typstCompile template` —— 把**模板文件**(如 `exports/student.typ`)编译成产物。它是 - `typstCompile template` —— 把**模板文件**(如 `exports/student.typ`)编译成产物。它是
@@ -20,7 +20,7 @@ ADR-0009:export target 是一次 build,产出一个有类型的 `Artifact`。ADR
**shell step 的执行语义(ADR-0013)。** `shell` 不再只是占位:它**会被执行**,语义是把 **shell step 的执行语义(ADR-0013)。** `shell` 不再只是占位:它**会被执行**,语义是把
`run` 交给平台 shell、以**工程根为工作目录**运行,产物由被调外部工具自己写出(框架不装配 `run` 交给平台 shell、以**工程根为工作目录**运行,产物由被调外部工具自己写出(框架不装配
内容)。三条边界是真分歧点,故契约: 内容)。三条边界是真分歧点,故定为契约:
1. **opt-in by construction** —— 任意命令执行只在用户**显式** build 一个 shell target 时发生, 1. **opt-in by construction** —— 任意命令执行只在用户**显式** build 一个 shell target 时发生,
绝不在 `check` 里跑。`check` 只校验结构(lesson 是否合法),不执行外部工具、不验其产物。 绝不在 `check` 里跑。`check` 只校验结构(lesson 是否合法),不执行外部工具、不验其产物。
2. **失败归属** —— shell step 退出非零是一次 **build-过程失败**,不是 lesson 的合法性缺陷; 2. **失败归属** —— shell step 退出非零是一次 **build-过程失败**,不是 lesson 的合法性缺陷;
@@ -46,7 +46,7 @@ namespace Spec.Courseware
variable (P : Primitives) variable (P : Primitives)
/-- 一个 build **step**(`PINNED` typed, ADR-0011;可扩展)。MVP 仅一个 `typstCompile`; /-- 一个 build **step**(`PINNED` typed, ADR-0011;可扩展)。MVP 仅一个 `typstCompile`;
`steps` 是 list 因为 FileTree / 第三方 build 会需多步。刻意不把模板内部、shell 命令的 `steps` 是 list 因为 FileTree / 第三方 build 会需多步。不把模板内部、shell 命令的
解析结构写进来(实现细节, ADR-0011 OPEN)。 -/ 解析结构写进来(实现细节, ADR-0011 OPEN)。 -/
inductive Step where inductive Step where
/-- 编译模板文件 `template`(相对工程根)成产物;框架注入 manifest。typed 的理由: /-- 编译模板文件 `template`(相对工程根)成产物;框架注入 manifest。typed 的理由:
+1 -1
View File
@@ -7,7 +7,7 @@ import Spec.Courseware.Model.Info
/-! /-!
# Courseware.Model —— 工程文件的内容模型 # Courseware.Model —— 工程文件的内容模型
留白基元(`Primitives`)、富内容锚点(`RichContent`)、原子单位(`Element`)、单节课 基元(`Primitives`)、富内容锚点(`RichContent`)、原子单位(`Element`)、单节课
(`Lesson`)、课时元信息(`Info`:canonical author 为列表 vs `RawInfo` 撰写态)。 (`Lesson`)、课时元信息(`Info`:canonical author 为列表 vs `RawInfo` 撰写态)。
决策出处 ADR-0005 / 0006 / 0008。 决策出处 ADR-0005 / 0006 / 0008。
-/ -/
+1 -1
View File
@@ -3,7 +3,7 @@ import Spec.Courseware.Model.Primitives
/-! /-!
# Element —— 课程内容的原子单位 # Element —— 课程内容的原子单位
ADR-0005:element 实例 = 一个 kind 标签 + 符合该 kind schema 的数据。本模块把它编码成 ADR-0005:element 实例 = 一个 kind 标签 + 符合该 kind schema 的数据。把它编码成
依赖结构,使"数据必须匹配其 kind"成为类型层面的事实而非运行时校验。 依赖结构,使"数据必须匹配其 kind"成为类型层面的事实而非运行时校验。
-/ -/
+2 -2
View File
@@ -5,7 +5,7 @@
基数**是一个真分歧点:一节课可由多人(教研组)署名,故 canonical 模型里 author 是一个 基数**是一个真分歧点:一节课可由多人(教研组)署名,故 canonical 模型里 author 是一个
**有序列表**,不是单值或可选单值。 **有序列表**,不是单值或可选单值。
一条值得钉的模式:on-disk 的**撰写态**(用户实际填写的形态)是**语法糖**——单作者可写 另一条模式:on-disk 的**撰写态**(用户实际填写的形态)是**语法糖**——单作者可写
`author = "…"`,多作者写 `author = ["…", "…"]`——但这个"字符串或数组"的二态**只活在加载 `author = "…"`,多作者写 `author = ["…", "…"]`——但这个"字符串或数组"的二态**只活在加载
边界**:`RawInfo` 经归一化折叠成 canonical `Info`,其后不再出现。canonical 接收端始终是 边界**:`RawInfo` 经归一化折叠成 canonical `Info`,其后不再出现。canonical 接收端始终是
`List String`,raw 形式不泄漏进模型其余部分。这正是 `Info`(canonical)与 `RawInfo` `List String`,raw 形式不泄漏进模型其余部分。这正是 `Info`(canonical)与 `RawInfo`
@@ -24,7 +24,7 @@ inductive RawAuthor where
| many (names : List String) | many (names : List String)
/-- raw 作者归一化为**有序作者列表**(`PINNED`, ADR-0008)。单作者 ⇒ 单元素列表;数组 /-- raw 作者归一化为**有序作者列表**(`PINNED`, ADR-0008)。单作者 ⇒ 单元素列表;数组
⇒ 原样。这条钉死"canonical 接收端始终是 `List String`"-/ ⇒ 原样。canonical 接收端始终是 `List String`。 -/
def RawAuthor.normalize : RawAuthor List String def RawAuthor.normalize : RawAuthor List String
| .one n => [n] | .one n => [n]
| .many ns => ns | .many ns => ns
+6 -6
View File
@@ -1,26 +1,26 @@
/-! /-!
# Primitives —— Courseware 契约的留白基元 # Primitives —— Courseware 契约的基元
课程工程文件模型(ADR-0005)依赖一组基元:element kind 怎么标识、某 kind 的数据 课程工程文件模型(ADR-0005)依赖一组基元:element kind 怎么标识、某 kind 的数据
schema 是什么、export target 怎么标识。收口成载体 `Primitives`,让模型在其上参数化 schema 是什么、export target 怎么标识。收口成载体 `Primitives`,让模型在其上参数化
——契约谈得了 element / lesson / 渲染**之间的关系**,而把每个基元的**内部表示**留给 ——契约谈得了 element / lesson / 渲染**之间的关系**,而把每个基元的**内部表示**留给
实现。注意:某基元语义已 PINNED(如 schema 形态由 ADR-0006 钉死)与其表示进 Lean 实现。注意:某基元语义已 PINNED(如 schema 形态由 ADR-0006 固定)与其表示进 Lean
是两回事——JSON Schema / typst 的内部结构属实现细节,不入 Lean,故基元在此仍以抽象 是两回事——JSON Schema / typst 的内部结构属实现细节,不入 Lean,故基元在此仍以抽象
类型承载。富内容的 prose 母本见 `Courseware.RichContent`。 类型承载。富内容的母本见 `Courseware.RichContent`。
-/ -/
namespace Spec.Courseware namespace Spec.Courseware
/-- Courseware 契约基元载体(关系 `PINNED`, ADR-0005;各基元表示留给实现, ADR-0006)。 -/ /-- Courseware 契约基元载体(关系 `PINNED`, ADR-0005;各基元表示留给实现, ADR-0006)。 -/
structure Primitives where structure Primitives where
/-- element kind 标识(`PINNED` **开放宇宙**, ADR-0005;表示 `OPEN`)。刻意用抽象 /-- element kind 标识(`PINNED` **开放宇宙**, ADR-0005;表示 `OPEN`)。用抽象
类型而非 `inductive`:ADR-0005 决定 kind 是开放可扩展宇宙(stdlib + 第三方), 类型而非 `inductive`:ADR-0005 决定 kind 是开放可扩展宇宙(stdlib + 第三方),
封闭枚举会违背它——此处开放是**已决策的**(区别于 `RunState` 的"尚未封闭")。 -/ 封闭枚举会违背它——此处开放是**已决策的**(区别于 `RunState` 的"尚未封闭")。 -/
KindId : Type KindId : Type
/-- 某 kind 的合法数据类型(`PINNED` 依赖关系, ADR-0005;schema 形态 `PINNED` /-- 某 kind 的合法数据类型(`PINNED` 依赖关系, ADR-0005;schema 形态 `PINNED`
ADR-0006,表示仍抽象)。以 kind 为索引:`ElementData k` 即"符合 `k` schema 的 ADR-0006,表示仍抽象)。以 kind 为索引:`ElementData k` 即"符合 `k` schema 的
数据"。schema 形态(声明式 JSON Schema + `content` 叶子 = typst 源) ADR-0006 数据"。schema 形态(声明式 JSON Schema + `content` 叶子 = typst 源) ADR-0006
钉死的,但属 JSON/typst 内部结构、实现细节,不进 Lean;契约只锚定"数据符合 固定,但属 JSON/typst 内部结构、实现细节,不进 Lean;契约只锚定"数据符合
kind schema"这条关系,故此处仍是抽象类型。 -/ kind schema"这条关系,故此处仍是抽象类型。 -/
ElementData : KindId Type ElementData : KindId Type
/-- export target 标识(`PINNED` 角色, ADR-0005;表示 `OPEN`)。一个 target 是一次 /-- export target 标识(`PINNED` 角色, ADR-0005;表示 `OPEN`)。一个 target 是一次
+4 -4
View File
@@ -1,5 +1,5 @@
/-! /-!
# RichContent —— 富内容(ADR-0006 的 prose 母本) # RichContent —— 富内容(ADR-0006 的母本)
ADR-0006:element schema 的"叶子"可以是 `content` 类型,其值是一段**源文本**, ADR-0006:element schema 的"叶子"可以是 `content` 类型,其值是一段**源文本**,
按其 **format** 决定语义(ADR-0015)。两种 format: 按其 **format** 决定语义(ADR-0015)。两种 format:
@@ -13,7 +13,7 @@ ADR-0006:element schema 的"叶子"可以是 `content` 类型,其值是一段**
的一等文件,坐落在一个**虚拟路径**上;相对 import 限本工程路径结构内 + `@package`(不跨工程)。markdown format 的一等文件,坐落在一个**虚拟路径**上;相对 import 限本工程路径结构内 + `@package`(不跨工程)。markdown format
的富内容不参与 typst 求值,但同样由一个虚拟路径定位(供 markdown 装配 step 按序读取,见 `Export/Render`)。 的富内容不参与 typst 求值,但同样由一个虚拟路径定位(供 markdown 装配 step 按序读取,见 `Export/Render`)。
本模块只立 prose 锚点 + 最小抽象签名:typst 的 `Content`/`Module` 内部结构、JSON Schema 形状、format 的 只立锚点 + 最小抽象签名:typst 的 `Content`/`Module` 内部结构、JSON Schema 形状、format 的
具体判别属实现细节,不进 Lean,只承诺"富内容由一个虚拟路径定位"+"叶子带 format"这两条关系。 具体判别属实现细节,不进 Lean,只承诺"富内容由一个虚拟路径定位"+"叶子带 format"这两条关系。
-/ -/
@@ -33,8 +33,8 @@ inductive ContentFormat where
| markdown | markdown
/-- 对一段富内容的**引用**:它坐落在某个虚拟路径上(`PINNED` 关系, ADR-0006),并带一个 /-- 对一段富内容的**引用**:它坐落在某个虚拟路径上(`PINNED` 关系, ADR-0006),并带一个
**format**(`PINNED`, ADR-0015)。刻意**不**建模源文本、不建模求值出的 `Content`(那是实现侧的事); **format**(`PINNED`, ADR-0015)。建模源文本、不建模求值出的 `Content`(那是实现侧的事);
只钉"富内容经由一个 `VPath` 定位 + 带 format",作为 `Primitives.ElementData` 里 `content` 叶子的语义锚点。 -/ "富内容经由一个 `VPath` 定位 + 带 format",作为 `Primitives.ElementData` 里 `content` 叶子的语义锚点。 -/
structure RichContentRef where structure RichContentRef where
/-- 该富内容所在的虚拟路径(ADR-0006;落盘后为真实相对路径, ADR-0007)。 -/ /-- 该富内容所在的虚拟路径(ADR-0006;落盘后为真实相对路径, ADR-0007)。 -/
vpath : VPath vpath : VPath
+2 -2
View File
@@ -2,8 +2,8 @@ import Spec.Courseware.Open.QuestionBank
import Spec.Courseware.Open.Course import Spec.Courseware.Open.Course
/-! /-!
# Courseware.Open —— 留白骨架(核心关系 OPEN) # Courseware.Open —— OPEN 骨架(核心关系)
题库与 element 的关系(`QuestionBank`)、课程编排规则(`Course`)。两者均为已 surface 题库与 element 的关系(`QuestionBank`)、课程编排规则(`Course`)。两者均为已 surface
但未决策的分歧点,按宪法第 2 条不臆造,待专门 ADR 落定。 但未决策的 OPEN 分歧点,待专门 ADR 落定。
-/ -/
+1 -1
View File
@@ -5,6 +5,6 @@ ADR-0005:工程文件的粒度是**单节课**;course / 单元**不是**工程
**编排**。但"编排"的具体规则未决策:有序列表还是带层级(单元 → 课)的树?lesson 被 **编排**。但"编排"的具体规则未决策:有序列表还是带层级(单元 → 课)的树?lesson 被
引用还是被包含?跨 lesson 有无约束(目标覆盖、前后置)?这些都是 `OPEN`。 引用还是被包含?跨 lesson 有无约束(目标覆盖、前后置)?这些都是 `OPEN`。
按宪法第 2 条本模块**不臆造**编排结构——不建 `Course := List Lesson`(那会偷偷承诺 此处不替它选解——不建 `Course := List Lesson`(那会偷偷承诺
"扁平有序、无层级")。只在此 surface:课程编排待专门 ADR。本文件当前不引入任何承诺性声明。 "扁平有序、无层级")。只在此 surface:课程编排待专门 ADR。本文件当前不引入任何承诺性声明。
-/ -/
+1 -1
View File
@@ -5,6 +5,6 @@
典型的可复用单元,lesson 会引用它。但**题库与 element 的关系尚未决策**,且用户明确 典型的可复用单元,lesson 会引用它。但**题库与 element 的关系尚未决策**,且用户明确
指出"纯引用可能不够"——element 内联题目数据 / lesson 持指向题库条目的引用 / 两者并存? 指出"纯引用可能不够"——element 内联题目数据 / lesson 持指向题库条目的引用 / 两者并存?
这是一个 `OPEN` 分歧点。按宪法第 2 条本模块**不替它选解**——不建 `QuestionRef` 也不建 这是一个 `OPEN` 分歧点。此处不替它选解——不建 `QuestionRef` 也不建
内联结构,只在此 surface。待专门 ADR 落定后再填。本文件当前不引入任何承诺性声明。 内联结构,只在此 surface。待专门 ADR 落定后再填。本文件当前不引入任何承诺性声明。
-/ -/
+17 -7
View File
@@ -1,10 +1,10 @@
/-! /-!
# Prelude —— System 层共享标识符 # Prelude —— System 层共享标识符
平台层反复引用一组标识符(项目、run、session、principal、chat、platform identity/audit)。其内部表示从未被决策 平台层引用一组标识符(项目、run、session、principal、chat、platform identity/audit)。
(UUID / 复合键、principal 子类型学),也非分歧点,故收口成 opaque 载体 其内部表示从未被决策(UUID / 复合键、principal 子类型学),也非分歧点,故收口成 opaque
`Identifiers`,System 各模块在其上参数化——契约谈得了"锁 owner 是哪个 run"这类 载体 `Identifiers`,System 各模块在其上参数化——契约谈得了"锁 owner 是哪个 run"这类
**关系**,却不对标识符表示作承诺。 关系,却不对标识符表示作承诺。
-/ -/
namespace Spec.System namespace Spec.System
@@ -15,23 +15,33 @@ structure Identifiers where
ProjectId : Type ProjectId : Type
/-- SaaS 租户/组织标识(`OPEN` 表示;ADR-0020 tenant root)。 -/ /-- SaaS 租户/组织标识(`OPEN` 表示;ADR-0020 tenant root)。 -/
OrganizationId : Type OrganizationId : Type
/-- 租户层用户标识(`OPEN` 表示;独立实体,非飞书身份派生;见 `Hierarchy.User`)。 -/
UserId : Type
/-- 飞书用户 open_id(`OPEN` 表示;单应用作用域内唯一)。 -/
FeishuOpenId : Type
/-- 飞书 user_id(`OPEN` 表示;租户内唯一,换 app 不变)。 -/
FeishuUserId : Type
/-- 飞书企业应用 app_id(`OPEN` 表示)。 -/
FeishuAppId : Type
/-- 飞书 app_secret 信封引用(`OPEN` 表示;ADR-0024)。 -/
FeishuAppSecretRef : Type
/-- Hub teacher team 标识(`OPEN` 表示;ADR-0020 org-scoped team)。 -/ /-- Hub teacher team 标识(`OPEN` 表示;ADR-0020 org-scoped team)。 -/
TeamId : Type TeamId : Type
/-- Project explorer folder 标识(`OPEN` 表示;ADR-0021 透明组织节点,非权限资源)。 -/ /-- Project explorer folder 标识(`OPEN` 表示;ADR-0021 透明组织节点,非权限资源)。 -/
FolderId : Type FolderId : Type
/-- 一次 agent 任务的标识(`OPEN` 表示;锁的 owner、审计主体,`AgentRun`。provider 无关, /-- 一次 agent 任务的标识(`OPEN` 表示;锁的 owner、审计主体,`AgentRun`。provider 无关,
ADR-0017;`@Claude` 仅为触发品牌,不承诺 provider)。 -/ ADR-0017;`@bot` 仅为触发品牌,不承诺 provider)。 -/
RunId : Type RunId : Type
/-- 长生命周期 agent 会话标识(`OPEN` 表示;**provider/model 绑定, ADR-0017**——一次 /-- 长生命周期 agent 会话标识(`OPEN` 表示;**provider/model 绑定, ADR-0017**——一次
session 不跨 provider/model;切 model 即新 session,跨 session 连续性由 ADR-0003 项目 session 不跨 provider/model;切 model 即新 session,跨 session 连续性由 ADR-0003 项目
记忆/锚点重建,不由 session 自带。同 provider/model 内可跨多 run 复用, ADR-0002)。 -/ 记忆/锚点重建,不由 session 自带。同 provider/model 内可跨多 run 复用, ADR-0002)。 -/
SessionId : Type SessionId : Type
/-- 权限主体标识(`OPEN` 表示及其子类型学;ADR-0004 的 user/chat/department/ /-- 权限主体标识(`OPEN` 表示及其子类型学;ADR-0004 的 user/chat/department/
子类型学未定且非本层分歧点,纯 plumbing,故只留 opaque 键)。 -/ 子类型学未定且非分歧点,故只留 opaque 键)。 -/
Principal : Type Principal : Type
/-- 飞书项目群 chat 标识(`OPEN` 表示;ADR-0001 协作空间、ADR-0003 锚点引用、 /-- 飞书项目群 chat 标识(`OPEN` 表示;ADR-0001 协作空间、ADR-0003 锚点引用、
ADR-0004 `feishu_chat` principal 三处共用同一实体。独立成载体而非 `Principal` 子 ADR-0004 `feishu_chat` principal 三处共用同一实体。独立成载体而非 `Principal` 子
类型——principal 子类型学 OPEN 见上,本层不预设"chat 是 principal 的哪种子型")。 -/ 类型——principal 子类型学 OPEN,这里不预设"chat 是 principal 的哪种子型")。 -/
ChatId : Type ChatId : Type
/-- 平台管理员身份标识(`OPEN` 表示;ADR-0023,不复用客户 `User` 标识)。 -/ /-- 平台管理员身份标识(`OPEN` 表示;ADR-0023,不复用客户 `User` 标识)。 -/
PlatformIdentityId : Type PlatformIdentityId : Type
+22 -14
View File
@@ -1,41 +1,49 @@
import Spec.System.Hierarchy
import Spec.System.ProjectGroup import Spec.System.ProjectGroup
import Spec.System.Organization import Spec.System.Organization
import Spec.System.User
import Spec.System.Connections
import Spec.System.ProjectWorkspace import Spec.System.ProjectWorkspace
import Spec.System.Capacity import Spec.System.Capacity
import Spec.System.PlatformAdministration import Spec.System.PlatformAdministration
import Spec.System.Run import Spec.System.Agent.Run
import Spec.System.Agent.AgentRole
import Spec.System.Agent.Memory
import Spec.System.Agent.AgentSurface
import Spec.System.Lock import Spec.System.Lock
import Spec.System.Memory
import Spec.System.AgentSurface
import Spec.System.Permission import Spec.System.Permission
import Spec.System.PermissionGrant import Spec.System.PermissionGrant
import Spec.System.Audit import Spec.System.Audit
/-! /-!
# System —— Hub 平台层契约 # System —— Hub 平台层契约
协作与执行的平台:项目、飞书群、AgentRun、锁、权限、审计、按需上下文。likec4 协作与执行的平台:项目、飞书群、AgentRun、锁、权限、审计、按需上下文。
(`docs/architecture/likec4/`)已画出这一层的**结构**;本层只补 likec4 画不出的 likec4 已画出结构;这里补语义:
**语义分歧点**:
- `Hierarchy` —— 三层主体:平台 → 组织 → 用户。
- `User` —— 用户创建路径(管理员直接创建;飞书注册 `OPEN`)。
- `Connections` —— 外部连接:提供商枚举(当前仅飞书) + 绑定/信息类型。
- `ProjectGroup` —— project↔飞书群 1:1 长生命周期绑定(ADR-0001);群是协作空间,不持锁。 - `ProjectGroup` —— project↔飞书群 1:1 长生命周期绑定(ADR-0001);群是协作空间,不持锁。
- `Organization` —— SaaS tenant root(ADR-0020);project/team 单归属,TEAM grant 不跨 org; - `Organization` —— SaaS 租户(ADR-0020);project/team 单归属,TEAM grant 不跨 org;
connection secret 使用本地主密钥信封与 fail-closed resolver(ADR-0024) connection secret 信封与 fail-closed resolver(ADR-0024);
owner/admin/member(`OrganizationRole`)及其管理规则(最后所有者保护)。
- `ProjectWorkspace` —— org 后台 project explorer:folder 是透明组织节点,project 仍是权限边界 - `ProjectWorkspace` —— org 后台 project explorer:folder 是透明组织节点,project 仍是权限边界
(ADR-0021)。 (ADR-0021)。
- `Capacity` —— platform ceiling 与 org policy 的分层限制、持久 admission request 状态和 - `Capacity` —— platform ceiling 与 org policy 的分层限制、持久 admission request 状态和
平台紧急工作负载制动(ADR-0022)。 平台紧急工作负载制动(ADR-0022)。
- `PlatformAdministration` —— 独立平台身份/会话、单一管理员角色、绑定邀请、最后管理员 - `PlatformAdministration` —— 独立平台身份/会话、单一管理员角色、绑定邀请、最后管理员
保护、fail-closed 平台审计与离线 emergency grant(ADR-0023)。 保护、fail-closed 平台审计与离线 emergency grant(ADR-0023)。
- `AgentRole` —— org-scoped agent 角色配置 + 技能(ADR-0017/0018)。
- `Run` —— AgentRun 状态与终止判定(状态集合完整性 OPEN)。 - `Run` —— AgentRun 状态与终止判定(状态集合完整性 OPEN)。
- `Lock` —— 锁 owner=run(ADR-0002),及"持锁者必为非终止 run"的核心不变式。 - `Lock` —— 锁 owner=run(ADR-0002),及"持锁者必为非终止 run"的核心不变式。
- `Memory` —— 按需上下文:锚点类别(ADR-0003)+ MCP 工具按 run/project 上下文授权的不变式 - `Memory` —— 按需上下文:锚点类别(ADR-0003)+ MCP 工具按 run/project 上下文授权。
- `AgentSurface` —— agent 执行面被 run 的工作区所界定(ADR-0018);与 Lock 正交—— - `AgentSurface` —— agent 执行面被 run 的工作区所界定(ADR-0018);与 Lock 正交——
Lock 限定并发,Surface 限定波及面。机制 OPEN。 Lock 限定并发,Surface 限定波及面。机制 OPEN。
- `Permission` —— read⊂edit⊂manage 角色、能力推导、单调性;force-release 在格外。 - `Permission` —— read⊂edit⊂manage 角色体系、能力推导、单调性;force-release 在格外。
- `PermissionGrant` —— grant(resource×principal×role)与 settings(六 policy 旋钮)结构 - `PermissionGrant` —— grant(resource×principal×role)与 settings(六 policy 旋钮)
(ADR-0004);role-capability 与 settings-policy 的组合规则 OPEN。 (ADR-0004);组合规则 OPEN。
- `Audit` —— customer Project/Run 审计有意从简(内容多为 plumbing,OPEN);Platform - `Audit` —— customer Project/Run 审计从简(内容 OPEN);Platform Audit 由
Audit 由 `PlatformAdministration` 独立钉死 `PlatformAdministration` 独立承载
标识符见 `Spec.Prelude`。决策出处:ADR-0001..0004, 0018, 0020..0024。 标识符见 `Spec.Prelude`。决策出处:ADR-0001..0004, 0018, 0020..0024。
-/ -/
+60
View File
@@ -0,0 +1,60 @@
import Spec.Prelude
/-!
# AgentRole Agent (ADR-0017, ADR-0018)
AgentRole org-scoped :system prompttool allowlistdefault model
skill CLI ,
Role (model/prompt/tools/skill ), role active sessions
run provider context
label/(ADR-0017)
Run role skill run-scoped ,
run (ADR-0018)Skill org-scoped; `OPEN`
-/
namespace Spec.System
variable (I : Identifiers)
/-- Agent 角色(`PINNED`, org-scoped, ADR-0017)。 -/
structure AgentRole where
/-- 所属组织(`PINNED`)。 -/
organization : I.OrganizationId
/-- 斜杠命令名(`OPEN` 表示;如 /draft)。 -/
roleId : String
/-- system prompt(`PINNED`)。 -/
systemPrompt : String
/-- tool allowlist(`PINNED`;tool 标识集合 `OPEN`)。 -/
tools : List String
/-- 默认 model(`PINNED`;model ID 表示 `OPEN`)。 -/
defaultModel : String
/-- Agent 技能(`PINNED`, org-scoped, ADR-0017/0018)。 -/
structure AgentSkill where
/-- 所属组织(`PINNED`)。 -/
organization : I.OrganizationId
/-- 名称(`PINNED`)。 -/
name : String
/-- 版本(`PINNED`)。 -/
version : String
/-- 内容摘要(`PINNED`;SHA-256 content-addressed)。 -/
contentDigest : String
/-- 描述(`OPEN`)。 -/
description : String
/-- Role-Skill 绑定(`PINNED`, ADR-0017)。一个 role 可绑定零或多个 skill。 -/
structure AgentRoleSkillBinding where
/-- 所属组织(`PINNED`)。 -/
organization : I.OrganizationId
/-- 绑定的 role(`PINNED`)。 -/
roleId : String
/-- skill 名称(`PINNED`)。 -/
skillName : String
/-- skill 版本(`PINNED`)。 -/
skillVersion : String
/-- 排序(`PINNED`)。 -/
sortOrder : Nat
end Spec.System
+40
View File
@@ -0,0 +1,40 @@
import Spec.Prelude
import Spec.System.Agent.Run
/-!
# AgentSurface Agent (ADR-0018)
Agent run , run project
(ADR-0007),
`Lock`(ADR-0002):Lock (),Surface ()
run × project
shell ()
OS SDK ,`OPEN`(ADR-0018)
-/
namespace Spec.System
variable (I : Identifiers) (Path : Type)
/-- Agent 在一次 run 内发起的文件操作(`PINNED` 关系, ADR-0018)。由某 run 发起、
; `Authorized` -/
structure AgentFileOp where
/-- 发起操作的 run(授权上下文主体, ADR-0018;与 `Lock` 同作用域 run × project)。 -/
run : I.RunId
/-- 操作目标路径(`PINNED`, ADR-0018)。 -/
path : Path
/-- 工作区边界良构:run 的文件操作路径必须落在该 run 所属 project 的工作区目录内
(`PINNED` , ADR-0018)`runWorkspace` `pathWithin`
( `OPEN`);"操作路径必须以 run 的工作区为根", agent
宿 -/
def AgentFileOp.Authorized
(op : AgentFileOp I Path)
(runWorkspace : I.RunId Option Path)
(pathWithin : Path Path Prop) : Prop :=
w, runWorkspace op.run = some w pathWithin op.path w
end Spec.System
@@ -3,13 +3,13 @@ import Spec.Prelude
/-! /-!
# Memory :(ADR-0003) # Memory :(ADR-0003)
ADR-0003:Hub ****,;Claude ADR-0003:Hub ,;Claude
API ****(ADR , OPENADR API (ADR , OPEN),
"例如",), likec4 :**MCP :MCP run/project ,Claude chat id
run/project ,Claude chat id**(ADR-0003 Consequences ) (ADR-0003 Consequences )
"chat id 与 project 绑定" `ProjectGroup.GroupBinding`(ADR-0001), "chat id 与 project 绑定" `ProjectGroup.GroupBinding`(ADR-0001),
,(线) (线)
-/ -/
namespace Spec.System namespace Spec.System
@@ -17,9 +17,8 @@ namespace Spec.System
variable (I : Identifiers) variable (I : Identifiers)
variable (MessageId CardId : Type) variable (MessageId CardId : Type)
/-- 上下文锚点(`PINNED` 类别, ADR-0003 列定;**枚举完整性 `OPEN`**——ADR 是"例如"式 /-- 上下文锚点(`PINNED` 类别, ADR-0003;枚举完整性 `OPEN`——ADR 是"例如"式列举,
, surface,) Hub , surface) Hub , -/
-/
inductive Anchor where inductive Anchor where
/-- 触发某次 run 的消息(`PINNED` 类别, ADR-0003 "trigger message id")。 -/ /-- 触发某次 run 的消息(`PINNED` 类别, ADR-0003 "trigger message id")。 -/
| triggerMessage : MessageId Anchor | triggerMessage : MessageId Anchor
@@ -35,13 +34,11 @@ MCP tools to read … through Feishu APIs")。 -/
structure McpReadRequest where structure McpReadRequest where
/-- 发起请求的 run(授权上下文主体, ADR-0003)。 -/ /-- 发起请求的 run(授权上下文主体, ADR-0003)。 -/
run : I.RunId run : I.RunId
/-- 请求读取的 chat(是否允许越界由下方 `Authorized` 钉死:不允许)。 -/ /-- 请求读取的 chat(授权由下方 `Authorized` 约束:不允许越界)。 -/
chat : I.ChatId chat : I.ChatId
/-- 请求获授权:其 chat 必须等于该 run 所属 project 的绑定群(`PINNED` 安全不变式, /-- 请求获授权:其 chat 必须等于该 run 所属 project 的绑定群(`PINNED` 安全不变式,
ADR-0003 Consequences "MCP tools must authorize by run/project context; Claude cannot ADR-0003)"chat 必须匹配 runproject 绑定", Claude chat id -/
pass arbitrary chat ids")。`runProject`/`boundChat` 由平台提供(表示 `OPEN`);本谓词只
"chat 必须匹配 run 的 project 绑定", Claude chat id -/
def McpReadRequest.Authorized def McpReadRequest.Authorized
(req : McpReadRequest I) (req : McpReadRequest I)
(runProject : I.RunId Option I.ProjectId) (runProject : I.RunId Option I.ProjectId)
@@ -1,16 +1,16 @@
/-! /-!
# Run AgentRun # Run AgentRun
`@Claude` `AgentRun`(ADR-0001),(ADR-0002) `@bot` `AgentRun`(ADR-0001),(ADR-0002)
ADR / likec4 ,****** ADR ,( Lock
**( Lock ), ),
-/ -/
namespace Spec.System namespace Spec.System
/-- AgentRun 运行状态(状态名 `PINNED`, ADR-0001..0003, ADR-0022 + likec4; /-- AgentRun 运行状态(状态名 `PINNED`, ADR-0001..0003, ADR-0022;完整性 `OPEN`
** `OPEN`**"状态恰好这些";( pending) ADR "状态就是这些";( pending) surface)
surface,) `RunState.Terminal` -/ `RunState.Terminal` -/
inductive RunState where inductive RunState where
| active | active
| waitingForUser | waitingForUser
-44
View File
@@ -1,44 +0,0 @@
import Spec.Prelude
import Spec.System.Run
/-!
# AgentSurface Agent (ADR-0018)
ADR-0001/0002/0004 "协作治理" `triggerAgent`: run
agent ADR / ADR-0017
Claude Code SDK `bypassPermissions` + Read/Write/Bash/Glob/Grep,
agent shell 宿****:spec ADR
:agent run , run project
(ADR-0007:; ADR "agent 操作落在该树内")
, `Lock`(ADR-0002):Lock ****(),Surface
****() run × project
shell (),****
OS (bubblewrap/)SDK ,`OPEN`
(ADR-0018),
-/
namespace Spec.System
variable (I : Identifiers) (Path : Type)
/-- Agent 在一次 run 内发起的文件操作(`PINNED` 关系, ADR-0018)。由某 run 发起、
; `Authorized` -/
structure AgentFileOp where
/-- 发起操作的 run(授权上下文主体, ADR-0018;与 `Lock` 同作用域 run × project)。 -/
run : I.RunId
/-- 操作目标路径(`PINNED` 字段, ADR-0018)。 -/
path : Path
/-- 工作区边界良构:run 的文件操作路径必须落在该 run 所属 project 的工作区目录内
(`PINNED` , ADR-0018)`runWorkspace` `pathWithin`
( `OPEN`"在内" plumbing,);
"操作路径必须以 run 的工作区为根", agent 宿 -/
def AgentFileOp.Authorized
(op : AgentFileOp I Path)
(runWorkspace : I.RunId Option Path)
(pathWithin : Path Path Prop) : Prop :=
w, runWorkspace op.run = some w pathWithin op.path w
end Spec.System
+6 -9
View File
@@ -1,21 +1,18 @@
import Spec.Prelude import Spec.Prelude
/-! /-!
# Audit Project/Run () # Audit Project/Run
likec4 `AuditLog` (`AgentRun -> AuditLog 'records lifecycle events'`), ( schema) ADR ,
****( schema) ADR / "审计以 run 为主体记录其生命周期事件"
, plumbing: , `OPEN`ADR-0023 Platform Audit ,
"审计以 run 为主体记录其生命周期事件", `OPEN`( `Spec.System.PlatformAdministration`,
:)
-/ -/
namespace Spec.System namespace Spec.System
/-- 审计条目的最小骨架(关系 `PINNED` / 内容 `OPEN`, likec4)。只承诺"一条审计记录 /-- 审计条目的最小骨架(关系 `PINNED` / 内容 `OPEN`, likec4)。只承诺"一条审计记录
run";事件类型、时间、actor、详情等字段 `OPEN`,待真实分歧点出现时由 run";事件类型、时间、actor、详情等字段 `OPEN`。 -/
ADR ADR-0023 Platform Audit fail-closed ,
`Spec.System.PlatformAdministration`, -/
structure AuditEntry (I : Identifiers) where structure AuditEntry (I : Identifiers) where
/-- 该审计条目所属的 run(`PINNED` 关系, likec4)。 -/ /-- 该审计条目所属的 run(`PINNED` 关系, likec4)。 -/
run : I.RunId run : I.RunId
+2 -2
View File
@@ -4,8 +4,8 @@ import Spec.Prelude
# Capacity SaaS capacity admission and abuse controls (ADR-0022) # Capacity SaaS capacity admission and abuse controls (ADR-0022)
, Organization , Organization
ADR-0022 admission; ADR-0022 admission;
, `OPEN`, , `OPEN`
-/ -/
namespace Spec.System namespace Spec.System
+24
View File
@@ -0,0 +1,24 @@
import Spec.System.Connections.Prelude
import Spec.System.Connections.Feishu
/-!
# Connections
/ `Connections.Prelude`;
`Connections.Feishu`
-/
namespace Spec.System
/-- 组织连接绑定(`PINNED`)。 -/
inductive ConnectionBinding (I : Identifiers) where
/-- 飞书(`PINNED`)。 -/
| feishu : FeishuAppBinding I ConnectionBinding I
/-- 用户连接信息(`PINNED`)。 -/
inductive ConnectionProfile (I : Identifiers) where
/-- 飞书(`PINNED`)。 -/
| feishu : FeishuProfile I ConnectionProfile I
end Spec.System
+31
View File
@@ -0,0 +1,31 @@
import Spec.Prelude
/-!
# Feishu
(1:1) API
-/
namespace Spec.System
variable (I : Identifiers)
/-- 组织的飞书应用绑定(`PINNED`, 1:1)。 -/
structure FeishuAppBinding where
/-- 飞书企业应用 app_id(`OPEN` 表示)。 -/
appId : I.FeishuAppId
/-- app_secret 信封引用(`PINNED`, ADR-0024)。 -/
appSecretEnvelope : I.FeishuAppSecretRef
/-- 用户的飞书信息(`PINNED`)。 -/
structure FeishuProfile where
/-- 应用内身份(`OPEN`);调 API 的直接句柄,换应用即变。 -/
openId : I.FeishuOpenId
/-- 租户内身份(`OPEN`);换应用不变,比 open_id 稳定。 -/
userId : I.FeishuUserId
/-- 显示名(`OPEN`)。 -/
name : Option String
/-- 头像 URL(`OPEN`)。 -/
avatarUrl : Option String
end Spec.System
+15
View File
@@ -0,0 +1,15 @@
/-!
# Connections.Prelude
/ IdP ()
provider connection , `Spec.System.Organization`
-/
namespace Spec.System
/-- 连接提供商(`PINNED`;当前仅飞书,未来可扩展钉钉/企微)。 -/
inductive ConnectionProvider where
/-- 飞书(`PINNED`)。 -/
| feishu
end Spec.System
+46
View File
@@ -0,0 +1,46 @@
import Spec.Prelude
import Spec.System.Connections
/-!
# Hierarchy
: ()
- ****(Platform): SaaS
- ****(Organization): SaaS
- ****(User):
****: "管理员"
-/
namespace Spec.System
variable (I : Identifiers)
/-- 平台(`PINNED`, SaaS 提供方)。只有一个,独立于组织。管理面见 `PlatformAdministration`(ADR-0023)。 -/
structure Platform where
/-- 平台自有飞书应用(`PINNED`, ADR-0023)。 -/
application : I.PlatformFeishuApplicationId
/-- 组织(`PINNED`, ADR-0020)。project/team 必须归属且仅归属一个 org。
`Connections`/tenancy/ `Spec.System.Organization` -/
structure Organization where
/-- 组织标识(`OPEN` 表示)。 -/
id : I.OrganizationId
/-- 外部连接(`PINNED`)。 -/
connections : List (ConnectionBinding I)
/-- 用户(`PINNED`, 租户层独立实体)。必属一个组织。外部连接见 `Connections`。 -/
structure User where
/-- 用户标识(`OPEN` 表示;组织内唯一,不可改;登录用)。 -/
id : I.UserId
/-- 所属组织(`PINNED`, ADR-0020)。 -/
organization : I.OrganizationId
/-- 显示名(`PINNED`, 可改)。 -/
displayName : String
/-- 密码哈希(`OPEN` 表示;id + 密码登录)。 -/
passwordHash : String
/-- 外部连接(`PINNED`)。 -/
connections : List (ConnectionProfile I)
end Spec.System
+10 -13
View File
@@ -1,35 +1,32 @@
import Spec.Prelude import Spec.Prelude
import Spec.System.Run import Spec.System.Agent.Run
/-! /-!
# Lock # Lock
ADR-0002 : Claude , **owner `AgentRun`** ADR-0002: agent , owner `AgentRun`(
( teacher / chat / session), teacher / chat / session) run
likec4 ** run**
-/ -/
namespace Spec.System namespace Spec.System
variable (I : Identifiers) variable (I : Identifiers)
/-- 项目级锁(`PINNED`, ADR-0002)。`owner : RunId`(非 SessionId/Principal)从类型 /-- 项目级锁(`PINNED`, ADR-0002)。`owner : RunId`从类型上编码"lock owner = run_id":
"lock owner = run_id": session / teacher -/ session / teacher -/
structure ProjectAgentLock where structure ProjectAgentLock where
/-- 作用域:项目级(`PINNED`, ADR-0002 `scope = project_id`)。 -/ /-- 作用域:项目级(`PINNED`, ADR-0002)。 -/
scope : I.ProjectId scope : I.ProjectId
/-- 持有者:一个 run(`PINNED`, ADR-0002 `owner = run_id`)。 -/ /-- 持有者:一个 run(`PINNED`, ADR-0002)。 -/
owner : I.RunId owner : I.RunId
/-- 锁表:每项目当前持锁 run(`PINNED` 排他性, ADR-0002)。`ProjectId → Option RunId` /-- 锁表:每项目当前持锁 run(`PINNED` 排他性, ADR-0002)。`ProjectId → Option RunId`
**** owner -/ owner -/
def LockTable := I.ProjectId Option I.RunId def LockTable := I.ProjectId Option I.RunId
/-- 锁表良构:**持锁者必为非终止 run**(`PINNED` 平台核心不变式, ADR-0002)。 /-- 锁表良构:持锁者必为非终止 run(`PINNED`, ADR-0002)。
"锁在 run 终止时释放": `p` `r` , `r` `p` `r` , `r` run -/
Lock Run likec4 "run owns lock while running","终止即
"这个约束;它正是契约相对结构图的增量。 -/
def LockTable.WellFormed def LockTable.WellFormed
(lt : LockTable I) (statusOf : I.RunId RunState) : Prop := (lt : LockTable I) (statusOf : I.RunId RunState) : Prop :=
p r, lt p = some r ¬ (statusOf r).Terminal p r, lt p = some r ¬ (statusOf r).Terminal
+40 -18
View File
@@ -1,18 +1,12 @@
import Spec.Prelude import Spec.Prelude
/-! /-!
# Organization SaaS tenant root (ADR-0020, ADR-0024) # Organization SaaS (ADR-0020, ADR-0024)
ADR-0020:Hub SaaS ,`Organization` tenant root project/team org;teamproject grant org
`Project` `Team` organization;team project `Hierarchy.Organization`; `Spec.System.User`;
organization (platform staff / break-glass / ) `PlatformAdministration`(ADR-0023) ADR-0024Agent
project `read/edit/manage` , `Spec.System.Agent.AgentRole`
:tenant root project/team team grant
org, model provider connection
provider `OPEN`;// ADR-0023
`Spec.System.PlatformAdministration`
writer authority fail-closed resolver ADR-0024
-/ -/
namespace Spec.System namespace Spec.System
@@ -41,19 +35,17 @@ structure TeamProjectGrantScope where
project : I.ProjectId project : I.ProjectId
/-- 获得授权的 team principal(`PINNED`, ADR-0020)。 -/ /-- 获得授权的 team principal(`PINNED`, ADR-0020)。 -/
team : I.TeamId team : I.TeamId
/-- Team-project grant 是良构的 iff project 与 team 解析到同一 organization /-- Team-project grant 是良构的 iff project 与 team 解析到同一 organization
(`PINNED`, ADR-0020)`projectOrg`/`teamOrg` ( `OPEN`); (`PINNED`, ADR-0020)`projectOrg`/`teamOrg` ( `OPEN`); org
org team grant -/ team grant -/
def TeamProjectGrantScope.WellScoped def TeamProjectGrantScope.WellScoped
(grant : TeamProjectGrantScope I) (grant : TeamProjectGrantScope I)
(projectOrg : I.ProjectId Option I.OrganizationId) (projectOrg : I.ProjectId Option I.OrganizationId)
(teamOrg : I.TeamId Option I.OrganizationId) : Prop := (teamOrg : I.TeamId Option I.OrganizationId) : Prop :=
o, projectOrg grant.project = some o teamOrg grant.team = some o o, projectOrg grant.project = some o teamOrg grant.team = some o
/-- Organization 的 model provider 凭据归属模式(`PINNED`, ADR-0021):BYOK 由 org /- BYOK 由组织所有者/管理员管理;platform-managed 由平台管理员管理。两种模式都不允许
;platform-managed org org process-global key `OPEN` -/
org process-global provider key `OPEN` -/
inductive ProviderCredentialMode where inductive ProviderCredentialMode where
| byok | byok
| platformManaged | platformManaged
@@ -78,7 +70,7 @@ inductive OrganizationConnectionStatus where
/-- Organization secret version 的信封绑定上下文(`PINNED`, ADR-0024):认证附加数据必须 /-- Organization secret version 的信封绑定上下文(`PINNED`, ADR-0024):认证附加数据必须
organizationconnectionsecret version purpose, org organizationconnectionsecret version purpose, org
connection plumbing, opaque -/ connection , opaque -/
structure OrganizationSecretBinding structure OrganizationSecretBinding
(OrganizationId ConnectionId SecretVersionId Purpose : Type) where (OrganizationId ConnectionId SecretVersionId Purpose : Type) where
/-- secret 所属 organization(`PINNED`, ADR-0024)。 -/ /-- secret 所属 organization(`PINNED`, ADR-0024)。 -/
@@ -104,4 +96,34 @@ def OrganizationSecretResolvable
organizationActive && connectionActive && organizationActive && connectionActive &&
(binding.organization == requestedOrganization) && authenticatedEnvelope (binding.organization == requestedOrganization) && authenticatedEnvelope
/-- 组织成员角色(`PINNED` 封闭三档)。与项目层 `Role`、平台层 `PlatformRole` 互不相交。 -/
inductive OrganizationRole where
/-- 组织所有者(`PINNED`):bootstrap,独家管 owner 群体,受最后所有者保护。 -/
| owner
/-- 组织管理员(`PINNED`):管成员/policy/BYOK,不能管 owner 群体。 -/
| admin
/-- 组织成员(`PINNED`):普通成员。 -/
| member
/-- 组织成员关系(`PINNED`)。 -/
structure OrganizationMembership where
/-- 成员(`PINNED`;独立实体,见 `Hierarchy.User`)。 -/
user : I.UserId
/-- 组织(`PINNED`)。 -/
organization : I.OrganizationId
/-- 角色(`PINNED`)。 -/
role : OrganizationRole
/-- 只有组织所有者能管 owner 群体(`PINNED`)。 -/
def CanManageOwnerGroup (actorRole : OrganizationRole) : Prop :=
actorRole = .owner
/-- 最后所有者保护(`PINNED`):撤销 owner 时同 org 必须还有一个不同的 owner。 -/
def LastOwnerProtected
(target : OrganizationMembership I)
(otherOwnersInOrg : List I.UserId) : Prop :=
target.role .owner
other, other otherOwnersInOrg other target.user
end Spec.System end Spec.System
+1 -2
View File
@@ -5,8 +5,7 @@ import Spec.Prelude
ADR-0004:"飞书云文档式"grant(`resource + principal + role`) settings ADR-0004:"飞书云文档式"grant(`resource + principal + role`) settings
;role `read / edit / manage`, **read edit manage** ; ;role `read / edit / manage`, **read edit manage** ;
**admin-only**, role "高 role 含低 **admin-only**, role
role "的单调性钉死。
-/ -/
namespace Spec.System namespace Spec.System
+9 -12
View File
@@ -4,16 +4,14 @@ import Spec.System.Permission
/-! /-!
# PermissionGrant (ADR-0004) # PermissionGrant (ADR-0004)
ADR-0004 "飞书云文档式":**grant**(`resource × principal × role`) **settings** ADR-0004 "飞书云文档式":grant(`resource × principal × role`) settings
( policy );role "谁能"(, `Permission`),settings "此资源 ( policy );role ( `Permission`),settings "此资源是否
"(策略)。本模块把 grant/settings 的结构钉死——`Permission` 已落 role 能 "
,"授权如何挂到资源/主体上"
principal (user/chat/department/) policy `OPEN`(ADR , principal (user/chat/department/) policy `OPEN`role-capability
)**role-capability settings-policy ** `OPEN` settings-policy `OPEN`ADR-0004 ,
ADR-0004 ,(AND?settings role?), ADR-0020 TEAM principal PROJECT resource
surface,ADR-0020 TEAM principal PROJECT resource organization, `Spec.System.Organization`
organization; tenant well-scopedness `Spec.System.Organization`
-/ -/
namespace Spec.System namespace Spec.System
@@ -45,9 +43,8 @@ structure PermissionGrant where
role : Role role : Role
/-- 资源策略设置(`PINNED` 结构 + 六旋钮, ADR-0004 `PermissionSettings`):与 grant 分离, /-- 资源策略设置(`PINNED` 结构 + 六旋钮, ADR-0004 `PermissionSettings`):与 grant 分离,
"此资源是否开某类操作" ADR ; `OPEN`(ADR , "此资源是否开某类操作" ADR ; `OPEN`(ADR )
) opaque `Policy` :"旋钮存在且相互独立","各旋钮 opaque `Policy` :"旋钮存在且相互独立";/ ADR -/
"——值域是实现/后续 ADR 的事。 -/
structure PermissionSettings where structure PermissionSettings where
/-- 设置所属资源(`PINNED`, ADR-0004)。 -/ /-- 设置所属资源(`PINNED`, ADR-0004)。 -/
resource : Resource I ArtifactId resource : Resource I ArtifactId
+2 -2
View File
@@ -8,8 +8,8 @@ import Spec.Prelude
invitation sessionmutation invitation sessionmutation
,线 ,线
cookie token hash TTL cookie token hash TTL
/recovery key CLI/SQL `OPEN`, /recovery key CLI/SQL `OPEN`
-/ -/
namespace Spec.System namespace Spec.System
+12 -15
View File
@@ -3,26 +3,23 @@ import Spec.Prelude
/-! /-!
# ProjectGroup (ADR-0001) # ProjectGroup (ADR-0001)
ADR-0001 : project ****;,** project ;,( `AgentRun`,
owner**( `AgentRun`, `Lock` / ADR-0002), session Claude `Lock` / ADR-0002) agent ;/
;/ Claude agent
projectgroup **active**likec4 "project has group","恰好一个、
"
**(`PINNED`, ADR-0021):** active binding 1:1; archived **(`PINNED`, ADR-0021):** active binding 1:1; archived
historical binding rows /, `GroupBinding` active historical binding rows , `GroupBinding` active
/ `OPEN`;pilot org admin / `OPEN`;pilot org admin
-/ -/
namespace Spec.System namespace Spec.System
variable (I : Identifiers) variable (I : Identifiers)
/-- 飞书项目群(`PINNED` 长生命周期协作空间, ADR-0001)。承载 project 与飞书 chat 的绑定; /-- 飞书项目群(`PINNED` 长生命周期协作空间, ADR-0001)。承载 project 与飞书 chat 的
** owner**( `AgentRun`, `Lock`); session -/ ;( `AgentRun`, `Lock`) -/
structure ProjectGroup where structure ProjectGroup where
/-- 群对应的飞书 chat(`PINNED` 关系, ADR-0001 "one project has one Feishu project /-- 群对应的飞书 chat(`PINNED` 关系, ADR-0001;chat 标识见 `Identifiers.ChatId`)。 -/
group";chat 标识见 `Identifiers.ChatId`)。 -/
chat : I.ChatId chat : I.ChatId
/-- 项目↔active 群绑定表(`PINNED` 每项目至多一个 active 群, ADR-0001/0021)。 /-- 项目↔active 群绑定表(`PINNED` 每项目至多一个 active 群, ADR-0001/0021)。
@@ -30,9 +27,9 @@ structure ProjectGroup where
); -/ ); -/
def GroupBinding := I.ProjectId Option I.ChatId def GroupBinding := I.ProjectId Option I.ChatId
/-- Active 绑定良构:**单射**——不同 project 不绑同一 active chat(`PINNED` 1:1 的另一半, /-- Active 绑定良构:单射——不同 project 不绑同一 active chat(`PINNED`, ADR-0001/0021)。
ADR-0001/0021)"每 project 至多一个群" `Option` ;"每群至多属于一个 "每 project 至多一个群" `Option` ;"每群至多属于一个 project"
project"。archived historical bindings 不在本快照不变式内。 -/ archived historical bindings -/
def GroupBinding.WellFormed (b : GroupBinding I) : Prop := def GroupBinding.WellFormed (b : GroupBinding I) : Prop :=
p₁ p₂ c, b p₁ = some c b p₂ = some c p₁ = p₂ p₁ p₂ c, b p₁ = some c b p₂ = some c p₁ = p₂
+5 -6
View File
@@ -3,12 +3,11 @@ import Spec.Prelude
/-! /-!
# ProjectWorkspace project explorer (ADR-0021) # ProjectWorkspace project explorer (ADR-0021)
ADR-0021 org "文件管理器式" folder + project: org "文件管理器式":folder ,
folder ,project project
:folder/project orgfolder :folder/project orgfolder project
project org policy folder visibility/team policy/ org policy folder visibility/team policy/ `OPEN`
,
-/ -/
namespace Spec.System namespace Spec.System
@@ -39,7 +38,7 @@ def ProjectFolderPlacement.WellScoped
o, projectOrg placement.project = some o folderOrg placement.folder = some o o, projectOrg placement.project = some o folderOrg placement.folder = some o
/-- Folder 当前透明(`PINNED`, ADR-0021):folder 不是权限资源,不持有 grants,移动 project /-- Folder 当前透明(`PINNED`, ADR-0021):folder 不是权限资源,不持有 grants,移动 project
project folder policy , -/ project folder policy , -/
structure FolderTransparent where structure FolderTransparent where
/-- 透明性命题本身;字段存在是为了让 contract 明确可引用(`PINNED`, ADR-0021)。 -/ /-- 透明性命题本身;字段存在是为了让 contract 明确可引用(`PINNED`, ADR-0021)。 -/
current : True current : True
+12
View File
@@ -0,0 +1,12 @@
import Spec.Prelude
/-!
# User
`Hierarchy.User`; `Spec.System.Connections`
; `OPEN`
-/
namespace Spec.System
end Spec.System