From 4f7ec780f6bffd437ae62873aee01c278247b4a2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 06:41:19 +0000 Subject: [PATCH 01/28] chore(deps): bump rand in /packages/cheers-acp-connector-rs Bumps [rand](https://github.com/rust-random/rand) from 0.8.6 to 0.10.2. - [Release notes](https://github.com/rust-random/rand/releases) - [Changelog](https://github.com/rust-random/rand/blob/master/CHANGELOG.md) - [Commits](https://github.com/rust-random/rand/compare/0.8.6...0.10.2) --- updated-dependencies: - dependency-name: rand dependency-version: 0.10.2 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- packages/cheers-acp-connector-rs/Cargo.lock | 8 ++++---- packages/cheers-acp-connector-rs/Cargo.toml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/cheers-acp-connector-rs/Cargo.lock b/packages/cheers-acp-connector-rs/Cargo.lock index fd20a02d..199a9689 100644 --- a/packages/cheers-acp-connector-rs/Cargo.lock +++ b/packages/cheers-acp-connector-rs/Cargo.lock @@ -333,7 +333,7 @@ dependencies = [ "libc", "mime_guess", "notify", - "rand 0.8.6", + "rand 0.10.2", "reqwest", "rustls", "serde", @@ -1701,9 +1701,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", "rand_chacha", @@ -2663,7 +2663,7 @@ dependencies = [ "http", "httparse", "log", - "rand 0.8.6", + "rand 0.8.7", "rustls", "rustls-pki-types", "sha1", diff --git a/packages/cheers-acp-connector-rs/Cargo.toml b/packages/cheers-acp-connector-rs/Cargo.toml index 7b635f99..1b86cb5e 100644 --- a/packages/cheers-acp-connector-rs/Cargo.toml +++ b/packages/cheers-acp-connector-rs/Cargo.toml @@ -42,7 +42,7 @@ mime_guess = "2" # unsolicited workspace_event frames). We drive it with a manual ~300ms debounce # (no notify-debouncer crate) so event coalescing + the watch TTL live in one task. notify = "8" -rand = "0.8" +rand = "0.10" # Self-update downloads (signed manifest + binaries) through the gateway's # release proxy. rustls-only, matching tokio-tungstenite's TLS stack. reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-native-roots"] } From a39b93578a07938f12c66ef5b15bfbd107385c20 Mon Sep 17 00:00:00 2001 From: haowei Date: Thu, 16 Jul 2026 14:52:44 +0800 Subject: [PATCH 02/28] =?UTF-8?q?docs(website):=20add=20user-facing=20ACP?= =?UTF-8?q?=20connector=20guide=20(EN=20+=20=E4=B8=AD=E6=96=87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New connector.html / connector.zh-CN.html walk a non-developer through installing, getting a token, writing one config file, and — importantly — keeping the ACP connector updated, with the "bot can't see attached files → update the connector" failure mode called out prominently (the exact symptom from a real haowe_codex report). Linked from both homepages' Docs grids; recorded in website/README.md. Co-Authored-By: Claude Opus 4.8 --- website/README.md | 4 + website/connector.html | 388 +++++++++++++++++++++++++++++++++++ website/connector.zh-CN.html | 378 ++++++++++++++++++++++++++++++++++ website/index.html | 1 + website/index.zh-CN.html | 1 + 5 files changed, 772 insertions(+) create mode 100644 website/connector.html create mode 100644 website/connector.zh-CN.html diff --git a/website/README.md b/website/README.md index 074b4463..38f0c28e 100644 --- a/website/README.md +++ b/website/README.md @@ -11,6 +11,10 @@ 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. ## Preview locally diff --git a/website/connector.html b/website/connector.html new file mode 100644 index 00000000..d49cb85e --- /dev/null +++ b/website/connector.html @@ -0,0 +1,388 @@ + + + + + +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
+curl -fsSL -o ~/.cheers/bin/cce-acp-connector \
+  "https://github.com/haowei2000/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 connector-v0.1.28 -R haowei2000/Cheers -p "cce-acp-connector-$os-$arch" -O ~/.cheers/bin/cce-acp-connector.
+ +

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/haowei2000/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
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..d0eb57d3 --- /dev/null +++ b/website/connector.zh-CN.html @@ -0,0 +1,378 @@ + + + + + +连接你的 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
+curl -fsSL -o ~/.cheers/bin/cce-acp-connector \
+  "https://github.com/haowei2000/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 connector-v0.1.28 -R haowei2000/Cheers -p "cce-acp-connector-$os-$arch" -O ~/.cheers/bin/cce-acp-connector
+ +

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/haowei2000/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 存在且非空
日志: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..51a5d0ac 100644 --- a/website/index.html +++ b/website/index.html @@ -555,6 +555,7 @@

Go deeper

📚 文档主页用户、管理员与运维指南索引。 + 🔗 连接你的 AI 智能体用 ACP 连接器把你自己的 Claude / Codex 接入 Cheers——安装、令牌、配置、更新。 🐳 Docker Compose 部署单机全栈、JWT 密钥、bot 配置、TLS、运维。 ☸️ Helm ChartKubernetes 部署、values、密钥、ingress。(英文) 🏗️ 架构系统设计、数据流与协议。(英文) From 2a5e8426c1625b3daf39febc8fc86e78ee6ad629 Mon Sep 17 00:00:00 2001 From: haowei Date: Thu, 16 Jul 2026 16:15:09 +0800 Subject: [PATCH 03/28] perf(post_message): trim the bot post_message hot path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP post_message tool maps to the channel.messages.create resource, which crosses MCP → loopback HTTP → connector WS → gateway. Four changes cut latency on that path and make it measurable: - loopback: HTTP keep-alive so the MCP↔connector socket is reused across resource calls instead of reconnecting per call (Connection: close → keep-alive, with carry-over buffering for pipelined requests, a clean-EOF signal, and a 120s idle timeout). Helps every tool, not just post_message. - gateway: spawn the post-commit broadcast (Redis PUBLISH + chain resolution + bot@bot trigger) off the reply's critical path, so post_message returns as soon as the row commits. Ordering stays correct — the frontend re-sorts incoming message frames by channel_seq. - gateway: resolve mention_names in one batched ANY() query instead of one round-trip per name (the lone N+1 in the create path), mirroring validate_mention_ids. Group-token expansion and error/order semantics preserved. - instrumentation: debug-level timing spans on the connector WS round-trip and the gateway db-path, plus the off-path broadcast, so the dominant hop is a number rather than a guess. Co-Authored-By: Claude Opus 4.8 --- .../src/bridge_runtime/mod.rs | 11 + .../cheers-acp-connector-rs/src/loopback.rs | 311 +++++++++++++----- server/src/domain/mentions.rs | 100 ++++-- server/src/gateway/ws/agent_bridge.rs | 40 ++- server/src/resource/messages.rs | 14 + 5 files changed, 344 insertions(+), 132 deletions(-) diff --git a/packages/cheers-acp-connector-rs/src/bridge_runtime/mod.rs b/packages/cheers-acp-connector-rs/src/bridge_runtime/mod.rs index ffd424ed..423b3c16 100644 --- a/packages/cheers-acp-connector-rs/src/bridge_runtime/mod.rs +++ b/packages/cheers-acp-connector-rs/src/bridge_runtime/mod.rs @@ -1767,6 +1767,10 @@ impl RuntimeContext { .await .pending_resources .insert(request.req_id.clone(), tx); + // Perf instrumentation: wall-clock of the connector→gateway→connector round-trip + // for one resource call (the WS hop). Compare with the gateway-side + // `messages.create db-path complete` span to see which hop dominates latency. + let started = std::time::Instant::now(); tracing::debug!(%req_id, %resource, "loopback resource_req sent"); self.io .send_data(DataOutbound::ResourceReq { @@ -1792,6 +1796,13 @@ impl RuntimeContext { error: Some("resource response timed out".to_string()), code: Some("RESOURCE_TIMEOUT".to_string()), }); + tracing::debug!( + %req_id, + %resource, + ok = response.ok, + elapsed_ms = started.elapsed().as_millis() as u64, + "loopback resource round-trip complete" + ); // inbox_deliver / inbox_stage create a channel file; record its id on the // active run so the Done reply attaches it as a chat attachment. if response.ok && (resource == "channel.files.create" || resource == "channel.files.stage") diff --git a/packages/cheers-acp-connector-rs/src/loopback.rs b/packages/cheers-acp-connector-rs/src/loopback.rs index f3d373ab..087d9594 100644 --- a/packages/cheers-acp-connector-rs/src/loopback.rs +++ b/packages/cheers-acp-connector-rs/src/loopback.rs @@ -2,14 +2,20 @@ use std::collections::BTreeMap; use std::net::SocketAddr; +use std::time::Duration; use anyhow::{anyhow, Context}; use serde_json::{json, Value}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::{mpsc, oneshot}; +use tokio::time::timeout; use uuid::Uuid; +/// Drop a kept-alive loopback connection after this long with no new request, so +/// a pooled-but-idle MCP socket can't leak a task/fd for the life of the session. +const LOOPBACK_IDLE_TIMEOUT: Duration = Duration::from_secs(120); + #[derive(Debug, Clone)] pub struct LoopbackHandle { pub url: String, @@ -69,131 +75,179 @@ async fn handle_connection( tx: mpsc::Sender, expected_token: &str, ) -> anyhow::Result<()> { - let request = read_http_request(&mut stream).await?; - if request.method != "POST" { - write_http_json( - &mut stream, - 405, - &json!({ "ok": false, "code": "METHOD_NOT_ALLOWED", "error": "loopback only accepts POST" }), - ) - .await?; - return Ok(()); - } - if !request_has_token(&request.headers, expected_token) { + // Persist across requests: bytes read past one request's body belong to the + // next pipelined request on the same kept-alive connection. + let mut buf: Vec = Vec::new(); + loop { + let request = + match timeout(LOOPBACK_IDLE_TIMEOUT, read_http_request(&mut stream, &mut buf)).await { + Ok(result) => match result? { + Some(request) => request, + None => return Ok(()), // client closed the idle connection cleanly + }, + Err(_) => return Ok(()), // idle too long — drop the connection + }; + let keep_alive = request.wants_keep_alive(); + + // Error responses always close: a bad method/token connection is not worth + // keeping around, and closing keeps the state machine simple. + if request.method != "POST" { + write_http_json( + &mut stream, + 405, + false, + &json!({ "ok": false, "code": "METHOD_NOT_ALLOWED", "error": "loopback only accepts POST" }), + ) + .await?; + return Ok(()); + } + if !request_has_token(&request.headers, expected_token) { + write_http_json( + &mut stream, + 401, + false, + &json!({ "ok": false, "code": "UNAUTHORIZED", "error": "invalid loopback token" }), + ) + .await?; + return Ok(()); + } + + let body: Value = + serde_json::from_slice(&request.body).context("loopback request body JSON")?; + let resource = body + .get("resource") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("loopback request requires resource"))? + .to_string(); + let params = body.get("params").cloned(); + let req_id = body + .get("req_id") + .or_else(|| body.get("reqId")) + .and_then(Value::as_str) + .map(ToString::to_string) + .unwrap_or_else(|| Uuid::new_v4().to_string()); + let (respond_to, response_rx) = oneshot::channel(); + tx.send(LoopbackRequest { + req_id: req_id.clone(), + resource, + params, + respond_to, + }) + .await + .context("loopback runtime receiver closed")?; + let response = response_rx.await.unwrap_or_else(|_| LoopbackResponse { + ok: false, + data: None, + error: Some("runtime did not answer loopback request".to_string()), + code: Some("RUNTIME_CLOSED".to_string()), + }); write_http_json( &mut stream, - 401, - &json!({ "ok": false, "code": "UNAUTHORIZED", "error": "invalid loopback token" }), + 200, + keep_alive, + &json!({ + "ok": response.ok, + "req_id": req_id, + "data": response.data, + "error": response.error, + "code": response.code, + }), ) .await?; - return Ok(()); + if !keep_alive { + return Ok(()); + } } - - let body: Value = - serde_json::from_slice(&request.body).context("loopback request body JSON")?; - let resource = body - .get("resource") - .and_then(Value::as_str) - .ok_or_else(|| anyhow!("loopback request requires resource"))? - .to_string(); - let params = body.get("params").cloned(); - let req_id = body - .get("req_id") - .or_else(|| body.get("reqId")) - .and_then(Value::as_str) - .map(ToString::to_string) - .unwrap_or_else(|| Uuid::new_v4().to_string()); - let (respond_to, response_rx) = oneshot::channel(); - tx.send(LoopbackRequest { - req_id: req_id.clone(), - resource, - params, - respond_to, - }) - .await - .context("loopback runtime receiver closed")?; - let response = response_rx.await.unwrap_or_else(|_| LoopbackResponse { - ok: false, - data: None, - error: Some("runtime did not answer loopback request".to_string()), - code: Some("RUNTIME_CLOSED".to_string()), - }); - write_http_json( - &mut stream, - 200, - &json!({ - "ok": response.ok, - "req_id": req_id, - "data": response.data, - "error": response.error, - "code": response.code, - }), - ) - .await?; - Ok(()) } #[derive(Debug)] struct HttpRequest { method: String, + version: String, headers: BTreeMap, body: Vec, } -async fn read_http_request(stream: &mut TcpStream) -> anyhow::Result { - let mut buf = Vec::new(); +impl HttpRequest { + /// HTTP/1.1 keeps the connection alive unless `Connection: close`; HTTP/1.0 + /// closes unless `Connection: keep-alive`. reqwest's pool sends 1.1, so the + /// common path reuses the socket. + fn wants_keep_alive(&self) -> bool { + match self.headers.get("connection") { + Some(value) if value.eq_ignore_ascii_case("close") => false, + Some(value) if value.eq_ignore_ascii_case("keep-alive") => true, + _ => self.version != "HTTP/1.0", + } + } +} + +/// Read one HTTP request off the stream, reusing `buf` as a carry-over buffer so +/// bytes belonging to a following pipelined request survive to the next call. +/// Returns `Ok(None)` on a clean EOF with nothing buffered — a client closing an +/// idle kept-alive connection, which is not an error. +async fn read_http_request( + stream: &mut TcpStream, + buf: &mut Vec, +) -> anyhow::Result> { let mut tmp = [0_u8; 1024]; - let header_end; - loop { + let header_end = loop { + if let Some(pos) = find_header_end(buf) { + break pos; + } let read = stream.read(&mut tmp).await?; if read == 0 { + if buf.is_empty() { + return Ok(None); // idle connection closed cleanly between requests + } return Err(anyhow!("connection closed before HTTP headers")); } buf.extend_from_slice(&tmp[..read]); - if let Some(pos) = find_header_end(&buf) { - header_end = pos; - break; - } if buf.len() > 64 * 1024 { return Err(anyhow!("loopback HTTP headers too large")); } - } - let headers_text = - std::str::from_utf8(&buf[..header_end]).context("loopback HTTP headers are not utf8")?; - let mut lines = headers_text.lines(); - let request_line = lines - .next() - .ok_or_else(|| anyhow!("missing loopback HTTP request line"))?; - let method = request_line - .split_whitespace() - .next() - .unwrap_or_default() - .to_string(); - let mut headers = BTreeMap::new(); - for line in lines { - if let Some((key, value)) = line.split_once(':') { - headers.insert(key.trim().to_ascii_lowercase(), value.trim().to_string()); + }; + let (method, version, headers) = { + let headers_text = std::str::from_utf8(&buf[..header_end]) + .context("loopback HTTP headers are not utf8")?; + let mut lines = headers_text.lines(); + let request_line = lines + .next() + .ok_or_else(|| anyhow!("missing loopback HTTP request line"))?; + let mut parts = request_line.split_whitespace(); + let method = parts.next().unwrap_or_default().to_string(); + let _path = parts.next(); + let version = parts.next().unwrap_or("HTTP/1.1").to_string(); + let mut headers = BTreeMap::new(); + for line in lines { + if let Some((key, value)) = line.split_once(':') { + headers.insert(key.trim().to_ascii_lowercase(), value.trim().to_string()); + } } - } + (method, version, headers) + }; let content_length = headers .get("content-length") .and_then(|value| value.parse::().ok()) .unwrap_or(0); let body_start = header_end + 4; - let mut body = buf.get(body_start..).unwrap_or_default().to_vec(); - while body.len() < content_length { + let body_end = body_start + content_length; + while buf.len() < body_end { let read = stream.read(&mut tmp).await?; if read == 0 { break; } - body.extend_from_slice(&tmp[..read]); + buf.extend_from_slice(&tmp[..read]); } - body.truncate(content_length); - Ok(HttpRequest { + let available_end = body_end.min(buf.len()); + let body = buf[body_start..available_end].to_vec(); + // Drop this request's bytes; anything left is the start of the next request. + buf.drain(..available_end); + Ok(Some(HttpRequest { method, + version, headers, body, - }) + })) } fn request_has_token(headers: &BTreeMap, expected: &str) -> bool { @@ -212,18 +266,26 @@ fn find_header_end(buf: &[u8]) -> Option { buf.windows(4).position(|window| window == b"\r\n\r\n") } -async fn write_http_json(stream: &mut TcpStream, status: u16, body: &Value) -> anyhow::Result<()> { +async fn write_http_json( + stream: &mut TcpStream, + status: u16, + keep_alive: bool, + body: &Value, +) -> anyhow::Result<()> { let status_text = match status { 200 => "OK", 401 => "Unauthorized", 405 => "Method Not Allowed", _ => "Error", }; + // Content-Length is always present, so the client can frame the body and reuse + // the socket when we keep it alive. + let connection = if keep_alive { "keep-alive" } else { "close" }; let body = serde_json::to_vec(body)?; stream .write_all( format!( - "HTTP/1.1 {status} {status_text}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + "HTTP/1.1 {status} {status_text}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: {connection}\r\n\r\n", body.len() ) .as_bytes(), @@ -247,4 +309,73 @@ mod tests { headers.insert("x-cheers-loopback-token".to_string(), "secret".to_string()); assert!(request_has_token(&headers, "secret")); } + + fn req(version: &str, connection: Option<&str>) -> HttpRequest { + let mut headers = BTreeMap::new(); + if let Some(value) = connection { + headers.insert("connection".to_string(), value.to_string()); + } + HttpRequest { + method: "POST".to_string(), + version: version.to_string(), + headers, + body: Vec::new(), + } + } + + #[test] + fn keep_alive_decision_follows_http_semantics() { + // HTTP/1.1 defaults to keep-alive; explicit close overrides. + assert!(req("HTTP/1.1", None).wants_keep_alive()); + assert!(!req("HTTP/1.1", Some("close")).wants_keep_alive()); + assert!(req("HTTP/1.1", Some("keep-alive")).wants_keep_alive()); + // HTTP/1.0 defaults to close; explicit keep-alive overrides. + assert!(!req("HTTP/1.0", None).wants_keep_alive()); + assert!(req("HTTP/1.0", Some("keep-alive")).wants_keep_alive()); + } + + #[tokio::test] + async fn parses_two_pipelined_requests_then_clean_eof() { + let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let addr = listener.local_addr().unwrap(); + + // Client writes two POSTs back-to-back on one connection, then half-closes + // the write side so the server sees EOF only after both are consumed. + let client = tokio::spawn(async move { + let mut stream = TcpStream::connect(addr).await.unwrap(); + let wire = "POST /resource HTTP/1.1\r\nContent-Length: 16\r\n\r\n\ + {\"resource\":\"a\"}\ + POST /resource HTTP/1.1\r\nContent-Length: 16\r\n\r\n\ + {\"resource\":\"b\"}"; + stream.write_all(wire.as_bytes()).await.unwrap(); + // Half-close the write side so the server sees EOF after both requests; + // keep the read side open (drop happens when the task ends). + stream.shutdown().await.unwrap(); + stream + }); + + let (mut server, _) = listener.accept().await.unwrap(); + let mut buf = Vec::new(); + + let first = read_http_request(&mut server, &mut buf) + .await + .unwrap() + .expect("first request"); + assert_eq!(first.method, "POST"); + assert_eq!(first.body, b"{\"resource\":\"a\"}"); + assert!(first.wants_keep_alive()); + + let second = read_http_request(&mut server, &mut buf) + .await + .unwrap() + .expect("second pipelined request"); + assert_eq!(second.body, b"{\"resource\":\"b\"}"); + + // Both consumed; the next read hits a clean EOF, which must be Ok(None). + let third = read_http_request(&mut server, &mut buf).await.unwrap(); + assert!(third.is_none()); + + // Keep the client socket alive until now, then let both ends close. + let _client_stream = client.await.unwrap(); + } } 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/gateway/ws/agent_bridge.rs b/server/src/gateway/ws/agent_bridge.rs index 5551738a..fa8aaabc 100644 --- a/server/src/gateway/ws/agent_bridge.rs +++ b/server/src/gateway/ws/agent_bridge.rs @@ -806,17 +806,35 @@ async fn handle_data_frame(frame: &Value, state: &AppState, bot: &BotInfo, socke && 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; + // Off the critical path: the message is already committed, so the + // resource_res below can return immediately. The live broadcast + // (Redis PUBLISH) + chain resolution + bot@bot trigger all run in a + // spawned task instead of blocking the caller's post_message reply. + // Ordering is safe: the frontend re-sorts incoming "message" frames + // by channel_seq (ChannelView.upsertMessage), so a broadcast that + // lands after the reply can't misorder the channel. + let registry = state.stream_registry.clone(); + let fanout = state.fanout.clone(); + let db = state.db.clone(); + let bot_locator = state.bot_locator.clone(); + let author_bot_id = bot.bot_id; + let created = created.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)" + ); + }); } } // bot 退出频道成功 → 成员集变了,在 WS 边界补发全量 presence 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, From 42db4e70816098a89168593a74fcd6c07e122abd Mon Sep 17 00:00:00 2001 From: haowei Date: Thu, 16 Jul 2026 16:15:17 +0800 Subject: [PATCH 04/28] docs(website): add MCP tool reference page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New website/mcp.html: a GitHub Pages reference for the Cheers MCP server — its 26 tools grouped by capability with read/write/destructive access tags, the four-hop request path, and where post_message spends its time. Reuses the site's existing chrome (nav, theme toggle, table/callout/docs-grid recipes and amber palette) so it matches index.html and plugin-dev.html. Wire it into the nav and developer docs of index.html and plugin-dev.html, and list it in website/README.md. Co-Authored-By: Claude Opus 4.8 --- website/README.md | 2 + website/index.html | 5 + website/mcp.html | 326 ++++++++++++++++++++++++++++++++++++++++ website/plugin-dev.html | 3 +- 4 files changed, 335 insertions(+), 1 deletion(-) create mode 100644 website/mcp.html diff --git a/website/README.md b/website/README.md index 074b4463..a572c787 100644 --- a/website/README.md +++ b/website/README.md @@ -11,6 +11,8 @@ 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). +- `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 diff --git a/website/index.html b/website/index.html index c514df82..925c1692 100644 --- a/website/index.html +++ b/website/index.html @@ -245,6 +245,7 @@ Screenshots For Users For Developers + MCP Tools Quick Start Docs @@ -478,6 +479,10 @@

How it fits together

Plugin Development → Build a workbench renderer plugin: one sandboxed HTML file. + + MCP Tool Reference → + The 26 tools an external agent uses to live inside a channel. +
diff --git a/website/mcp.html b/website/mcp.html new file mode 100644 index 00000000..3b589bb1 --- /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..0123f1ac 100644 --- a/website/plugin-dev.html +++ b/website/plugin-dev.html @@ -139,7 +139,8 @@ @@ -463,15 +463,15 @@

How it fits together

@@ -559,13 +559,13 @@

Go deeper

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

@@ -575,10 +575,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..ff4016c6 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,13 @@

    深入了解

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

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

    深入了解

    🍻 Cheers —— 人类与 AI 智能体的开源协作平台。MIT 许可。
    diff --git a/website/mcp.html b/website/mcp.html index 3b589bb1..04f9b474 100644 --- a/website/mcp.html +++ b/website/mcp.html @@ -159,7 +159,7 @@ @@ -278,15 +278,15 @@

    Where a post_message spends its time

    Read more

    diff --git a/website/plugin-dev.html b/website/plugin-dev.html index 0123f1ac..97ea3d20 100644 --- a/website/plugin-dev.html +++ b/website/plugin-dev.html @@ -145,7 +145,7 @@ @@ -256,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

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

      7. Install & bind

      Read more

      From cb894eecdfa039711ef5fdd62bf491f764c238b5 Mon Sep 17 00:00:00 2001 From: haowei Date: Thu, 16 Jul 2026 21:25:04 +0800 Subject: [PATCH 21/28] chore: point repo references at ElePerson/Cheers after transfer The GitHub repo moved from haowei2000/Cheers to ElePerson/Cheers on 2026-07-16. Old links only work via GitHub's transfer redirect (and the old GitHub Pages URL is already dead), so update the hardcoded owner in user-facing surfaces: website pages, READMEs, docs/help guides, the connector-download fallbacks (BotOnboardingWizard, gateway config default, install.sh), the enrollment help text, and the Helm chart home URL. Every rewritten URL was verified to resolve, including the releases/latest connector asset download. ghcr.io/haowei2000/* image references are left as-is: GHCR packages do not transfer with the repo, and no public images exist under eleperson yet. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 4 +-- README.md | 6 ++-- README.zh-CN.md | 6 ++-- deploy/helm/cheers/Chart.yaml | 2 +- ...45\345\205\245\346\214\207\345\215\227.md" | 6 ++-- ...\205\245\346\214\207\345\215\227.zh-CN.md" | 6 ++-- ...15\347\275\256\346\214\207\345\215\227.md" | 6 ++-- ...\275\256\346\214\207\345\215\227.zh-CN.md" | 6 ++-- .../src/features/bots/BotOnboardingWizard.tsx | 2 +- server/assets/install.sh | 6 ++-- server/src/api/enrollment.rs | 2 +- server/src/config.rs | 2 +- website/README.md | 4 +-- website/index.html | 36 +++++++++---------- website/index.zh-CN.html | 36 +++++++++---------- website/mcp.html | 12 +++---- website/plugin-dev.html | 26 +++++++------- 17 files changed, 84 insertions(+), 84 deletions(-) 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/docs/help/AgentBridge\346\216\245\345\205\245\346\214\207\345\215\227.md" "b/docs/help/AgentBridge\346\216\245\345\205\245\346\214\207\345\215\227.md" index a3aa0833..c07b9586 100644 --- "a/docs/help/AgentBridge\346\216\245\345\205\245\346\214\207\345\215\227.md" +++ "b/docs/help/AgentBridge\346\216\245\345\205\245\346\214\207\345\215\227.md" @@ -76,7 +76,7 @@ ACP-capable agent; new deployments must not use the old OpenClaw package path. ## ACP / OpenCode ACP Provider Install — download the prebuilt binary from the project's -[GitHub Releases](https://github.com/haowei2000/Cheers/releases/latest) +[GitHub Releases](https://github.com/ElePerson/Cheers/releases/latest) (no Rust toolchain needed; assets are published per platform as `cce-acp-connector-{darwin,linux}-{arm64,amd64}` by the `release-connector` workflow): @@ -84,7 +84,7 @@ Install — download the prebuilt binary from the project's 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 curl -fsSL -o ~/.cheers/bin/cce-acp-connector \ - "https://github.com/haowei2000/Cheers/releases/latest/download/cce-acp-connector-$os-$arch" + "https://github.com/ElePerson/Cheers/releases/latest/download/cce-acp-connector-$os-$arch" chmod +x ~/.cheers/bin/cce-acp-connector ~/.cheers/bin/cce-acp-connector --help # add ~/.cheers/bin to PATH for convenience ``` @@ -97,7 +97,7 @@ chmod +x ~/.cheers/bin/cce-acp-connector > (`gh auth login` once, and repo access required): > > ```bash -> gh release download connector-v0.1.22 -R haowei2000/Cheers \ +> gh release download connector-v0.1.22 -R ElePerson/Cheers \ > -p "cce-acp-connector-$os-$arch" -O ~/.cheers/bin/cce-acp-connector > chmod +x ~/.cheers/bin/cce-acp-connector > ``` diff --git "a/docs/help/AgentBridge\346\216\245\345\205\245\346\214\207\345\215\227.zh-CN.md" "b/docs/help/AgentBridge\346\216\245\345\205\245\346\214\207\345\215\227.zh-CN.md" index 674b7ec9..311157a7 100644 --- "a/docs/help/AgentBridge\346\216\245\345\205\245\346\214\207\345\215\227.zh-CN.md" +++ "b/docs/help/AgentBridge\346\216\245\345\205\245\346\214\207\345\215\227.zh-CN.md" @@ -244,7 +244,7 @@ ACP Connector 适合把本机 stdio ACP agent 接到 Cheers。它本身负责: ### 4.1 安装 Rust ACP Connector -推荐直接下载 [GitHub Releases](https://github.com/haowei2000/Cheers/releases/latest) +推荐直接下载 [GitHub Releases](https://github.com/ElePerson/Cheers/releases/latest) 上的预编译二进制(无需 Rust 工具链;`release-connector` workflow 会按平台发布 `cce-acp-connector-{darwin,linux}-{arm64,amd64}` 四个产物): @@ -252,7 +252,7 @@ ACP Connector 适合把本机 stdio ACP agent 接到 Cheers。它本身负责: 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 curl -fsSL -o ~/.cheers/bin/cce-acp-connector \ - "https://github.com/haowei2000/Cheers/releases/latest/download/cce-acp-connector-$os-$arch" + "https://github.com/ElePerson/Cheers/releases/latest/download/cce-acp-connector-$os-$arch" chmod +x ~/.cheers/bin/cce-acp-connector ~/.cheers/bin/cce-acp-connector --help # 建议把 ~/.cheers/bin 加进 PATH ``` @@ -264,7 +264,7 @@ chmod +x ~/.cheers/bin/cce-acp-connector > GitHub CLI 认证下载(先 `gh auth login`): > > ```bash -> gh release download connector-v0.1.22 -R haowei2000/Cheers \ +> gh release download connector-v0.1.22 -R ElePerson/Cheers \ > -p "cce-acp-connector-$os-$arch" -O ~/.cheers/bin/cce-acp-connector > chmod +x ~/.cheers/bin/cce-acp-connector > ``` diff --git "a/docs/help/\346\234\254\345\234\260Bot\351\205\215\347\275\256\346\214\207\345\215\227.md" "b/docs/help/\346\234\254\345\234\260Bot\351\205\215\347\275\256\346\214\207\345\215\227.md" index 2e89c3ba..0158b124 100644 --- "a/docs/help/\346\234\254\345\234\260Bot\351\205\215\347\275\256\346\214\207\345\215\227.md" +++ "b/docs/help/\346\234\254\345\234\260Bot\351\205\215\347\275\256\346\214\207\345\215\227.md" @@ -40,7 +40,7 @@ Three rules: ### 1.1 Get the connector binary (prebuilt release) Download the platform binary from the project's -[GitHub Releases](https://github.com/haowei2000/Cheers/releases/latest) — no Rust +[GitHub Releases](https://github.com/ElePerson/Cheers/releases/latest) — no Rust toolchain needed (`release-connector` publishes `cce-acp-connector-{darwin,linux}-{arm64,amd64}` per tag): @@ -48,7 +48,7 @@ toolchain needed (`release-connector` publishes 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 curl -fsSL -o ~/.cheers/bin/cce-acp-connector \ - "https://github.com/haowei2000/Cheers/releases/latest/download/cce-acp-connector-$os-$arch" + "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 to your shell profile to keep it cce-acp-connector --help @@ -57,7 +57,7 @@ cce-acp-connector --help To pin a version, replace `latest/download` with `download/connector-v` (e.g. `download/connector-v0.1.22`). While the repository is **private**, plain curl returns 404 — download with the authenticated GitHub CLI instead: -`gh release download connector-v0.1.22 -R haowei2000/Cheers -p "cce-acp-connector-$os-$arch" -O ~/.cheers/bin/cce-acp-connector`. +`gh release download connector-v0.1.22 -R ElePerson/Cheers -p "cce-acp-connector-$os-$arch" -O ~/.cheers/bin/cce-acp-connector`. Developers hacking on the connector itself can keep using the source build (`cargo build` → `target/debug/cce-acp-connector`); the commands below assume `cce-acp-connector` is on `PATH` either way. diff --git "a/docs/help/\346\234\254\345\234\260Bot\351\205\215\347\275\256\346\214\207\345\215\227.zh-CN.md" "b/docs/help/\346\234\254\345\234\260Bot\351\205\215\347\275\256\346\214\207\345\215\227.zh-CN.md" index fd712d29..41e2a0e4 100644 --- "a/docs/help/\346\234\254\345\234\260Bot\351\205\215\347\275\256\346\214\207\345\215\227.zh-CN.md" +++ "b/docs/help/\346\234\254\345\234\260Bot\351\205\215\347\275\256\346\214\207\345\215\227.zh-CN.md" @@ -38,7 +38,7 @@ ### 1.1 获取连接器二进制(预编译 Release) -直接从项目的 [GitHub Releases](https://github.com/haowei2000/Cheers/releases/latest) +直接从项目的 [GitHub Releases](https://github.com/ElePerson/Cheers/releases/latest) 下载对应平台的二进制,无需 Rust 工具链(`release-connector` workflow 按 tag 发布 `cce-acp-connector-{darwin,linux}-{arm64,amd64}` 四个产物): @@ -46,7 +46,7 @@ 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 curl -fsSL -o ~/.cheers/bin/cce-acp-connector \ - "https://github.com/haowei2000/Cheers/releases/latest/download/cce-acp-connector-$os-$arch" + "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" # 写进 shell profile 长期生效 cce-acp-connector --help @@ -55,7 +55,7 @@ cce-acp-connector --help 需要固定版本时,把 `latest/download` 换成 `download/connector-v<版本号>` (例如 `download/connector-v0.1.22`)。仓库还是**私有**时匿名 curl 会 404, 有权限的用户改用 GitHub CLI 认证下载: -`gh release download connector-v0.1.22 -R haowei2000/Cheers -p "cce-acp-connector-$os-$arch" -O ~/.cheers/bin/cce-acp-connector`。 +`gh release download connector-v0.1.22 -R ElePerson/Cheers -p "cce-acp-connector-$os-$arch" -O ~/.cheers/bin/cce-acp-connector`。 开发连接器本身的同学仍可用源码构建 (`cargo build` → `target/debug/cce-acp-connector`);下文命令默认 `cce-acp-connector` 已在 `PATH` 上,两种方式均可。 diff --git a/frontend/src/features/bots/BotOnboardingWizard.tsx b/frontend/src/features/bots/BotOnboardingWizard.tsx index a6e88216..56e4c895 100644 --- a/frontend/src/features/bots/BotOnboardingWizard.tsx +++ b/frontend/src/features/bots/BotOnboardingWizard.tsx @@ -38,7 +38,7 @@ type Mode = "manual" | "script" | "agent"; /** Where prebuilt connector binaries are published (release-connector workflow). * Keep in sync with the default in server/assets/install.sh. */ -const CONNECTOR_RELEASES_REPO = "haowei2000/Cheers"; +const CONNECTOR_RELEASES_REPO = "ElePerson/Cheers"; /** Same-origin download (gateway proxies the GitHub release): works from hosts * that can reach this server but not GitHub. GitHub stays the fallback. */ const CONNECTOR_DOWNLOAD_CMD = `os=$(uname -s | tr 'A-Z' 'a-z'); arch=$(uname -m | sed -e 's/x86_64/amd64/' -e 's/aarch64/arm64/') diff --git a/server/assets/install.sh b/server/assets/install.sh index 80445dec..afe10f82 100644 --- a/server/assets/install.sh +++ b/server/assets/install.sh @@ -15,7 +15,7 @@ # CHEERS_API_BASE gateway API base; default injected at serve time # CHEERS_CONNECTOR_BIN path to cce-acp-connector (else found on PATH, else a # prebuilt release binary is downloaded for this platform) -# CHEERS_CONNECTOR_REPO GitHub owner/repo for releases (default haowei2000/Cheers) +# CHEERS_CONNECTOR_REPO GitHub owner/repo for releases (default ElePerson/Cheers) # CHEERS_CONNECTOR_VERSION connector version, e.g. 0.1.22 (default: latest) # CHEERS_INSTALL_DAEMON=0 skip the launchd/systemd unit (just write + start) # CHEERS_AUTO_UPDATE=1 enable signed self-update in the written config @@ -93,7 +93,7 @@ fi # against a newer glibc "downloads fine" and then crash-loops the keep-alive # service (seen in the wild: GLIBC_2.39 binary on Ubuntu 22.04 / glibc 2.35). if [ -z "$BIN" ]; then - REPO="${CHEERS_CONNECTOR_REPO:-haowei2000/Cheers}" + REPO="${CHEERS_CONNECTOR_REPO:-ElePerson/Cheers}" VER="${CHEERS_CONNECTOR_VERSION:-latest}" os="$(uname -s)"; arch="$(uname -m)" case "$os" in Darwin) os=darwin ;; Linux) os=linux ;; *) os="" ;; esac @@ -186,7 +186,7 @@ if [ -z "$BIN" ]; then Config and token are in place, but no connector binary was found and none could be downloaded for this platform. Build it once: - git clone https://github.com/haowei2000/Cheers && cd Cheers/packages/cheers-acp-connector-rs + git clone https://github.com/ElePerson/Cheers && cd Cheers/packages/cheers-acp-connector-rs cargo build --release # → target/release/cce-acp-connector then re-run with CHEERS_CONNECTOR_BIN=/path/to/cce-acp-connector, or start by hand: cce-acp-connector start --config "$CONFIG_FILE" --name "$ACCOUNT_ID" 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/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/website/README.md b/website/README.md index a572c787..075c3c03 100644 --- a/website/README.md +++ b/website/README.md @@ -30,7 +30,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. @@ -39,7 +39,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/index.html b/website/index.html index 925c1692..2a0faccf 100644 --- a/website/index.html +++ b/website/index.html @@ -252,7 +252,7 @@ @@ -414,7 +414,7 @@

      Get productive in minutes

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

      Read the full user guide →

      +

      Read the full user guide →

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

      How it fits together

      @@ -559,13 +559,13 @@

      Go deeper

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

      @@ -575,10 +575,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..ff4016c6 100644 --- a/website/index.zh-CN.html +++ b/website/index.zh-CN.html @@ -251,7 +251,7 @@ @@ -412,7 +412,7 @@

      几分钟即可上手

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

      阅读完整用户指南 →

      +

      阅读完整用户指南 →

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

      整体结构

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

      深入了解

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

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

      深入了解

      🍻 Cheers —— 人类与 AI 智能体的开源协作平台。MIT 许可。
      diff --git a/website/mcp.html b/website/mcp.html index 3b589bb1..04f9b474 100644 --- a/website/mcp.html +++ b/website/mcp.html @@ -159,7 +159,7 @@ @@ -278,15 +278,15 @@

      Where a post_message spends its time

      Read more

      diff --git a/website/plugin-dev.html b/website/plugin-dev.html index 0123f1ac..97ea3d20 100644 --- a/website/plugin-dev.html +++ b/website/plugin-dev.html @@ -145,7 +145,7 @@ @@ -256,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

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

        7. Install & bind

        Read more

        From cb796b847fe74e71f05a22662ddbd6e55875e350 Mon Sep 17 00:00:00 2001 From: haowei Date: Thu, 16 Jul 2026 21:29:57 +0800 Subject: [PATCH 22/28] fix(connector): migrate to rand 0.10 API thread_rng() -> rng(), Rng::gen_range -> RngExt::random_range Co-Authored-By: Claude Fable 5 --- packages/cheers-acp-connector-rs/src/bridge.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cheers-acp-connector-rs/src/bridge.rs b/packages/cheers-acp-connector-rs/src/bridge.rs index bcda1cbd..72fdea5e 100644 --- a/packages/cheers-acp-connector-rs/src/bridge.rs +++ b/packages/cheers-acp-connector-rs/src/bridge.rs @@ -4,7 +4,7 @@ use std::time::Duration; use anyhow::{anyhow, Context}; use futures_util::{SinkExt, StreamExt}; -use rand::Rng; +use rand::RngExt; use serde::Serialize; use serde_json::Value; use tokio::net::TcpStream; @@ -436,7 +436,7 @@ pub fn compute_backoff(attempt: u32, opts: ReconnectOptions) -> Duration { .base_ms .saturating_mul(2_u64.saturating_pow(attempt.saturating_sub(1))); let capped = exp.min(opts.max_ms); - let jitter = rand::thread_rng().gen_range(0.5..=1.0); + let jitter = rand::rng().random_range(0.5..=1.0); Duration::from_millis((capped as f64 * jitter).round() as u64) } From 90167f2199937d3baf4e918bf61c354a3228a4a4 Mon Sep 17 00:00:00 2001 From: haowei Date: Thu, 16 Jul 2026 21:31:20 +0800 Subject: [PATCH 23/28] docs(website): connector guide fixes from review - create ~/.cheers/workspace in setup (connector requires allowed_roots to exist) - add troubleshooting row for allowed_roots does not exist - point all links at ElePerson/Cheers after the repo transfer - escape & in Q&A hrefs; unpin release tag in gh download callout Co-Authored-By: Claude Fable 5 --- website/connector.html | 22 ++++++++++++---------- website/connector.zh-CN.html | 22 ++++++++++++---------- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/website/connector.html b/website/connector.html index d49cb85e..8a99e454 100644 --- a/website/connector.html +++ b/website/connector.html @@ -156,7 +156,7 @@ @@ -202,15 +202,16 @@

        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
        +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/haowei2000/Cheers/releases/latest/download/cce-acp-connector-$os-$arch"
        +  "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 connector-v0.1.28 -R haowei2000/Cheers -p "cce-acp-connector-$os-$arch" -O ~/.cheers/bin/cce-acp-connector.
        + 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:

        @@ -309,7 +310,7 @@

        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/haowei2000/Cheers/releases/latest/download/cce-acp-connector-$os-$arch"
        +  "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 @@ -332,6 +333,7 @@

        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 @@ -341,15 +343,15 @@

        8. Troubleshooting

        Read more

        diff --git a/website/connector.zh-CN.html b/website/connector.zh-CN.html index d0eb57d3..25eee4d3 100644 --- a/website/connector.zh-CN.html +++ b/website/connector.zh-CN.html @@ -156,7 +156,7 @@ @@ -198,14 +198,15 @@

        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
        +mkdir -p ~/.cheers/bin ~/.cheers/workspace   # workspace 是智能体的工作目录——必须存在,否则连接器会拒绝启动
         curl -fsSL -o ~/.cheers/bin/cce-acp-connector \
        -  "https://github.com/haowei2000/Cheers/releases/latest/download/cce-acp-connector-$os-$arch"
        +  "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 connector-v0.1.28 -R haowei2000/Cheers -p "cce-acp-connector-$os-$arch" -O ~/.cheers/bin/cce-acp-connector
        + gh release download -R ElePerson/Cheers -p "cce-acp-connector-$os-$arch" -O ~/.cheers/bin/cce-acp-connector + (不带 tag 时 gh 会取最新的 release)。

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

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

        @@ -301,7 +302,7 @@

        方式 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/haowei2000/Cheers/releases/latest/download/cce-acp-connector-$os-$arch"
        +  "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

        自动更新默认关闭是有意为之——更新意味着执行下载来的代码,所以由机器主人自己选择开启。 @@ -322,6 +323,7 @@

        8. 排障

        你看到的现象可能原因处理 机器人无视你附加的文件——让你上传或给路径,尽管消息上明明有小标签连接器版本旧于"把附加 context 渲染进 prompt"的那一版更新连接器(≥ 0.1.28)后 restart;同时确认 allow_attachments = truestart 立刻退出,日志提到 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;确认服务器在线 @@ -331,15 +333,15 @@

        8. 排障

        延伸阅读

        From 06566ce2befa342556645d9a38567bddabc8a02e Mon Sep 17 00:00:00 2001 From: haowei Date: Thu, 16 Jul 2026 21:31:51 +0800 Subject: [PATCH 24/28] fix(workbench): align plugin docs and temp-load caps with actual behavior - PLUGIN_DEVELOPMENT.md: cheers:render is sent on ready and after a conflicted save; external edits do not push a re-render - pluginManifest.ts: correct the bundle-cap comment (server cap lands with install validation) - WorkbenchDrawer: measure the 2 MiB cap in UTF-8 bytes; accept .htm and uppercase extensions on drop Co-Authored-By: Claude Fable 5 --- docs/developer/PLUGIN_DEVELOPMENT.md | 16 ++++++++++++---- .../features/chat/workbench/WorkbenchDrawer.tsx | 12 +++++++----- .../chat/workbench/sandbox/pluginManifest.ts | 5 +++-- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/docs/developer/PLUGIN_DEVELOPMENT.md b/docs/developer/PLUGIN_DEVELOPMENT.md index 82242561..70329e4e 100644 --- a/docs/developer/PLUGIN_DEVELOPMENT.md +++ b/docs/developer/PLUGIN_DEVELOPMENT.md @@ -108,7 +108,7 @@ a specific origin; the host, in turn, only accepts messages from your iframe). | Direction | `type` | Payload | When | |---|---|---|---| | plugin → host | `cheers:ready` | — | iframe loaded; "assign me work". Send it **after** your message listener is wired. | -| host → plugin | `cheers:render` | `{ path, format, content, version, rendererId }` | Assigns **one** file. Re-sent when the file changes externally and after a save conflict. `rendererId` says which of your manifest's renderers was picked. | +| host → plugin | `cheers:render` | `{ path, format, content, version, rendererId }` | Assigns **one** file. Sent in reply to your `cheers:ready`, and re-sent after a conflicted save — those are the only triggers (§5.2). `rendererId` says which of your manifest's renderers was picked. | | plugin → host | `cheers:unsupported` | `{ reason? }` | Runtime verdict: you inspected `content` and can't render it. The host hides your iframe and shows the reason. | | plugin → host | `cheers:save` | `{ content }` | Write the assigned file back (whole-content replace). | | host → plugin | `cheers:saved` | `{ ok, version, error? }` | Result of your save. On `ok`, adopt the new `version`. | @@ -127,9 +127,15 @@ user edits in your UI ──▶ plugin: cheers:save {content} ├─ ok → host: cheers:saved {ok:true, version} (adopt version) └─ conflict → host: cheers:saved {ok:false, error} host: cheers:render {…fresh content/version} (re-render) -file changed by someone else (bot / another member) ──▶ host: cheers:render {…} ``` +Those are the **only** two `cheers:render` triggers: your `cheers:ready` and a +conflicted save. A file changed by someone else (bot / another member) does **not** +push a new render into an open iframe — you see the fresh content when the user +reopens the file (remount → `ready` → `render`), or via the re-render that follows +your own conflicted save. There is no way to re-read the assigned file yourself: +the §5.4 resource whitelist covers channel data only, not file content. + ### 5.3 Rules - **Single-file capability.** One `cheers:render` = one file. You can only render and @@ -230,8 +236,10 @@ Recipes in words: `JSON.parse` in try/catch and verify shapes, `cheers:unsupported` when they don't hold; save with `JSON.stringify(data, null, 2)`. - **Use channel context** — call the §5.4 resource helper, e.g. `channel.members` to - resolve author ids to names in your UI. Data may be stale seconds later; re-fetch on - each `cheers:render` rather than caching across renders. + resolve author ids to names in your UI. Data may be stale seconds later, and renders + are rare (§5.2: on `ready` and after a conflicted save — never on external edits), so + re-fetch via the resource helper when freshness matters rather than counting on a new + `cheers:render` to refresh it for you. ## 8. Install & bind diff --git a/frontend/src/features/chat/workbench/WorkbenchDrawer.tsx b/frontend/src/features/chat/workbench/WorkbenchDrawer.tsx index bbda0565..33420ed0 100644 --- a/frontend/src/features/chat/workbench/WorkbenchDrawer.tsx +++ b/frontend/src/features/chat/workbench/WorkbenchDrawer.tsx @@ -245,7 +245,8 @@ function WorkbenchDrawerImpl({ open, onClose, channelId, sendResourceReq, openFi // same-id session plugin shadows the installed one for this session, so existing // bindings transparently resolve to the fresh bundle while you iterate. const loadTemporaryPlugin = useCallback((html: string) => { - if (html.length > MAX_PLUGIN_BUNDLE_BYTES) { + const byteLength = new TextEncoder().encode(html).length; + if (byteLength > MAX_PLUGIN_BUNDLE_BYTES) { setNotice("Plugin bundle too large (max 2 MiB)"); return; } @@ -266,9 +267,10 @@ function WorkbenchDrawerImpl({ open, onClose, channelId, sendResourceReq, openFi // One extension entry point, routed by extension: .json => template, .html => plugin. const loadExtensionFile = useCallback( (file: File) => { - if (file.name.endsWith(".json")) void file.text().then(loadTemporary); - else if (file.name.endsWith(".html")) void file.text().then(loadTemporaryPlugin); - else setNotice("Drop a .json template or a .html renderer plugin"); + const name = file.name.toLowerCase(); + if (name.endsWith(".json")) void file.text().then(loadTemporary); + else if (name.endsWith(".html") || name.endsWith(".htm")) void file.text().then(loadTemporaryPlugin); + else setNotice("Drop a .json template or a .html/.htm renderer plugin"); }, [loadTemporary, loadTemporaryPlugin] ); @@ -507,7 +509,7 @@ function WorkbenchDrawerImpl({ open, onClose, channelId, sendResourceReq, openFi diff --git a/frontend/src/features/chat/workbench/sandbox/pluginManifest.ts b/frontend/src/features/chat/workbench/sandbox/pluginManifest.ts index 586b5b04..f4e04595 100644 --- a/frontend/src/features/chat/workbench/sandbox/pluginManifest.ts +++ b/frontend/src/features/chat/workbench/sandbox/pluginManifest.ts @@ -34,8 +34,9 @@ export interface PluginMeta { transient?: boolean; } -/** Upper bound for a plugin bundle. Guards the session (temporary) load path; the - * server enforces the same cap on install. Also the sane ceiling for iframe srcDoc. */ +/** 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; /** Shape-check a parsed plugin manifest. Returns an error message, or null when valid. */ From ab0f935775b53efb56502349b21c6266bbb69b22 Mon Sep 17 00:00:00 2001 From: haowei Date: Thu, 16 Jul 2026 21:48:22 +0800 Subject: [PATCH 25/28] =?UTF-8?q?fix(workbench):=20protocol-1=20review=20f?= =?UTF-8?q?ixes=20=E2=80=94=20SDK=20save=20rule,=20text-safe=20registry,?= =?UTF-8?q?=20validator=20parity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SDK: enforce single-in-flight save (host does not correlate saves); document the rule as normative in PLUGIN_DEVELOPMENT.md - registry.ts: replace literal NUL glob placeholders with \u0000 escapes so the file diffs as text on GitHub - pluginManifest.ts: mirror the server's per-key manifest validation so session loads and installs accept the same manifests - server: accept protocol 1.0 (JSON number equal to 1) — the frontend cannot distinguish 1.0 from 1 after JSON.parse - examples: lit-review uses dataHas instead of deprecated jsonHas Co-Authored-By: Claude Fable 5 --- docs/arch/examples/README.md | 2 +- docs/arch/examples/cheers-plugin-sdk.js | 20 ++++- docs/arch/examples/lit-review.plugin.html | 2 +- docs/developer/PLUGIN_DEVELOPMENT.md | 21 +++-- .../chat/workbench/renderers/registry.test.ts | 14 ++++ .../chat/workbench/renderers/registry.ts | Bin 7699 -> 7709 bytes .../sandbox/examples/lit-review.html | 2 +- .../workbench/sandbox/pluginManifest.test.ts | 57 ++++++++++++++ .../chat/workbench/sandbox/pluginManifest.ts | 54 +++++++++++-- server/src/domain/workbench_plugins.rs | 73 ++++++++++++++---- 10 files changed, 208 insertions(+), 37 deletions(-) diff --git a/docs/arch/examples/README.md b/docs/arch/examples/README.md index 16d0ab2c..87e9ca6e 100644 --- a/docs/arch/examples/README.md +++ b/docs/arch/examples/README.md @@ -10,7 +10,7 @@ | 文件 | 场景 | 说明 | |---|---|---| | [`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 index 7afd8141..0b67f09c 100644 --- a/docs/arch/examples/cheers-plugin-sdk.js +++ b/docs/arch/examples/cheers-plugin-sdk.js @@ -7,13 +7,18 @@ * * var host = cheersPlugin({ * onRender: function (file) { - * // { path, format, content, version, rendererId } — also re-sent when the - * // file changes externally and after a save conflict: always re-draw here. + * // { path, format, content, version, rendererId } — sent in reply to your + * // cheers:ready and re-sent after a conflicted save (the only two triggers, + * // §5.2 — external edits do NOT re-render): always re-draw here. * } * }); * host.save(content) // -> Promise<{version}>; rejects on failure (on a * // version conflict a fresh onRender follows — let - * // the user reapply, don't blind-retry) + * // the user reapply, don't blind-retry). At most ONE + * // save may be in flight: the host does not correlate + * // saves (cheers:saved carries no reqId), so calling + * // save() again before the previous one settles + * // rejects immediately. * host.resource(name, params) // -> Promise for the read-only channel.* * // whitelist (channel.info / members / messages …) * host.unsupported(reason) // -> final verdict: this content can't be rendered @@ -23,7 +28,7 @@ function cheersPlugin(opts) { var reqId = 0; var pendingRes = {}; // reqId -> {resolve, reject} - var pendingSave = null; // single in-flight save; the host answers in order + var pendingSave = null; // the ONE allowed in-flight save (hosts do not correlate saves) window.addEventListener("message", function (e) { var m = e.data; if (!m || typeof m !== "object") return; @@ -43,6 +48,13 @@ function cheersPlugin(opts) { var api = { save: function (content) { return new Promise(function (resolve, reject) { + if (pendingSave) { + // cheers:saved carries no correlation id, so overlapping saves could adopt + // each other's results. Reject loudly instead of queueing silently — await + // the previous save() before issuing the next one. + reject(new Error("save already in flight — await the previous save() first")); + return; + } pendingSave = { resolve: resolve, reject: reject }; parent.postMessage({ type: "cheers:save", content: content }, "*"); }); diff --git a/docs/arch/examples/lit-review.plugin.html b/docs/arch/examples/lit-review.plugin.html index da88c017..b653da8c 100644 --- a/docs/arch/examples/lit-review.plugin.html +++ b/docs/arch/examples/lit-review.plugin.html @@ -9,7 +9,7 @@ "protocol": 1, "title": "Literature review", "renderers": [ - { "id": "papers", "title": "Paper tracker", "match": { "format": "json", "jsonHas": ["papers"] } } + { "id": "papers", "title": "Paper tracker", "match": { "format": "json", "dataHas": ["papers"] } } ] } diff --git a/docs/developer/PLUGIN_DEVELOPMENT.md b/docs/developer/PLUGIN_DEVELOPMENT.md index 485989c8..40da5d68 100644 --- a/docs/developer/PLUGIN_DEVELOPMENT.md +++ b/docs/developer/PLUGIN_DEVELOPMENT.md @@ -120,8 +120,8 @@ a specific origin; the host, in turn, only accepts messages from your iframe). | plugin → host | `cheers:ready` | — | iframe loaded; "assign me work". Send it **after** your message listener is wired. | | host → plugin | `cheers:render` | `{ path, format, content, version, rendererId }` | Assigns **one** file. Sent in reply to your `cheers:ready`, and re-sent after a conflicted save — those are the only triggers (§5.2). `rendererId` says which of your manifest's renderers was picked. | | plugin → host | `cheers:unsupported` | `{ reason? }` | Runtime verdict: you inspected `content` and can't render it. The host hides your iframe and shows the reason. | -| plugin → host | `cheers:save` | `{ content }` | Write the assigned file back (whole-content replace). | -| host → plugin | `cheers:saved` | `{ ok, version, error? }` | Result of your save. On `ok`, adopt the new `version`. | +| plugin → host | `cheers:save` | `{ content }` | Write the assigned file back (whole-content replace). At most **one** save in flight (§5.3). | +| host → plugin | `cheers:saved` | `{ ok, version, error? }` | Result of your save. On `ok`, adopt the new `version`. Carries **no correlation id** — it answers "the" pending save, which is why only one may be in flight. | | plugin → host | `cheers:resource` | `{ reqId, resource, params }` | Read-only channel info (whitelist, §5.4). `reqId` is your correlation number. | | host → plugin | `cheers:resource:result` | `{ reqId, ok, data\|error }` | Resource read result. | @@ -155,6 +155,10 @@ the §5.4 resource whitelist covers channel data only, not file content. it last sent you. On conflict you get `cheers:saved {ok:false}` **followed by a fresh `cheers:render`** — re-render from the new content and let the user reapply. Do not retry the save blindly. +- **One save in flight.** Hosts do **not** correlate saves: each `cheers:save` is + answered by an independent `cheers:saved` with no request id, and completion order is + not guaranteed. Plugins MUST wait for `cheers:saved` before sending the next + `cheers:save` (the SDK enforces this — an overlapping `save()` rejects). - **Safe rendering.** `content` is untrusted text (it may come from a bot or another member). Write it to the DOM with `textContent` or controlled form values — **never concatenate into `innerHTML`**. @@ -229,8 +233,8 @@ files, there is no external loading in the sandbox: ```js var host = cheersPlugin({ onRender: function (file) { - // { path, format, content, version, rendererId } — re-sent on external change - // and after a save conflict: always re-draw here. + // { path, format, content, version, rendererId } — sent on your ready and + // re-sent after a conflicted save (the only triggers, §5.2): always re-draw here. }, }); host.save(next).then(function (r) { /* r.version */ }).catch(function (e) { /* show e */ }); @@ -248,7 +252,7 @@ Complete working examples (upload as-is, or drop on the drawer to try): todo list → interactive checklist. The canonical *"markdown convention + narrow `match` + line-preserving rewrite"* recipe. - [`lit-review.plugin.html`](../arch/examples/lit-review.plugin.html) — paper-tracker - table over `{ "papers": [...] }` JSON (`match.jsonHas` pre-filter + runtime array + table over `{ "papers": [...] }` JSON (`match.dataHas` pre-filter + runtime array check + form-driven JSON writeback). - [`code-review.plugin.html`](../arch/examples/code-review.plugin.html) — markdown review findings (`## file` sections, `- [ ] [P0|P1|P2]` items) with severity badges. @@ -263,9 +267,10 @@ Recipes in words: - **Claim a markdown convention** — declare `format:"markdown"` plus `requireAny` /`requireAll` markers for your convention; split content into lines on render, edit lines in place, `join("\n")` on save so non-convention lines survive byte-for-byte. -- **Claim a JSON structure** — declare `jsonHas` for your top-level keys; on render, - `JSON.parse` in try/catch and verify shapes, `cheers:unsupported` when they don't - hold; save with `JSON.stringify(data, null, 2)`. +- **Claim a JSON structure** — declare `dataHas` for your top-level keys (or + `dataKind:"array"` for a top-level array); on render, `JSON.parse` in try/catch and + verify shapes, `cheers:unsupported` when they don't hold; save with + `JSON.stringify(data, null, 2)`. - **Use channel context** — call the §5.4 resource helper, e.g. `channel.members` to resolve author ids to names in your UI. Data may be stale seconds later, and renders are rare (§5.2: on `ready` and after a conflicted save — never on external edits), so diff --git a/frontend/src/features/chat/workbench/renderers/registry.test.ts b/frontend/src/features/chat/workbench/renderers/registry.test.ts index ea0bf036..823e5aec 100644 --- a/frontend/src/features/chat/workbench/renderers/registry.test.ts +++ b/frontend/src/features/chat/workbench/renderers/registry.test.ts @@ -62,8 +62,22 @@ describe("accepts — declared acceptance", () => { }; const r = getRenderer("plugin:g:r", [p])!; expect(accepts(r, "reviews/a.md", "x")).toBe(true); + expect(accepts(r, "reviews/a/b.md", "x")).toBe(false); // * stops at "/" expect(accepts(r, "notes/a.md", "x")).toBe(false); }); + + it("** in a glob crosses path segments (the \\u0000 placeholder path)", () => { + const p: PluginMeta = { + plugin_id: "g2", + title: "g2", + manifest: { + renderers: [{ id: "r", title: "r", match: { format: "markdown", glob: "reviews/**/*.md" } }], + }, + }; + const r = getRenderer("plugin:g2:r", [p])!; + expect(accepts(r, "reviews/a/b/c.md", "x")).toBe(true); + expect(accepts(r, "notes/a/b.md", "x")).toBe(false); + }); }); describe("candidatesFor — content-aware, specificity-ordered", () => { diff --git a/frontend/src/features/chat/workbench/renderers/registry.ts b/frontend/src/features/chat/workbench/renderers/registry.ts index 500a1c12927f8eab13a9fbbdade41cfde21c4b4f..6378e794e1a6c9cdbc3dd5ea6de6df7523cf1c1f 100644 GIT binary patch delta 30 fcmbPiGuLK=CkIZ|NEHPDk?9Eo delta 20 bcmbPhGudW?CkG?LWPgriMuyEhI8sFcLk0!r diff --git a/frontend/src/features/chat/workbench/sandbox/examples/lit-review.html b/frontend/src/features/chat/workbench/sandbox/examples/lit-review.html index da88c017..b653da8c 100644 --- a/frontend/src/features/chat/workbench/sandbox/examples/lit-review.html +++ b/frontend/src/features/chat/workbench/sandbox/examples/lit-review.html @@ -9,7 +9,7 @@ "protocol": 1, "title": "Literature review", "renderers": [ - { "id": "papers", "title": "Paper tracker", "match": { "format": "json", "jsonHas": ["papers"] } } + { "id": "papers", "title": "Paper tracker", "match": { "format": "json", "dataHas": ["papers"] } } ] } diff --git a/frontend/src/features/chat/workbench/sandbox/pluginManifest.test.ts b/frontend/src/features/chat/workbench/sandbox/pluginManifest.test.ts index ca525c7a..770bbefb 100644 --- a/frontend/src/features/chat/workbench/sandbox/pluginManifest.test.ts +++ b/frontend/src/features/chat/workbench/sandbox/pluginManifest.test.ts @@ -90,4 +90,61 @@ describe("validatePluginManifest", () => { 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 index c9095a57..165298c4 100644 --- a/frontend/src/features/chat/workbench/sandbox/pluginManifest.ts +++ b/frontend/src/features/chat/workbench/sandbox/pluginManifest.ts @@ -4,8 +4,8 @@ /** 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 a HIGHER protocol - * (they'd speak messages we don't) instead of half-rendering them. */ + * 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 @@ -58,12 +58,46 @@ export interface PluginMeta { * server-side install validation). Also the sane ceiling for iframe srcDoc. */ export const MAX_PLUGIN_BUNDLE_BYTES = 2 * 1024 * 1024; -/** Shape-check a parsed plugin manifest. Returns an error message, or null when valid. */ +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 (typeof o.title !== "string" || !o.title.trim()) return "manifest.title 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) @@ -74,12 +108,16 @@ export function validatePluginManifest(m: unknown): string | null { 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()) return "each renderer needs a non-empty string id"; - if (typeof rr.title !== "string" || !rr.title.trim()) return "each renderer needs a non-empty string title"; + 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 (rr.match !== undefined && (!rr.match || typeof rr.match !== "object" || Array.isArray(rr.match))) - return "renderer match must be an object when present"; + 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/server/src/domain/workbench_plugins.rs b/server/src/domain/workbench_plugins.rs index 2f9a9c04..7d406bda 100644 --- a/server/src/domain/workbench_plugins.rs +++ b/server/src/domain/workbench_plugins.rs @@ -24,8 +24,9 @@ fn plugin_id_ok(id: &str) -> bool { 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'-')) + && b.iter().all(|c| { + c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, b'.' | b'_' | b'-') + }) } fn validate_match(m: &Value) -> Result<(), String> { @@ -78,7 +79,12 @@ pub fn validate_manifest(plugin_id: &str, manifest: &Value) -> Result<(), String return Err("manifest.title must be a non-empty string (max 255 bytes)".into()); } if let Some(p) = obj.get("protocol") { - if p.as_i64() != Some(PLUGIN_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})" )); @@ -214,16 +220,39 @@ mod tests { fn rejects_unsupported_protocol() { let mut m = ok_manifest(); m["protocol"] = json!(2); - assert!(validate_manifest("md-checklist", &m).unwrap_err().contains("protocol")); + 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")); + 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")); + assert!(validate_manifest("md-checklist", &m) + .unwrap_err() + .contains("legacy")); } #[test] @@ -240,9 +269,13 @@ mod tests { fn rejects_missing_or_empty_renderers() { let mut m = ok_manifest(); m["renderers"] = json!([]); - assert!(validate_manifest("md-checklist", &m).unwrap_err().contains("renderers")); + 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")); + assert!(validate_manifest("md-checklist", &m) + .unwrap_err() + .contains("renderers")); } #[test] @@ -252,11 +285,17 @@ mod tests { { "id": "dup", "title": "One" }, { "id": "dup", "title": "Two" } ]); - assert!(validate_manifest("md-checklist", &m).unwrap_err().contains("duplicate")); + 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")); + 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")); + assert!(validate_manifest("md-checklist", &m) + .unwrap_err() + .contains("title")); } #[test] @@ -267,10 +306,16 @@ mod tests { "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")); + 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")); + 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")); + assert!(validate_manifest("md-checklist", &m) + .unwrap_err() + .contains("requireAll")); } } From a311df95cab6227b7c330f77f46f23af97feb4ab Mon Sep 17 00:00:00 2001 From: haowei Date: Thu, 16 Jul 2026 21:52:02 +0800 Subject: [PATCH 26/28] =?UTF-8?q?fix(workbench):=20registry.ts=20as=20plai?= =?UTF-8?q?n=20text=20=E2=80=94=20escape=20NUL=20glob=20placeholders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Literal 0x00 bytes in globToRegExp made git classify the file as binary, hiding every diff of this file on GitHub. Same runtime value via \u0000 escapes. Co-Authored-By: Claude Fable 5 --- .../chat/workbench/renderers/registry.ts | Bin 5988 -> 5998 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/frontend/src/features/chat/workbench/renderers/registry.ts b/frontend/src/features/chat/workbench/renderers/registry.ts index aa0841f7f6ea9863d2d05ffc640482da5772f662..03d83c9271bcb57fe594383a45fa0807f9305294 100644 GIT binary patch delta 30 fcmaE&_fBtv6+2r@sR0m7wq{R;GB?j>KgtgPpD+ot delta 20 ccmaE-_e5`l6+0utWC!+SMuyER*^lxA089Y}mH+?% From 7fe32cf7c5e40c2d85e69daa76badf58e4f04224 Mon Sep 17 00:00:00 2001 From: haowei Date: Thu, 16 Jul 2026 21:58:44 +0800 Subject: [PATCH 27/28] fix(workbench): official-plugin review fixes - Dockerfile: make the asset cache-bust predicate actually match (relative find paths never matched */assets/*) - workbench_official: checked i32 conversion for manifest version; SkipAdminClaimed logs at info - checklist/kanban manifests: offer renderers for * [ ] bullets too - table plugin: Set-based column dedup (survives constructor/toString keys) Co-Authored-By: Claude Fable 5 --- server/Dockerfile | 6 ++-- .../cheers-checklist.plugin.html | 5 +++- .../cheers-kanban-md.plugin.html | 6 +++- .../cheers-table.plugin.html | 6 ++-- server/src/domain/workbench_official.rs | 28 +++++++++++++------ 5 files changed, 36 insertions(+), 15 deletions(-) diff --git a/server/Dockerfile b/server/Dockerfile index 398b671a..ac6939d1 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -39,8 +39,10 @@ COPY server/assets ./assets # the empty stub binary (exits 0 immediately → CrashLoopBackOff). Touch the real # sources so their mtime beats the stubs and both crates actually recompile. # assets too: include_str! deps are tracked via dep-info mtimes — an assets-only -# change would otherwise be skipped by the same stale-artifact check. -RUN find src assets /app/packages/cheers-acp-connector-rs/bridge-protocol/src \( -name '*.rs' -o -path '*/assets/*' \) -exec touch {} + \ +# change would otherwise be skipped by the same stale-artifact check. The find +# starts at the RELATIVE path `assets`, so entries print as `assets/...` — +# the pattern must be 'assets/*' (a '*/assets/*' pattern would never match). +RUN find src assets /app/packages/cheers-acp-connector-rs/bridge-protocol/src \( -name '*.rs' -o -path 'assets/*' \) -exec touch {} + \ && cargo build --release # ── runtime ────────────────────────────────────────────────────────────────── diff --git a/server/assets/workbench-plugins/cheers-checklist.plugin.html b/server/assets/workbench-plugins/cheers-checklist.plugin.html index 2c72a7c8..6ee785c4 100644 --- a/server/assets/workbench-plugins/cheers-checklist.plugin.html +++ b/server/assets/workbench-plugins/cheers-checklist.plugin.html @@ -14,7 +14,10 @@ { "id": "checklist", "title": "Checklist", - "match": { "format": "markdown", "requireAny": ["- [ ]", "- [x]", "- [X]"] } + "match": { + "format": "markdown", + "requireAny": ["- [ ]", "- [x]", "- [X]", "* [ ]", "* [x]", "* [X]"] + } } ] } diff --git a/server/assets/workbench-plugins/cheers-kanban-md.plugin.html b/server/assets/workbench-plugins/cheers-kanban-md.plugin.html index 3528477b..3e9d8822 100644 --- a/server/assets/workbench-plugins/cheers-kanban-md.plugin.html +++ b/server/assets/workbench-plugins/cheers-kanban-md.plugin.html @@ -15,7 +15,11 @@ { "id": "kanban", "title": "Kanban", - "match": { "format": "markdown", "requireAll": ["## "], "requireAny": ["- [ ]", "- [x]", "- [X]"] } + "match": { + "format": "markdown", + "requireAll": ["## "], + "requireAny": ["- [ ]", "- [x]", "- [X]", "* [ ]", "* [x]", "* [X]"] + } } ] } diff --git a/server/assets/workbench-plugins/cheers-table.plugin.html b/server/assets/workbench-plugins/cheers-table.plugin.html index b55852d4..44e12e90 100644 --- a/server/assets/workbench-plugins/cheers-table.plugin.html +++ b/server/assets/workbench-plugins/cheers-table.plugin.html @@ -109,9 +109,11 @@ // Columns = union of row keys, first-seen order. Empty file => one starter column. function columns() { - var cols = [], seen = {}; + // Set, not {}: a plain object inherits Object.prototype, so row keys like + // "constructor" or "toString" would look already-seen and lose their column. + var cols = [], seen = new Set(); rows.forEach(function (r) { - Object.keys(r).forEach(function (k) { if (!seen[k]) { seen[k] = 1; cols.push(k); } }); + Object.keys(r).forEach(function (k) { if (!seen.has(k)) { seen.add(k); cols.push(k); } }); }); return cols.length ? cols : ["value"]; } diff --git a/server/src/domain/workbench_official.rs b/server/src/domain/workbench_official.rs index e389ac32..56fdda0e 100644 --- a/server/src/domain/workbench_official.rs +++ b/server/src/domain/workbench_official.rs @@ -90,6 +90,12 @@ pub async fn seed(db: &PgPool) -> Result<(), sqlx::Error> { 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", @@ -105,10 +111,7 @@ pub async fn seed(db: &PgPool) -> Result<(), sqlx::Error> { 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); + let title = manifest.get("title").and_then(Value::as_str).unwrap_or(&id); workbench_plugins::install( db, &id, @@ -126,14 +129,15 @@ pub async fn seed(db: &PgPool) -> Result<(), sqlx::Error> { DO UPDATE SET seeded_version = $2, seeded_at = NOW()", ) .bind(&id) - .bind(version as i32) + .bind(version_i32) .execute(db) .await?; info!(plugin = %id, version, "official workbench plugin seeded"); } Action::Noop => {} Action::SkipAdminClaimed => { - warn!(plugin = %id, "official plugin id is admin-claimed; leaving their row alone"); + // 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"); } } } @@ -151,11 +155,17 @@ mod tests { 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}"); + 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!( - m.get("version").and_then(Value::as_i64).is_some(), - "official manifests carry an integer version (drives re-seeding): {id}" + i32::try_from(version).is_ok(), + "official manifest version must fit the INT seeded_version column: {id}" ); } } From 5c9f4e1929dd62013f16fb0060e5ebe1165d7c06 Mon Sep 17 00:00:00 2001 From: haowei Date: Thu, 16 Jul 2026 22:12:51 +0800 Subject: [PATCH 28/28] =?UTF-8?q?fix(workbench):=20yaml-boards=20review=20?= =?UTF-8?q?fixes=20=E2=80=94=20table=20safety,=20comment=20edge=20cases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - builtin table: only offered for arrays of plain objects; render and update guard non-object rows so a null or scalar row can neither crash the app nor corrupt the file on save - yamlDoc: preserve header comments of comment-only files across the first save; disable lineWidth refolding of untouched scalars; align the array-replacement contract comment with the code and name the same-length-reorder comment hazard - docs: write-side YAML 1.1 ambiguity note (unquoted no/yes/on/off) - pluginManifest: yaml joins the documented format list Co-Authored-By: Claude Fable 5 --- docs/developer/PLUGIN_DEVELOPMENT.md | 8 +++- .../chat/workbench/lens/builtins.test.ts | 34 ++++++++++++++++ .../features/chat/workbench/lens/builtins.tsx | 39 +++++++++++++++---- .../chat/workbench/renderers/registry.test.ts | 16 ++++++++ .../chat/workbench/renderers/registry.ts | 19 ++++++--- .../chat/workbench/sandbox/pluginManifest.ts | 8 ++-- .../features/chat/workbench/yamlDoc.test.ts | 17 ++++++++ .../src/features/chat/workbench/yamlDoc.ts | 24 ++++++++---- 8 files changed, 139 insertions(+), 26 deletions(-) create mode 100644 frontend/src/features/chat/workbench/lens/builtins.test.ts diff --git a/docs/developer/PLUGIN_DEVELOPMENT.md b/docs/developer/PLUGIN_DEVELOPMENT.md index 1e0791a7..124f465f 100644 --- a/docs/developer/PLUGIN_DEVELOPMENT.md +++ b/docs/developer/PLUGIN_DEVELOPMENT.md @@ -109,7 +109,13 @@ cover YAML: the builtin table (arrays of objects) and chart (`series`) accept bo JSON and YAML, and their writes go through a comment-preserving round-trip — comments and blank lines in the file survive machine edits. Also remember YAML 1.2 semantics: `yes`/`no`/`on`/`off` are plain strings, not booleans — agents writing -1.1-style files will get strings back. +1.1-style files will get strings back. The same ambiguity exists on the **write +side**: when a lens saves a string cell whose value is a YAML 1.1 boolean literal +(`no`, `yes`, `on`, `off`, any case), it is written **unquoted** (`answer: no`). A +YAML 1.2 parser reads that back as the same string, but a YAML 1.1 consumer — e.g. +PyYAML's `safe_load`, common in agent stacks — reads it as a **boolean**. Boards +are YAML 1.2 documents; parse them with a 1.2 parser (JS `yaml`, `ruamel.yaml`) +rather than relying on 1.1 semantics. ## 5. Protocol reference (protocol 1) diff --git a/frontend/src/features/chat/workbench/lens/builtins.test.ts b/frontend/src/features/chat/workbench/lens/builtins.test.ts new file mode 100644 index 00000000..af4c93fd --- /dev/null +++ b/frontend/src/features/chat/workbench/lens/builtins.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { inferColumns, updateRowCell } from "./builtins"; + +// The registry only OFFERS the table for arrays of plain objects, but the lens can +// still receive anything (template bindings, files edited after binding) — these are +// the lens's own last-line guards against crashing or corrupting the file. +describe("table lens row guards", () => { + it("inferColumns unions keys across plain-object rows, first-seen order", () => { + expect(inferColumns([{ a: 1 }, { b: 2, a: 3 }])).toEqual([ + { key: "a", label: "a" }, + { key: "b", label: "b" }, + ]); + }); + + it("inferColumns skips null/scalar/array rows instead of throwing or faking columns", () => { + // Object.keys(null) throws; Object.keys("alpha") yields "0","1",… index columns + expect(inferColumns([{ a: 1 }, null, "alpha", 7, ["x"]])).toEqual([{ key: "a", label: "a" }]); + expect(inferColumns([null, "alpha"])).toEqual([{ key: "value", label: "value" }]); + }); + + it("updateRowCell edits object rows immutably", () => { + const rows = [{ a: "1" }, { a: "2" }]; + expect(updateRowCell(rows, 1, "a", "3")).toEqual([{ a: "1" }, { a: "3" }]); + expect(rows[1]).toEqual({ a: "2" }); // input untouched + }); + + it("updateRowCell refuses non-object rows — a save can never corrupt the file", () => { + // {..."alpha"} would silently become {"0":"a","1":"l",…} and get written back + expect(updateRowCell(["alpha"], 0, "a", "x")).toBeNull(); + expect(updateRowCell([null], 0, "a", "x")).toBeNull(); + expect(updateRowCell([["x"]], 0, "a", "x")).toBeNull(); + expect(updateRowCell([{ a: 1 }], 5, "a", "x")).toBeNull(); // out of range + }); +}); diff --git a/frontend/src/features/chat/workbench/lens/builtins.tsx b/frontend/src/features/chat/workbench/lens/builtins.tsx index 7650778d..ef913b72 100644 --- a/frontend/src/features/chat/workbench/lens/builtins.tsx +++ b/frontend/src/features/chat/workbench/lens/builtins.tsx @@ -6,26 +6,45 @@ import { registerLens, type LensProps } from "./registry"; interface TableConfig { columns: { key: string; label: string; options?: string[] }[]; } +// A tabular row is a PLAIN OBJECT. YAML happily parses `- alpha` to a string row and a +// bare `-` to null; the registry no longer offers the table for those, but the lens +// still guards every row itself (a template binding or a file edited after binding can +// hand it anything) — Object.keys(null) throws to the root ErrorBoundary, and +// Object.keys("alpha") fabricates per-character index columns. +function isPlainRow(v: unknown): v is Record { + return v !== null && typeof v === "object" && !Array.isArray(v); +} // Without a template config (the pickable path — any JSON/YAML array), infer the -// columns from the union of row keys, first-seen order. `options` dropdowns remain a -// config-only feature. -function inferColumns(rows: Record[]): TableConfig["columns"] { +// columns from the union of row keys, first-seen order. Non-object rows contribute +// nothing. `options` dropdowns remain a config-only feature. Exported for tests. +export function inferColumns(rows: unknown[]): TableConfig["columns"] { const keys: string[] = []; const seen = new Set(); - for (const r of rows) + for (const r of rows) { + if (!isPlainRow(r)) continue; for (const k of Object.keys(r)) if (!seen.has(k)) { seen.add(k); keys.push(k); } + } return (keys.length ? keys : ["value"]).map((k) => ({ key: k, label: k })); } +// Pure cell edit, exported for tests. REFUSES to touch a non-object row: spreading a +// string ({..."alpha"}) silently becomes {"0":"a","1":"l",…} and Save would write that +// corruption into the user's file — null tells the caller to no-op instead. +export function updateRowCell(rows: unknown[], i: number, key: string, v: string): unknown[] | null { + if (!isPlainRow(rows[i])) return null; + return rows.map((r, j) => (j === i && isPlainRow(r) ? { ...r, [key]: v } : r)); +} function TableLens({ data, config, onChange }: LensProps) { - const rows = Array.isArray(data) ? (data as Record[]) : []; + const rows = Array.isArray(data) ? (data as unknown[]) : []; const configured = (config as TableConfig | undefined)?.columns; const columns = configured?.length ? configured : inferColumns(rows); - const update = (i: number, key: string, v: string) => - onChange(rows.map((r, j) => (j === i ? { ...r, [key]: v } : r))); + const update = (i: number, key: string, v: string) => { + const next = updateRowCell(rows, i, key, v); + if (next) onChange(next); + }; const add = () => onChange([...rows, Object.fromEntries(columns.map((c) => [c.key, c.options?.[0] ?? ""]))]); const del = (i: number) => onChange(rows.filter((_, j) => j !== i)); @@ -46,7 +65,11 @@ function TableLens({ data, config, onChange }: LensProps) { {columns.map((c) => ( - {c.options ? ( + {!isPlainRow(r) ? ( + // null/scalar row: read-only placeholder — an input would promise an + // edit that update() must refuse. Delete (index-based) still works. + + ) : c.options ? (