diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 907d0ede..9d1f3cca 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: true contact_links: - name: Security vulnerability - url: https://github.com/haowei2000/Cheers/security/advisories/new + url: https://github.com/ElePerson/Cheers/security/advisories/new about: Please report vulnerabilities privately instead of opening a public issue. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f8b651d..61c6dd98 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -148,7 +148,7 @@ jobs: - uses: actions/checkout@v4 - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: "20" cache: "npm" diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e674b57..9f8e6944 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -101,5 +101,5 @@ First tagged public-preview release. - Status: early public preview. Deployment hardening, permission boundaries, and wider agent-ecosystem integration are still evolving. -[Unreleased]: https://github.com/haowei2000/Cheers/compare/v0.1.0...HEAD -[0.1.0]: https://github.com/haowei2000/Cheers/releases/tag/v0.1.0 +[Unreleased]: https://github.com/ElePerson/Cheers/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/ElePerson/Cheers/releases/tag/v0.1.0 diff --git a/README.md b/README.md index c7d0cfa6..c6b5fde0 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ > **Language**: English | [中文](README.zh-CN.md) -[![CI](https://github.com/haowei2000/Cheers/actions/workflows/ci.yml/badge.svg?branch=develop)](https://github.com/haowei2000/Cheers/actions/workflows/ci.yml) -[![Release](https://img.shields.io/github/v/release/haowei2000/Cheers)](https://github.com/haowei2000/Cheers/releases) +[![CI](https://github.com/ElePerson/Cheers/actions/workflows/ci.yml/badge.svg?branch=develop)](https://github.com/ElePerson/Cheers/actions/workflows/ci.yml) +[![Release](https://img.shields.io/github/v/release/ElePerson/Cheers)](https://github.com/ElePerson/Cheers/releases) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) -🌐 **Live overview:** +🌐 **Live overview:** 🚀 **Try it live:** — public sign-up is open. Create an account (with email verification), then start a channel and `@`-mention an AI agent. diff --git a/README.zh-CN.md b/README.zh-CN.md index 3cd7458d..c33cdc19 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -2,11 +2,11 @@ > **语言**:中文 | [English](README.md) -[![CI](https://github.com/haowei2000/Cheers/actions/workflows/ci.yml/badge.svg?branch=develop)](https://github.com/haowei2000/Cheers/actions/workflows/ci.yml) -[![Release](https://img.shields.io/github/v/release/haowei2000/Cheers)](https://github.com/haowei2000/Cheers/releases) +[![CI](https://github.com/ElePerson/Cheers/actions/workflows/ci.yml/badge.svg?branch=develop)](https://github.com/ElePerson/Cheers/actions/workflows/ci.yml) +[![Release](https://img.shields.io/github/v/release/ElePerson/Cheers)](https://github.com/ElePerson/Cheers/releases) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) -🌐 **在线介绍页:** +🌐 **在线介绍页:** 🚀 **在线体验:** —— 已开放公开注册。用邮箱验证码注册一个账号,然后创建频道并 `@` 提及 AI 智能体开始体验。 diff --git a/deploy/helm/cheers/Chart.yaml b/deploy/helm/cheers/Chart.yaml index d1653913..6b7913d3 100644 --- a/deploy/helm/cheers/Chart.yaml +++ b/deploy/helm/cheers/Chart.yaml @@ -13,6 +13,6 @@ keywords: - cheers - acp - gateway -home: https://github.com/haowei2000/Cheers +home: https://github.com/ElePerson/Cheers maintainers: - name: Cheers diff --git a/deploy/helm/cheers/README.md b/deploy/helm/cheers/README.md index ff139a27..7355412e 100644 --- a/deploy/helm/cheers/README.md +++ b/deploy/helm/cheers/README.md @@ -75,8 +75,8 @@ and every `v*.*.*` tag: | Image | Tags | |---|---| -| `ghcr.io/haowei2000/cheers-gateway` | `main`, `latest`, semver (`0.1.0`, `0.1`), `sha-` | -| `ghcr.io/haowei2000/cheers-frontend` | `main`, `latest`, semver (`0.1.0`, `0.1`), `sha-` | +| `ghcr.io/eleperson/cheers-gateway` | `main`, `latest`, semver (`0.1.0`, `0.1`), `sha-` | +| `ghcr.io/eleperson/cheers-frontend` | `main`, `latest`, semver (`0.1.0`, `0.1`), `sha-` | To install from GHCR instead of building + loading local images (skip step 1 of the local-dev path): @@ -84,9 +84,9 @@ of the local-dev path): ```bash helm upgrade --install cheers deploy/helm/cheers -n cheers --create-namespace \ -f deploy/helm/cheers/values-dev.yaml \ - --set gateway.image.repository=ghcr.io/haowei2000/cheers-gateway \ + --set gateway.image.repository=ghcr.io/eleperson/cheers-gateway \ --set gateway.image.tag=main \ - --set frontend.image.repository=ghcr.io/haowei2000/cheers-frontend \ + --set frontend.image.repository=ghcr.io/eleperson/cheers-frontend \ --set frontend.image.tag=main \ --set-file secrets.jwtPrivateKey=/tmp/jwt_priv.pem \ --set-file secrets.jwtPublicKey=/tmp/jwt_pub.pem diff --git a/docs/arch/RENDERER_PLUGIN.md b/docs/arch/RENDERER_PLUGIN.md index 6d760f8b..e46e1967 100644 --- a/docs/arch/RENDERER_PLUGIN.md +++ b/docs/arch/RENDERER_PLUGIN.md @@ -1,8 +1,11 @@ # 渲染器插件开发指南 -> **Language**: [English](../developer/PLUGIN_DEVELOPMENT.md) | 中文(本文,设计原文) +> **Language**: [English(规范版)](../developer/PLUGIN_DEVELOPMENT.md) | 中文(本文,设计原文) +> +> ⚠️ **规范以英文版为准**:协议消息表、manifest 校验规则、Troubleshooting 都维护在 +> [PLUGIN_DEVELOPMENT.md](../developer/PLUGIN_DEVELOPMENT.md);本文保留设计动机与中文语境,两边冲突时以英文版为准。 -> 状态:**v1 已实现**。host 侧 `render/save` 协议已落地——工作台 **File 面板**里选中一个文件,用顶部「渲染器」下拉选内置 lens 或已装插件;绑定(`path → renderer id`)存进 `.workbench.json`。旧的 `panels`/`init` 沙箱(场景插件)仍并存。代码:`renderers/registry.ts`、`renderers/RendererHost.tsx`、`sandbox/SandboxRenderer.tsx`、`panels/FilePanel.tsx`。 +> 状态:**v1 已实现**。host 侧 `render/save` 协议已落地——工作台 **File 面板**里选中一个文件,用顶部「渲染器」下拉选内置 lens 或已装插件;绑定(`path → renderer id`)存进 `.workbench.json`。旧的 `panels`/`init` 沙箱(场景插件)**已退役**(上传会被拒绝,示例已删除)。代码:`renderers/registry.ts`、`renderers/RendererHost.tsx`、`sandbox/SandboxRenderer.tsx`、`panels/FilePanel.tsx`。 > 关联:[[WORKBENCH]]「关系与边界」· [[context-and-environment]] ## 0. 一分钟心智模型 @@ -43,11 +46,13 @@ | `title` | 给人看的名字 | | `renderers[]` | 这个插件提供的渲染器,可多个 | | `renderers[].id` | 渲染器在插件内唯一 | -| `renderers[].match.format` | `markdown` / `json` / `toml` / `xml` / `text`。host 按扩展名归类(`.md`→markdown,无扩展名→text)。 | +| `renderers[].match.format` | `markdown` / `json` / `yaml` / `toml` / `xml` / `text`(可为数组)。host 按扩展名归类(`.md`→markdown,`.yaml`/`.yml`→yaml,无扩展名→text)。 | | `renderers[].match.glob` | (可选)按路径窄化,如 `"reviews/*.md"` | | `renderers[].match.requireAll` | (可选)内容必须**全部包含**这些子串。用于「md 含某些标题」,如 `["## 待办","## 进行中"]`。 | | `renderers[].match.requireAny` | (可选)内容至少包含**其一**,如 `["- [ ]","- [x]"]`(待办行)。 | -| `renderers[].match.jsonHas` | (可选,仅 json)解析后的对象必须**含全部**这些顶层键,如 `["columns","cards"]`。 | +| `renderers[].match.jsonHas` | (可选,仅 json)解析后的对象必须**含全部**这些顶层键,如 `["columns","cards"]`。**已弃用**:新 manifest 用 `dataHas`。 | + +> **协议 1 补充(规范见英文版)**:manifest 顶层可声明 `"protocol": 1`(缺省即 1);`match.format` 可为字符串**数组**;新增 `dataHas`(结构化内容顶层键,格式无关,取代 `jsonHas`)与 `dataKind`(`"object" | "array"`,声明顶层形状——认领「JSON 数组」的唯一方式)。服务端安装时校验 manifest(错误表见英文版 §8);bundle ≤ 2 MiB。 > **`match` = 你声明「我接受什么」**。host 拿文件内容廉价评估(子串/JSON 键,**不**启动你的沙箱),据此决定你**是否出现在该文件的渲染器候选里**——所以一个需要 `## ` 标题的渲染器,不会被推荐给一篇纯散文。 > @@ -202,6 +207,7 @@ var info = await res("channel.info", {}); // → { ok, data } ## 7. 安装与绑定 +- **试用/开发**(人人):把 `.html` 拖进工作台抽屉(或点「Load extension」选文件)——**仅本浏览器会话**生效,渲染器立即进入匹配文件的候选列表(⏱ 标记);同 id 会话插件会遮蔽已安装版本,方便迭代调试,刷新即消失。 - **安装**(admin):设置 → Workbench extensions → 上传 `.html`(进 `workbench_plugins` 表,全频道可见)。 - **绑定**:打开某文件时,工作台按 `.workbench.json` 的 `bindings[path]` 选渲染器;没有就默认「原文」(textarea)或让用户从候选里挑,选择持久化进 `.workbench.json`(`path → rendererId`)。**绑定不进文件**,文件始终是纯内容。 diff --git a/docs/arch/RESEARCH_SCENARIO.md b/docs/arch/RESEARCH_SCENARIO.md index 6c56473e..ac04b3a9 100644 --- a/docs/arch/RESEARCH_SCENARIO.md +++ b/docs/arch/RESEARCH_SCENARIO.md @@ -372,9 +372,10 @@ crosshair + tooltip, recessive hairline grid, tabular numerals. ### 6.3 Knowledge graph — sandbox plugin (Phase 2) Paper/citation graph fits the sandboxed iframe plugin renderer -(`workbench/sandbox/`, table `workbench_plugins`, example -`sandbox/examples/research-plugin.html`): reads a `context_files` JSON via the -whitelisted postMessage fs proxy, draws client-side, network-isolated by CSP. +(`workbench/sandbox/`, table `workbench_plugins`, current-protocol example +`sandbox/examples/lit-review.html`): the host assigns one `context_files` JSON +over the render/save postMessage protocol, the plugin draws client-side, +network-isolated by the sandbox. ### 6.4 Daily recommendation / email — out of scope SMTP exists, but no scheduler/cron. A recurring digest needs new backend work; excluded diff --git a/docs/arch/WORKBENCH.md b/docs/arch/WORKBENCH.md index 0d703edd..aec4087e 100644 --- a/docs/arch/WORKBENCH.md +++ b/docs/arch/WORKBENCH.md @@ -43,8 +43,8 @@ Workbench 是频道右侧的工作台:一个频道一个,浏览/编辑 bot 工 | 本质 | **声明式场景清单**(数据) | **渲染器**(代码),可选自带一个默认场景 | | 是什么 | 「视图声明 + seed 文件」,激活时 seed 进当前频道 | 自带 UI 渲染逻辑的代码单元 | | 需要插件机制吗 | **不需要**——它只是数据 | **需要**——沙箱、服务端安装、代码加载 | -| 作用域 | **全局**(admin 装,全频道可见)**或临时**(浏览器会话内上传,仅本会话) | server-level(admin 装一次,全频道) | -| 装在哪 | 全局:设置 → Workbench extensions(admin);临时:工作台抽屉「临时模板」(人人) | 设置 → Workbench extensions(admin) | +| 作用域 | **全局**(admin 装,全频道可见)**或临时**(浏览器会话内上传,仅本会话) | server-level(admin 装一次,全频道)**或临时**(会话内,调试用) | +| 装在哪 | 全局:设置 → Workbench extensions(admin);临时:工作台抽屉「Load extension」(人人) | 全局:设置 → Workbench extensions(admin);临时:工作台抽屉拖入 `.html`(人人,仅本会话,同 id 遮蔽已装版) | | 渲染靠 | 内置 lens **或** 插件提供的渲染器 | 自身(iframe 里自带渲染器) | | 格式 | 声明式 manifest JSON(纯数据) | `.html` bundle(代码)+ 内嵌 manifest | | 后端存储 | `workbench_templates` 表(全局,仅 manifest);临时模板**不入库** | `workbench_plugins` 表(manifest + bundle) | diff --git a/docs/arch/examples/README.md b/docs/arch/examples/README.md index f26a262b..901b8125 100644 --- a/docs/arch/examples/README.md +++ b/docs/arch/examples/README.md @@ -5,12 +5,14 @@ ## 渲染器插件(代码/沙箱) -设置 → Workbench extensions → 「插件」卡上传 `.html`。 +设置 → Workbench extensions → 「插件」卡上传 `.html`;或**直接把 `.html` 拖进工作台抽屉临时试用**(仅本会话,⏱ 标记,刷新即消失——插件开发的调试回路)。 + +> 另有一套**官方基础渲染插件**(checklist / table / kanban-md / frontmatter)随 gateway 启动自动预装(Settings 里带 Official 徽章),源码在 [`server/assets/workbench-plugins/`](../../../server/assets/workbench-plugins/),同时是最好的参考实现;规则见英文指南 §9。 | 文件 | 场景 | 说明 | |---|---|---| | [`md-checklist.plugin.html`](./md-checklist.plugin.html) | 通用 | 把 Markdown 待办(`- [ ]` / `- [x]`)渲染成可勾选清单,勾选写回 md。声明了**接受判断**(`match.requireAny` + 运行期 `cheers:unsupported`),只接受含待办行的 markdown。 | -| [`lit-review.plugin.html`](./lit-review.plugin.html) | **科研** | 论文追踪表:把 `{ "papers": [...] }` 结构的 JSON 渲染成可编辑表格(标题/作者/期刊/年份/笔记 + 阅读状态下拉 + 星级评分),编辑写回 JSON。`match.jsonHas: ["papers"]` 预筛,运行期再验证 `papers` 是数组。 | +| [`lit-review.plugin.html`](./lit-review.plugin.html) | **科研** | 论文追踪表:把 `{ "papers": [...] }` 结构的 JSON 渲染成可编辑表格(标题/作者/期刊/年份/笔记 + 阅读状态下拉 + 星级评分),编辑写回 JSON。`match.dataHas: ["papers"]` 预筛,运行期再验证 `papers` 是数组。 | | [`code-review.plugin.html`](./code-review.plugin.html) | **代码** | 代码评审清单:渲染 `## 文件路径` 分节、`- [ ] [P0/P1/P2] 描述` 的 markdown 评审记录,severity 徽章 + 顶部进度条,勾选就地改写该行并写回。`match.requireAny: ["[P0]","[P1]","[P2]"]`。 | ## 环境模板(数据) diff --git a/docs/arch/examples/cheers-plugin-sdk.js b/docs/arch/examples/cheers-plugin-sdk.js new file mode 100644 index 00000000..0b67f09c --- /dev/null +++ b/docs/arch/examples/cheers-plugin-sdk.js @@ -0,0 +1,75 @@ +/* cheers-plugin-sdk v1 — protocol 1. + * + * Copy-paste this function INLINE into your plugin's diff --git a/docs/arch/examples/md-checklist.plugin.html b/docs/arch/examples/md-checklist.plugin.html index 98b33ea1..03d7666f 100644 --- a/docs/arch/examples/md-checklist.plugin.html +++ b/docs/arch/examples/md-checklist.plugin.html @@ -6,6 +6,7 @@ ``` -Complete working examples (upload as-is): +### 6.1 Optional inline SDK + +If you'd rather not hand-roll the listener/promise plumbing, copy +[`docs/arch/examples/cheers-plugin-sdk.js`](../arch/examples/cheers-plugin-sdk.js) +(~50 lines, protocol 1) **inline** into your `` block). DOMParser * does NOT execute scripts, so reading an untrusted bundle here is inert. */ -export function parsePluginHtml(html: string): { id: string; title: string; manifest: unknown } { +export function parsePluginHtml(html: string): { id: string; title: string; manifest: PluginManifest } { const doc = new DOMParser().parseFromString(html, "text/html"); const el = doc.querySelector("#cheers-plugin"); if (!el || !el.textContent) { throw new Error('Missing embedded manifest: diff --git a/frontend/src/features/chat/workbench/sandbox/examples/md-checklist.html b/frontend/src/features/chat/workbench/sandbox/examples/md-checklist.html index 6d7a45a8..4d0f4ba6 100644 --- a/frontend/src/features/chat/workbench/sandbox/examples/md-checklist.html +++ b/frontend/src/features/chat/workbench/sandbox/examples/md-checklist.html @@ -7,6 +7,7 @@ - - - -
- Sandbox Notes - - -
- -
- - - diff --git a/frontend/src/features/chat/workbench/sandbox/examples/research-plugin.html b/frontend/src/features/chat/workbench/sandbox/examples/research-plugin.html deleted file mode 100644 index 59376790..00000000 --- a/frontend/src/features/chat/workbench/sandbox/examples/research-plugin.html +++ /dev/null @@ -1,145 +0,0 @@ - - - - - - - - - -
Research
-
-
- - - diff --git a/frontend/src/features/chat/workbench/sandbox/pluginManifest.test.ts b/frontend/src/features/chat/workbench/sandbox/pluginManifest.test.ts new file mode 100644 index 00000000..770bbefb --- /dev/null +++ b/frontend/src/features/chat/workbench/sandbox/pluginManifest.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; +import { validatePluginManifest } from "./pluginManifest"; + +const ok = { + id: "md-checklist", + title: "Markdown checklist", + renderers: [{ id: "checklist", title: "Checklist", match: { format: "markdown" } }], +}; + +describe("validatePluginManifest", () => { + it("accepts a well-formed manifest", () => { + expect(validatePluginManifest(ok)).toBeNull(); + }); + + it("accepts a renderer without match (match is optional)", () => { + expect( + validatePluginManifest({ ...ok, renderers: [{ id: "r", title: "R" }] }) + ).toBeNull(); + }); + + it("accepts multiple renderers with distinct ids", () => { + expect( + validatePluginManifest({ + ...ok, + renderers: [ + { id: "a", title: "A" }, + { id: "b", title: "B" }, + ], + }) + ).toBeNull(); + }); + + it("rejects non-objects", () => { + expect(validatePluginManifest(null)).toMatch(/object/); + expect(validatePluginManifest("hi")).toMatch(/object/); + expect(validatePluginManifest([ok])).toMatch(/object/); + }); + + it("rejects missing/empty id or title", () => { + expect(validatePluginManifest({ ...ok, id: undefined })).toMatch(/id/); + expect(validatePluginManifest({ ...ok, id: " " })).toMatch(/id/); + expect(validatePluginManifest({ ...ok, title: undefined })).toMatch(/title/); + expect(validatePluginManifest({ ...ok, title: "" })).toMatch(/title/); + }); + + it("rejects missing or empty renderers", () => { + expect(validatePluginManifest({ id: "x", title: "X" })).toMatch(/renderers/); + expect(validatePluginManifest({ id: "x", title: "X", renderers: [] })).toMatch(/renderers/); + }); + + it("rejects retired panels manifests with a dedicated message", () => { + expect( + validatePluginManifest({ id: "x", title: "X", panels: [{ id: "notes", title: "Notes" }] }) + ).toMatch(/legacy|panels/); + // even alongside renderers — a mixed manifest is a legacy manifest + expect( + validatePluginManifest({ ...ok, panels: [] }) + ).toMatch(/legacy|panels/); + }); + + it("accepts protocol 1 (or absent) and rejects anything else", () => { + expect(validatePluginManifest({ ...ok, protocol: 1 })).toBeNull(); + expect(validatePluginManifest({ ...ok, protocol: 2 })).toMatch(/protocol/); + expect(validatePluginManifest({ ...ok, protocol: "1" })).toMatch(/protocol/); + }); + + it("rejects malformed renderer entries", () => { + expect(validatePluginManifest({ ...ok, renderers: ["nope"] })).toMatch(/renderer/); + expect(validatePluginManifest({ ...ok, renderers: [{ title: "no id" }] })).toMatch(/id/); + expect(validatePluginManifest({ ...ok, renderers: [{ id: "r" }] })).toMatch(/title/); + }); + + it("rejects duplicate renderer ids within a plugin", () => { + expect( + validatePluginManifest({ + ...ok, + renderers: [ + { id: "dup", title: "One" }, + { id: "dup", title: "Two" }, + ], + }) + ).toMatch(/duplicate/); + }); + + it("rejects a non-object match", () => { + expect( + validatePluginManifest({ ...ok, renderers: [{ id: "r", title: "R", match: "markdown" }] }) + ).toMatch(/match/); + expect( + validatePluginManifest({ ...ok, renderers: [{ id: "r", title: "R", match: ["markdown"] }] }) + ).toMatch(/match/); + }); + + // Parity with the server's validate_manifest (server/src/domain/workbench_plugins.rs): + // whatever session-loads here must also install there. + + it("rejects ids outside ^[a-z0-9][a-z0-9._-]{0,63}$ (server id charset)", () => { + expect(validatePluginManifest({ ...ok, id: "Bad_Upper" })).toMatch(/id/); + expect(validatePluginManifest({ ...ok, id: "-leading-dash" })).toMatch(/id/); + expect(validatePluginManifest({ ...ok, id: "has space" })).toMatch(/id/); + expect(validatePluginManifest({ ...ok, id: "x".repeat(65) })).toMatch(/id/); + expect(validatePluginManifest({ ...ok, id: "x".repeat(64) })).toBeNull(); + }); + + it("rejects over-long titles and renderer ids (server byte caps)", () => { + expect(validatePluginManifest({ ...ok, title: "x".repeat(256) })).toMatch(/title/); + expect(validatePluginManifest({ ...ok, title: "x".repeat(255) })).toBeNull(); + expect( + validatePluginManifest({ ...ok, renderers: [{ id: "r".repeat(65), title: "R" }] }) + ).toMatch(/id/); + }); + + it("type-checks known match keys but ignores unknown ones", () => { + expect( + validatePluginManifest({ + ...ok, + renderers: [ + { + id: "r", + title: "R", + match: { + format: ["json", "yaml"], + dataKind: "array", + dataHas: ["rows"], + futureKey: { anything: true }, + }, + }, + ], + }) + ).toBeNull(); + expect( + validatePluginManifest({ ...ok, renderers: [{ id: "r", title: "R", match: { dataKind: "tuple" } }] }) + ).toMatch(/dataKind/); + expect( + validatePluginManifest({ ...ok, renderers: [{ id: "r", title: "R", match: { format: [] } }] }) + ).toMatch(/format/); + expect( + validatePluginManifest({ ...ok, renderers: [{ id: "r", title: "R", match: { format: [1] } }] }) + ).toMatch(/format/); + expect( + validatePluginManifest({ ...ok, renderers: [{ id: "r", title: "R", match: { requireAll: "x" } }] }) + ).toMatch(/requireAll/); + expect( + validatePluginManifest({ ...ok, renderers: [{ id: "r", title: "R", match: { jsonHas: [1] } }] }) + ).toMatch(/jsonHas/); + expect( + validatePluginManifest({ ...ok, renderers: [{ id: "r", title: "R", match: { glob: 5 } }] }) + ).toMatch(/glob/); + }); +}); diff --git a/frontend/src/features/chat/workbench/sandbox/pluginManifest.ts b/frontend/src/features/chat/workbench/sandbox/pluginManifest.ts new file mode 100644 index 00000000..70e0fbae --- /dev/null +++ b/frontend/src/features/chat/workbench/sandbox/pluginManifest.ts @@ -0,0 +1,126 @@ +// Plugin manifest types + shape validation. Deliberately dependency-free (no DOM, no +// api client) so it is unit-testable in node and shared by every manifest consumer: +// the admin upload, the drawer's temporary (session) load, and the renderer registry. + +/** The protocol version this host implements (manifest `protocol` field). A manifest + * without `protocol` defaults to 1 — the documented default covering every plugin + * installed before the field existed. Hosts skip plugins declaring any protocol other + * than 1 (they'd speak messages we don't) instead of half-rendering them. */ +export const PLUGIN_PROTOCOL = 1; + +// What a renderer ACCEPTS — declared by the renderer, evaluated cheaply by the host to +// build the candidate list (no plugin boot). A renderer is offered for a file only if +// its content passes this. The fine "can I really render this" judgment still happens +// inside the renderer at render time (it may reply cheers:unsupported). +// Hosts ignore unknown match keys, so this vocabulary can grow within protocol 1. +export interface RendererMatch { + /** Coarse format(s) by file extension: "markdown" | "json" | "yaml" | "toml" | "xml" + * | "text". A single string or a list; "text" is the catch-all (matches any path). */ + format?: string | string[]; + glob?: string; // optional path glob, e.g. "reviews/*.md" + requireAll?: string[]; // content must contain ALL of these substrings (e.g. md headings) + requireAny?: string[]; // content must contain AT LEAST ONE of these + /** Parsed STRUCTURED content (JSON or YAML) must have ALL these top-level keys. + * Successor of jsonHas — prefer this. */ + dataHas?: string[]; + /** Top-level shape of parsed structured content. The only way to claim "a JSON/YAML + * array" (dataHas can't — arrays have no keys). */ + dataKind?: "object" | "array"; + /** DEPRECATED alias of dataHas with frozen JSON-ONLY semantics (never matches yaml). + * Valid forever under protocol 1; new manifests should use dataHas. */ + jsonHas?: string[]; +} + +export interface PluginManifest { + id?: string; + title?: string; + /** Protocol version (see PLUGIN_PROTOCOL). Absent = 1. */ + protocol?: number; + // renderer plugins declare renderers (render/save protocol, SandboxRenderer). The old + // `panels` scenario-plugin protocol is retired; an installed plugin without `renderers` + // simply contributes nothing. + renderers?: { id: string; title: string; match?: RendererMatch }[]; +} + +export interface PluginMeta { + plugin_id: string; + title: string; + manifest: PluginManifest; + /** 'system' = official, seeded by the gateway release (API refuses PUT; deleting is + * allowed and sticks until a release ships a newer version). 'admin' = API-installed. */ + origin?: "admin" | "system"; + /** Inline sandbox bundle — present only on SESSION-loaded (temporary) plugins; the + * server never sends it (installed bundles are fetched lazily via fetchBundle). */ + bundle?: string; + /** Session-only marker (loaded via the drawer, never installed) — ⏱ in pickers. */ + transient?: boolean; +} + +/** Upper bound for a plugin bundle, in UTF-8 bytes. Client-side cap for session + * (temporary) loads, matching the cap the server will enforce at install (lands with + * server-side install validation). Also the sane ceiling for iframe srcDoc. */ +export const MAX_PLUGIN_BUNDLE_BYTES = 2 * 1024 * 1024; + +const utf8Bytes = (s: string): number => new TextEncoder().encode(s).length; + +// Per-key match validation — mirrors the server's validate_match +// (server/src/domain/workbench_plugins.rs). Known keys are type-checked; UNKNOWN keys +// are allowed — hosts ignore them, which is what lets the vocabulary grow within +// protocol 1. Session load and install are the same dev loop, so the two hosts must +// accept exactly the same manifests. +function validateMatch(m: unknown): string | null { + if (!m || typeof m !== "object" || Array.isArray(m)) return "renderer match must be an object"; + const mo = m as Record; + const f = mo.format; + if (f !== undefined) { + const ok = + typeof f === "string" || + (Array.isArray(f) && f.length > 0 && f.every((x) => typeof x === "string")); + if (!ok) return "match.format must be a string or a non-empty array of strings"; + } + if (mo.glob !== undefined && typeof mo.glob !== "string") return "match.glob must be a string"; + for (const key of ["requireAll", "requireAny", "dataHas", "jsonHas"] as const) { + const v = mo[key]; + if (v !== undefined && !(Array.isArray(v) && v.every((x) => typeof x === "string"))) + return `match.${key} must be an array of strings`; + } + if (mo.dataKind !== undefined && mo.dataKind !== "object" && mo.dataKind !== "array") + return 'match.dataKind must be "object" or "array"'; + return null; +} + +/** Shape-check a parsed plugin manifest. Returns an error message, or null when valid. + * Kept check-for-check in sync with the server's validate_manifest + * (server/src/domain/workbench_plugins.rs): a manifest that session-loads here must + * also install there, and vice versa. */ +export function validatePluginManifest(m: unknown): string | null { + if (!m || typeof m !== "object" || Array.isArray(m)) return "manifest must be a JSON object"; + const o = m as Record; + if (typeof o.id !== "string" || !o.id.trim()) return "manifest.id must be a non-empty string"; + if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(o.id)) + return "manifest.id must match ^[a-z0-9][a-z0-9._-]{0,63}$"; + if (typeof o.title !== "string" || !o.title.trim() || utf8Bytes(o.title) > 255) + return "manifest.title must be a non-empty string (max 255 bytes)"; + if (o.protocol !== undefined && o.protocol !== PLUGIN_PROTOCOL) + return `unsupported protocol ${JSON.stringify(o.protocol)} (this host implements protocol ${PLUGIN_PROTOCOL}; omit the field or set ${PLUGIN_PROTOCOL})`; + if ("panels" in o) + return "legacy scenario-plugin manifest (`panels`): that protocol is retired — declare renderers[] instead; see docs/developer/PLUGIN_DEVELOPMENT.md"; + if (!Array.isArray(o.renderers) || o.renderers.length === 0) + return "manifest.renderers must be a non-empty array"; + const seen = new Set(); + for (const r of o.renderers as unknown[]) { + if (!r || typeof r !== "object" || Array.isArray(r)) return "each renderer must be an object"; + const rr = r as Record; + if (typeof rr.id !== "string" || !rr.id.trim() || utf8Bytes(rr.id) > 64) + return "each renderer needs a non-empty string id (max 64 bytes)"; + if (seen.has(rr.id)) return `duplicate renderer id: ${rr.id}`; + seen.add(rr.id); + if (typeof rr.title !== "string" || !rr.title.trim()) + return "each renderer needs a non-empty string title"; + if (rr.match !== undefined) { + const err = validateMatch(rr.match); + if (err) return err; + } + } + return null; +} diff --git a/frontend/src/features/chat/workbench/yamlDoc.test.ts b/frontend/src/features/chat/workbench/yamlDoc.test.ts new file mode 100644 index 00000000..98140a0b --- /dev/null +++ b/frontend/src/features/chat/workbench/yamlDoc.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import { parse } from "yaml"; +import { applyEdits } from "./yamlDoc"; + +// NB: single space before inline comments — Document.toString() normalizes longer runs +// (the one whitespace liberty the round-trip takes; comments themselves are kept). +const BOARD = `# sprint board (bot-maintained — keep comments!) +title: Sprint 12 # visible name + +# the actual rows +rows: + - name: alpha + done: false + - name: beta + done: true + +notes: keep +`; + +describe("applyEdits — comment-preserving rewrite", () => { + it("round-trips unchanged data byte-for-byte", () => { + expect(applyEdits(BOARD, parse(BOARD))).toBe(BOARD); + }); + + it("keeps comments above and inline when a scalar changes", () => { + const data = parse(BOARD); + data.title = "Sprint 13"; + const out = applyEdits(BOARD, data); + expect(out).toContain("# sprint board (bot-maintained — keep comments!)"); + expect(out).toContain("# visible name"); + expect(out).toContain("# the actual rows"); + expect(out).toContain("Sprint 13"); + expect(parse(out).title).toBe("Sprint 13"); + }); + + it("keeps blank lines", () => { + const data = parse(BOARD); + data.notes = "changed"; + const out = applyEdits(BOARD, data); + expect(out).toContain("\n\nnotes: changed"); + }); + + it("edits nested map values in place", () => { + const data = parse(BOARD); + data.rows[0].done = true; + const out = applyEdits(BOARD, data); + expect(parse(out).rows[0].done).toBe(true); + expect(out).toContain("# the actual rows"); // sibling comment survives + expect(out).toContain("name: beta"); // untouched row intact + }); + + it("same-length array edits keep sibling comments", () => { + const src = "items:\n # first\n - a\n # second\n - b\n"; + const out = applyEdits(src, { items: ["a", "c"] }); + expect(out).toContain("# first"); + expect(parse(out).items).toEqual(["a", "c"]); + }); + + it("adds and deletes keys", () => { + const data = parse(BOARD); + delete data.notes; + data.owner = "wei"; + const out = applyEdits(BOARD, data); + const round = parse(out); + expect(round.notes).toBeUndefined(); + expect(round.owner).toBe("wei"); + expect(out).toContain("# visible name"); // unrelated comments survive + }); + + it("array push replaces the array node (documented loss) but data is correct", () => { + const data = parse(BOARD); + data.rows.push({ name: "gamma", done: false }); + const out = applyEdits(BOARD, data); + expect(parse(out).rows).toHaveLength(3); + expect(out).toContain("# sprint board (bot-maintained — keep comments!)"); // top comment survives + }); + + it("falls back to plain stringify on anchors/aliases", () => { + const src = "base: &b { x: 1 }\nuse: *b\n"; + const out = applyEdits(src, { base: { x: 2 }, use: { x: 1 } }); + expect(parse(out)).toEqual({ base: { x: 2 }, use: { x: 1 } }); + }); + + it("falls back on multi-document streams", () => { + const src = "a: 1\n---\nb: 2\n"; + const out = applyEdits(src, { a: 3 }); + expect(parse(out)).toEqual({ a: 3 }); + }); + + it("falls back on unparseable input and empty input", () => { + expect(parse(applyEdits(": : :", { a: 1 }))).toEqual({ a: 1 }); + expect(parse(applyEdits("", { a: 1 }))).toEqual({ a: 1 }); + }); + + it("replaces the root when its type changes", () => { + const out = applyEdits("a: 1\n", [1, 2]); + expect(parse(out)).toEqual([1, 2]); + }); + + it("a comment-only file keeps its header comments across the first save", () => { + // a seeded board is often just "# fill me in" — contents parse to null, and the + // first lens save used to fall into the stringify path and eat the header + const out = applyEdits("# fill me in\n# (the bot appends rows)\n", { rows: [{ name: "a" }] }); + expect(out).toContain("# fill me in"); + expect(out).toContain("# (the bot appends rows)"); + expect(parse(out)).toEqual({ rows: [{ name: "a" }] }); + }); + + it("does not refold long plain scalars it never touched", () => { + const long = "word ".repeat(30).trim(); // 149 chars, well past the default 80-col fold + const src = `text: ${long}\nother: 1\n`; + const out = applyEdits(src, { text: long, other: 2 }); + expect(out).toContain(`text: ${long}\n`); // still ONE line — no lineWidth reflow + expect(parse(out)).toEqual({ text: long, other: 2 }); + }); +}); diff --git a/frontend/src/features/chat/workbench/yamlDoc.ts b/frontend/src/features/chat/workbench/yamlDoc.ts new file mode 100644 index 00000000..88e75ab3 --- /dev/null +++ b/frontend/src/features/chat/workbench/yamlDoc.ts @@ -0,0 +1,67 @@ +import { parseDocument, stringify as yamlStringify, visit, type Document } from "yaml"; + +// Comment-preserving YAML rewrite. The workbench machine-rewrites board files (lenses +// save whole documents), and a naive parse -> stringify would eat every comment — the +// exact failure that forced `.workbench.json`'s `_doc` field. So instead of +// re-serializing, we PATCH the parsed Document (a CST that carries comments and blank +// lines) with a deep diff of old vs new data, and only the changed nodes are touched. +// +// Documented loss cases (correctness always wins over comment retention): +// - unparseable input, MULTI-document streams, and anchors/aliases fall back to a full +// re-stringify (patching through an alias would silently edit the anchor's other +// readers — bailing is safer); +// - an array whose LENGTH changed is replaced wholesale, so comments attached inside +// that array are lost (per-index diffing can't tell an insert from N edits). A +// SAME-length reorder is NOT detected — it is patched per index, so item comments +// keep their old positions and can end up annotating different items. + +function isPlainObj(v: unknown): v is Record { + return !!v && typeof v === "object" && !Array.isArray(v); +} + +type Path = (string | number)[]; + +function diffApply(doc: Document, path: Path, prev: unknown, next: unknown): void { + if (Object.is(prev, next)) return; + if (isPlainObj(prev) && isPlainObj(next)) { + for (const k of Object.keys(prev)) if (!(k in next)) doc.deleteIn([...path, k]); + for (const k of Object.keys(next)) { + if (!(k in prev)) doc.setIn([...path, k], next[k]); + else diffApply(doc, [...path, k], prev[k], next[k]); + } + return; + } + if (Array.isArray(prev) && Array.isArray(next) && prev.length === next.length) { + for (let i = 0; i < next.length; i++) diffApply(doc, [...path, i], prev[i], next[i]); + return; + } + // Scalar change, type change, or array resize: replace this node wholesale. + if (path.length === 0) doc.contents = doc.createNode(next) as never; + else doc.setIn(path, next); +} + +/** Serialize `next` by patching the Document parsed from `prevText`, preserving the + * comments and blank lines of everything that didn't change. */ +export function applyEdits(prevText: string, next: unknown): string { + if (!prevText.trim()) return yamlStringify(next); + // parseDocument, not parseAllDocuments: for a COMMENT-ONLY file the all-documents + // parser returns zero docs (forcing the lossy stringify fallback, eating a seeded + // "# fill me in" header on the first save), while parseDocument yields one document + // with null contents whose comments live on doc.commentBefore/doc.comment — those + // sit outside `contents` and so survive the wholesale root replacement below. A + // multi-doc stream surfaces as a MULTIPLE_DOCS error and bails like before. + const doc = parseDocument(prevText); + if (doc.errors.length > 0) return yamlStringify(next); // unparseable or multi-doc: bail (lossy) + let hasAlias = false; + visit(doc, { + Alias: () => { + hasAlias = true; + return visit.BREAK; + }, + }); + if (hasAlias) return yamlStringify(next); // aliases: bail (see header) + diffApply(doc, [], doc.toJS() as unknown, next); + // lineWidth 0 = never refold long plain scalars the diff didn't touch — the default + // (80) reflows them whenever ANYTHING else is edited, polluting git/bot-watched diffs. + return doc.toString({ lineWidth: 0 }); +} diff --git a/frontend/src/features/workbench/WorkbenchManager.tsx b/frontend/src/features/workbench/WorkbenchManager.tsx index dd51cad6..aabf97c8 100644 --- a/frontend/src/features/workbench/WorkbenchManager.tsx +++ b/frontend/src/features/workbench/WorkbenchManager.tsx @@ -146,9 +146,24 @@ export function WorkbenchManager() { > {p.title} + {p.origin === "system" && ( + + Official + + )} {p.plugin_id} + + +
+ + + diff --git a/server/migrations/0048_workbench_plugin_origin.sql b/server/migrations/0048_workbench_plugin_origin.sql new file mode 100644 index 00000000..5b2b2df9 --- /dev/null +++ b/server/migrations/0048_workbench_plugin_origin.sql @@ -0,0 +1,21 @@ +-- Official (gateway-seeded) workbench plugins: provenance + seed bookkeeping. +-- Note: 0009's header comment still shows the retired `panels` manifest shape; applied +-- migrations are checksummed and immutable, so the correction lives here — the current +-- manifest shape is { id, protocol, title, renderers:[{id,title,match}] }, validated on +-- install (docs/developer/PLUGIN_DEVELOPMENT.md). + +-- 'admin' = installed via the API by an admin (the only value that existed before). +-- 'system' = seeded from the gateway binary; the binary is its source of truth, so the +-- API rejects PUT on these rows (copy under a new id to customize). +ALTER TABLE workbench_plugins + ADD COLUMN origin VARCHAR(16) NOT NULL DEFAULT 'admin'; + +-- One row per official plugin ever seeded into THIS database: the embedded `version` +-- it has seen. Deleting the plugin row does NOT delete its state row — that is what +-- keeps an admin deletion sticky across restarts of the same release; only a release +-- shipping a HIGHER version re-seeds it. +CREATE TABLE workbench_official_plugin_state ( + plugin_id VARCHAR(64) PRIMARY KEY, + seeded_version INT NOT NULL, + seeded_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/server/src/api/enrollment.rs b/server/src/api/enrollment.rs index 6dd8fc22..d9ebcb3c 100644 --- a/server/src/api/enrollment.rs +++ b/server/src/api/enrollment.rs @@ -525,7 +525,7 @@ const GUIDANCE_TEMPLATE: &str = r#"You are being connected to a Cheers chat work CHEERS_ENROLL_CODE='{ENROLL_CODE}' bash <(curl -fsSL {INSTALL_URL}) -2. The script finds `cce-acp-connector` on PATH, or downloads the prebuilt binary — first from this Cheers server itself (no GitHub access needed), then from the GitHub release (https://github.com/haowei2000/Cheers/releases). Only if no downloaded binary runs on this platform, build it once from the Cheers repo (packages/cheers-acp-connector-rs: `cargo build --release`), then re-run with CHEERS_CONNECTOR_BIN=/path/to/target/release/cce-acp-connector. +2. The script finds `cce-acp-connector` on PATH, or downloads the prebuilt binary — first from this Cheers server itself (no GitHub access needed), then from the GitHub release (https://github.com/ElePerson/Cheers/releases). Only if no downloaded binary runs on this platform, build it once from the Cheers repo (packages/cheers-acp-connector-rs: `cargo build --release`), then re-run with CHEERS_CONNECTOR_BIN=/path/to/target/release/cce-acp-connector. 3. The bot's agent adapter (e.g. `claude-agent-acp` for Claude) must be installed too — the script auto-installs it via npm when missing (needs node/npm). If the script reports the adapter missing, install it (`npm install -g @agentclientprotocol/claude-agent-acp`) and run the finish command the script printed — do NOT re-run the one-liner, the code is single-use. diff --git a/server/src/api/workbench.rs b/server/src/api/workbench.rs index 7bd045a9..7c1ca705 100644 --- a/server/src/api/workbench.rs +++ b/server/src/api/workbench.rs @@ -46,6 +46,8 @@ pub async fn get_bundle( } /// PUT /api/v1/workbench/plugins/:id — install/update (admin). Body: { title, manifest, bundle }. +/// The manifest is validated against the protocol-1 shape (docs/developer/PLUGIN_DEVELOPMENT.md); +/// a broken/legacy plugin is rejected here with a named reason, never served half-working. pub async fn install_plugin( State(state): State, Extension(claims): Extension, @@ -53,30 +55,47 @@ pub async fn install_plugin( Json(body): Json, ) -> Result, AppError> { require_admin(&claims)?; - let title = body - .get("title") - .and_then(Value::as_str) - .unwrap_or(&plugin_id) - .to_string(); + // Official (gateway-seeded) plugins are managed by releases — an admin PUT here + // would be silently clobbered on the next version bump. Refuse instead. + if domain::workbench_plugins::get_origin(&state.db, &plugin_id).await?.as_deref() + == Some("system") + { + return Err(AppError::BadRequest( + "official plugin — managed by gateway releases; copy it under a new id to customize" + .into(), + )); + } let manifest = body .get("manifest") - .map(|m| m.to_string()) - .unwrap_or_else(|| "{}".to_string()); - let bundle = body - .get("bundle") - .and_then(Value::as_str) - .unwrap_or("") - .to_string(); + .ok_or_else(|| AppError::BadRequest("plugin manifest is required".into()))?; + domain::workbench_plugins::validate_manifest(&plugin_id, manifest) + .map_err(AppError::BadRequest)?; + let manifest_str = manifest.to_string(); + if manifest_str.len() > domain::workbench_plugins::MAX_MANIFEST_BYTES { + return Err(AppError::PayloadTooLarge("plugin manifest exceeds 64 KiB".into())); + } + let bundle = body.get("bundle").and_then(Value::as_str).unwrap_or(""); if bundle.trim().is_empty() { return Err(AppError::BadRequest("plugin bundle is required".into())); } + if bundle.len() > domain::workbench_plugins::MAX_BUNDLE_BYTES { + return Err(AppError::PayloadTooLarge("plugin bundle exceeds 2 MiB".into())); + } + // validate_manifest guarantees a non-empty manifest.title; it wins over body.title + // (the two came apart only because the upload UI sends both). + let title = manifest + .get("title") + .and_then(Value::as_str) + .unwrap_or(&plugin_id) + .to_string(); domain::workbench_plugins::install( &state.db, &plugin_id, &title, - &manifest, - &bundle, + &manifest_str, + bundle, &claims.sub, + "admin", ) .await?; Ok(Json(json!({ "plugin_id": plugin_id, "ok": true }))) diff --git a/server/src/config.rs b/server/src/config.rs index 136ebb99..fd914fe8 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -176,7 +176,7 @@ impl Config { connector_release_repo: env::var("CHEERS_CONNECTOR_RELEASE_REPO") .ok() .filter(|v| !v.trim().is_empty()) - .unwrap_or_else(|| "haowei2000/Cheers".into()), + .unwrap_or_else(|| "ElePerson/Cheers".into()), connector_release_version: env::var("CHEERS_CONNECTOR_RELEASE_VERSION") .ok() .filter(|v| !v.trim().is_empty()), diff --git a/server/src/domain/mentions.rs b/server/src/domain/mentions.rs index 36ca045a..4860cbd0 100644 --- a/server/src/domain/mentions.rs +++ b/server/src/domain/mentions.rs @@ -198,16 +198,26 @@ pub async fn resolve_mention_names( channel_id: Uuid, names: &[String], ) -> Result, MentionParseError> { - let mut mentions = Vec::new(); - for name in names { - if let Some(scope) = group_mention_scope(name) { - for mention in expand_group_mention(db, channel_id, scope).await? { - push_unique(&mut mentions, mention.member_id, mention.member_type); - } - continue; - } - let row = sqlx::query( - "SELECT cm.member_id, cm.member_type + if names.is_empty() { + return Ok(Vec::new()); + } + + // Plain (non-group) names resolve in ONE batched query instead of one round-trip + // per name — mirroring `validate_mention_ids`. Group tokens (@all/@bots/@humans) + // still expand per token via `expand_group_mention`. + let plain_names: Vec = names + .iter() + .filter(|n| group_mention_scope(n).is_none()) + .cloned() + .collect(); + + let name_to_mention: std::collections::HashMap = if plain_names.is_empty() { + std::collections::HashMap::new() + } else { + let rows = sqlx::query( + "SELECT cm.member_id, cm.member_type, + COALESCE(u.username, ba.username) AS username, + COALESCE(u.display_name, ba.display_name) AS display_name FROM channel_memberships cm LEFT JOIN users u ON u.user_id = cm.member_id AND cm.member_type = 'user' @@ -215,34 +225,62 @@ pub async fn resolve_mention_names( ON ba.bot_id = cm.member_id AND cm.member_type = 'bot' WHERE cm.channel_id = $1 AND ( - (cm.member_type = 'user' AND (u.username = $2 OR u.display_name = $2)) + (cm.member_type = 'user' AND (u.username = ANY($2) OR u.display_name = ANY($2))) OR - (cm.member_type = 'bot' AND (ba.username = $2 OR ba.display_name = $2)) - ) - LIMIT 1", + (cm.member_type = 'bot' AND (ba.username = ANY($2) OR ba.display_name = ANY($2))) + )", ) .bind(channel_id.to_string()) - .bind(name) - .fetch_optional(db) + .bind(&plain_names) + .fetch_all(db) .await?; - let Some(row) = row else { - return Err(MentionParseError::NameNotFound { name: name.clone() }); - }; - - let member_id: Uuid = row - .try_get::("member_id") - .ok() - .and_then(|s| s.parse().ok()) - .ok_or_else(|| MentionParseError::NameNotFound { name: name.clone() })?; + // Key every returned member by both its username and display_name. First row + // wins on a name collision — ambiguous names were already resolved + // arbitrarily under the old per-name `LIMIT 1`. + let mut map: std::collections::HashMap = + std::collections::HashMap::new(); + for row in &rows { + let Some(member_id) = row + .try_get::("member_id") + .ok() + .and_then(|s| s.parse::().ok()) + else { + continue; + }; + let member_type = match row.try_get::("member_type").as_deref() { + Ok("bot") => MemberType::Bot, + Ok("user") => MemberType::User, + _ => continue, + }; + let mention = Mention { + member_id, + member_type, + }; + for key in ["username", "display_name"] { + if let Ok(Some(value)) = row.try_get::, _>(key) { + map.entry(value).or_insert(mention); + } + } + } + map + }; - let member_type = match row.try_get::("member_type").as_deref() { - Ok("bot") => MemberType::Bot, - Ok("user") => MemberType::User, - _ => return Err(MentionParseError::NameNotFound { name: name.clone() }), + // Second pass in input order: groups expand in place; plain names resolve from + // the batch map. The first unresolved name errors — preserving the old + // "first not found in order" semantics. + let mut mentions = Vec::new(); + for name in names { + if let Some(scope) = group_mention_scope(name) { + for mention in expand_group_mention(db, channel_id, scope).await? { + push_unique(&mut mentions, mention.member_id, mention.member_type); + } + continue; + } + let Some(mention) = name_to_mention.get(name) else { + return Err(MentionParseError::NameNotFound { name: name.clone() }); }; - - push_unique(&mut mentions, member_id, member_type); + push_unique(&mut mentions, mention.member_id, mention.member_type); } Ok(mentions) } diff --git a/server/src/domain/mod.rs b/server/src/domain/mod.rs index bfac1d7c..70acae88 100644 --- a/server/src/domain/mod.rs +++ b/server/src/domain/mod.rs @@ -24,6 +24,7 @@ pub mod stt_settings; pub mod task_chains; pub mod trace; pub mod usage_store; +pub mod workbench_official; pub mod workbench_plugins; pub mod workbench_templates; pub mod workspaces; diff --git a/server/src/domain/workbench_official.rs b/server/src/domain/workbench_official.rs new file mode 100644 index 00000000..56fdda0e --- /dev/null +++ b/server/src/domain/workbench_official.rs @@ -0,0 +1,215 @@ +//! Official workbench plugins — embedded in the gateway binary (include_str!) and +//! seeded into `workbench_plugins` at startup, so a fresh install has a useful +//! renderer set with zero admin steps. +//! +//! Policy (pure `decide`, unit-tested): +//! - the BINARY is the source of truth for official plugins (`origin='system'`); +//! - an admin deletion is sticky across restarts of the same release — only a release +//! shipping a HIGHER manifest `version` re-seeds that plugin; +//! - a row an admin claimed under the same id (`origin='admin'`) is never overwritten. +//! +//! Sources live in server/assets/workbench-plugins/*.plugin.html (self-contained, +//! also hand-uploadable for testing). A unit test walks every embedded bundle through +//! extract_manifest + validate_manifest, so CI cannot ship a malformed official plugin. + +use serde_json::Value; +use sqlx::PgPool; +use tracing::{info, warn}; + +use super::workbench_plugins; + +/// The official set (one self-contained .html per plugin). +pub const OFFICIAL: &[&str] = &[ + include_str!("../../assets/workbench-plugins/cheers-checklist.plugin.html"), + include_str!("../../assets/workbench-plugins/cheers-table.plugin.html"), + include_str!("../../assets/workbench-plugins/cheers-kanban-md.plugin.html"), + include_str!("../../assets/workbench-plugins/cheers-frontmatter.plugin.html"), +]; + +/// Extract the embedded `#cheers-plugin` manifest from a bundle. A literal substring +/// scan is enough — these files are ours and CI-verified (see module tests). +pub fn extract_manifest(html: &str) -> Result { + const OPEN: &str = r#"") + .ok_or("unterminated manifest script")? + + start; + serde_json::from_str(&html[start..end]).map_err(|e| format!("manifest is not valid JSON: {e}")) +} + +/// What the seeder should do for one official plugin. +#[derive(Debug, PartialEq, Eq)] +pub enum Action { + Install, + Noop, + SkipAdminClaimed, +} + +/// Pure seeding policy. `seeded_version` = this DB's state row (None = never seeded: +/// fresh DB or pre-feature DB); `existing_origin` = the current plugin row's origin +/// (None = no row, e.g. admin-deleted). +pub fn decide( + embedded_version: i64, + seeded_version: Option, + existing_origin: Option<&str>, +) -> Action { + if existing_origin == Some("admin") { + // The id belongs to an admin-installed plugin (claimed before this feature, or + // re-claimed after deleting the official one). Their row, not ours. + return Action::SkipAdminClaimed; + } + match seeded_version { + None => Action::Install, + Some(v) if embedded_version > v => Action::Install, // upgrade — restores deletions too + Some(_) => Action::Noop, // same/older release: an admin deletion stays deleted + } +} + +/// Seed the official set. Called once at gateway startup (after migrations). Upserts +/// are idempotent; per-plugin problems are logged and skipped, never fatal. +pub async fn seed(db: &PgPool) -> Result<(), sqlx::Error> { + for bundle in OFFICIAL { + let manifest = match extract_manifest(bundle) { + Ok(m) => m, + Err(e) => { + warn!(error = %e, "official plugin bundle is malformed; skipping"); + continue; + } + }; + let id = manifest + .get("id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + if let Err(e) = workbench_plugins::validate_manifest(&id, &manifest) { + warn!(plugin = %id, error = %e, "official plugin failed manifest validation; skipping"); + continue; + } + let version = manifest.get("version").and_then(Value::as_i64).unwrap_or(1); + // Stored in an INT column — a wrapping `as i32` on an out-of-range version would + // go negative and re-seed on every startup (resurrecting admin deletions). + let Ok(version_i32) = i32::try_from(version) else { + warn!(plugin = %id, version, "official plugin version does not fit i32; skipping"); + continue; + }; + + let seeded: Option = sqlx::query_scalar( + "SELECT seeded_version FROM workbench_official_plugin_state WHERE plugin_id = $1", + ) + .bind(&id) + .fetch_optional(db) + .await?; + let origin: Option = + sqlx::query_scalar("SELECT origin FROM workbench_plugins WHERE plugin_id = $1") + .bind(&id) + .fetch_optional(db) + .await?; + + match decide(version, seeded.map(i64::from), origin.as_deref()) { + Action::Install => { + let title = manifest.get("title").and_then(Value::as_str).unwrap_or(&id); + workbench_plugins::install( + db, + &id, + title, + &manifest.to_string(), + bundle, + "system", + "system", + ) + .await?; + sqlx::query( + "INSERT INTO workbench_official_plugin_state (plugin_id, seeded_version) + VALUES ($1, $2) + ON CONFLICT (plugin_id) + DO UPDATE SET seeded_version = $2, seeded_at = NOW()", + ) + .bind(&id) + .bind(version_i32) + .execute(db) + .await?; + info!(plugin = %id, version, "official workbench plugin seeded"); + } + Action::Noop => {} + Action::SkipAdminClaimed => { + // Documented steady state (admin claimed the id), not a problem — info, not warn. + info!(plugin = %id, "official plugin id is admin-claimed; leaving their row alone"); + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// CI gate: every embedded official bundle must carry a valid protocol-1 manifest. + #[test] + fn every_official_bundle_is_valid() { + assert!(!OFFICIAL.is_empty()); + for bundle in OFFICIAL { + let m = extract_manifest(bundle).expect("manifest extracts"); + let id = m.get("id").and_then(Value::as_str).expect("has id"); + assert!( + id.starts_with("cheers-"), + "official ids use the cheers- prefix: {id}" + ); + workbench_plugins::validate_manifest(id, &m).expect("manifest validates"); + let version = m.get("version").and_then(Value::as_i64).unwrap_or_else(|| { + panic!("official manifests carry an integer version (drives re-seeding): {id}") + }); + assert!( + i32::try_from(version).is_ok(), + "official manifest version must fit the INT seeded_version column: {id}" + ); + } + } + + #[test] + fn official_ids_are_unique() { + let mut ids: Vec = OFFICIAL + .iter() + .map(|b| { + extract_manifest(b).unwrap()["id"] + .as_str() + .unwrap() + .to_string() + }) + .collect(); + ids.sort(); + ids.dedup(); + assert_eq!(ids.len(), OFFICIAL.len()); + } + + #[test] + fn decide_fresh_db_installs() { + assert_eq!(decide(1, None, None), Action::Install); + } + + #[test] + fn decide_same_version_is_noop_and_keeps_deletions() { + // present at same version + assert_eq!(decide(1, Some(1), Some("system")), Action::Noop); + // admin deleted it; same release restarts must NOT resurrect it + assert_eq!(decide(1, Some(1), None), Action::Noop); + // downgrade (rollback deploy): leave the newer data alone + assert_eq!(decide(1, Some(2), Some("system")), Action::Noop); + } + + #[test] + fn decide_version_bump_reinstalls_even_after_deletion() { + assert_eq!(decide(2, Some(1), Some("system")), Action::Install); + assert_eq!(decide(2, Some(1), None), Action::Install); // restore deleted + } + + #[test] + fn decide_never_touches_admin_claimed_ids() { + assert_eq!(decide(2, Some(1), Some("admin")), Action::SkipAdminClaimed); + assert_eq!(decide(1, None, Some("admin")), Action::SkipAdminClaimed); + } +} diff --git a/server/src/domain/workbench_plugins.rs b/server/src/domain/workbench_plugins.rs index f6b32164..900e56b3 100644 --- a/server/src/domain/workbench_plugins.rs +++ b/server/src/domain/workbench_plugins.rs @@ -1,14 +1,131 @@ //! Server-level workbench plugins: install / list / fetch-bundle / delete. //! A plugin is admin-installed and global (all channels see it). The bundle is opaque -//! to the server — it runs sandboxed in the browser. +//! to the server — it runs sandboxed in the browser. The MANIFEST is not opaque: +//! installs validate it against the protocol-1 shape (docs/developer/PLUGIN_DEVELOPMENT.md) +//! so a broken or legacy plugin is rejected with a named reason instead of being served +//! to every channel and failing there. use serde_json::{json, Value}; use sqlx::{PgPool, Row}; +/// Hard cap for a plugin bundle (also the sane ceiling for an iframe srcDoc). The +/// frontend mirrors this on the session (temporary) load path. +pub const MAX_BUNDLE_BYTES: usize = 2 * 1024 * 1024; +/// Hard cap for the serialized manifest. +pub const MAX_MANIFEST_BYTES: usize = 64 * 1024; +/// The plugin protocol this server accepts. A manifest without `protocol` defaults to 1 +/// (the documented default covering pre-field installs). +pub const PLUGIN_PROTOCOL: i64 = 1; + +fn plugin_id_ok(id: &str) -> bool { + // ^[a-z0-9][a-z0-9._-]{0,63}$ (DB column is VARCHAR(64)) + let b = id.as_bytes(); + if b.is_empty() || b.len() > 64 { + return false; + } + (b[0].is_ascii_lowercase() || b[0].is_ascii_digit()) + && b.iter().all(|c| { + c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, b'.' | b'_' | b'-') + }) +} + +fn validate_match(m: &Value) -> Result<(), String> { + let mo = m.as_object().ok_or("renderer match must be an object")?; + // Known keys are type-checked; UNKNOWN keys are allowed — hosts ignore them, which + // is what lets the match vocabulary grow within protocol 1. + if let Some(f) = mo.get("format") { + let ok = f.is_string() + || f.as_array() + .is_some_and(|a| !a.is_empty() && a.iter().all(Value::is_string)); + if !ok { + return Err("match.format must be a string or a non-empty array of strings".into()); + } + } + if let Some(g) = mo.get("glob") { + if !g.is_string() { + return Err("match.glob must be a string".into()); + } + } + for key in ["requireAll", "requireAny", "dataHas", "jsonHas"] { + if let Some(v) = mo.get(key) { + if !v.as_array().is_some_and(|a| a.iter().all(Value::is_string)) { + return Err(format!("match.{key} must be an array of strings")); + } + } + } + if let Some(k) = mo.get("dataKind") { + if !matches!(k.as_str(), Some("object" | "array")) { + return Err("match.dataKind must be \"object\" or \"array\"".into()); + } + } + Ok(()) +} + +/// Install-time manifest validation (protocol 1). Pure; returns the rejection reason. +pub fn validate_manifest(plugin_id: &str, manifest: &Value) -> Result<(), String> { + if !plugin_id_ok(plugin_id) { + return Err("plugin id must match ^[a-z0-9][a-z0-9._-]{0,63}$".into()); + } + let obj = manifest + .as_object() + .ok_or("manifest must be a JSON object")?; + match obj.get("id").and_then(Value::as_str) { + Some(id) if id == plugin_id => {} + Some(_) => return Err("manifest.id must equal the plugin id in the URL".into()), + None => return Err("manifest.id must be a non-empty string".into()), + } + let title = obj.get("title").and_then(Value::as_str).unwrap_or(""); + if title.trim().is_empty() || title.len() > 255 { + return Err("manifest.title must be a non-empty string (max 255 bytes)".into()); + } + if let Some(p) = obj.get("protocol") { + // Accept any JSON NUMBER equal to 1 (so `1.0` too): the frontend cannot be + // stricter — JSON.parse collapses 1.0 to 1 — and the two hosts must accept + // the same manifests. Strings ("1") are still not versions. + let is_v1 = + p.as_i64() == Some(PLUGIN_PROTOCOL) || p.as_f64() == Some(PLUGIN_PROTOCOL as f64); + if !is_v1 { + return Err(format!( + "unsupported protocol {p} (this server accepts protocol {PLUGIN_PROTOCOL}; omit the field or set {PLUGIN_PROTOCOL})" + )); + } + } + if obj.contains_key("panels") { + return Err( + "legacy scenario-plugin manifest (`panels`): that protocol is retired — declare renderers[] instead; see docs/developer/PLUGIN_DEVELOPMENT.md" + .into(), + ); + } + let renderers = obj + .get("renderers") + .and_then(Value::as_array) + .filter(|a| !a.is_empty()) + .ok_or("manifest.renderers must be a non-empty array")?; + let mut seen = std::collections::HashSet::new(); + for r in renderers { + let ro = r.as_object().ok_or("each renderer must be an object")?; + let rid = ro.get("id").and_then(Value::as_str).unwrap_or(""); + if rid.trim().is_empty() || rid.len() > 64 { + return Err("each renderer needs a non-empty string id (max 64 bytes)".into()); + } + if !seen.insert(rid) { + return Err(format!("duplicate renderer id: {rid}")); + } + let rtitle = ro.get("title").and_then(Value::as_str).unwrap_or(""); + if rtitle.trim().is_empty() { + return Err("each renderer needs a non-empty string title".into()); + } + if let Some(m) = ro.get("match") { + validate_match(m)?; + } + } + Ok(()) +} + /// List installed plugins (metadata only — manifest parsed, bundle omitted to stay light). pub async fn list(db: &PgPool) -> Result, sqlx::Error> { let rows = sqlx::query( - "SELECT plugin_id, title, manifest FROM workbench_plugins ORDER BY installed_at DESC", + "SELECT plugin_id, title, manifest, origin FROM workbench_plugins ORDER BY installed_at DESC", ) .fetch_all(db) .await?; @@ -24,11 +141,20 @@ pub async fn list(db: &PgPool) -> Result, sqlx::Error> { "plugin_id": r.try_get::("plugin_id").unwrap_or_default(), "title": r.try_get::("title").unwrap_or_default(), "manifest": manifest, + "origin": r.try_get::("origin").unwrap_or_else(|_| "admin".into()), }) }) .collect()) } +/// The origin of an installed plugin ('admin' | 'system'), or None if not installed. +pub async fn get_origin(db: &PgPool, plugin_id: &str) -> Result, sqlx::Error> { + sqlx::query_scalar("SELECT origin FROM workbench_plugins WHERE plugin_id = $1") + .bind(plugin_id) + .fetch_optional(db) + .await +} + /// The sandboxed bundle (HTML/JS) for one plugin, or None if not installed. pub async fn get_bundle(db: &PgPool, plugin_id: &str) -> Result, sqlx::Error> { sqlx::query_scalar("SELECT bundle FROM workbench_plugins WHERE plugin_id = $1") @@ -37,7 +163,8 @@ pub async fn get_bundle(db: &PgPool, plugin_id: &str) -> Result, .await } -/// Install or update a plugin (admin only — enforced at the API layer). +/// Install or update a plugin. Admin-only on the API path (which also refuses to touch +/// `origin='system'` rows); the startup seeder calls this with origin='system'. pub async fn install( db: &PgPool, plugin_id: &str, @@ -45,18 +172,20 @@ pub async fn install( manifest: &str, bundle: &str, installed_by: &str, + origin: &str, ) -> Result<(), sqlx::Error> { sqlx::query( - "INSERT INTO workbench_plugins (plugin_id, title, manifest, bundle, installed_by) - VALUES ($1, $2, $3, $4, $5) + "INSERT INTO workbench_plugins (plugin_id, title, manifest, bundle, installed_by, origin) + VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (plugin_id) - DO UPDATE SET title = $2, manifest = $3, bundle = $4, updated_at = NOW()", + DO UPDATE SET title = $2, manifest = $3, bundle = $4, origin = $6, updated_at = NOW()", ) .bind(plugin_id) .bind(title) .bind(manifest) .bind(bundle) .bind(installed_by) + .bind(origin) .execute(db) .await?; Ok(()) @@ -69,3 +198,136 @@ pub async fn delete(db: &PgPool, plugin_id: &str) -> Result { .await?; Ok(res.rows_affected()) } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn ok_manifest() -> Value { + json!({ + "id": "md-checklist", + "title": "Markdown checklist", + "protocol": 1, + "renderers": [ + { "id": "checklist", "title": "Checklist", + "match": { "format": "markdown", "requireAny": ["- [ ]", "- [x]"] } } + ] + }) + } + + #[test] + fn accepts_well_formed_manifest() { + assert_eq!(validate_manifest("md-checklist", &ok_manifest()), Ok(())); + } + + #[test] + fn protocol_absent_defaults_to_1() { + let mut m = ok_manifest(); + m.as_object_mut().unwrap().remove("protocol"); + assert_eq!(validate_manifest("md-checklist", &m), Ok(())); + } + + #[test] + fn rejects_unsupported_protocol() { + let mut m = ok_manifest(); + m["protocol"] = json!(2); + assert!(validate_manifest("md-checklist", &m) + .unwrap_err() + .contains("protocol")); + m["protocol"] = json!("1"); // strings are not versions + assert!(validate_manifest("md-checklist", &m) + .unwrap_err() + .contains("protocol")); + } + + #[test] + fn accepts_any_json_number_equal_to_1_as_protocol() { + // JSON.parse collapses 1.0 to 1, so the frontend cannot distinguish them — + // installs must not be stricter than session loads. + let mut m = ok_manifest(); + m["protocol"] = json!(1.0); + assert_eq!(validate_manifest("md-checklist", &m), Ok(())); + m["protocol"] = json!(1.5); + assert!(validate_manifest("md-checklist", &m) + .unwrap_err() + .contains("protocol")); + m["protocol"] = json!("2"); + assert!(validate_manifest("md-checklist", &m) + .unwrap_err() + .contains("protocol")); + } + + #[test] + fn rejects_legacy_panels_manifest() { + let mut m = ok_manifest(); + m["panels"] = json!([{ "id": "notes", "title": "Notes" }]); + assert!(validate_manifest("md-checklist", &m) + .unwrap_err() + .contains("legacy")); + } + + #[test] + fn rejects_id_mismatch_and_bad_ids() { + assert!(validate_manifest("other-id", &ok_manifest()) + .unwrap_err() + .contains("equal")); + assert!(validate_manifest("Bad_Upper", &ok_manifest()).is_err()); + assert!(validate_manifest("-leading-dash", &ok_manifest()).is_err()); + assert!(validate_manifest(&"x".repeat(65), &ok_manifest()).is_err()); + } + + #[test] + fn rejects_missing_or_empty_renderers() { + let mut m = ok_manifest(); + m["renderers"] = json!([]); + assert!(validate_manifest("md-checklist", &m) + .unwrap_err() + .contains("renderers")); + m.as_object_mut().unwrap().remove("renderers"); + assert!(validate_manifest("md-checklist", &m) + .unwrap_err() + .contains("renderers")); + } + + #[test] + fn rejects_duplicate_or_malformed_renderers() { + let mut m = ok_manifest(); + m["renderers"] = json!([ + { "id": "dup", "title": "One" }, + { "id": "dup", "title": "Two" } + ]); + assert!(validate_manifest("md-checklist", &m) + .unwrap_err() + .contains("duplicate")); + m["renderers"] = json!([{ "title": "no id" }]); + assert!(validate_manifest("md-checklist", &m) + .unwrap_err() + .contains("id")); + m["renderers"] = json!([{ "id": "r" }]); + assert!(validate_manifest("md-checklist", &m) + .unwrap_err() + .contains("title")); + } + + #[test] + fn type_checks_known_match_keys_but_ignores_unknown_ones() { + let mut m = ok_manifest(); + m["renderers"] = json!([{ "id": "r", "title": "R", + "match": { "format": ["json", "yaml"], "dataKind": "array", + "dataHas": ["rows"], "futureKey": { "anything": true } } }]); + assert_eq!(validate_manifest("md-checklist", &m), Ok(())); + m["renderers"] = json!([{ "id": "r", "title": "R", "match": { "dataKind": "tuple" } }]); + assert!(validate_manifest("md-checklist", &m) + .unwrap_err() + .contains("dataKind")); + m["renderers"] = json!([{ "id": "r", "title": "R", "match": { "format": [] } }]); + assert!(validate_manifest("md-checklist", &m) + .unwrap_err() + .contains("format")); + m["renderers"] = json!([{ "id": "r", "title": "R", "match": { "requireAll": "x" } }]); + assert!(validate_manifest("md-checklist", &m) + .unwrap_err() + .contains("requireAll")); + } +} diff --git a/server/src/gateway/mod.rs b/server/src/gateway/mod.rs index b0b4b071..d77fd861 100644 --- a/server/src/gateway/mod.rs +++ b/server/src/gateway/mod.rs @@ -12,6 +12,7 @@ pub mod reclaimer; #[allow(dead_code)] pub mod redis_registry; pub mod registry; +pub mod resource_effects; pub mod stream; pub mod transcription_worker; pub mod workspace_rpc; diff --git a/server/src/gateway/resource_effects.rs b/server/src/gateway/resource_effects.rs new file mode 100644 index 00000000..406bfc7e --- /dev/null +++ b/server/src/gateway/resource_effects.rs @@ -0,0 +1,157 @@ +//! Transport-neutral resource dispatch — `resource::dispatch` **plus the side effects +//! it structurally cannot perform**. +//! +//! [`resource::dispatch`] is deliberately db-only: it takes a `PgPool` and nothing else. +//! That keeps the resource layer transport-agnostic and easy to test, but it also means +//! a successful write there is only *half* the operation. Creating a message must also +//! push the live `message` frame to the channel and trigger any @mentioned bots; leaving +//! a channel must re-broadcast presence; a workspace write must nudge open Desk views. +//! All of those need `AppState` (fanout / stream_registry / bot_locator), which +//! `dispatch` does not have. +//! +//! Historically each of those effects was patched in at the bot-bridge WS boundary, so +//! the rule "creating a message ⇒ broadcast + trigger" existed only at that one call +//! site. That is a trap for the next transport: a Streamable-HTTP MCP endpoint calling +//! `resource::dispatch` directly would persist the row, return 200, and **silently** skip +//! the broadcast and the bot trigger — no error, just a channel that never updates and an +//! @mentioned bot that never wakes. Bugs of omission don't announce themselves. +//! +//! [`dispatch_with_effects`] is the seam that closes it: **every** transport routes +//! resource frames through here and gets the complete semantics of each verb. Adding a +//! new effect means adding it once, here, rather than in every boundary. +//! +//! Not covered: `workspace.read`, which is an alternate *dispatch* (brokering a read of +//! another bot's machine) rather than a post-dispatch effect, and stays at the bot-bridge +//! boundary. It fails loudly (`UNKNOWN_RESOURCE`) elsewhere rather than silently. + +use serde_json::{json, Value}; +use uuid::Uuid; + +use crate::{ + app_state::AppState, + gateway::{realtime::frame::WireFrame, stream::broadcast_and_trigger_created_message}, + resource::{self, Principal}, +}; + +/// Dispatch a `resource_req` frame and apply the post-write effects the db-only +/// resource layer can't. Returns the `resource_res` frame verbatim from +/// [`resource::dispatch`] — effects never change the reply, and never fail it. +pub async fn dispatch_with_effects(state: &AppState, principal: Principal, frame: &Value) -> Value { + let resp = resource::dispatch(&state.db, principal, frame).await; + + // Effects run only for a write that actually landed. + if resp.get("ok").and_then(Value::as_bool) != Some(true) { + return resp; + } + match frame.get("resource").and_then(Value::as_str) { + Some("channel.messages.create") => { + if let Some(created) = resp.get("data") { + spawn_created_message_effects(state, principal.principal_id, created.clone()); + } + } + // A bot left → the member set changed, so re-broadcast the full presence. + Some("channel.leave") => { + if let Some(cid) = frame + .get("params") + .and_then(|p| p.get("channel_id")) + .and_then(Value::as_str) + .and_then(|s| Uuid::parse_str(s).ok()) + { + crate::gateway::presence::broadcast_presence(state, cid).await; + } + } + // Live Desk: a mutating `fs.*` verb changed the channel's workspace files — + // nudge any open Desk view to re-pull. Data-free: clients re-fetch through + // their own authz'd fs.ls/fs.read. Board name "files" is a cross-slice contract. + Some("fs.write" | "fs.edit" | "fs.append" | "fs.rm" | "fs.mv") => { + if let Some(cid) = resp + .get("data") + .and_then(|d| d.get("channel_id")) + .and_then(Value::as_str) + .and_then(|s| s.parse::().ok()) + { + let wire = WireFrame::channel( + cid, + "board_signal", + json!({ "channel_id": cid, "board": "files" }), + ); + state.fanout.broadcast_channel(cid, wire).await; + } + } + // The agent wrote its own status card (the set_status tool). Persisted by + // dispatch; the live member_updated push to every channel it's in needs fanout. + Some("bot.status.write") => { + let bot_id = principal.principal_id.to_string(); + crate::api::bots::broadcast_bot_member_update(state, &bot_id).await; + audit_status_write(state, &bot_id, frame).await; + } + _ => {} + } + resp +} + +/// Broadcast the new message and trigger any @mentioned bots — **off the caller's +/// critical path**. The row is already committed, so the `resource_res` returns without +/// waiting on a Redis PUBLISH or the next bot@bot hop. Ordering is safe: the frontend +/// re-sorts incoming `message` frames by `channel_seq` (`ChannelView.upsertMessage`), so +/// a broadcast that lands after the reply cannot misorder the channel. +fn spawn_created_message_effects(state: &AppState, author_bot_id: Uuid, created: Value) { + let registry = state.stream_registry.clone(); + let fanout = state.fanout.clone(); + let db = state.db.clone(); + let bot_locator = state.bot_locator.clone(); + tokio::spawn(async move { + let started = std::time::Instant::now(); + let _ = broadcast_and_trigger_created_message( + ®istry, + &fanout, + &db, + &bot_locator, + author_bot_id, + &created, + ) + .await; + tracing::debug!( + elapsed_ms = started.elapsed().as_millis() as u64, + "post_message broadcast+trigger complete (off critical path)" + ); + }); +} + +/// Record a self-status write to `acp_event_log` so status changes are auditable +/// alongside every other ACP event. Summary ONLY — which fields were set and their char +/// lengths, NEVER the text itself. `channel_id` is NULL (a self-card write isn't +/// channel-scoped); `session_id` rides the frame if present. Best-effort: a log-write +/// failure must never disrupt the live agent. +async fn audit_status_write(state: &AppState, bot_id: &str, frame: &Value) { + let params = frame.get("params"); + let field_len = |key: &str| { + params + .and_then(|p| p.get(key)) + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.chars().count()) + }; + let audit_payload = json!({ + "status_text_len": field_len("status_text"), + "status_emoji_len": field_len("status_emoji"), + "info_len": field_len("info"), + }); + if let Err(err) = sqlx::query( + "INSERT INTO acp_event_log (id, bot_id, channel_id, session_id, name, home, payload) + VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)", + ) + .bind(Uuid::new_v4().to_string()) + .bind(bot_id) + .bind(Option::<&str>::None) + .bind(frame.get("session_id").and_then(Value::as_str)) + .bind("bot.status.write") + .bind("cheers") + .bind(audit_payload.to_string()) + .execute(&state.db) + .await + { + tracing::warn!(bot_id = %bot_id, error = %err, "bot.status.write audit log write failed"); + } +} diff --git a/server/src/gateway/ws/agent_bridge.rs b/server/src/gateway/ws/agent_bridge.rs index 5551738a..6efe892a 100644 --- a/server/src/gateway/ws/agent_bridge.rs +++ b/server/src/gateway/ws/agent_bridge.rs @@ -21,10 +21,7 @@ use crate::{ domain::{acp_capability, channel_seq, sessions}, gateway::{ realtime::frame::WireFrame, - stream::{ - broadcast_and_trigger_created_message, handle_delta, handle_done, handle_send, - handle_session_update, - }, + stream::{handle_delta, handle_done, handle_send, handle_session_update}, }, infra::crypto::hash_bot_token, infra::db::models::MESSAGE_SCHEMA_VERSION, @@ -792,117 +789,23 @@ async fn handle_data_frame(frame: &Value, state: &AppState, bot: &BotInfo, socke "resource_req" => { // `workspace.read` is a REMOTE-workspace pull (unified context model, P3): // a reader bot resolves ANOTHER bot's workspace file under its own read - // grant. It can't go through the db-only `resource::dispatch` — brokering - // the read needs `AppState` (the identity-free workspace RPC + presence). - // Intercept here at the WS boundary; every other verb falls through. + // grant. Brokering it needs the identity-free workspace RPC, so it is an + // alternate dispatch rather than a plain resource verb — intercepted here. + // + // Every other verb goes through `dispatch_with_effects`, never the db-only + // `resource::dispatch`: the live broadcast / bot@bot trigger / presence / + // Desk signal a successful write implies all live there, so any transport + // gets the complete semantics (see gateway::resource_effects). let resp = if frame.get("resource").and_then(Value::as_str) == Some("workspace.read") { broker_workspace_read(state, bot.bot_id, frame).await } else { - resource::dispatch(&state.db, resource::Principal::bot(bot.bot_id), frame).await - }; - // bot 主动 post_message(channel.messages.create)落库后,resource::dispatch 只有 - // db、无 fanout/registry/bot_locator,故在此 WS 边界补 live 广播 + bot@bot 触发。 - if frame.get("resource").and_then(Value::as_str) == Some("channel.messages.create") - && resp.get("ok").and_then(Value::as_bool) == Some(true) - { - if let Some(created) = resp.get("data") { - // Fire-and-forget: the returned trigger handle is intentionally - // dropped so the next bot@bot hop runs off this read loop. - let _ = broadcast_and_trigger_created_message( - &state.stream_registry, - &state.fanout, - &state.db, - &state.bot_locator, - bot.bot_id, - created, - ) - .await; - } - } - // bot 退出频道成功 → 成员集变了,在 WS 边界补发全量 presence - //(resource::dispatch 只有 db,拿不到 fanout/bot_locator)。 - if frame.get("resource").and_then(Value::as_str) == Some("channel.leave") - && resp.get("ok").and_then(Value::as_bool) == Some(true) - { - if let Some(cid) = frame - .get("params") - .and_then(|p| p.get("channel_id")) - .and_then(Value::as_str) - .and_then(|s| Uuid::parse_str(s).ok()) - { - crate::gateway::presence::broadcast_presence(state, cid).await; - } - } - // Live Desk: a successful mutating `fs.*` verb changed the channel's - // workspace files — nudge any open Desk view to re-pull. Mirrors the - // channel.messages.create / channel.leave live-broadcast pattern above: - // resource::dispatch only holds `db`, so the fanout tick is emitted here - // at the WS boundary. Data-free — clients re-fetch via their own authz'd - // fs.ls/fs.read. board name "files" (cross-slice contract). - if matches!( - frame.get("resource").and_then(Value::as_str), - Some("fs.write" | "fs.edit" | "fs.append" | "fs.rm" | "fs.mv") - ) && resp.get("ok").and_then(Value::as_bool) == Some(true) - { - if let Some(cid) = resp - .get("data") - .and_then(|d| d.get("channel_id")) - .and_then(Value::as_str) - .and_then(|s| s.parse::().ok()) - { - let wire = WireFrame::channel( - cid, - "board_signal", - json!({ "channel_id": cid, "board": "files" }), - ); - state.fanout.broadcast_channel(cid, wire).await; - } - } - // Agent wrote its own status card (`bot.status.write`, the set_status - // tool): persisted in dispatch (db-only); the live member_updated push - // to every channel the bot is in needs the fanout, so it's emitted - // here at the WS boundary — same pattern as the blocks above. - if frame.get("resource").and_then(Value::as_str) == Some("bot.status.write") - && resp.get("ok").and_then(Value::as_bool) == Some(true) - { - crate::api::bots::broadcast_bot_member_update(state, &bot.bot_id.to_string()).await; - // Traceability (audit items 3 + 9): record the self-status write to - // acp_event_log so status changes are auditable alongside every other - // ACP event. Summary ONLY — which fields were set and their char - // lengths, NEVER the text itself. channel_id is NULL (a self-card write - // isn't channel-scoped); session_id rides the frame if present. - // Best-effort: a log-write failure must never disrupt the live agent. - let params = frame.get("params"); - let field_len = |key: &str| { - params - .and_then(|p| p.get(key)) - .and_then(Value::as_str) - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(|s| s.chars().count()) - }; - let audit_payload = json!({ - "status_text_len": field_len("status_text"), - "status_emoji_len": field_len("status_emoji"), - "info_len": field_len("info"), - }); - if let Err(err) = sqlx::query( - "INSERT INTO acp_event_log (id, bot_id, channel_id, session_id, name, home, payload) - VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)", + crate::gateway::resource_effects::dispatch_with_effects( + state, + resource::Principal::bot(bot.bot_id), + frame, ) - .bind(Uuid::new_v4().to_string()) - .bind(bot.bot_id.to_string()) - .bind(Option::<&str>::None) - .bind(frame.get("session_id").and_then(Value::as_str)) - .bind("bot.status.write") - .bind("cheers") - .bind(audit_payload.to_string()) - .execute(&state.db) .await - { - tracing::warn!(bot_id = %bot.bot_id, error = %err, "bot.status.write audit log write failed"); - } - } + }; // resource_res 发回给 bot(通过同一条 data WS) let _ = ws_send(socket, &resp).await; } diff --git a/server/src/main.rs b/server/src/main.rs index 610f3284..822b47a0 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -51,6 +51,10 @@ async fn main() -> anyhow::Result<()> { // migration), so the gateway is reachable for the login/demo flow. server::domain::seed::ensure_admin_user(&db).await?; + // Seed the official workbench plugins (embedded in this binary). Version-gated + // upserts: admin deletions stick within a release; see domain/workbench_official.rs. + server::domain::workbench_official::seed(&db).await?; + // S3 / RustFS client for gateway-proxied file storage. Bucket bootstrap is // best-effort: a missing object store must not block the core chat loop. let s3 = infra::s3::build_client(&config); diff --git a/server/src/resource/messages.rs b/server/src/resource/messages.rs index 14ca9223..52bd3879 100644 --- a/server/src/resource/messages.rs +++ b/server/src/resource/messages.rs @@ -177,6 +177,10 @@ fn fallback_message_value(channel_id: Uuid) -> Value { } pub async fn handle_create(db: &PgPool, principal: &Principal, params: &Value) -> ResourceResult { + // Perf instrumentation: total time spent in the DB-only create handler (authz + + // mention resolution + tx). Broadcast/bot-trigger happen at the WS boundary and + // are timed separately in agent_bridge — see docs on the post_message hot path. + let started = std::time::Instant::now(); let channel_id: Uuid = params .get("channel_id") .and_then(|v| v.as_str()) @@ -283,6 +287,7 @@ pub async fn handle_create(db: &PgPool, principal: &Principal, params: &Value) - let files = load_message_file_refs(db, &file_ids) .await .map_err(|_| super::resource_error("INTERNAL_ERROR", "db error"))?; + let files_len = files.len(); let mention_dtos = mention_dtos(&mentions); let dto = MessageDto { @@ -306,6 +311,15 @@ pub async fn handle_create(db: &PgPool, principal: &Principal, params: &Value) - context_bundle, }; + tracing::debug!( + %channel_id, + %msg_id, + mentions = mentions.len(), + files = files_len, + elapsed_ms = started.elapsed().as_millis() as u64, + "messages.create db-path complete" + ); + Ok(serde_json::to_value(dto).unwrap_or_else(|_| { serde_json::json!({ "v": MESSAGE_SCHEMA_VERSION, diff --git a/server/tests/official_plugins.rs b/server/tests/official_plugins.rs new file mode 100644 index 00000000..0f468bbe --- /dev/null +++ b/server/tests/official_plugins.rs @@ -0,0 +1,125 @@ +//! Integration tests for official workbench-plugin seeding (domain/workbench_official). +//! +//! Same harness as flows.rs: `#[sqlx::test]` provisions an isolated temp database per +//! test (runs ./migrations, drops it afterwards); needs `DATABASE_URL` pointing at a +//! Postgres that may CREATE DATABASE. Gated by the `integration` feature: +//! +//! ```bash +//! DATABASE_URL=postgres://cheers:cheers@localhost:5432/cheers \ +//! cargo test --features integration --test official_plugins +//! ``` +#![cfg(feature = "integration")] + +use sqlx::{PgPool, Row}; + +use server::domain::workbench_official::{extract_manifest, seed, OFFICIAL}; + +async fn plugin_rows(db: &PgPool) -> Vec<(String, String)> { + sqlx::query("SELECT plugin_id, origin FROM workbench_plugins ORDER BY plugin_id") + .fetch_all(db) + .await + .unwrap() + .into_iter() + .map(|r| (r.get::("plugin_id"), r.get::("origin"))) + .collect() +} + +fn first_official_id() -> String { + extract_manifest(OFFICIAL[0]).unwrap()["id"] + .as_str() + .unwrap() + .to_string() +} + +#[sqlx::test] +async fn seeds_full_set_on_empty_db(db: PgPool) { + seed(&db).await.unwrap(); + let rows = plugin_rows(&db).await; + assert_eq!(rows.len(), OFFICIAL.len()); + assert!(rows.iter().all(|(_, origin)| origin == "system")); + let states: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM workbench_official_plugin_state") + .fetch_one(&db) + .await + .unwrap(); + assert_eq!(states as usize, OFFICIAL.len()); + // bundles are actually served + let bundle: Option = + sqlx::query_scalar("SELECT bundle FROM workbench_plugins WHERE plugin_id = $1") + .bind(first_official_id()) + .fetch_optional(&db) + .await + .unwrap(); + assert!(bundle.unwrap().contains("cheers-plugin")); +} + +#[sqlx::test] +async fn reseeding_same_version_is_idempotent_and_keeps_deletions(db: PgPool) { + seed(&db).await.unwrap(); + let id = first_official_id(); + // admin deletes one official plugin + sqlx::query("DELETE FROM workbench_plugins WHERE plugin_id = $1") + .bind(&id) + .execute(&db) + .await + .unwrap(); + // same release restarts: the deletion must stick + seed(&db).await.unwrap(); + assert_eq!(plugin_rows(&db).await.len(), OFFICIAL.len() - 1); +} + +#[sqlx::test] +async fn version_bump_restores_a_deleted_official_plugin(db: PgPool) { + seed(&db).await.unwrap(); + let id = first_official_id(); + sqlx::query("DELETE FROM workbench_plugins WHERE plugin_id = $1") + .bind(&id) + .execute(&db) + .await + .unwrap(); + // simulate "this DB last saw an older release" — the embedded version is now higher + sqlx::query("UPDATE workbench_official_plugin_state SET seeded_version = 0 WHERE plugin_id = $1") + .bind(&id) + .execute(&db) + .await + .unwrap(); + seed(&db).await.unwrap(); + let rows = plugin_rows(&db).await; + assert_eq!(rows.len(), OFFICIAL.len()); + assert!(rows.iter().any(|(pid, origin)| pid == &id && origin == "system")); +} + +#[sqlx::test] +async fn admin_claimed_id_is_never_overwritten(db: PgPool) { + seed(&db).await.unwrap(); + let id = first_official_id(); + // admin deletes the official plugin, then installs their own under the same id + sqlx::query("DELETE FROM workbench_plugins WHERE plugin_id = $1") + .bind(&id) + .execute(&db) + .await + .unwrap(); + server::domain::workbench_plugins::install( + &db, + &id, + "Admin's own", + r#"{"id":"x"}"#, + "", + "someone", + "admin", + ) + .await + .unwrap(); + // even a version bump must not clobber their row + sqlx::query("UPDATE workbench_official_plugin_state SET seeded_version = 0 WHERE plugin_id = $1") + .bind(&id) + .execute(&db) + .await + .unwrap(); + seed(&db).await.unwrap(); + let title: String = sqlx::query_scalar("SELECT title FROM workbench_plugins WHERE plugin_id = $1") + .bind(&id) + .fetch_one(&db) + .await + .unwrap(); + assert_eq!(title, "Admin's own"); +} diff --git a/website/README.md b/website/README.md index 074b4463..1440769a 100644 --- a/website/README.md +++ b/website/README.md @@ -11,6 +11,12 @@ Pages: - `index.zh-CN.html` — Chinese mirror of the homepage; the two link to each other via the 中文 / EN button in the nav. Keep both in sync when editing. - `plugin-dev.html` — workbench renderer plugin guide (English only for now). +- `connector.html` / `connector.zh-CN.html` — user-facing guide to the ACP + connector (connect your own Claude/Codex bot): install, token, config, keeping + it updated, and the "bot can't see attached files → update the connector" + fix. The two mirror each other via the 中文 / EN button; keep both in sync. +- `mcp.html` — Cheers MCP tool reference: the 26 tools an external agent uses, + the request path, and post_message performance notes (English only for now). ## Preview locally @@ -28,7 +34,7 @@ python3 -m http.server -d website 8080 # → http://localhost:8080 [`.github/workflows/pages.yml`](../.github/workflows/pages.yml) publishes `website/` on every push to `main` that touches it (or on manual `workflow_dispatch`). One-time setup: repo Settings → Pages → Source = -**"GitHub Actions"**. Live site: +**"GitHub Actions"**. Live site: Any other static host also works — the site is a handful of self-contained HTML files plus the `imgs/` screenshots, with no build step. @@ -37,7 +43,7 @@ HTML files plus the `imgs/` screenshots, with no build step. - Light/dark theme follows the OS by default; the ◐ button cycles auto → light → dark and remembers the choice. -- Documentation and repo links point at `https://github.com/haowei2000/Cheers` +- Documentation and repo links point at `https://github.com/ElePerson/Cheers` — update them if the canonical repo URL changes. - Content is intentionally kept in sync with `README.md` (and `README.zh-CN.md` for the Chinese page); when features or the stack change, diff --git a/website/connector.html b/website/connector.html new file mode 100644 index 00000000..8a99e454 --- /dev/null +++ b/website/connector.html @@ -0,0 +1,390 @@ + + + + + +Connect your AI agent — Cheers ACP Connector + + + + + + + + +
+
+
User Guide
+

Connect your AI agent

+

The ACP connector is a small program you run on your own computer. It plugs your + personal Claude or Codex into a Cheers bot so it can read channels, see the files you attach, and reply — all + using your own agent subscription. This guide walks a non-developer through it: install, connect, keep it + updated, and fix the most common surprise.

+ +
The one thing to remember: when the connector is out of date, your bot + quietly loses new features — most visibly, it stops seeing files you attach to a message. If a bot + replies "please upload the files or give me the path" even though you attached them, jump to + step 6 — keep it updated.
+ +

1. What the connector is (30-second version)

+

Cheers has no AI built in. Instead, you bring your own agent and connect it from your machine. + Three pieces talk to each other:

+
your browser ──▶ Cheers gateway (the server) ◀──── connector (runs on your PC) ──▶ your AI agent
+                                                    └ one config file = one bot
+
    +
  • The gateway is the Cheers server your team already runs.
  • +
  • The connector (cce-acp-connector) is the little bridge you install and run.
  • +
  • Your agent is Claude or Codex, signed in with your own subscription — no extra API key needed.
  • +
+

One config file describes one bot. Want two bots (say a Claude bot and a Codex bot)? Two files, run two of them. + They're fully independent.

+ +

2. Before you start

+
+ + + + + +
You needHow to check
A Cheers server you can reachAsk your admin for the address, e.g. ws://localhost:8000 or your team's URL
Your agent installedRun command -v claude-agent-acp (or codex-acp) — it should print a path
Your agent signed inThe folder ~/.claude (or ~/.codex) exists — that's your subscription login
Permission to manage a botYou (or an admin) can open Settings → Bots in Cheers
+

That's it — you do not need Rust, Docker, or an API key. Signing into your agent once is enough.

+ +

3. Install the connector

+

Download the prebuilt binary for your OS — no toolchain required. Paste this into a terminal:

+
# pick the right file for your OS/CPU, download it, make it runnable
+os=$(uname -s | tr 'A-Z' 'a-z'); arch=$(uname -m | sed -e 's/x86_64/amd64/' -e 's/aarch64/arm64/')
+mkdir -p ~/.cheers/bin ~/.cheers/workspace   # workspace = where the agent works — must exist, or the connector refuses to start
+curl -fsSL -o ~/.cheers/bin/cce-acp-connector \
+  "https://github.com/ElePerson/Cheers/releases/latest/download/cce-acp-connector-$os-$arch"
+chmod +x ~/.cheers/bin/cce-acp-connector
+export PATH="$HOME/.cheers/bin:$PATH"   # add this line to your ~/.zshrc so it sticks
+cce-acp-connector --help                # prints help = success
+
If the repo is private, plain curl returns a 404. Download with the + GitHub CLI instead, e.g. + gh release download -R ElePerson/Cheers -p "cce-acp-connector-$os-$arch" -O ~/.cheers/bin/cce-acp-connector + (with no tag, gh fetches the latest release).
+ +

4. Create the bot and get its token

+

A token is the connector's password to log in to the gateway as your bot. Get one in the UI:

+
    +
  1. Sign in to Cheers and open Settings → Bots.
  2. +
  3. Create a bot (or open an existing one). Give it a name like claude, and set its + bridge/provider type to generic / ACP.
  4. +
  5. Issue a token and copy it. Save it into a small file that only you can read: +
    mkdir -p ~/.cheers/secrets && chmod 700 ~/.cheers/secrets
    +# paste the token in place of PASTE_TOKEN_HERE:
    +printf '%s' 'PASTE_TOKEN_HERE' > ~/.cheers/secrets/claude.token
    +chmod 600 ~/.cheers/secrets/claude.token
    +
  6. +
+
Keep the token in a file, never inside the config. Config files get shared, + screenshotted, and committed to git — a token pasted inline leaks. Issuing a new token also replaces the + old one, so only one connector should use a bot at a time.
+ +

5. Write one config file

+

Create ~/.cheers/cheers-daemon.claude.toml. This is the minimal form — the rest have sensible + defaults. Change the two URLs to match your gateway, and the command to your agent's path:

+
version = 1
+
+[daemon]
+state_path = "state-claude.json"   # these are relative to this file's folder (~/.cheers)
+log_dir    = "logs-claude"
+
+[accounts.claude.bridge]
+control_url    = "ws://localhost:8000/ws/agent-bridge/control"
+data_url       = "ws://localhost:8000/ws/agent-bridge/data"
+bot_token_file = "secrets/claude.token"   # the file from step 4 — never paste the token here
+
+[accounts.claude.adapter]
+type    = "stdio"
+command = "/opt/homebrew/bin/claude-agent-acp"   # output of: command -v claude-agent-acp
+args    = []
+
+[accounts.claude.policy.prompt]
+allow             = true
+allow_attachments = true    # ← lets the bot receive files you attach to a message
+allow_images      = true
+
+[accounts.claude.policy.workspace]
+default_cwd   = "~/.cheers/workspace"
+allowed_roots = ["~/.cheers/workspace"]
+backend_may_set_cwd = true
+
+[accounts.claude.policy.env]
+inherit = false
+allow   = ["HOME", "PATH"]   # HOME lets the agent read your ~/.claude subscription login
+
+[accounts.claude.policy.permission]
+forward_to_backend = true    # tool-use asks show up as an approval card in the channel
+wait_timeout_ms    = 900000
+on_timeout         = "cancel"
+auto_allow         = false
+
+[accounts.claude.policy.mcp]
+inject_cheers = true         # gives the agent the Cheers tools (read files, post messages…)
+

The config folder ends up looking like this:

+
~/.cheers/
+├─ cheers-daemon.claude.toml     # one file = one bot
+├─ secrets/claude.token          # the token, chmod 600
+├─ workspace/                    # where the agent works
+└─ logs-claude/                  # its logs
+ +

Start it & check it's online

+
cce-acp-connector start  --config ~/.cheers/cheers-daemon.claude.toml --name claude
+cce-acp-connector status --name claude          # → status=running
+cce-acp-connector logs   --name claude --lines 40
+# expect "initialized ACP agent" + "BridgeRuntime started", no ERROR lines
+

Now @-mention the bot in a Cheers channel. A reply means you're connected. 🎉 Because the token lives in a file, + restarts need no extra steps: cce-acp-connector restart --name claude.

+ +

6. Keep it updated (important)

+

New Cheers features often need a matching connector version. An outdated connector still connects and + runs fine — it just silently skips whatever it doesn't understand yet. The most common casualty is + attached context: the file chips show in the chat, but your bot never receives them and asks you + to upload the files. Updating fixes it.

+ +

See whether you're behind

+
cce-acp-connector status --name claude
+# if a newer release exists you'll see an "update available:" line here,
+# and a warning in the logs at startup.
+ +

Option A — turn on signed auto-update (connector ≥ 0.1.27)

+

Add this to your config, then restart once. From then on the connector updates itself safely (it checks an + ed25519 signature and checksum, waits until it's idle, swaps in place, and rolls back if the new one is unhealthy):

+
[update]
+auto = true
+
Upgrading a very old install? Binaries older than 0.1.27 reject a + config that contains [update]. In that case update the binary first (Option B), then add the + [update] section.
+ +

Option B — update by hand

+

Re-run the download from step 3 (it overwrites the old binary), then restart:

+
os=$(uname -s | tr 'A-Z' 'a-z'); arch=$(uname -m | sed -e 's/x86_64/amd64/' -e 's/aarch64/arm64/')
+curl -fsSL -o ~/.cheers/bin/cce-acp-connector \
+  "https://github.com/ElePerson/Cheers/releases/latest/download/cce-acp-connector-$os-$arch"
+chmod +x ~/.cheers/bin/cce-acp-connector
+cce-acp-connector restart --name claude
+

Auto-update is off by default on purpose — updating runs downloaded code, so the owner of the + machine opts in. You can hard-disable it anytime with CHEERS_ACP_NO_SELF_UPDATE=1.

+ +

7. How attaching files works

+

In the message composer, Add context lets you attach a file (or a line range like + review.md:1-1, or a file from a workspace). Each attachment becomes a small chip on your message. + When you send, Cheers passes those references to the bot's connector, which puts them into the agent's prompt so + it knows what you're pointing at.

+
    +
  • The bot resolves the reference on its side (it reads the file through the Cheers tools) — so it works even for + large files without pasting the whole thing.
  • +
  • For this to happen, the bot's config needs allow_attachments = true (it's in the template above) + and a current connector. Old connectors drop the references silently.
  • +
+ +

8. Troubleshooting

+
+ + + + + + + + + +
What you seeLikely causeFix
Bot ignores files you attached — asks you to upload them or give a path, even though the chips are on your messageThe connector is older than the version that renders attached context into the promptUpdate the connector (≥ 0.1.28), then restart. Also confirm allow_attachments = true.
start exits right away, log mentions the tokenThe token file is missing or emptyRe-do step 4; check ~/.cheers/secrets/<name>.token exists and isn't empty
start exits right away, log: allowed_roots does not existThe folder your config points at (allowed_roots / default_cwd) hasn't been created — it must exist before startupmkdir -p ~/.cheers/workspace (or whichever directory your config names), then start again
log: command not foundWrong path to your agentcommand -v claude-agent-acp and put the absolute path in adapter.command
Agent starts but never replies / auth failsThe agent can't read your subscription loginMake sure policy.env.allow includes HOME
Keeps reconnecting / never connectsWrong gateway address, or gateway downCheck the two ws://… URLs with your admin; confirm the server is up
unsupported protocolVersion … ClosingYour agent's version doesn't match the connectorUpdate the agent (claude-agent-acp / codex-acp) to a current version
A tool action hangs "waiting for approval"auto_allow = false (the safe default)Approve it on the channel's approval card, or set auto_allow = true to trust it locally
+ +

Read more

+ +
+
+ +
+
+
🍻 Cheers — open-source collaboration for humans & AI agents. MIT licensed.
+ +
+
+ + + + diff --git a/website/connector.zh-CN.html b/website/connector.zh-CN.html new file mode 100644 index 00000000..25eee4d3 --- /dev/null +++ b/website/connector.zh-CN.html @@ -0,0 +1,380 @@ + + + + + +连接你的 AI 智能体 — Cheers ACP 连接器 + + + + + + + + +
+
+
用户指南
+

连接你的 AI 智能体

+

ACP 连接器是一个跑在你自己电脑上的小程序。它把你个人的 Claude 或 Codex 接到一个 Cheers 机器人上, + 让它能读频道、看到你附加的文件、并回复——全程用你自己的智能体订阅。本文面向非开发者,带你走一遍:安装、连接、保持更新, + 并解决那个最常见的意外。

+ +
只需记住一件事:连接器一旦过期,你的机器人会悄悄失去新功能——最明显的就是 + 看不到你附加到消息里的文件。如果你明明附了文件,机器人却回复"请上传这些文件或提供路径",直接跳到 + 第 6 步——保持更新
+ +

1. 连接器是什么(30 秒版)

+

Cheers 本身不内置 AI。相反,你自带智能体,从你的电脑把它连进来。三个部件互相通信:

+
你的浏览器 ──▶ Cheers 网关(服务器) ◀──── 连接器(跑在你电脑上) ──▶ 你的 AI 智能体
+                                          └ 一个配置文件 = 一个机器人
+
    +
  • 网关是你团队已经在跑的 Cheers 服务器。
  • +
  • 连接器cce-acp-connector)是你要安装并运行的小桥梁。
  • +
  • 你的智能体是 Claude 或 Codex,用你自己的订阅登录——不需要额外的 API key。
  • +
+

一个配置文件描述一个机器人。想要两个机器人(比如一个 Claude、一个 Codex)?两个文件,跑两份,彼此完全独立。

+ +

2. 开始之前

+
+ + + + + +
你需要怎么确认
能连上的 Cheers 服务器问管理员要地址,比如 ws://localhost:8000 或团队的 URL
已安装智能体运行 command -v claude-agent-acp(或 codex-acp),应打印出一个路径
智能体已登录存在 ~/.claude(或 ~/.codex)目录——那就是你的订阅登录
有管理机器人的权限你(或管理员)能在 Cheers 里打开 设置 → 机器人
+

就这些——你需要 Rust、Docker,也不需要 API key。给智能体登录一次就够了。

+ +

3. 安装连接器

+

下载对应你系统的预编译二进制,无需任何工具链。把下面这段粘进终端:

+
# 自动挑选对应系统/CPU 的文件、下载、赋予可执行权限
+os=$(uname -s | tr 'A-Z' 'a-z'); arch=$(uname -m | sed -e 's/x86_64/amd64/' -e 's/aarch64/arm64/')
+mkdir -p ~/.cheers/bin ~/.cheers/workspace   # workspace 是智能体的工作目录——必须存在,否则连接器会拒绝启动
+curl -fsSL -o ~/.cheers/bin/cce-acp-connector \
+  "https://github.com/ElePerson/Cheers/releases/latest/download/cce-acp-connector-$os-$arch"
+chmod +x ~/.cheers/bin/cce-acp-connector
+export PATH="$HOME/.cheers/bin:$PATH"   # 把这行加进 ~/.zshrc 就能长期生效
+cce-acp-connector --help                # 打印出帮助 = 成功
+
如果仓库是私有的,直接 curl 会返回 404。改用 GitHub CLI 下载,例如 + gh release download -R ElePerson/Cheers -p "cce-acp-connector-$os-$arch" -O ~/.cheers/bin/cce-acp-connector + (不带 tag 时 gh 会取最新的 release)。
+ +

4. 创建机器人并拿到令牌

+

令牌(token)是连接器以你的机器人身份登录网关用的"密码"。在界面里获取:

+
    +
  1. 登录 Cheers,打开 设置 → 机器人
  2. +
  3. 创建一个机器人(或打开已有的)。取个名字如 claude,把 bridge/provider 类型设为 + generic / ACP
  4. +
  5. 签发令牌并复制。把它存进一个只有你能读的小文件: +
    mkdir -p ~/.cheers/secrets && chmod 700 ~/.cheers/secrets
    +# 把 PASTE_TOKEN_HERE 换成你复制的令牌:
    +printf '%s' 'PASTE_TOKEN_HERE' > ~/.cheers/secrets/claude.token
    +chmod 600 ~/.cheers/secrets/claude.token
    +
  6. +
+
令牌放文件里,绝不要写进配置文件。配置文件会被分享、截图、提交到 git——内联的令牌一定会泄露。 + 另外,重新签发会作废旧令牌,所以同一个机器人同一时间只能有一个连接器在用。
+ +

5. 写一个配置文件

+

创建 ~/.cheers/cheers-daemon.claude.toml。这是最小形式——其余字段都有合理默认值。把两个 URL 改成你的网关地址, + 把 command 改成你智能体的路径:

+
version = 1
+
+[daemon]
+state_path = "state-claude.json"   # 相对于本文件所在目录(~/.cheers)
+log_dir    = "logs-claude"
+
+[accounts.claude.bridge]
+control_url    = "ws://localhost:8000/ws/agent-bridge/control"
+data_url       = "ws://localhost:8000/ws/agent-bridge/data"
+bot_token_file = "secrets/claude.token"   # 第 4 步的文件——绝不在这里直接写令牌
+
+[accounts.claude.adapter]
+type    = "stdio"
+command = "/opt/homebrew/bin/claude-agent-acp"   # command -v claude-agent-acp 的输出
+args    = []
+
+[accounts.claude.policy.prompt]
+allow             = true
+allow_attachments = true    # ← 让机器人能收到你附加的文件
+allow_images      = true
+
+[accounts.claude.policy.workspace]
+default_cwd   = "~/.cheers/workspace"
+allowed_roots = ["~/.cheers/workspace"]
+backend_may_set_cwd = true
+
+[accounts.claude.policy.env]
+inherit = false
+allow   = ["HOME", "PATH"]   # HOME 让智能体能读到你 ~/.claude 的订阅登录
+
+[accounts.claude.policy.permission]
+forward_to_backend = true    # 工具调用请求会作为审批卡片出现在频道里
+wait_timeout_ms    = 900000
+on_timeout         = "cancel"
+auto_allow         = false
+
+[accounts.claude.policy.mcp]
+inject_cheers = true         # 给智能体注入 Cheers 工具(读文件、发消息……)
+

配置目录最终长这样:

+
~/.cheers/
+├─ cheers-daemon.claude.toml     # 一个文件 = 一个机器人
+├─ secrets/claude.token          # 令牌,权限 600
+├─ workspace/                    # 智能体的工作目录
+└─ logs-claude/                  # 它的日志
+ +

启动并确认在线

+
cce-acp-connector start  --config ~/.cheers/cheers-daemon.claude.toml --name claude
+cce-acp-connector status --name claude          # → status=running
+cce-acp-connector logs   --name claude --lines 40
+# 应看到 "initialized ACP agent" 和 "BridgeRuntime started",没有 ERROR
+

现在在 Cheers 频道里 @ 这个机器人。有回复就说明连上了。🎉 因为令牌存在文件里,重启不需要任何额外步骤: + cce-acp-connector restart --name claude

+ +

6. 保持更新 (重要)

+

Cheers 的新功能往往需要相匹配的连接器版本。过期的连接器照样能连、照样能跑——它只是会悄悄跳过自己还看不懂的东西。 + 最常见的"受害者"就是附加的 context:文件小标签(chip)在聊天里显示得好好的,但机器人根本没收到,还反过来让你上传文件。 + 更新一下就能修好。

+ +

先看你是不是落后了

+
cce-acp-connector status --name claude
+# 若有更新版本,这里会出现一行 "update available:",
+# 启动时的日志里也会有一条提醒。
+ +

方式 A —— 打开带签名的自动更新(连接器 ≥ 0.1.27)

+

在配置里加上这段,然后重启一次。之后连接器会安全地自我更新(校验 ed25519 签名和 sha256、等空闲、原地替换, + 新版本不健康还会自动回滚):

+
[update]
+auto = true
+
升级很老的版本?低于 0.1.27 的二进制会拒绝含 [update] 的配置。 + 这种情况先用方式 B 更新二进制,再加 [update] 段。
+ +

方式 B —— 手动更新

+

重新跑一遍第 3 步的下载(会覆盖旧二进制),然后重启:

+
os=$(uname -s | tr 'A-Z' 'a-z'); arch=$(uname -m | sed -e 's/x86_64/amd64/' -e 's/aarch64/arm64/')
+curl -fsSL -o ~/.cheers/bin/cce-acp-connector \
+  "https://github.com/ElePerson/Cheers/releases/latest/download/cce-acp-connector-$os-$arch"
+chmod +x ~/.cheers/bin/cce-acp-connector
+cce-acp-connector restart --name claude
+

自动更新默认关闭是有意为之——更新意味着执行下载来的代码,所以由机器主人自己选择开启。 + 你随时可以用 CHEERS_ACP_NO_SELF_UPDATE=1 彻底禁用它。

+ +

7. 附加文件是怎么工作的

+

在消息输入框里,Add context(添加上下文)让你附加一个文件(或像 review.md:1-1 这样的行范围, + 或工作区里的某个文件)。每个附件会在你的消息上变成一个小标签。发送时,Cheers 把这些引用传给机器人的连接器, + 连接器再把它们写进智能体的 prompt,让它知道你指的是什么。

+
    +
  • 机器人在它那侧解析这些引用(通过 Cheers 工具去读文件)——所以大文件也不用整段粘贴。
  • +
  • 要实现这一点,机器人的配置需要 allow_attachments = true(上面模板里已有),并且连接器要是 + 较新版本。旧连接器会静默丢弃这些引用。
  • +
+ +

8. 排障

+
+ + + + + + + + + +
你看到的现象可能原因处理
机器人无视你附加的文件——让你上传或给路径,尽管消息上明明有小标签连接器版本旧于"把附加 context 渲染进 prompt"的那一版更新连接器(≥ 0.1.28)后 restart;同时确认 allow_attachments = true
start 立刻退出,日志提到 token令牌文件缺失或为空重做第 4 步;确认 ~/.cheers/secrets/<名字>.token 存在且非空
start 立刻退出,日志:allowed_roots does not exist配置里 allowed_roots / default_cwd 指向的目录还没创建——启动前它必须存在mkdir -p ~/.cheers/workspace(或你配置里写的那个目录),再重新 start
日志:command not found智能体路径不对command -v claude-agent-acp,把绝对路径填进 adapter.command
智能体启动了但从不回复 / 鉴权失败智能体读不到你的订阅登录确保 policy.env.allow 里包含 HOME
反复重连 / 一直连不上网关地址不对,或网关没起找管理员核对两个 ws://… URL;确认服务器在线
unsupported protocolVersion … Closing你的智能体版本和连接器不匹配把智能体(claude-agent-acp / codex-acp)更新到较新版本
某个工具动作卡在"等待审批"auto_allow = false(安全默认)在频道的审批卡片上批准;或设 auto_allow = true 在本地信任它
+ +

延伸阅读

+ +
+
+ +
+
+
🍻 Cheers —— 面向人类与 AI 智能体的开源协作。MIT 许可。
+ +
+
+ + + + diff --git a/website/index.html b/website/index.html index c514df82..7d72ee5b 100644 --- a/website/index.html +++ b/website/index.html @@ -245,13 +245,14 @@ Screenshots For Users For Developers + MCP Tools Quick Start Docs @@ -413,7 +414,7 @@

Get productive in minutes

  • 3Add a botUse the member list to add an agent to the channel.
  • 4Ask it@bot your first question and see it reply.
  • -

    Read the full user guide →

    +

    Read the full user guide →

    @@ -462,15 +463,15 @@

    How it fits together

    @@ -510,7 +515,7 @@

    Run Cheers your way

    # 3. bring up the core stack docker compose up -d # UI → http://localhost · API → http://localhost:8000/health -

    Full Docker Compose guide →

    +

    Full Docker Compose guide →

    @@ -554,13 +559,14 @@

    Go deeper

    English is the default; most guides have a Chinese mirror (.zh-CN.md).

    @@ -570,10 +576,10 @@

    Go deeper

    🍻 Cheers — open-source collaboration for humans & AI agents. MIT licensed.
    diff --git a/website/index.zh-CN.html b/website/index.zh-CN.html index 1ffd6171..205e7356 100644 --- a/website/index.zh-CN.html +++ b/website/index.zh-CN.html @@ -251,7 +251,7 @@ @@ -412,7 +412,7 @@

    几分钟即可上手

  • 3添加 Bot通过成员列表把一个智能体加入频道。
  • 4提问@bot 你的第一个问题,看它回复。
  • -

    阅读完整用户指南 →

    +

    阅读完整用户指南 →

    @@ -461,15 +461,15 @@

    整体结构

    @@ -553,13 +553,14 @@

    深入了解

    英文是默认文档语言;多数指南都有中文镜像(.zh-CN.md)。

    @@ -570,10 +571,10 @@

    深入了解

    🍻 Cheers —— 人类与 AI 智能体的开源协作平台。MIT 许可。
    diff --git a/website/mcp.html b/website/mcp.html new file mode 100644 index 00000000..04f9b474 --- /dev/null +++ b/website/mcp.html @@ -0,0 +1,326 @@ + + + + + +MCP Tool Reference — Cheers + + + + + + + +
    +
    +
    Developer Reference
    +

    The Cheers MCP server

    +

    The standard bridge an external agent uses to live inside a channel — reading + messages, posting, handling attachments, and keeping its own private workspace. A thin stdio translator that + maps each tool call to one gateway resource verb; all the logic lives in the Rust gateway.

    + +

    A translator, not a backend

    +

    The MCP server speaks JSON-RPC over stdio to the agent and forwards each tool call as a + {resource, params} envelope. It holds no business logic — post_message becomes + channel.messages.create, inbox_open becomes channel.files.read, and so + on. Persistence, authorization, fan-out, and bot-to-bot triggering all happen in the gateway.

    +
    Never fetch the gateway HTTP API yourself. An agent has no gateway HTTP + session — download / preview URLs on attachments are for the human web UI and return 401. The only way to read + an attachment is inbox_open.
    + +

    Every tool call is a four-hop round-trip

    +

    A tool call is not a direct call — it crosses four processes, and each hop adds one serialization pass and one + round-trip. The reply travels the same path in reverse. Latency is a property of the path, not of any + single line of tool code.

    +
    +
    01
    Agent
    Calls a tool, e.g. post_message.
    stdio · JSON-RPC
    +
    02
    cheers-mcp-server
    Maps tool → resource verb, POSTs the envelope.
    loopback HTTP
    +
    03
    ACP connector
    Correlates by req_id over the data socket.
    agent-bridge WS
    +
    04
    Rust gateway
    Authz, seq, insert, broadcast, trigger.
    Postgres · Redis
    +
    + +

    26 tools, by capability

    +

    read tools never mutate state. write tools persist a change; + a few are destructive and remove data or membership.

    + +

    Channel read · 4 tools

    +
    + + + + + +
    ToolAccessWhat it does
    get_channel_inforeadMetadata for a channel: name, type, workspace.
    list_membersreadWho is in the room — humans and bots, with member_id, bio, and live status.
    get_contextreadCondensed context bundle: topic, pinned info, summary.
    read_activityreadThe unified channel_seq event stream: messages plus channel operations.
    + +

    Messages read · 4 tools

    +
    + + + + + +
    ToolAccessWhat it does
    read_messagesreadRecent messages by pagination cursor or channel_seq.
    messages_indexreadmin_seq, max_seq and count for finalized messages.
    messages_by_seqreadFetch messages in an inclusive channel_seq range.
    search_messagesreadCase-insensitive substring search over message content.
    + +

    Context references read · 3 tools

    +
    + + + + +
    ToolAccessWhat it does
    read_planreadThe channel's live plan / progress board. Resolves a handed-over "plan" reference.
    read_sessionsreadBot sessions active in this channel (id, bot, mode).
    read_costreadToken-usage / cost totals for this channel.
    + +

    Messages & status write · 2 tools

    +
    + + + +
    ToolAccessWhat it does
    post_messagewritePost to a channel. @-mention a bot (by id or name) to hand off work; attach context references so the recipient reads the same plan or file.
    set_statuswriteUpdate your own member card — status text, emoji, bio — pushed live to every channel.
    + +

    Membership 1 tool

    +
    + + +
    ToolAccessWhat it does
    leave_channeldestructiveRemove yourself from a channel. You stop receiving its tasks immediately; a human must re-invite you.
    + +

    Inbox chat attachments · 4 tools

    +
    + + + + + +
    ToolAccessWhat it does
    inbox_listreadFiles people uploaded to the chat (pdf/csv/images), addressed by file_id.
    inbox_openreadOpen an attachment by file_id; text inline, binaries as base64 (≤8MB). Read-only.
    inbox_deliverwritePost a new file (base64, ≤8MB) into the channel as a downloadable attachment.
    inbox_stagewriteRegister a local file path for lazy delivery — uploaded on demand when a user clicks it.
    + +

    Desk private workspace · 7 tools

    +
    + + + + + + + + +
    ToolAccessWhat it does
    desk_listreadList your editable workspace files under a path prefix.
    desk_readreadRead one of your workspace files by path; returns text + version.
    desk_writewriteCreate or overwrite a workspace file (≤256KB), with optimistic version lock.
    desk_editwriteReplace exactly one occurrence of old_string with new_string in a file.
    desk_appendwriteAppend text to a workspace file, creating it if missing.
    desk_mvwriteRename or move a workspace file or subtree.
    desk_rmdestructiveRemove a workspace file or subtree (recursive for a subtree).
    + +

    Cross-bot 1 tool

    +
    + + +
    ToolAccessWhat it does
    read_workspacereadLive-read a file from another bot's workspace via a handed-over reference — brokered under your own permission, never a stale snapshot.
    + +
    Desk vs. Inbox. If you're thinking in a path it's the desk (your private, + editable workspace). If you're holding a file_id it's the inbox (read-only files people uploaded). Never + desk_write a file_id, and never inbox_open a path.
    + +

    Where a post_message spends its time

    +

    The tool code is lean — no embedding or model calls. The measurable cost lives on the path: the connection + between the MCP server and the connector, and work done inline on the gateway before the reply is sent.

    +
    + + + + +
    CostWhereNote
    Connection setup per callMCP → connectorThe loopback HTTP hop; kept-alive so the socket is reused across calls.
    Live broadcastgatewayRedis PUBLISH + chain resolution, moved off the reply's critical path.
    Mention resolutiongatewaymention_names resolved in one batched query instead of one per name.
    + +

    Read more

    + +
    +
    + +
    +
    +
    🍻 Cheers — open-source collaboration for humans & AI agents. MIT licensed.
    + +
    +
    + + + + diff --git a/website/plugin-dev.html b/website/plugin-dev.html index bb06125a..97ea3d20 100644 --- a/website/plugin-dev.html +++ b/website/plugin-dev.html @@ -139,12 +139,13 @@ @@ -255,24 +256,24 @@

    4. Minimal skeleton

    5. Working examples

    Ready to upload as-is (Settings → Workbench extensions → upload the .html):

    Matching environment templates (Workbench drawer → upload the .json): - md-demo · - lit-review · - code-project. - Full index: examples README.

    + md-demo · + lit-review · + code-project. + Full index: examples README.

    6. Security model

      @@ -294,15 +295,15 @@

      7. Install & bind

      Read more