diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index a187a0ee..c24c85ec 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -34,6 +34,9 @@ jobs: filters: | gateway: - "server/**" + # The gateway image compiles the shared bridge-protocol crate + # (path dep) — a protocol change alone must rebuild it. + - "packages/cheers-acp-connector-rs/bridge-protocol/**" - "docker-compose.yml.template" - ".github/workflows/cd.yml" frontend: @@ -85,7 +88,10 @@ jobs: id: build uses: docker/build-push-action@v6 with: - context: ./server + # Repo-root context: the gateway build needs the shared + # bridge-protocol crate under packages/ (path dependency). + context: . + file: ./server/Dockerfile push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4deb7afd..0f8b651d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,10 @@ jobs: filters: | gateway: - "server/**" + # The gateway's frame constructors are pinned to the shared + # bridge-protocol golden fixtures — a fixture edit must re-run + # gateway tests too. + - "packages/cheers-acp-connector-rs/bridge-protocol/**" - ".github/workflows/ci.yml" frontend: - "frontend/**" @@ -185,11 +189,13 @@ jobs: cargo fmt --manifest-path packages/cheers-acp-connector-rs/Cargo.toml -- --check cargo fmt --manifest-path packages/cheers-mcp-server/Cargo.toml -- --check + # --workspace: the connector dir is a 2-member mini-workspace (binary + + # the shared cheers-bridge-protocol crate the gateway also depends on). - name: Check Rust connector daemon - run: cargo check --manifest-path packages/cheers-acp-connector-rs/Cargo.toml + run: cargo check --workspace --manifest-path packages/cheers-acp-connector-rs/Cargo.toml - name: Test Rust connector daemon - run: cargo test --manifest-path packages/cheers-acp-connector-rs/Cargo.toml + run: cargo test --workspace --manifest-path packages/cheers-acp-connector-rs/Cargo.toml - name: Check Rust MCP server run: cargo check --manifest-path packages/cheers-mcp-server/Cargo.toml diff --git a/.github/workflows/release-connector.yml b/.github/workflows/release-connector.yml index 337a1585..5c3e7330 100644 --- a/.github/workflows/release-connector.yml +++ b/.github/workflows/release-connector.yml @@ -101,3 +101,65 @@ jobs: # On tag pushes this creates/updates the release for the tag; each matrix # job appends its binaries to the same release. fail_on_unmatched_files: true + + # Signed update manifest for the connector's opt-in self-updater ([update] + # auto = true). Runs after every binary is attached: downloads them back from + # the release, records their sha256 in connector-manifest.json, and signs the + # manifest bytes with the CONNECTOR_SIGNING_KEY ed25519 secret (the matching + # public key ships inside the connector at + # packages/cheers-acp-connector-rs/release-signing-pubkey.pem). A connector + # only swaps binaries whose hash appears in a manifest that verifies against + # that pinned key — a compromised gateway or GitHub proxy can't push code. + manifest: + name: sign update manifest + needs: build + if: startsWith(github.ref, 'refs/tags/connector-v') + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ github.token }} + steps: + - name: Download release assets + run: | + tag="${GITHUB_REF#refs/tags/}" + echo "TAG=$tag" >> "$GITHUB_ENV" + echo "VERSION=${tag#connector-v}" >> "$GITHUB_ENV" + mkdir assets + gh release download "$tag" --repo "$GITHUB_REPOSITORY" --dir assets \ + --pattern 'cce-acp-connector-*' --pattern 'cheers-mcp-server-*' + ls -l assets + + - name: Build + sign manifest + env: + CONNECTOR_SIGNING_KEY: ${{ secrets.CONNECTOR_SIGNING_KEY }} + run: | + if [ -z "$CONNECTOR_SIGNING_KEY" ]; then + echo "::error::CONNECTOR_SIGNING_KEY secret is not set — cannot publish a signed" \ + "update manifest. Self-updating connectors will not pick up this release." \ + "Add the ed25519 private key (PEM) as a repo secret and re-run this job." + exit 1 + fi + python3 - << 'EOF' + import hashlib, json, os, pathlib + assets = { + p.name: {"sha256": hashlib.sha256(p.read_bytes()).hexdigest()} + for p in sorted(pathlib.Path("assets").iterdir()) + } + manifest = {"version": os.environ["VERSION"], "assets": assets} + pathlib.Path("connector-manifest.json").write_text( + json.dumps(manifest, indent=2, sort_keys=True) + "\n" + ) + EOF + printf '%s' "$CONNECTOR_SIGNING_KEY" > signing-key.pem + openssl pkeyutl -sign -inkey signing-key.pem -rawin \ + -in connector-manifest.json -out manifest.sig.bin + base64 -w0 manifest.sig.bin > connector-manifest.json.sig + rm -f signing-key.pem manifest.sig.bin + cat connector-manifest.json + + - name: Attach manifest to release + uses: softprops/action-gh-release@v2 + with: + files: | + connector-manifest.json + connector-manifest.json.sig + fail_on_unmatched_files: true diff --git a/.gitignore b/.gitignore index e08fc7dc..fc422ac2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ .env .env.local *.pem +# PUBLIC verification key (not a secret) — compiled into the connector so it can +# verify signed release manifests; the private half lives only in CI secrets. +!packages/cheers-acp-connector-rs/release-signing-pubkey.pem # Python __pycache__/ @@ -21,7 +24,8 @@ target/ *.rs.bk # 运行时与数据(设计书约定排除) -data/ +# 仅根目录的数据目录(不锚定会吞掉任意层级的 data/,例如 bridge-protocol/fixtures/data/) +/data/ tmp/ .tmp/ *.db diff --git a/AGENTS.md b/AGENTS.md index 3e643d1a..b6904dcd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,8 +70,8 @@ Required order when connector behavior materially changes: 1. Update `packages/cheers-acp-connector-rs/Cargo.toml` and `Cargo.lock` when the Rust connector version or dependencies change. 2. Run `cargo fmt --check`, `cargo test`, and `cargo check` for `packages/cheers-acp-connector-rs`. 3. Rebuild and push the `opencode-bot` image from the same merged commit so container deployments contain the new Rust connector and MCP server binaries. -4. Upgrade machines that run the connector locally by installing the Rust binary from the repo or the approved release artifact, then restart the corresponding connector daemon. -5. Upgrade container deployments by pulling or deploying the rebuilt `opencode-bot` image and recreating the service. +4. Upgrade machines that run the connector locally by installing the Rust binary from the repo or the approved release artifact, then restart the corresponding connector daemon. Hosts that opted into `[update] auto = true` pick the release up themselves once the gateway's `CHEERS_CONNECTOR_RELEASE_VERSION` is bumped — that only works if the tag's `manifest` CI job succeeded (it signs `connector-manifest.json` with the `CONNECTOR_SIGNING_KEY` repo secret; the matching public key is `packages/cheers-acp-connector-rs/release-signing-pubkey.pem`, compiled into the binary). +5. Upgrade container deployments by pulling or deploying the rebuilt `opencode-bot` image and recreating the service (containers never self-update). Do not reintroduce the old npm connector package or the retired `@haowei0520/acp-connector` release workflow. diff --git a/CLAUDE.md b/CLAUDE.md index 7e8480ae..2c83b292 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,7 +57,7 @@ UI: frontend NodePort → (sign in `admin` / `admin1234 ```bash # First-time install: build images → load into kind → install the release -docker build -t cheers/gateway:dev server +docker build -t cheers/gateway:dev -f server/Dockerfile . # root context: server needs packages/.../bridge-protocol docker build -t cheers/frontend:dev --build-arg VITE_API_BASE_URL=/api/v1 frontend kind load docker-image cheers/gateway:dev cheers/frontend:dev --name cheers openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out /tmp/jwt_priv.pem @@ -71,7 +71,7 @@ helm upgrade --install cheers deploy/helm/cheers -n cheers --create-namespace \ ```bash # Redeploy after a code change: rebuild → reload into kind → roll the pod. # Shortcut for all of the below: ./scripts/redeploy.sh [gateway|frontend|both] -docker build -t cheers/frontend:dev --build-arg VITE_API_BASE_URL=/api/v1 frontend # gateway: docker build -t cheers/gateway:dev server +docker build -t cheers/frontend:dev --build-arg VITE_API_BASE_URL=/api/v1 frontend # gateway: docker build -t cheers/gateway:dev -f server/Dockerfile . kind load docker-image cheers/frontend:dev --name cheers kubectl -n cheers rollout restart deployment/cheers-frontend # or deployment/cheers-gateway kubectl -n cheers rollout status deployment/cheers-frontend diff --git a/CLAUDE.zh-CN.md b/CLAUDE.zh-CN.md index 60a7eaae..10f46ea0 100644 --- a/CLAUDE.zh-CN.md +++ b/CLAUDE.zh-CN.md @@ -54,7 +54,7 @@ UI:前端 NodePort → (登录 `admin` / `admin12345 ```bash # 首次安装:构建镜像 → 加载进 kind → 安装 release -docker build -t cheers/gateway:dev server +docker build -t cheers/gateway:dev -f server/Dockerfile . # 根上下文:server 依赖 packages/.../bridge-protocol docker build -t cheers/frontend:dev --build-arg VITE_API_BASE_URL=/api/v1 frontend kind load docker-image cheers/gateway:dev cheers/frontend:dev --name cheers openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out /tmp/jwt_priv.pem @@ -68,7 +68,7 @@ helm upgrade --install cheers deploy/helm/cheers -n cheers --create-namespace \ ```bash # 代码变更后重新部署:重建 → 重新加载进 kind → 滚动重启 pod。 # 以下步骤的快捷方式:./scripts/redeploy.sh [gateway|frontend|both] -docker build -t cheers/frontend:dev --build-arg VITE_API_BASE_URL=/api/v1 frontend # gateway 用:docker build -t cheers/gateway:dev server +docker build -t cheers/frontend:dev --build-arg VITE_API_BASE_URL=/api/v1 frontend # gateway 用:docker build -t cheers/gateway:dev -f server/Dockerfile . kind load docker-image cheers/frontend:dev --name cheers kubectl -n cheers rollout restart deployment/cheers-frontend # 或 deployment/cheers-gateway kubectl -n cheers rollout status deployment/cheers-frontend diff --git a/deploy/helm/cheers/README.md b/deploy/helm/cheers/README.md index 05a268a1..ff139a27 100644 --- a/deploy/helm/cheers/README.md +++ b/deploy/helm/cheers/README.md @@ -42,7 +42,7 @@ deploy/helm/cheers/ kind create cluster --name cheers --config deploy/kind-config.yaml # 1) build the app images and load them into the cluster -docker build -t cheers/gateway:dev server +docker build -t cheers/gateway:dev -f server/Dockerfile . docker build -t cheers/frontend:dev --build-arg VITE_API_BASE_URL=/api/v1 frontend kind load docker-image cheers/gateway:dev cheers/frontend:dev --name cheers diff --git a/docker-compose.production.tls.yml b/docker-compose.production.tls.yml index 9ef14915..568141d8 100644 --- a/docker-compose.production.tls.yml +++ b/docker-compose.production.tls.yml @@ -35,21 +35,32 @@ services: - RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS=${RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS:-https://cheers.example.com} tls-edge: - image: caddy:2-alpine + # Custom Caddy build with the Cloudflare DNS plugin, for ACME DNS-01. + build: + context: . + dockerfile: docker/Dockerfile.caddy + image: ${TLS_EDGE_IMAGE:-cheers/tls-edge:latest} restart: unless-stopped ports: - "${HTTP_PORT:-80}:80" - "${HTTPS_PORT:-443}:443" volumes: - ./docker/Caddyfile:/etc/caddy/Caddyfile:ro - - ${TLS_CERT_DIR:-./certs}:/etc/caddy/certs:ro + # caddy_data persists the issued ACME certs across restarts — without it + # Caddy re-requests on every boot and will hit Let's Encrypt rate limits. - caddy_data:/data - caddy_config:/config environment: + # Primary public host. APP_DOMAIN_LEGACY is served in parallel during a + # domain migration; leave it empty when there is only one domain. - APP_DOMAIN=${APP_DOMAIN:-cheers.example.com} + - APP_DOMAIN_LEGACY=${APP_DOMAIN_LEGACY:-} - STORAGE_S3_BUCKET=${STORAGE_S3_BUCKET:-cheers-files} - - TLS_CERT_FILE=${TLS_CERT_FILE:-/etc/caddy/certs/fullchain.pem} - - TLS_KEY_FILE=${TLS_KEY_FILE:-/etc/caddy/certs/privkey.pem} + # ACME account email (expiry notices) and the Cloudflare API token used + # for the DNS-01 challenge. The token needs Zone:DNS:Edit on the zone(s) + # covering APP_DOMAIN / APP_DOMAIN_LEGACY. Provide it out-of-band in .env. + - ACME_EMAIL=${ACME_EMAIL:-} + - CF_API_TOKEN=${CF_API_TOKEN:-} depends_on: - gateway - frontend diff --git a/docker-compose.yml.template b/docker-compose.yml.template index 7f30291b..4aa580d5 100644 --- a/docker-compose.yml.template +++ b/docker-compose.yml.template @@ -44,6 +44,11 @@ services: - TRUST_PROXY_HEADERS=${TRUST_PROXY_HEADERS:-true} # 自助注册默认关闭(安全默认);公开实例显式设为 true。 - OPEN_REGISTRATION=${OPEN_REGISTRATION:-false} + # Connector self-update: the release version this gateway advertises in + # its hello + serves via /api/v1/connector/download. Unset ⇒ no update + # signal (download proxy then tracks the latest GitHub release). + - CHEERS_CONNECTOR_RELEASE_REPO=${CHEERS_CONNECTOR_RELEASE_REPO:-} + - CHEERS_CONNECTOR_RELEASE_VERSION=${CHEERS_CONNECTOR_RELEASE_VERSION:-} # SMTP(可选) - SMTP_HOST=${SMTP_HOST:-} - SMTP_PORT=${SMTP_PORT:-587} diff --git a/docker/Caddyfile b/docker/Caddyfile index ca9f20ae..f47e88c3 100644 --- a/docker/Caddyfile +++ b/docker/Caddyfile @@ -1,17 +1,16 @@ { admin off + email {$ACME_EMAIL} log { output stdout format console } } -http://{$APP_DOMAIN} { - redir https://{$APP_DOMAIN}{uri} 301 -} - -{$APP_DOMAIN} { - tls {$TLS_CERT_FILE} {$TLS_KEY_FILE} +# Shared reverse-proxy rules, applied to every hostname we serve. Keeping them +# in a snippet lets the primary and legacy domains share one definition during +# a domain migration. +(cheers_app) { encode gzip handle /api/v1/* { @@ -46,3 +45,16 @@ http://{$APP_DOMAIN} { reverse_proxy frontend:80 } } + +# Primary host, plus an optional legacy host that stays live during a domain +# migration (set APP_DOMAIN_LEGACY empty once the old domain is retired). Caddy +# provisions and auto-renews a certificate for each hostname via the Cloudflare +# ACME DNS-01 challenge, which works while the domain stays behind Cloudflare's +# proxy. For a grey-cloud (proxy-off) deployment, delete the tls block so Caddy +# falls back to automatic HTTP-01 / TLS-ALPN-01 and use stock caddy:2-alpine. +{$APP_DOMAIN} {$APP_DOMAIN_LEGACY} { + tls { + dns cloudflare {env.CF_API_TOKEN} + } + import cheers_app +} diff --git a/docker/Dockerfile.caddy b/docker/Dockerfile.caddy new file mode 100644 index 00000000..db6ae0d8 --- /dev/null +++ b/docker/Dockerfile.caddy @@ -0,0 +1,20 @@ +# Custom Caddy image with the Cloudflare DNS provider compiled in. +# +# Why: the production TLS edge obtains its certificate through the ACME +# DNS-01 challenge. DNS-01 is the only ACME method that still works while the +# domain sits behind Cloudflare's proxy ("orange cloud") — TLS-ALPN-01 fails +# because Cloudflare terminates TLS at its edge, and HTTP-01 is intercepted by +# "Always Use HTTPS". DNS-01 also renews unattended every ~60 days without +# ever toggling the cloud color. It needs the caddy-dns/cloudflare module, +# which is not part of the stock caddy:2-alpine image, so we build our own. +# +# If you instead run the domain grey-cloud (DNS-only / proxy off), you do not +# need this image at all: use stock `caddy:2-alpine` and drop the `tls { dns +# cloudflare ... }` block in the Caddyfile so Caddy falls back to automatic +# HTTP-01 / TLS-ALPN-01 issuance. +FROM caddy:2-builder-alpine AS builder +RUN xcaddy build \ + --with github.com/caddy-dns/cloudflare + +FROM caddy:2-alpine +COPY --from=builder /usr/bin/caddy /usr/bin/caddy diff --git a/docs/arch/CONNECTOR_TOML_CONFIG.md b/docs/arch/CONNECTOR_TOML_CONFIG.md index c9fb08ad..484f721b 100644 --- a/docs/arch/CONNECTOR_TOML_CONFIG.md +++ b/docs/arch/CONNECTOR_TOML_CONFIG.md @@ -60,6 +60,26 @@ Applies to the whole daemon process, not a single bot. | `state_path` | string | `state.json` | Where the daemon persists runtime state (relative to the config file's directory). | | `log_dir` | string | `~/.cheers/acp-connector//` | Where the daemon writes its `stdout`/`stderr` logs. When set, they become `/.stdout.log` / `.stderr.log`. | +### `[update]` — opt-in self-update (optional) + +Process-wide. When enabled and the gateway hello advertises a newer connector +release (`server_capabilities.latest_connector_version`, i.e. the gateway's +`CHEERS_CONNECTOR_RELEASE_VERSION`), the connector downloads that release's +`connector-manifest.json` + `.sig` through the gateway's download proxy, verifies +the manifest's **ed25519 signature** against the release key compiled into the +binary, verifies each binary's **sha256** against the manifest, waits until no +prompt turn is in flight, atomically swaps itself (and the sibling +`cheers-mcp-server`, which ships in lockstep) and re-execs with the same argv/PID. +The replaced binary is kept as `.old`; if the new binary fails to reach a +healthy bridge connection 3 boots in a row it is rolled back automatically and +that version is blocked from retry. Never active inside containers (update the +image instead); `CHEERS_ACP_NO_SELF_UPDATE=1` force-disables it everywhere. + +| Key | Type | Default | Meaning | +|-------------------|--------|---------|---------| +| `auto` | bool | `false` | Opt in to automatic self-update. Off by default: updating executes code fetched over the network, so the host owner must enable it deliberately. Even when off, a newer advertised release is logged as a manual-update warning at startup and on connect, and shown by `cce-acp-connector status`. | +| `public_key_file` | string | embedded key | Path (relative to the config file) to an ed25519 SPKI PEM that overrides the release-signing public key compiled into the binary. Only needed by forks publishing their own signed releases. | + --- ## Per-bot: `[accounts.]` diff --git a/docs/arch/CONNECTOR_TOML_CONFIG.zh-CN.md b/docs/arch/CONNECTOR_TOML_CONFIG.zh-CN.md index d5d255d3..9943a292 100644 --- a/docs/arch/CONNECTOR_TOML_CONFIG.zh-CN.md +++ b/docs/arch/CONNECTOR_TOML_CONFIG.zh-CN.md @@ -53,6 +53,23 @@ version = 1 | `state_path` | string | `state.json` | 守护进程持久化运行态的位置(相对配置文件所在目录)。 | | `log_dir` | string | `~/.cheers/acp-connector//` | 守护进程写 `stdout`/`stderr` 日志的位置。设置后日志为 `/.stdout.log` / `.stderr.log`。 | +### `[update]` — 自动更新(可选,需显式开启) + +进程级配置。开启后,当网关 hello 通告了更新的连接器版本 +(`server_capabilities.latest_connector_version`,即网关的 +`CHEERS_CONNECTOR_RELEASE_VERSION`),连接器会通过网关的下载代理拉取该版本的 +`connector-manifest.json` + `.sig`,用编译进二进制的发布公钥校验清单的 +**ed25519 签名**,再逐个校验二进制的 **sha256**,等到没有进行中的对话轮次后, +原子替换自身(以及同目录配套发布的 `cheers-mcp-server`)并以相同 argv/PID +重新 exec。被替换的旧二进制保留为 `.old`;新二进制若连续 3 次启动都无法 +建立健康的桥接连接,会自动回滚并拉黑该版本。容器内永不自更新(应更新镜像); +`CHEERS_ACP_NO_SELF_UPDATE=1` 可强制禁用。 + +| 键 | 类型 | 默认 | 含义 | +|-------------------|--------|------------|------| +| `auto` | bool | `false` | 是否开启自动更新。默认关闭:更新本质是执行从网络下载的代码,必须由宿主机所有者显式开启。即使关闭,发现新版本时也会在启动和连接时打印手动更新提醒,并在 `cce-acp-connector status` 中显示。 | +| `public_key_file` | string | 内置公钥 | ed25519 SPKI PEM 路径(相对配置文件),覆盖编译进二进制的发布签名公钥。仅自建签名发布的 fork 需要。 | + --- ## 单个 bot:`[accounts.]` diff --git a/docs/help/docker-compose-deploy.md b/docs/help/docker-compose-deploy.md index 1e2fe14d..8a29e4cb 100644 --- a/docs/help/docker-compose-deploy.md +++ b/docs/help/docker-compose-deploy.md @@ -170,17 +170,35 @@ The TLS overlay adds a Caddy `tls-edge` service that terminates HTTPS and reverse-proxies to the gateway/frontend/rustfs. It also binds the service host ports to loopback so only Caddy is publicly exposed. +Caddy obtains and auto-renews its certificate via ACME — no manual cert files. +The image is built from `docker/Dockerfile.caddy` with the Cloudflare DNS +plugin so it can use the **DNS-01** challenge, which works while the domain +stays behind Cloudflare's proxy (orange cloud) and renews unattended. + ```bash -# place certs at ./certs/fullchain.pem and ./certs/privkey.pem -docker compose -f docker-compose.yml -f docker-compose.production.tls.yml up -d +docker compose -f docker-compose.yml -f docker-compose.production.tls.yml up -d --build ``` Set in `.env` for production: - `APP_DOMAIN` — your public host, e.g. `cheers.example.com` -- `CORS_ALLOWED_ORIGINS=https://cheers.example.com` +- `APP_DOMAIN_LEGACY` — optional second host served in parallel during a domain + migration (leave empty when there is only one domain) +- `CORS_ALLOWED_ORIGINS=https://cheers.example.com` (comma-separate both hosts + during a migration) - `STORAGE_S3_PUBLIC_ENDPOINT=https://cheers.example.com` -- `HTTP_PORT=80`, `HTTPS_PORT=443`, and the `TLS_*` cert paths +- `ACME_EMAIL` — email for the ACME account (expiry notices) +- `CF_API_TOKEN` — Cloudflare API token with **Zone:DNS:Edit** on the zone(s) + covering the domain(s), used for the DNS-01 challenge +- `HTTP_PORT=80`, `HTTPS_PORT=443` + +Set the Cloudflare SSL/TLS mode to **Full (strict)** so the edge trusts Caddy's +ACME certificate on the origin hop. + +> **Non-Cloudflare / grey-cloud (proxy off):** you don't need the custom image +> or a token. Use stock `caddy:2-alpine` and delete the `tls { dns cloudflare +> ... }` block in `docker/Caddyfile`; Caddy then issues via HTTP-01 / TLS-ALPN-01 +> directly (requires ports 80/443 reachable and the domain pointing at the host). Add `--profile bot` to the command if you also run the bot. diff --git a/docs/help/docker-compose-deploy.zh-CN.md b/docs/help/docker-compose-deploy.zh-CN.md index e55b8e26..c3d196bf 100644 --- a/docs/help/docker-compose-deploy.zh-CN.md +++ b/docs/help/docker-compose-deploy.zh-CN.md @@ -160,17 +160,32 @@ TLS overlay 增加一个 Caddy `tls-edge` 服务,终止 HTTPS 并反向代理 gateway/frontend/rustfs,同时把各服务的宿主端口绑定到回环地址, 只有 Caddy 对外暴露。 +Caddy 通过 ACME 自动签发并续期证书,无需手动准备证书文件。镜像由 +`docker/Dockerfile.caddy` 构建,内置 Cloudflare DNS 插件以走 **DNS-01** +挑战——它在域名保持 Cloudflare 代理(橙云)时依然可用,且可无人值守续期。 + ```bash -# 证书放到 ./certs/fullchain.pem 和 ./certs/privkey.pem -docker compose -f docker-compose.yml -f docker-compose.production.tls.yml up -d +docker compose -f docker-compose.yml -f docker-compose.production.tls.yml up -d --build ``` 生产环境在 `.env` 中设置: - `APP_DOMAIN` —— 公网域名,如 `cheers.example.com` -- `CORS_ALLOWED_ORIGINS=https://cheers.example.com` +- `APP_DOMAIN_LEGACY` —— 可选的第二域名,域名迁移过渡期并行提供服务 + (只有一个域名时留空) +- `CORS_ALLOWED_ORIGINS=https://cheers.example.com`(迁移期把两个域名用逗号分隔) - `STORAGE_S3_PUBLIC_ENDPOINT=https://cheers.example.com` -- `HTTP_PORT=80`、`HTTPS_PORT=443` 及 `TLS_*` 证书路径 +- `ACME_EMAIL` —— ACME 账户邮箱(接收到期通知) +- `CF_API_TOKEN` —— Cloudflare API Token,需对相关 Zone 具备 + **Zone:DNS:Edit** 权限,用于 DNS-01 挑战 +- `HTTP_PORT=80`、`HTTPS_PORT=443` + +将 Cloudflare 的 SSL/TLS 模式设为 **Full (strict)**,让边缘信任源站上 Caddy 的 +ACME 证书。 + +> **非 Cloudflare / 灰云(关闭代理):** 无需自定义镜像或 Token。改用官方 +> `caddy:2-alpine`,并删除 `docker/Caddyfile` 中的 `tls { dns cloudflare ... }` +> 块,Caddy 会直接走 HTTP-01 / TLS-ALPN-01 签发(需 80/443 可达且域名指向本机)。 若同时运行 Bot,命令追加 `--profile bot`。 diff --git a/frontend/src/features/chat/MessageItem.tsx b/frontend/src/features/chat/MessageItem.tsx index 13784ff9..1638d4d6 100644 --- a/frontend/src/features/chat/MessageItem.tsx +++ b/frontend/src/features/chat/MessageItem.tsx @@ -1,5 +1,5 @@ import { memo, useContext, useState } from "react"; -import { Square, Reply, Copy, Forward, CheckSquare, Check, AlertCircle, RotateCw, Loader2 } from "lucide-react"; +import { Square, MessageCircleMore, Reply, Copy, Forward, CheckSquare, Check, AlertCircle, RotateCw, Loader2 } from "lucide-react"; import toast from "react-hot-toast"; import { cn } from "@/lib/cn"; import { formatTime } from "@/lib/format"; @@ -73,14 +73,29 @@ async function copyMessage(message: Message) { } } -/** Hover toolbar: reply · copy · forward · select. Hidden while streaming. */ -function ActionBar({ message, actions }: { message: Message; actions: MessageActionHandlers }) { +/** Hover toolbar: reply · copy · forward · select. Hidden while streaming. + * `reversed` rows (own messages) put the header on the right, so the toolbar + * anchors left to avoid overlapping the name/timestamp/avatar. */ +function ActionBar({ + message, + actions, + reversed, +}: { + message: Message; + actions: MessageActionHandlers; + reversed?: boolean; +}) { const btn = "flex items-center justify-center w-7 h-7 rounded text-zinc-400 hover:text-zinc-100 hover:bg-zinc-700/70"; return ( -
+
- {showActions && } + {showActions && }
); }); diff --git a/packages/cheers-acp-connector-rs/Cargo.lock b/packages/cheers-acp-connector-rs/Cargo.lock index 93f97262..fd20a02d 100644 --- a/packages/cheers-acp-connector-rs/Cargo.lock +++ b/packages/cheers-acp-connector-rs/Cargo.lock @@ -318,13 +318,14 @@ dependencies = [ [[package]] name = "cce-acp-connector" -version = "0.1.26" +version = "0.1.27" dependencies = [ "agent-client-protocol", "agent-client-protocol-schema", "anyhow", "async-trait", "base64", + "cheers-bridge-protocol", "chrono", "clap", "ed25519-dalek", @@ -332,7 +333,8 @@ dependencies = [ "libc", "mime_guess", "notify", - "rand", + "rand 0.8.6", + "reqwest", "rustls", "serde", "serde_json", @@ -353,6 +355,31 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "cheers-bridge-protocol" +version = "0.1.27" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "chrono" version = "0.4.44" @@ -633,6 +660,17 @@ dependencies = [ "crypto-common 0.2.2", ] +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "dyn-clone" version = "1.0.20" @@ -730,6 +768,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -870,8 +917,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -881,10 +930,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", + "rand_core 0.10.1", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -936,6 +988,29 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + [[package]] name = "httparse" version = "1.10.1" @@ -951,6 +1026,65 @@ dependencies = [ "typenum", ] +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -975,6 +1109,88 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "id-arena" version = "2.3.0" @@ -987,6 +1203,27 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -1030,6 +1267,12 @@ dependencies = [ "libc", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1098,6 +1341,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "lock_api" version = "0.4.14" @@ -1113,6 +1362,12 @@ version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.2.0" @@ -1263,6 +1518,12 @@ dependencies = [ "base64ct", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project" version = "1.1.13" @@ -1324,6 +1585,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -1358,6 +1628,62 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.2", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -1381,7 +1707,18 @@ checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", ] [[package]] @@ -1391,7 +1728,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -1403,6 +1740,21 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1449,6 +1801,44 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "ring" version = "0.17.14" @@ -1523,6 +1913,7 @@ version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ + "web-time", "zeroize", ] @@ -1543,6 +1934,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -1697,6 +2094,18 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.21.0" @@ -1799,7 +2208,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -1834,6 +2243,12 @@ dependencies = [ "der", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" @@ -1878,6 +2293,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -1897,7 +2332,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", ] [[package]] @@ -1911,6 +2355,17 @@ dependencies = [ "syn", ] +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "thread_local" version = "1.1.9" @@ -1950,6 +2405,16 @@ dependencies = [ "time-core", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tinyvec" version = "1.11.0" @@ -2074,6 +2539,51 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -2135,6 +2645,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "tungstenite" version = "0.24.0" @@ -2147,11 +2663,11 @@ dependencies = [ "http", "httparse", "log", - "rand", + "rand 0.8.6", "rustls", "rustls-pki-types", "sha1", - "thiserror", + "thiserror 1.0.69", "utf-8", ] @@ -2191,12 +2707,30 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + [[package]] name = "utf-8" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -2237,6 +2771,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2274,6 +2817,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.122" @@ -2340,6 +2893,26 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "winapi-util" version = "0.1.11" @@ -2667,6 +3240,35 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.50" @@ -2687,12 +3289,66 @@ dependencies = [ "syn", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/packages/cheers-acp-connector-rs/Cargo.toml b/packages/cheers-acp-connector-rs/Cargo.toml index fa6669f5..7b635f99 100644 --- a/packages/cheers-acp-connector-rs/Cargo.toml +++ b/packages/cheers-acp-connector-rs/Cargo.toml @@ -1,6 +1,13 @@ +# Mini-workspace: the binary + the shared bridge-protocol frame crate (also +# consumed by ../../server via a path dependency). The workspace root staying +# at this directory keeps `cross`/`--locked` release builds and the bot-image +# COPY of this whole directory working unchanged. +[workspace] +members = [".", "bridge-protocol"] + [package] name = "cce-acp-connector" -version = "0.1.26" +version = "0.1.27" edition = "2021" [[bin]] @@ -25,6 +32,7 @@ anyhow = "1" async-trait = "0.1" base64 = "0.22" chrono = { version = "0.4", features = ["serde"] } +cheers-bridge-protocol = { path = "bridge-protocol" } clap = { version = "4", features = ["derive"] } ed25519-dalek = { version = "2", features = ["pkcs8", "pem"] } futures-util = "0.3" @@ -35,6 +43,9 @@ mime_guess = "2" # (no notify-debouncer crate) so event coalescing + the watch TTL live in one task. notify = "8" rand = "0.8" +# 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"] } serde = { version = "1", features = ["derive"] } serde_json = "1" # Content etags for safe remote writes: lowercase-hex SHA-256 of raw file bytes diff --git a/packages/cheers-acp-connector-rs/README.md b/packages/cheers-acp-connector-rs/README.md index 5e7e6ab5..6a80065b 100644 --- a/packages/cheers-acp-connector-rs/README.md +++ b/packages/cheers-acp-connector-rs/README.md @@ -35,6 +35,22 @@ version = 1 state_path = "state.json" log_dir = "logs" +# OPT-IN self-update (default off). When the gateway advertises a newer release, +# the connector downloads the ed25519-signed sha256 manifest through the +# gateway's release proxy, verifies it against the release key compiled into the +# binary, verifies each binary hash, waits until no prompt is in flight, swaps +# itself (and the sibling cheers-mcp-server) in place, and re-execs. The +# previous binary is kept as .old and restored automatically if the new one +# fails to connect 3 boots in a row. Containers never self-update (image +# rebuilds own that), and CHEERS_ACP_NO_SELF_UPDATE=1 force-disables it. +# Forks that sign their own releases point public_key_file at their key. +# With auto = false the connector still nags: a newer advertised release is +# logged as a manual-update warning at startup and on connect, and shows up in +# `cce-acp-connector status` (with the download URL). +[update] +auto = false +# public_key_file = "release-signing-pubkey.pem" + [accounts.haowei_claude.bridge] control_url = "wss://cheers.example.com/ws/agent-bridge/control" data_url = "wss://cheers.example.com/ws/agent-bridge/data" diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/Cargo.toml b/packages/cheers-acp-connector-rs/bridge-protocol/Cargo.toml new file mode 100644 index 00000000..861da033 --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "cheers-bridge-protocol" +# The crate version tracks the connector release; the WIRE version is the +# separate BRIDGE_PROTOCOL_VERSION constant. +version = "0.1.27" +edition = "2021" + +# Frame types shared verbatim by the gateway (server/) and the connector — +# drift between the two ends is a compile error instead of a runtime mystery. +# Keep the dependency surface at serde(+json) only so the two consumers can +# never disagree about transitive types. +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/compat/ready_plugin_version.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/compat/ready_plugin_version.json new file mode 100644 index 00000000..6db4e247 --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/compat/ready_plugin_version.json @@ -0,0 +1,12 @@ +{ + "type": "ready", + "v": 1, + "plugin_version": "0.9.3", + "runtime": { + "protocol": "acp", + "name": "legacy-ts-connector" + }, + "connector_capabilities": { + "streaming": true + } +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_connector/cancel.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_connector/cancel.json new file mode 100644 index 00000000..76d5d442 --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_connector/cancel.json @@ -0,0 +1,5 @@ +{ + "msg_id": "11111111-2222-4333-8444-555555555555", + "reason": "user_cancelled", + "type": "cancel" +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_connector/channel_joined.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_connector/channel_joined.json new file mode 100644 index 00000000..495a8a3a --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_connector/channel_joined.json @@ -0,0 +1,11 @@ +{ + "type": "channel_joined", + "channel": { + "channel_id": "77777777-8888-4999-8aaa-bbbbbbbbbbbb", + "channel_name": "general", + "channel_type": "public", + "workspace_id": "cccccccc-dddd-4eee-8fff-000000000000", + "joined_at": "2026-06-01T10:15:30Z" + }, + "invited_by": "33333333-4444-4555-8666-777777777777" +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_connector/channel_left.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_connector/channel_left.json new file mode 100644 index 00000000..ed7ef77a --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_connector/channel_left.json @@ -0,0 +1,5 @@ +{ + "type": "channel_left", + "channel_id": "77777777-8888-4999-8aaa-bbbbbbbbbbbb", + "reason": "removed_by_admin" +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_connector/config_option_set.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_connector/config_option_set.json new file mode 100644 index 00000000..8932d2aa --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_connector/config_option_set.json @@ -0,0 +1,8 @@ +{ + "config_id": "model", + "provider_session_key": "cheers:channel:77777777-8888-4999-8aaa-bbbbbbbbbbbb:bot:6f9619ff-8b86-4d01-b42d-00c04fc964ff", + "request_id": "99999999-aaaa-4bbb-8ccc-dddddddddddd", + "type": "config_option_set", + "v": 1, + "value": "claude-sonnet-5" +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_connector/config_update.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_connector/config_update.json new file mode 100644 index 00000000..421f5d20 --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_connector/config_update.json @@ -0,0 +1,7 @@ +{ + "settings": { + "agentNativePermissionMode": "acceptEdits" + }, + "type": "config_update", + "v": 1 +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_connector/hello.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_connector/hello.json new file mode 100644 index 00000000..935e8bf0 --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_connector/hello.json @@ -0,0 +1,38 @@ +{ + "bot_display_name": "Helper", + "bot_id": "6f9619ff-8b86-4d01-b42d-00c04fc964ff", + "bot_username": "helper", + "bridge_protocol_version": 1, + "connection_id": "11111111-2222-4333-8444-555555555555", + "connector_config": { + "agentNativePermissionMode": "default" + }, + "memberships": [ + { + "channel_id": "77777777-8888-4999-8aaa-bbbbbbbbbbbb", + "channel_name": "general", + "channel_type": "public", + "joined_at": "2026-06-01T10:15:30Z", + "workspace_id": "cccccccc-dddd-4eee-8fff-000000000000" + } + ], + "server_capabilities": { + "acp_security": true, + "auth": [ + "authorization_bearer", + "auth_frame" + ], + "file_upload": false, + "latest_connector_version": "0.1.27", + "resource_req": true, + "resume": "ack_only", + "runtime_session_control": true, + "send_ack": true, + "task_stream": "control", + "terminal_ack": true + }, + "session_id": "11111111-2222-4333-8444-555555555555", + "stream": "control", + "type": "hello", + "v": 1 +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_connector/mode_set.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_connector/mode_set.json new file mode 100644 index 00000000..e70d9f1b --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_connector/mode_set.json @@ -0,0 +1,7 @@ +{ + "mode": "acceptEdits", + "provider_session_key": "cheers:channel:77777777-8888-4999-8aaa-bbbbbbbbbbbb:bot:6f9619ff-8b86-4d01-b42d-00c04fc964ff", + "request_id": "99999999-aaaa-4bbb-8ccc-dddddddddddd", + "type": "mode_set", + "v": 1 +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_connector/permission_resolution.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_connector/permission_resolution.json new file mode 100644 index 00000000..fc883c66 --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_connector/permission_resolution.json @@ -0,0 +1,10 @@ +{ + "message_id": "eeeeeeee-ffff-4000-8111-222222222222", + "option_id": "allow_once", + "request_id": "99999999-aaaa-4bbb-8ccc-dddddddddddd", + "resolution": "allow", + "resolved_at": "2026-06-01T10:15:30+00:00", + "resolved_by": "33333333-4444-4555-8666-777777777777", + "type": "permission_resolution", + "v": 1 +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_connector/runtime_session_control.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_connector/runtime_session_control.json new file mode 100644 index 00000000..aca3ca66 --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_connector/runtime_session_control.json @@ -0,0 +1,18 @@ +{ + "type": "runtime_session_control", + "v": 1, + "request_id": "99999999-aaaa-4bbb-8ccc-dddddddddddd", + "action": "terminate", + "session": { + "id": "eeeeeeee-ffff-4000-8111-222222222222", + "provider_session_key": "cheers:channel:77777777-8888-4999-8aaa-bbbbbbbbbbbb:bot:6f9619ff-8b86-4d01-b42d-00c04fc964ff", + "primary_scope_type": "channel", + "primary_scope_id": "77777777-8888-4999-8aaa-bbbbbbbbbbbb", + "cwd": "/workspace", + "additional_dirs": [] + }, + "runtime": { + "protocol": "acp" + }, + "reason": "user closed session" +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_connector/task.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_connector/task.json new file mode 100644 index 00000000..09c42083 --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_connector/task.json @@ -0,0 +1,41 @@ +{ + "additional_dirs": [ + "/data" + ], + "attachments": [ + { + "content_type": "text/markdown", + "file_id": "file-1", + "filename": "notes.md", + "size_bytes": 12 + } + ], + "channel_id": "77777777-8888-4999-8aaa-bbbbbbbbbbbb", + "cwd": "/workspace", + "depth": 0, + "msg_id": "eeeeeeee-ffff-4000-8111-222222222222", + "pinned": [ + "Always answer in English." + ], + "placeholder_msg_id": "33333333-4444-4555-8666-777777777777", + "provider_session_key": "cheers:channel:77777777-8888-4999-8aaa-bbbbbbbbbbbb:bot:6f9619ff-8b86-4d01-b42d-00c04fc964ff", + "session": { + "provider_session_key": "cheers:channel:77777777-8888-4999-8aaa-bbbbbbbbbbbb:bot:6f9619ff-8b86-4d01-b42d-00c04fc964ff", + "task_scope_id": "99999999-aaaa-4bbb-8ccc-dddddddddddd" + }, + "session_policy": { + "after_task": "keep_active", + "on_missing": "create", + "on_paused": "resume" + }, + "task_id": "99999999-aaaa-4bbb-8ccc-dddddddddddd", + "trigger": "user_message", + "trigger_message": { + "content": "hello bot", + "msg_id": "eeeeeeee-ffff-4000-8111-222222222222" + }, + "trigger_msg_id": "eeeeeeee-ffff-4000-8111-222222222222", + "trigger_seq": 42, + "type": "task", + "v": 1 +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_gateway/auth.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_gateway/auth.json new file mode 100644 index 00000000..4f376c3a --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_gateway/auth.json @@ -0,0 +1,10 @@ +{ + "type": "auth", + "v": 1, + "token": "agb_fixture_token", + "bridge_protocol_version": 1, + "connector": { + "name": "cce-acp-connector", + "version": "0.1.27" + } +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_gateway/config_option_status.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_gateway/config_option_status.json new file mode 100644 index 00000000..1b348bee --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_gateway/config_option_status.json @@ -0,0 +1,9 @@ +{ + "type": "config_option_status", + "v": 1, + "request_id": "99999999-aaaa-4bbb-8ccc-dddddddddddd", + "ok": true, + "provider_session_key": "cheers:channel:77777777-8888-4999-8aaa-bbbbbbbbbbbb:bot:6f9619ff-8b86-4d01-b42d-00c04fc964ff", + "config_id": "model", + "value": "claude-sonnet-5" +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_gateway/config_options.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_gateway/config_options.json new file mode 100644 index 00000000..b2c7092e --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_gateway/config_options.json @@ -0,0 +1,13 @@ +{ + "type": "config_options", + "v": 1, + "options": { + "configOptions": [ + { + "id": "model", + "value": "claude-sonnet-5" + } + ], + "currentModeId": "default" + } +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_gateway/config_status.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_gateway/config_status.json new file mode 100644 index 00000000..e77f8e82 --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_gateway/config_status.json @@ -0,0 +1,15 @@ +{ + "type": "config_status", + "v": 1, + "revision": 3, + "ok": false, + "applied": [ + "model" + ], + "rejected": [ + { + "field": "cwd", + "reason": "outside allowed_roots" + } + ] +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_gateway/ping.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_gateway/ping.json new file mode 100644 index 00000000..5f5760db --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_gateway/ping.json @@ -0,0 +1,3 @@ +{ + "type": "ping" +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_gateway/ready.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_gateway/ready.json new file mode 100644 index 00000000..0e510de8 --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_gateway/ready.json @@ -0,0 +1,16 @@ +{ + "type": "ready", + "v": 1, + "connector_version": "0.1.27", + "runtime": { + "protocol": "acp", + "name": "claude-agent-acp", + "version": "1.4.2" + }, + "connector_capabilities": { + "runtime_protocols": [ + "acp" + ], + "streaming": true + } +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_gateway/runtime_session_control_ack.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_gateway/runtime_session_control_ack.json new file mode 100644 index 00000000..77cb61f0 --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/control/to_gateway/runtime_session_control_ack.json @@ -0,0 +1,14 @@ +{ + "type": "runtime_session_control_ack", + "v": 1, + "request_id": "99999999-aaaa-4bbb-8ccc-dddddddddddd", + "action": "terminate", + "ok": true, + "session": { + "id": "eeeeeeee-ffff-4000-8111-222222222222", + "provider_session_key": "cheers:channel:77777777-8888-4999-8aaa-bbbbbbbbbbbb:bot:6f9619ff-8b86-4d01-b42d-00c04fc964ff", + "provider_session_id": "acp-session-1", + "status": "terminated" + }, + "applied_at": "2026-06-01T10:15:30+00:00" +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_connector/error.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_connector/error.json new file mode 100644 index 00000000..3c85cdfe --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_connector/error.json @@ -0,0 +1,6 @@ +{ + "code": "CAPABILITY_DENIED", + "detail": "signature verification failed", + "type": "error", + "v": 1 +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_connector/hello.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_connector/hello.json new file mode 100644 index 00000000..518cf85f --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_connector/hello.json @@ -0,0 +1,25 @@ +{ + "bot_id": "6f9619ff-8b86-4d01-b42d-00c04fc964ff", + "bridge_protocol_version": 1, + "connection_id": "11111111-2222-4333-8444-555555555555", + "last_event_seq": 0, + "server_capabilities": { + "acp_security": true, + "auth": [ + "authorization_bearer", + "auth_frame" + ], + "file_upload": false, + "latest_connector_version": "0.1.27", + "resource_req": true, + "resume": "ack_only", + "runtime_session_control": true, + "send_ack": true, + "task_stream": "control", + "terminal_ack": true + }, + "session_id": "11111111-2222-4333-8444-555555555555", + "stream": "data", + "type": "hello", + "v": 1 +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_connector/pong.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_connector/pong.json new file mode 100644 index 00000000..22e9f907 --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_connector/pong.json @@ -0,0 +1,3 @@ +{ + "type": "pong" +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_connector/realize_file.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_connector/realize_file.json new file mode 100644 index 00000000..84a73c8a --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_connector/realize_file.json @@ -0,0 +1,9 @@ +{ + "channel_id": "77777777-8888-4999-8aaa-bbbbbbbbbbbb", + "file_id": "file-1", + "remote_ref": "/workspace/report.pdf", + "roots": [ + "/workspace" + ], + "type": "realize_file" +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_connector/resource_res_err.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_connector/resource_res_err.json new file mode 100644 index 00000000..2435195c --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_connector/resource_res_err.json @@ -0,0 +1,8 @@ +{ + "code": "E_FORBIDDEN", + "error": "SEE denied for this event", + "ok": false, + "req_id": "req-1", + "type": "resource_res", + "v": 1 +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_connector/resource_res_ok.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_connector/resource_res_ok.json new file mode 100644 index 00000000..942631ad --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_connector/resource_res_ok.json @@ -0,0 +1,9 @@ +{ + "data": { + "messages": [] + }, + "ok": true, + "req_id": "req-1", + "type": "resource_res", + "v": 1 +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_connector/resume_ack.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_connector/resume_ack.json new file mode 100644 index 00000000..4cd85a69 --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_connector/resume_ack.json @@ -0,0 +1,6 @@ +{ + "replayed": 0, + "type": "resume_ack", + "up_to_seq": 42, + "v": 1 +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_connector/send_ack_err.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_connector/send_ack_err.json new file mode 100644 index 00000000..63981795 --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_connector/send_ack_err.json @@ -0,0 +1,8 @@ +{ + "client_msg_id": "client-msg-1", + "code": "SEND_FAILED", + "error": "channel not writable", + "ok": false, + "type": "send_ack", + "v": 1 +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_connector/send_ack_ok.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_connector/send_ack_ok.json new file mode 100644 index 00000000..1b8d1fc1 --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_connector/send_ack_ok.json @@ -0,0 +1,8 @@ +{ + "client_msg_id": "client-msg-1", + "finalized_placeholder": true, + "message_id": "6f9619ff-8b86-4d01-b42d-00c04fc964ff", + "ok": true, + "type": "send_ack", + "v": 1 +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_connector/terminal_ack_err.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_connector/terminal_ack_err.json new file mode 100644 index 00000000..cb65a544 --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_connector/terminal_ack_err.json @@ -0,0 +1,8 @@ +{ + "client_msg_id": "client-msg-2", + "code": "TERMINAL_REJECTED", + "error": "not the owner of msg", + "ok": false, + "type": "terminal_ack", + "v": 1 +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_connector/terminal_ack_ok.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_connector/terminal_ack_ok.json new file mode 100644 index 00000000..fe5015a2 --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_connector/terminal_ack_ok.json @@ -0,0 +1,7 @@ +{ + "client_msg_id": "client-msg-2", + "msg_id": "6f9619ff-8b86-4d01-b42d-00c04fc964ff", + "ok": true, + "type": "terminal_ack", + "v": 1 +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_connector/workspace_req_git_log.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_connector/workspace_req_git_log.json new file mode 100644 index 00000000..a9521cc1 --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_connector/workspace_req_git_log.json @@ -0,0 +1,10 @@ +{ + "limit": 20, + "op": "git_log", + "path": "", + "req_id": "req-3", + "root": "/workspace", + "roots": [], + "skip": 40, + "type": "workspace_req" +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_connector/workspace_req_read.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_connector/workspace_req_read.json new file mode 100644 index 00000000..db888ac6 --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_connector/workspace_req_read.json @@ -0,0 +1,10 @@ +{ + "op": "read", + "path": "src/main.rs", + "req_id": "req-1", + "root": "/workspace", + "roots": [ + "/workspace" + ], + "type": "workspace_req" +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_connector/workspace_req_watch.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_connector/workspace_req_watch.json new file mode 100644 index 00000000..93165e24 --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_connector/workspace_req_watch.json @@ -0,0 +1,10 @@ +{ + "op": "watch", + "path": "", + "req_id": "req-4", + "root": "/workspace", + "roots": [ + "/workspace" + ], + "type": "workspace_req" +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_connector/workspace_req_write.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_connector/workspace_req_write.json new file mode 100644 index 00000000..10953f87 --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_connector/workspace_req_write.json @@ -0,0 +1,12 @@ +{ + "content_b64": "aGVsbG8=", + "if_etag": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "op": "write", + "path": "notes.md", + "req_id": "req-2", + "root": "/workspace", + "roots": [ + "/workspace" + ], + "type": "workspace_req" +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/acp_event.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/acp_event.json new file mode 100644 index 00000000..068b71a9 --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/acp_event.json @@ -0,0 +1,16 @@ +{ + "type": "acp_event", + "v": 1, + "name": "session/update:plan", + "channel_id": "77777777-8888-4999-8aaa-bbbbbbbbbbbb", + "msg_id": "33333333-4444-4555-8666-777777777777", + "provider_session_key": "cheers:channel:77777777-8888-4999-8aaa-bbbbbbbbbbbb:bot:6f9619ff-8b86-4d01-b42d-00c04fc964ff", + "payload": { + "entries": [ + { + "content": "Fix the bug", + "status": "pending" + } + ] + } +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/auth.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/auth.json new file mode 100644 index 00000000..4f376c3a --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/auth.json @@ -0,0 +1,10 @@ +{ + "type": "auth", + "v": 1, + "token": "agb_fixture_token", + "bridge_protocol_version": 1, + "connector": { + "name": "cce-acp-connector", + "version": "0.1.27" + } +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/delta.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/delta.json new file mode 100644 index 00000000..6909be84 --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/delta.json @@ -0,0 +1,9 @@ +{ + "type": "delta", + "v": 1, + "msg_id": "33333333-4444-4555-8666-777777777777", + "seq": 7, + "delta": "Hello, wor", + "provider_session_key": "cheers:channel:77777777-8888-4999-8aaa-bbbbbbbbbbbb:bot:6f9619ff-8b86-4d01-b42d-00c04fc964ff", + "provider_session_id": "acp-session-1" +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/done.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/done.json new file mode 100644 index 00000000..e7a6fd7a --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/done.json @@ -0,0 +1,13 @@ +{ + "type": "done", + "v": 1, + "client_msg_id": "client-msg-2", + "msg_id": "33333333-4444-4555-8666-777777777777", + "file_ids": [ + "file-1" + ], + "mention_ids": [], + "content": "Hello, world!", + "provider_session_key": "cheers:channel:77777777-8888-4999-8aaa-bbbbbbbbbbbb:bot:6f9619ff-8b86-4d01-b42d-00c04fc964ff", + "provider_session_id": "acp-session-1" +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/error.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/error.json new file mode 100644 index 00000000..cacf9625 --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/error.json @@ -0,0 +1,8 @@ +{ + "type": "error", + "v": 1, + "client_msg_id": "client-msg-2", + "msg_id": "33333333-4444-4555-8666-777777777777", + "message": "prompt timed out", + "provider_session_key": "cheers:channel:77777777-8888-4999-8aaa-bbbbbbbbbbbb:bot:6f9619ff-8b86-4d01-b42d-00c04fc964ff" +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/file_upload.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/file_upload.json new file mode 100644 index 00000000..453c6cae --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/file_upload.json @@ -0,0 +1,9 @@ +{ + "type": "file_upload", + "v": 1, + "client_file_id": "client-file-1", + "channel_id": "77777777-8888-4999-8aaa-bbbbbbbbbbbb", + "filename": "report.pdf", + "content_type": "application/pdf", + "data_b64": "aGVsbG8=" +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/permission_cancel.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/permission_cancel.json new file mode 100644 index 00000000..b0b2e83c --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/permission_cancel.json @@ -0,0 +1,6 @@ +{ + "type": "permission_cancel", + "v": 1, + "request_id": "99999999-aaaa-4bbb-8ccc-dddddddddddd", + "reason": "timeout" +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/permission_request.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/permission_request.json new file mode 100644 index 00000000..83ecece0 --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/permission_request.json @@ -0,0 +1,25 @@ +{ + "type": "permission_request", + "v": 1, + "client_msg_id": "client-msg-4", + "channel_id": "77777777-8888-4999-8aaa-bbbbbbbbbbbb", + "request_id": "99999999-aaaa-4bbb-8ccc-dddddddddddd", + "task_id": "99999999-aaaa-4bbb-8ccc-dddddddddddd", + "msg_id": "33333333-4444-4555-8666-777777777777", + "acp_session_id": "acp-session-1", + "provider_session_key": "cheers:channel:77777777-8888-4999-8aaa-bbbbbbbbbbbb:bot:6f9619ff-8b86-4d01-b42d-00c04fc964ff", + "provider_session_id": "acp-session-1", + "title": "Run command", + "body": "The agent wants to run `cargo test`.", + "tool": { + "kind": "execute", + "title": "cargo test" + }, + "options": [ + { + "option_id": "allow_once", + "kind": "allow_once", + "name": "Allow once" + } + ] +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/ping.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/ping.json new file mode 100644 index 00000000..5f5760db --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/ping.json @@ -0,0 +1,3 @@ +{ + "type": "ping" +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/resource_req.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/resource_req.json new file mode 100644 index 00000000..c983a7f3 --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/resource_req.json @@ -0,0 +1,9 @@ +{ + "type": "resource_req", + "v": 1, + "req_id": "req-1", + "resource": "channel.activity.read", + "params": { + "channel_id": "77777777-8888-4999-8aaa-bbbbbbbbbbbb" + } +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/resume.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/resume.json new file mode 100644 index 00000000..9c086da0 --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/resume.json @@ -0,0 +1,5 @@ +{ + "type": "resume", + "v": 1, + "last_event_seq": 42 +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/send.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/send.json new file mode 100644 index 00000000..209b742d --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/send.json @@ -0,0 +1,10 @@ +{ + "type": "send", + "v": 1, + "client_msg_id": "client-msg-3", + "channel_id": "77777777-8888-4999-8aaa-bbbbbbbbbbbb", + "text": "Proactive update: build finished.", + "file_ids": [], + "mention_ids": [], + "provider_session_key": "cheers:channel:77777777-8888-4999-8aaa-bbbbbbbbbbbb:bot:6f9619ff-8b86-4d01-b42d-00c04fc964ff" +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/session_update.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/session_update.json new file mode 100644 index 00000000..f6c1b67b --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/session_update.json @@ -0,0 +1,9 @@ +{ + "type": "session_update", + "v": 1, + "provider_session_key": "cheers:channel:77777777-8888-4999-8aaa-bbbbbbbbbbbb:bot:6f9619ff-8b86-4d01-b42d-00c04fc964ff", + "provider_session_id": "acp-session-1", + "metadata": { + "model": "claude-sonnet-5" + } +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/trace.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/trace.json new file mode 100644 index 00000000..4f284405 --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/trace.json @@ -0,0 +1,15 @@ +{ + "type": "trace", + "v": 1, + "msg_id": "33333333-4444-4555-8666-777777777777", + "task_id": "99999999-aaaa-4bbb-8ccc-dddddddddddd", + "channel_id": "77777777-8888-4999-8aaa-bbbbbbbbbbbb", + "run_id": "run-1", + "provider_session_key": "cheers:channel:77777777-8888-4999-8aaa-bbbbbbbbbbbb:bot:6f9619ff-8b86-4d01-b42d-00c04fc964ff", + "stream": "progress", + "seq": 3, + "ts": 1780000000000, + "phase": "tool_call", + "status": "running", + "title": "Reading files" +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/workspace_event.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/workspace_event.json new file mode 100644 index 00000000..33182b9a --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/workspace_event.json @@ -0,0 +1,9 @@ +{ + "type": "workspace_event", + "v": 1, + "root": "/workspace", + "paths": [ + "src/main.rs" + ], + "kind": "change" +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/workspace_res.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/workspace_res.json new file mode 100644 index 00000000..b770ddab --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/data/to_gateway/workspace_res.json @@ -0,0 +1,9 @@ +{ + "type": "workspace_res", + "v": 1, + "req_id": "req-2", + "ok": true, + "data": { + "etag": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + } +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/tolerance/extra_unknown_field.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/tolerance/extra_unknown_field.json new file mode 100644 index 00000000..76f1ede1 --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/tolerance/extra_unknown_field.json @@ -0,0 +1,8 @@ +{ + "type": "cancel", + "msg_id": "33333333-4444-4555-8666-777777777777", + "reason": "user_cancelled", + "brand_new_optional_field": { + "added_in": "a future gateway" + } +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/tolerance/unknown_frame_type.json b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/tolerance/unknown_frame_type.json new file mode 100644 index 00000000..b5b81412 --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/fixtures/tolerance/unknown_frame_type.json @@ -0,0 +1,7 @@ +{ + "type": "hologram_sync", + "v": 1, + "payload": { + "future": true + } +} diff --git a/packages/cheers-acp-connector-rs/bridge-protocol/src/lib.rs b/packages/cheers-acp-connector-rs/bridge-protocol/src/lib.rs new file mode 100644 index 00000000..47b7bda5 --- /dev/null +++ b/packages/cheers-acp-connector-rs/bridge-protocol/src/lib.rs @@ -0,0 +1,1122 @@ +//! Agent Bridge WS protocol — the frame types shared by the Cheers gateway +//! (`server/`, which SENDS `*Inbound` and PARSES `*Outbound`) and the +//! `cce-acp-connector` binary (the WS client). +//! +//! Naming is from the CONNECTOR's perspective: `ControlInbound`/`DataInbound` +//! travel gateway→connector, `ControlOutbound`/`DataOutbound` travel +//! connector→gateway. The `ControlToConnector`-style aliases below read +//! naturally from either end. +//! +//! ## Wire-compat rules +//! +//! The golden fixtures in `../fixtures/` are the wire source of truth; both +//! ends' tests are pinned to them. Safe without coordination: adding optional +//! fields, dropping an explicit `null` for an absent Option, key order. NOT +//! safe without a fleet version floor: removing the `task` frame's duplicated +//! `msg_id`/session identifiers, dropping one of hello's two version fields, +//! adding `v` to the version-less frames (`cancel`, `realize_file`, +//! `workspace_req`, `pong`), or renaming `config_update.settings`' camelCase +//! keys (that casing IS the contract). `fixtures/compat/*` may only change +//! together with an explicit version gate. +//! +//! ## Future v2 negotiation (documented, deliberately not built) +//! +//! Additive `supported_versions: [..]` on `auth` and `hello`; absent ⇒ +//! `[bridge_protocol_version]`; both ends select max(intersection) and stamp +//! it as `v`; empty intersection ⇒ close 4400. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub const BRIDGE_PROTOCOL_VERSION: u32 = 1; +pub const WS_CLOSE_AUTH_FAIL: u16 = 4401; +pub const WS_CLOSE_SUPERSEDED: u16 = 4402; +pub const WS_CLOSE_BOT_UNAVAILABLE: u16 = 4403; +/// Gateway 4400: protocol error / unsupported bridge protocol version. Fatal — +/// retrying the same handshake can never succeed, the binary must be updated. +pub const WS_CLOSE_UNSUPPORTED_PROTOCOL: u16 = 4400; + +pub fn is_fatal_close_code(code: u16) -> bool { + matches!( + code, + WS_CLOSE_AUTH_FAIL + | WS_CLOSE_SUPERSEDED + | WS_CLOSE_BOT_UNAVAILABLE + | WS_CLOSE_UNSUPPORTED_PROTOCOL + ) +} + +fn default_bridge_protocol_version() -> u32 { + BRIDGE_PROTOCOL_VERSION +} + +fn default_auth_frame_type() -> String { + "auth".to_string() +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConnectorInfo { + pub name: String, + pub version: String, +} + +impl ConnectorInfo { + pub fn new(name: impl Into, version: impl Into) -> Self { + Self { + name: name.into(), + version: version.into(), + } + } +} + +/// Deliberately anonymous: in a SHARED crate `env!("CARGO_PKG_VERSION")` would +/// report this crate's version, not the connector's — the binary passes its +/// real identity via `AgentBridgeAuth::new` (see `local_connector_info()` in +/// the connector's bridge.rs). +impl Default for ConnectorInfo { + fn default() -> Self { + Self::new("unknown", "0.0.0") + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentBridgeAuth { + #[serde(rename = "type", default = "default_auth_frame_type")] + pub frame_type: String, + #[serde(default = "default_bridge_protocol_version")] + pub v: u32, + pub token: String, + #[serde(default = "default_bridge_protocol_version")] + pub bridge_protocol_version: u32, + #[serde(default)] + pub connector: ConnectorInfo, +} + +impl AgentBridgeAuth { + pub fn new(token: impl Into, connector: ConnectorInfo) -> Self { + Self { + frame_type: default_auth_frame_type(), + v: BRIDGE_PROTOCOL_VERSION, + token: token.into(), + bridge_protocol_version: BRIDGE_PROTOCOL_VERSION, + connector, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChannelInfo { + pub channel_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub channel_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub channel_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub joined_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AcpSecurityHello { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub algorithm: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub require_capability: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allow_plaintext_fallback: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub phase: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AcpCapabilityEnvelope { + pub delegation_id: String, + pub ts: i64, + pub nonce: String, + pub signature: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub request_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub algorithm: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kid: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ConnectorControlSettings { + #[serde( + default, + rename = "agentNativePermissionMode", + skip_serializing_if = "Option::is_none" + )] + pub agent_native_permission_mode: Option, + #[serde( + default, + rename = "permissionMode", + skip_serializing_if = "Option::is_none" + )] + pub permission_mode: Option, + #[serde( + default, + rename = "requestTimeoutMs", + skip_serializing_if = "Option::is_none" + )] + pub request_timeout_ms: Option, + #[serde( + default, + rename = "promptTimeoutMs", + skip_serializing_if = "Option::is_none" + )] + pub prompt_timeout_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde( + default, + rename = "configOptions", + skip_serializing_if = "Option::is_none" + )] + pub config_options: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConnectorControlConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub revision: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub settings: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub updated_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub options: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerCapabilities { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub task_stream: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runtime_session_control: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub task: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cancel: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permission_resolution: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub connector_config: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_option_set: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub membership_events: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resource_req: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub file_upload: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub send: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub send_ack: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub terminal_ack: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trace: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_update: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resume: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub acp_security: Option, + /// Version of the connector release this gateway serves via its download + /// proxy — the self-updater's trigger signal (see `self_update`). Absent on + /// gateways that pin no release version. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub latest_connector_version: Option, + #[serde(flatten)] + pub extra: serde_json::Map, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AttachmentInfo { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub file_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub filename: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub content_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub size_bytes: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub summary: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub is_image: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub image_b64: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub is_audio: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub audio_b64: Option, + #[serde(flatten)] + pub extra: serde_json::Map, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RuntimeSessionRef { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_session_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_account_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_agent_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub primary_scope_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub primary_scope_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub task_scope_id: Option, + #[serde(flatten)] + pub extra: serde_json::Map, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionPolicy { + pub on_missing: String, + pub on_paused: String, + pub after_task: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RuntimeDescriptor { + pub protocol: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config: Option, + #[serde(flatten)] + pub extra: serde_json::Map, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RuntimeSessionControlSession { + pub id: String, + pub provider_session_key: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub primary_scope_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub primary_scope_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub task_scope_id: Option, + /// The session's ACP `cwd` (absolute), if the Backend pinned one for an + /// eager create/resume. Re-validated against `allowed_roots` by the connector. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option, + /// The session's ACP `additionalDirectories`, re-validated against `allowed_roots`. + #[serde(default)] + pub additional_dirs: Vec, + #[serde(flatten)] + pub extra: serde_json::Map, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RuntimeSessionAckSession { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_session_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(flatten)] + pub extra: serde_json::Map, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PermissionOption { + pub option_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kind: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PermissionResolution { + pub request_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message_id: Option, + pub resolution: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub option_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resolved_by: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resolved_at: Option, + #[serde(flatten)] + pub extra: serde_json::Map, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConfigStatusRejectedField { + pub field: String, + pub reason: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResourceResponse { + #[serde(default = "default_bridge_protocol_version")] + pub v: u32, + pub req_id: String, + pub ok: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub data: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub code: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BridgeErrorFrame { + #[serde(default = "default_bridge_protocol_version")] + pub v: u32, + pub code: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub request_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_msg_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub retryable: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum ControlInbound { + #[serde(rename = "hello")] + Hello { + #[serde(default = "default_bridge_protocol_version")] + v: u32, + #[serde(default = "default_bridge_protocol_version")] + bridge_protocol_version: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + stream: Option, + bot_id: String, + bot_username: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + bot_display_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + connection_id: Option, + session_id: String, + #[serde(default)] + memberships: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + acp_security: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + connector_config: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + server_capabilities: Option, + }, + #[serde(rename = "runtime_session_control")] + RuntimeSessionControl { + #[serde(default = "default_bridge_protocol_version")] + v: u32, + request_id: String, + action: String, + session: RuntimeSessionControlSession, + runtime: RuntimeDescriptor, + #[serde(default, skip_serializing_if = "Option::is_none")] + reason: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + deadline_ms: Option, + }, + #[serde(rename = "task")] + Task { + #[serde(default = "default_bridge_protocol_version")] + v: u32, + task_id: String, + channel_id: String, + trigger_msg_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + msg_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + trigger_seq: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + depth: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + trigger: Option, + placeholder_msg_id: String, + provider_session_key: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + session_policy: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + trigger_message: Option, + #[serde(default)] + attachments: Vec, + /// Pinned context blocks (already formatted strings) prepended to the prompt + /// every request — the channel's "convention prompt" (e.g. a prompt template). + #[serde(default)] + pinned: Vec, + /// The session's primary working directory (ACP `cwd`), if the Backend + /// pinned one for this session. Absolute; the connector re-validates it + /// against `allowed_roots` and falls back to `default_cwd` when unset or + /// rejected. Immutable for the session's lifetime. + #[serde(default, skip_serializing_if = "Option::is_none")] + cwd: Option, + /// Extra roots for this session's effective root set (ACP + /// `additionalDirectories`). Each is re-validated against `allowed_roots`; + /// out-of-policy entries are dropped. + #[serde(default)] + additional_dirs: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + binding_config: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + session: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + enqueued_at: Option, + }, + #[serde(rename = "channel_joined")] + ChannelJoined { + channel: ChannelInfo, + #[serde(default, skip_serializing_if = "Option::is_none")] + invited_by: Option, + }, + #[serde(rename = "channel_left")] + ChannelLeft { channel_id: String, reason: String }, + #[serde(rename = "cancel")] + Cancel { + msg_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + reason: Option, + }, + #[serde(rename = "config_update")] + ConfigUpdate { + #[serde(default = "default_bridge_protocol_version")] + v: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + revision: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + settings: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + updated_at: Option, + }, + #[serde(rename = "config_option_set")] + ConfigOptionSet { + #[serde(default = "default_bridge_protocol_version")] + v: u32, + request_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_session_key: Option, + config_id: String, + value: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + updated_at: Option, + }, + // Session-targeted mode change (ACP session/set_mode). Distinct from the + // bot-wide config_update.agentNativePermissionMode AND from config_option_set: + // it value-gates on the L0 allowed_modes envelope (config_option_set checks + // only the config id), so a delegated mode change can't escape the clamp. + #[serde(rename = "mode_set")] + ModeSet { + #[serde(default = "default_bridge_protocol_version")] + v: u32, + request_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_session_key: Option, + mode: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + updated_at: Option, + }, + #[serde(rename = "permission_resolution")] + PermissionResolution { + #[serde(default = "default_bridge_protocol_version")] + v: u32, + #[serde(flatten)] + resolution: PermissionResolution, + }, + #[serde(rename = "pong")] + Pong, + #[serde(rename = "error")] + Error { + #[serde(flatten)] + error: BridgeErrorFrame, + }, + #[serde(other)] + Unknown, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum ControlOutbound { + #[serde(rename = "auth")] + Auth { + #[serde(default = "default_bridge_protocol_version")] + v: u32, + token: String, + #[serde(default = "default_bridge_protocol_version")] + bridge_protocol_version: u32, + #[serde(default)] + connector: ConnectorInfo, + }, + #[serde(rename = "ready")] + Ready { + #[serde(default = "default_bridge_protocol_version")] + v: u32, + /// Optional only for PARSING legacy frames (the retired TS connector + /// sent `plugin_version` instead) — this connector always sets it. + #[serde(default, skip_serializing_if = "Option::is_none")] + connector_version: Option, + // wire-compat: legacy alias for connector_version, kept so a typed + // gateway parse still accepts the retired TS connector's ready frame + // (pinned by fixtures/compat/ready_plugin_version.json). + #[serde(default, skip_serializing_if = "Option::is_none")] + plugin_version: Option, + /// Optional only for PARSING (a malformed/ancient ready without a + /// runtime descriptor must still reach the ready handler, not fall + /// through to Unknown) — this connector always sets it. + #[serde(default, skip_serializing_if = "Option::is_none")] + runtime: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + connector_capabilities: Option, + }, + #[serde(rename = "ping")] + Ping, + #[serde(rename = "runtime_session_control_ack")] + RuntimeSessionControlAck { + #[serde(default = "default_bridge_protocol_version")] + v: u32, + request_id: String, + action: String, + ok: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + session: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + applied_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + code: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + retryable: Option, + }, + #[serde(rename = "config_status")] + ConfigStatus { + #[serde(default = "default_bridge_protocol_version")] + v: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + revision: Option, + ok: bool, + #[serde(default)] + applied: Vec, + #[serde(default)] + rejected: Vec, + }, + #[serde(rename = "config_options")] + ConfigOptions { + #[serde(default = "default_bridge_protocol_version")] + v: u32, + options: Value, + }, + #[serde(rename = "config_option_status")] + ConfigOptionStatus { + #[serde(default = "default_bridge_protocol_version")] + v: u32, + request_id: String, + ok: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_session_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + config_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + value: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + options: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + code: Option, + }, + #[serde(other)] + Unknown, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum DataInbound { + #[serde(rename = "hello")] + Hello { + #[serde(default = "default_bridge_protocol_version")] + v: u32, + #[serde(default = "default_bridge_protocol_version")] + bridge_protocol_version: u32, + stream: String, + bot_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + connection_id: Option, + session_id: String, + #[serde(default)] + last_event_seq: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + acp_security: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + server_capabilities: Option, + }, + #[serde(rename = "pong")] + Pong, + #[serde(rename = "resume_ack")] + ResumeAck { + #[serde(default = "default_bridge_protocol_version")] + v: u32, + replayed: u64, + up_to_seq: u64, + }, + #[serde(rename = "send_ack")] + SendAck { + #[serde(default = "default_bridge_protocol_version")] + v: u32, + client_msg_id: String, + ok: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + message_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + finalized_placeholder: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + permission_resolution: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + code: Option, + }, + #[serde(rename = "terminal_ack")] + TerminalAck { + #[serde(default = "default_bridge_protocol_version")] + v: u32, + client_msg_id: String, + ok: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + msg_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + queued: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + job_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + code: Option, + }, + #[serde(rename = "file_upload_ack")] + FileUploadAck { + #[serde(default = "default_bridge_protocol_version")] + v: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + client_file_id: Option, + ok: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + file_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + filename: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + content_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + size_bytes: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + preview_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + download_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + code: Option, + }, + #[serde(rename = "resource_res")] + ResourceRes { + #[serde(flatten)] + response: ResourceResponse, + }, + #[serde(rename = "error")] + Error { + #[serde(flatten)] + error: BridgeErrorFrame, + }, + /// Gateway → connector: realize a staged file. Connector reads the local path, + /// base64-encodes it, and calls channel.files.realize to upload to S3. + #[serde(rename = "realize_file")] + RealizeFile { + file_id: String, + remote_ref: String, + channel_id: String, + /// The owning session's ACP root set (`cwd` + `additionalDirectories`). The + /// connector confines `remote_ref` to these (∩ `allowed_roots`); empty ⇒ + /// the session's implicit root is the connector `default_cwd`. + #[serde(default)] + roots: Vec, + }, + /// Gateway → connector: browse/read/write the agent's real workspace, confined + /// to `policy.workspace.allowed_roots`. Connector replies with `workspace_res` + /// correlated by `req_id`. `op` ∈ { "ls", "read", "write", "validate_cwd", + /// "git_status", "git_diff", "git_log", "git_show", "git_commit_files", + /// "workspace_meta", "watch", "unwatch" }. The git ops are READ-ONLY. + /// `workspace_meta` describes the workspace policy (allowed/effective roots, + /// default_cwd, git availability) without touching the filesystem. `watch` + /// starts a debounced recursive fs watcher on the resolved (clamped) dir and + /// streams unsolicited `workspace_event` frames; `unwatch` (by `watch_id`) + /// stops it. + #[serde(rename = "workspace_req")] + WorkspaceReq { + req_id: String, + op: String, + #[serde(default)] + path: String, + /// Which allowed root to resolve `path` against (absolute path string). + /// Defaults to the connector's default_cwd / first allowed root. + #[serde(default, skip_serializing_if = "Option::is_none")] + root: Option, + /// base64 file bytes for `op == "write"`. + #[serde(default, skip_serializing_if = "Option::is_none")] + content_b64: Option, + /// `op == "write"` precondition (safe remote writes). Absent/null ⇒ + /// unconditional overwrite (back-compat). `""` ⇒ create-only (fail if the + /// file already exists). A 64-char lowercase-hex SHA-256 ⇒ overwrite only + /// if the current file's bytes hash to it, else `E_CONFLICT`. + #[serde(default, skip_serializing_if = "Option::is_none")] + if_etag: Option, + /// Optional session root set to scope this browse to (`cwd` + + /// `additionalDirectories`). Empty ⇒ the full `allowed_roots` (bot-wide + /// browse). When set, the effective roots are these ∩ `allowed_roots`. + #[serde(default)] + roots: Vec, + /// `op == "git_diff"`: diff the staged index (`--staged`) instead of the + /// working tree. + #[serde(default, skip_serializing_if = "Option::is_none")] + staged: Option, + /// `op == "git_log"`: max commits to return (clamped to ≤100 by the + /// connector). + #[serde(default, skip_serializing_if = "Option::is_none")] + limit: Option, + /// `op == "git_log"`: commits to skip before collecting (`--skip`), for + /// pagination (clamped to ≤100000 by the connector). + #[serde(default, skip_serializing_if = "Option::is_none")] + skip: Option, + /// `op == "git_show" | "git_commit_files"`: the commit ref (a hex hash, as + /// emitted by `git_log`; validated `^[0-9a-fA-F]{7,64}$` before use as argv). + #[serde(default, skip_serializing_if = "Option::is_none")] + commit: Option, + /// `op == "git_show"`: optional repo-root-relative path filter — limits the + /// commit diff to one file (as listed by `git_commit_files`). Validated + /// (relative, no `..`, no leading `-`/`:`) and passed as a `:(top)`-anchored + /// pathspec after `--`, never as a flag. + #[serde(default, skip_serializing_if = "Option::is_none")] + commit_path: Option, + /// `op == "unwatch"`: the `watch_id` returned by a prior `watch` reply, + /// identifying the fs watcher to stop. + #[serde(default, skip_serializing_if = "Option::is_none")] + watch_id: Option, + }, + #[serde(other)] + Unknown, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum DataOutbound { + #[serde(rename = "auth")] + Auth { + #[serde(default = "default_bridge_protocol_version")] + v: u32, + token: String, + #[serde(default = "default_bridge_protocol_version")] + bridge_protocol_version: u32, + #[serde(default)] + connector: ConnectorInfo, + }, + #[serde(rename = "ping")] + Ping, + #[serde(rename = "resume")] + Resume { + #[serde(default = "default_bridge_protocol_version")] + v: u32, + last_event_seq: u64, + }, + #[serde(rename = "delta")] + Delta { + #[serde(default = "default_bridge_protocol_version")] + v: u32, + msg_id: String, + seq: u64, + delta: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_session_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + acp_capability: Option, + }, + #[serde(rename = "done")] + Done { + #[serde(default = "default_bridge_protocol_version")] + v: u32, + client_msg_id: String, + msg_id: String, + #[serde(default)] + file_ids: Vec, + #[serde(default)] + mention_ids: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + content: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_session_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + acp_capability: Option, + }, + #[serde(rename = "error")] + Error { + #[serde(default = "default_bridge_protocol_version")] + v: u32, + client_msg_id: String, + msg_id: String, + message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_session_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + acp_capability: Option, + }, + #[serde(rename = "send")] + Send { + #[serde(default = "default_bridge_protocol_version")] + v: u32, + client_msg_id: String, + channel_id: String, + text: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + in_reply_to_msg_id: Option, + #[serde(default)] + file_ids: Vec, + #[serde(default)] + mention_ids: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_session_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + acp_capability: Option, + }, + #[serde(rename = "file_upload")] + FileUpload { + #[serde(default = "default_bridge_protocol_version")] + v: u32, + client_file_id: String, + channel_id: String, + filename: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + content_type: Option, + data_b64: String, + }, + #[serde(rename = "resource_req")] + ResourceReq { + #[serde(default = "default_bridge_protocol_version")] + v: u32, + req_id: String, + resource: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + params: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + encrypted: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + encrypted_payload: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + acp_capability: Option, + }, + /// Connector → gateway: reply to a `workspace_req`, correlated by `req_id`. + #[serde(rename = "workspace_res")] + WorkspaceRes { + #[serde(default = "default_bridge_protocol_version")] + v: u32, + req_id: String, + ok: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + data: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + code: Option, + }, + /// Connector → gateway: UNSOLICITED filesystem-change notification for an active + /// `watch`. Bot-scoped (no channel_id — the gateway maps bot → channels and + /// fans out to the workspace panels). `root` is the browse root the paths are + /// relative to; `paths` is the coalesced, de-duplicated, capped (≤50) set of + /// changed entries; `kind` is the change class (currently always "change"). + #[serde(rename = "workspace_event")] + WorkspaceEvent { + #[serde(default = "default_bridge_protocol_version")] + v: u32, + root: String, + paths: Vec, + kind: String, + }, + #[serde(rename = "permission_request")] + PermissionRequest { + #[serde(default = "default_bridge_protocol_version")] + v: u32, + client_msg_id: String, + channel_id: String, + request_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + task_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + msg_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + acp_session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_session_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + title: Option, + body: String, + /// Structured tool detail derived from the ACP `toolCall` + /// (title / kind / raw_input / locations) so the channel card can show + /// WHAT is being approved + a risk badge. None when the agent sent no + /// toolCall (e.g. a plain message permission). + #[serde(default, skip_serializing_if = "Option::is_none")] + tool: Option, + #[serde(default)] + options: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + acp_capability: Option, + }, + /// Tell the gateway a previously-forwarded permission request reached a + /// terminal state locally (timeout / agent cancel) with no human decision, + /// so the channel card stops hanging "pending" forever. + #[serde(rename = "permission_cancel")] + PermissionCancel { + #[serde(default = "default_bridge_protocol_version")] + v: u32, + request_id: String, + /// "timeout" | "cancelled" + reason: String, + }, + #[serde(rename = "session_update")] + SessionUpdate { + #[serde(default = "default_bridge_protocol_version")] + v: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_session_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + metadata: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + acp_capability: Option, + }, + #[serde(rename = "trace")] + Trace { + #[serde(default = "default_bridge_protocol_version")] + v: u32, + msg_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + task_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + channel_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + run_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + session_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_session_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + session_id: Option, + stream: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + seq: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + ts: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + phase: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + message: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + data: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + acp_capability: Option, + }, + /// Generic ACP-event passthrough (docs/arch/ACP_EVENT_TAXONOMY.md): forwards an + /// ACP `session/update` verbatim so Cheers sees the full event surface. `name` + /// is the registry name (e.g. `session/update:tool_call`); `payload` is the raw + /// update. The connector stays ACP-generic — it never interprets the payload. + #[serde(rename = "acp_event")] + AcpEvent { + #[serde(default = "default_bridge_protocol_version")] + v: u32, + name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + channel_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + task_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + msg_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_session_key: Option, + payload: Value, + }, + #[serde(other)] + Unknown, +} + +// Directional aliases so gateway-side code reads naturally. +pub type ControlToConnector = ControlInbound; +pub type ControlToGateway = ControlOutbound; +pub type DataToConnector = DataInbound; +pub type DataToGateway = DataOutbound; diff --git a/packages/cheers-acp-connector-rs/release-signing-pubkey.pem b/packages/cheers-acp-connector-rs/release-signing-pubkey.pem new file mode 100644 index 00000000..9b2ea0dd --- /dev/null +++ b/packages/cheers-acp-connector-rs/release-signing-pubkey.pem @@ -0,0 +1,3 @@ +-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAiQ/9mQl9329tou0luCo8oHuBTR+HApWJAqbBMFquPw0= +-----END PUBLIC KEY----- diff --git a/packages/cheers-acp-connector-rs/src/bridge.rs b/packages/cheers-acp-connector-rs/src/bridge.rs index 3f92f799..bcda1cbd 100644 --- a/packages/cheers-acp-connector-rs/src/bridge.rs +++ b/packages/cheers-acp-connector-rs/src/bridge.rs @@ -5,7 +5,7 @@ use std::time::Duration; use anyhow::{anyhow, Context}; use futures_util::{SinkExt, StreamExt}; use rand::Rng; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use serde_json::Value; use tokio::net::TcpStream; use tokio_tungstenite::{ @@ -14,41 +14,6 @@ use tokio_tungstenite::{ MaybeTlsStream, WebSocketStream, }; -pub const BRIDGE_PROTOCOL_VERSION: u32 = 1; -pub const WS_CLOSE_AUTH_FAIL: u16 = 4401; -pub const WS_CLOSE_SUPERSEDED: u16 = 4402; -pub const WS_CLOSE_BOT_UNAVAILABLE: u16 = 4403; - -pub fn is_fatal_close_code(code: u16) -> bool { - matches!( - code, - WS_CLOSE_AUTH_FAIL | WS_CLOSE_SUPERSEDED | WS_CLOSE_BOT_UNAVAILABLE - ) -} - -fn default_bridge_protocol_version() -> u32 { - BRIDGE_PROTOCOL_VERSION -} - -fn default_auth_frame_type() -> String { - "auth".to_string() -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ConnectorInfo { - pub name: String, - pub version: String, -} - -impl Default for ConnectorInfo { - fn default() -> Self { - Self { - name: "cce-acp-connector".to_string(), - version: env!("CARGO_PKG_VERSION").to_string(), - } - } -} - #[cfg(test)] mod tests { use super::*; @@ -440,29 +405,13 @@ mod tests { } } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AgentBridgeAuth { - #[serde(rename = "type", default = "default_auth_frame_type")] - pub frame_type: String, - #[serde(default = "default_bridge_protocol_version")] - pub v: u32, - pub token: String, - #[serde(default = "default_bridge_protocol_version")] - pub bridge_protocol_version: u32, - #[serde(default)] - pub connector: ConnectorInfo, -} +pub use cheers_bridge_protocol::*; -impl AgentBridgeAuth { - pub fn new(token: impl Into) -> Self { - Self { - frame_type: default_auth_frame_type(), - v: BRIDGE_PROTOCOL_VERSION, - token: token.into(), - bridge_protocol_version: BRIDGE_PROTOCOL_VERSION, - connector: ConnectorInfo::default(), - } - } +/// This binary's true identity for the auth/ready handshake — `env!` must live +/// in THIS crate so it reports the connector's version, not the protocol +/// crate's. +pub fn local_connector_info() -> ConnectorInfo { + ConnectorInfo::new("cce-acp-connector", env!("CARGO_PKG_VERSION")) } #[derive(Debug, Clone, Copy)] @@ -501,976 +450,6 @@ pub struct SessionConfig { pub send_ack_timeout_ms: u64, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ChannelInfo { - pub channel_id: String, - #[serde(default)] - pub channel_name: Option, - #[serde(default)] - pub channel_type: Option, - #[serde(default)] - pub workspace_id: Option, - #[serde(default)] - pub joined_at: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AcpSecurityHello { - #[serde(default)] - pub enabled: Option, - #[serde(default)] - pub mode: Option, - #[serde(default)] - pub algorithm: Option, - #[serde(default)] - pub require_capability: Option, - #[serde(default)] - pub allow_plaintext_fallback: Option, - #[serde(default)] - pub phase: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AcpCapabilityEnvelope { - pub delegation_id: String, - pub ts: i64, - pub nonce: String, - pub signature: String, - #[serde(default)] - pub request_id: Option, - #[serde(default)] - pub algorithm: Option, - #[serde(default)] - pub kid: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ConnectorControlSettings { - #[serde(default, rename = "agentNativePermissionMode")] - pub agent_native_permission_mode: Option, - #[serde(default, rename = "permissionMode")] - pub permission_mode: Option, - #[serde(default, rename = "requestTimeoutMs")] - pub request_timeout_ms: Option, - #[serde(default, rename = "promptTimeoutMs")] - pub prompt_timeout_ms: Option, - #[serde(default)] - pub cwd: Option, - #[serde(default)] - pub model: Option, - #[serde(default, rename = "configOptions")] - pub config_options: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ConnectorControlConfig { - #[serde(default)] - pub revision: Option, - #[serde(default)] - pub settings: Option, - #[serde(default)] - pub updated_at: Option, - #[serde(default)] - pub last_status: Option, - #[serde(default)] - pub options: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ServerCapabilities { - #[serde(default)] - pub auth: Option, - #[serde(default)] - pub task_stream: Option, - #[serde(default)] - pub runtime_session_control: Option, - #[serde(default)] - pub task: Option, - #[serde(default)] - pub cancel: Option, - #[serde(default)] - pub permission_resolution: Option, - #[serde(default)] - pub connector_config: Option, - #[serde(default)] - pub config_option_set: Option, - #[serde(default)] - pub membership_events: Option, - #[serde(default)] - pub resource_req: Option, - #[serde(default)] - pub file_upload: Option, - #[serde(default)] - pub send: Option, - #[serde(default)] - pub send_ack: Option, - #[serde(default)] - pub terminal_ack: Option, - #[serde(default)] - pub trace: Option, - #[serde(default)] - pub session_update: Option, - #[serde(default)] - pub resume: Option, - #[serde(default)] - pub acp_security: Option, - #[serde(flatten)] - pub extra: serde_json::Map, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AttachmentInfo { - #[serde(default)] - pub file_id: Option, - #[serde(default)] - pub filename: Option, - #[serde(default)] - pub content_type: Option, - #[serde(default)] - pub size_bytes: Option, - #[serde(default)] - pub summary: Option, - #[serde(default)] - pub is_image: Option, - #[serde(default)] - pub image_b64: Option, - #[serde(default)] - pub is_audio: Option, - #[serde(default)] - pub audio_b64: Option, - #[serde(flatten)] - pub extra: serde_json::Map, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RuntimeSessionRef { - #[serde(default)] - pub id: Option, - #[serde(default)] - pub provider_session_key: Option, - #[serde(default)] - pub provider_session_id: Option, - #[serde(default)] - pub provider_account_id: Option, - #[serde(default)] - pub provider_agent_id: Option, - #[serde(default)] - pub primary_scope_type: Option, - #[serde(default)] - pub primary_scope_id: Option, - #[serde(default)] - pub task_scope_id: Option, - #[serde(flatten)] - pub extra: serde_json::Map, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SessionPolicy { - pub on_missing: String, - pub on_paused: String, - pub after_task: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RuntimeDescriptor { - pub protocol: String, - #[serde(default)] - pub name: Option, - #[serde(default)] - pub version: Option, - #[serde(default)] - pub provider_session_id: Option, - #[serde(default)] - pub config: Option, - #[serde(flatten)] - pub extra: serde_json::Map, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RuntimeSessionControlSession { - pub id: String, - pub provider_session_key: String, - #[serde(default)] - pub primary_scope_type: Option, - #[serde(default)] - pub primary_scope_id: Option, - #[serde(default)] - pub task_scope_id: Option, - /// The session's ACP `cwd` (absolute), if the Backend pinned one for an - /// eager create/resume. Re-validated against `allowed_roots` by the connector. - #[serde(default)] - pub cwd: Option, - /// The session's ACP `additionalDirectories`, re-validated against `allowed_roots`. - #[serde(default)] - pub additional_dirs: Vec, - #[serde(flatten)] - pub extra: serde_json::Map, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RuntimeSessionAckSession { - #[serde(default)] - pub id: Option, - #[serde(default)] - pub session_id: Option, - #[serde(default)] - pub provider_session_key: Option, - #[serde(default)] - pub provider_session_id: Option, - #[serde(default)] - pub status: Option, - #[serde(flatten)] - pub extra: serde_json::Map, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PermissionOption { - pub option_id: String, - #[serde(default)] - pub kind: Option, - #[serde(default)] - pub name: Option, - #[serde(default)] - pub description: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PermissionResolution { - pub request_id: String, - #[serde(default)] - pub message_id: Option, - pub resolution: String, - #[serde(default)] - pub option_id: Option, - #[serde(default)] - pub resolved_by: Option, - #[serde(default)] - pub resolved_at: Option, - #[serde(flatten)] - pub extra: serde_json::Map, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ConfigStatusRejectedField { - pub field: String, - pub reason: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ResourceResponse { - #[serde(default = "default_bridge_protocol_version")] - pub v: u32, - pub req_id: String, - pub ok: bool, - #[serde(default)] - pub data: Option, - #[serde(default)] - pub error: Option, - #[serde(default)] - pub code: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BridgeErrorFrame { - #[serde(default = "default_bridge_protocol_version")] - pub v: u32, - pub code: String, - #[serde(default)] - pub detail: Option, - #[serde(default)] - pub request_id: Option, - #[serde(default)] - pub client_msg_id: Option, - #[serde(default)] - pub retryable: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type")] -pub enum ControlInbound { - #[serde(rename = "hello")] - Hello { - #[serde(default = "default_bridge_protocol_version")] - v: u32, - #[serde(default = "default_bridge_protocol_version")] - bridge_protocol_version: u32, - #[serde(default)] - stream: Option, - bot_id: String, - bot_username: String, - #[serde(default)] - bot_display_name: Option, - #[serde(default)] - connection_id: Option, - session_id: String, - #[serde(default)] - memberships: Vec, - #[serde(default)] - acp_security: Option, - #[serde(default)] - connector_config: Option, - #[serde(default)] - server_capabilities: Option, - }, - #[serde(rename = "runtime_session_control")] - RuntimeSessionControl { - #[serde(default = "default_bridge_protocol_version")] - v: u32, - request_id: String, - action: String, - session: RuntimeSessionControlSession, - runtime: RuntimeDescriptor, - #[serde(default)] - reason: Option, - #[serde(default)] - deadline_ms: Option, - }, - #[serde(rename = "task")] - Task { - #[serde(default = "default_bridge_protocol_version")] - v: u32, - task_id: String, - channel_id: String, - trigger_msg_id: String, - #[serde(default)] - msg_id: Option, - #[serde(default)] - trigger_seq: Option, - #[serde(default)] - depth: Option, - #[serde(default)] - trigger: Option, - placeholder_msg_id: String, - provider_session_key: String, - #[serde(default)] - session_id: Option, - #[serde(default)] - session_policy: Option, - #[serde(default)] - trigger_message: Option, - #[serde(default)] - attachments: Vec, - /// Pinned context blocks (already formatted strings) prepended to the prompt - /// every request — the channel's "convention prompt" (e.g. a prompt template). - #[serde(default)] - pinned: Vec, - /// The session's primary working directory (ACP `cwd`), if the Backend - /// pinned one for this session. Absolute; the connector re-validates it - /// against `allowed_roots` and falls back to `default_cwd` when unset or - /// rejected. Immutable for the session's lifetime. - #[serde(default)] - cwd: Option, - /// Extra roots for this session's effective root set (ACP - /// `additionalDirectories`). Each is re-validated against `allowed_roots`; - /// out-of-policy entries are dropped. - #[serde(default)] - additional_dirs: Vec, - #[serde(default)] - binding_config: Option, - #[serde(default)] - session: Option, - #[serde(default)] - enqueued_at: Option, - }, - #[serde(rename = "channel_joined")] - ChannelJoined { - channel: ChannelInfo, - #[serde(default)] - invited_by: Option, - }, - #[serde(rename = "channel_left")] - ChannelLeft { channel_id: String, reason: String }, - #[serde(rename = "cancel")] - Cancel { - msg_id: String, - #[serde(default)] - reason: Option, - }, - #[serde(rename = "config_update")] - ConfigUpdate { - #[serde(default = "default_bridge_protocol_version")] - v: u32, - #[serde(default)] - revision: Option, - #[serde(default)] - settings: Option, - #[serde(default)] - updated_at: Option, - }, - #[serde(rename = "config_option_set")] - ConfigOptionSet { - #[serde(default = "default_bridge_protocol_version")] - v: u32, - request_id: String, - #[serde(default)] - session_id: Option, - #[serde(default)] - provider_session_key: Option, - config_id: String, - value: String, - #[serde(default)] - updated_at: Option, - }, - // Session-targeted mode change (ACP session/set_mode). Distinct from the - // bot-wide config_update.agentNativePermissionMode AND from config_option_set: - // it value-gates on the L0 allowed_modes envelope (config_option_set checks - // only the config id), so a delegated mode change can't escape the clamp. - #[serde(rename = "mode_set")] - ModeSet { - #[serde(default = "default_bridge_protocol_version")] - v: u32, - request_id: String, - #[serde(default)] - session_id: Option, - #[serde(default)] - provider_session_key: Option, - mode: String, - #[serde(default)] - updated_at: Option, - }, - #[serde(rename = "permission_resolution")] - PermissionResolution { - #[serde(default = "default_bridge_protocol_version")] - v: u32, - #[serde(flatten)] - resolution: PermissionResolution, - }, - #[serde(rename = "pong")] - Pong, - #[serde(rename = "error")] - Error { - #[serde(flatten)] - error: BridgeErrorFrame, - }, - #[serde(other)] - Unknown, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type")] -pub enum ControlOutbound { - #[serde(rename = "auth")] - Auth { - #[serde(default = "default_bridge_protocol_version")] - v: u32, - token: String, - #[serde(default = "default_bridge_protocol_version")] - bridge_protocol_version: u32, - #[serde(default)] - connector: ConnectorInfo, - }, - #[serde(rename = "ready")] - Ready { - #[serde(default = "default_bridge_protocol_version")] - v: u32, - connector_version: String, - runtime: RuntimeDescriptor, - #[serde(default)] - connector_capabilities: Option, - }, - #[serde(rename = "ping")] - Ping, - #[serde(rename = "runtime_session_control_ack")] - RuntimeSessionControlAck { - #[serde(default = "default_bridge_protocol_version")] - v: u32, - request_id: String, - action: String, - ok: bool, - #[serde(default)] - session: Option, - #[serde(default)] - applied_at: Option, - #[serde(default)] - code: Option, - #[serde(default)] - error: Option, - #[serde(default)] - retryable: Option, - }, - #[serde(rename = "config_status")] - ConfigStatus { - #[serde(default = "default_bridge_protocol_version")] - v: u32, - #[serde(default)] - revision: Option, - ok: bool, - #[serde(default)] - applied: Vec, - #[serde(default)] - rejected: Vec, - }, - #[serde(rename = "config_options")] - ConfigOptions { - #[serde(default = "default_bridge_protocol_version")] - v: u32, - options: Value, - }, - #[serde(rename = "config_option_status")] - ConfigOptionStatus { - #[serde(default = "default_bridge_protocol_version")] - v: u32, - request_id: String, - ok: bool, - #[serde(default)] - session_id: Option, - #[serde(default)] - provider_session_key: Option, - #[serde(default)] - config_id: Option, - #[serde(default)] - value: Option, - #[serde(default)] - options: Option, - #[serde(default)] - error: Option, - #[serde(default)] - code: Option, - }, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type")] -pub enum DataInbound { - #[serde(rename = "hello")] - Hello { - #[serde(default = "default_bridge_protocol_version")] - v: u32, - #[serde(default = "default_bridge_protocol_version")] - bridge_protocol_version: u32, - stream: String, - bot_id: String, - #[serde(default)] - connection_id: Option, - session_id: String, - #[serde(default)] - last_event_seq: u64, - #[serde(default)] - acp_security: Option, - #[serde(default)] - server_capabilities: Option, - }, - #[serde(rename = "pong")] - Pong, - #[serde(rename = "resume_ack")] - ResumeAck { - #[serde(default = "default_bridge_protocol_version")] - v: u32, - replayed: u64, - up_to_seq: u64, - }, - #[serde(rename = "send_ack")] - SendAck { - #[serde(default = "default_bridge_protocol_version")] - v: u32, - client_msg_id: String, - ok: bool, - #[serde(default)] - message_id: Option, - #[serde(default)] - finalized_placeholder: Option, - #[serde(default)] - permission_resolution: Option, - #[serde(default)] - error: Option, - #[serde(default)] - code: Option, - }, - #[serde(rename = "terminal_ack")] - TerminalAck { - #[serde(default = "default_bridge_protocol_version")] - v: u32, - client_msg_id: String, - ok: bool, - #[serde(default)] - msg_id: Option, - #[serde(default)] - queued: Option, - #[serde(default)] - job_id: Option, - #[serde(default)] - error: Option, - #[serde(default)] - code: Option, - }, - #[serde(rename = "file_upload_ack")] - FileUploadAck { - #[serde(default = "default_bridge_protocol_version")] - v: u32, - #[serde(default)] - client_file_id: Option, - ok: bool, - #[serde(default)] - file_id: Option, - #[serde(default)] - filename: Option, - #[serde(default)] - content_type: Option, - #[serde(default)] - size_bytes: Option, - #[serde(default)] - preview_url: Option, - #[serde(default)] - download_url: Option, - #[serde(default)] - error: Option, - #[serde(default)] - code: Option, - }, - #[serde(rename = "resource_res")] - ResourceRes { - #[serde(flatten)] - response: ResourceResponse, - }, - #[serde(rename = "error")] - Error { - #[serde(flatten)] - error: BridgeErrorFrame, - }, - /// Gateway → connector: realize a staged file. Connector reads the local path, - /// base64-encodes it, and calls channel.files.realize to upload to S3. - #[serde(rename = "realize_file")] - RealizeFile { - file_id: String, - remote_ref: String, - channel_id: String, - /// The owning session's ACP root set (`cwd` + `additionalDirectories`). The - /// connector confines `remote_ref` to these (∩ `allowed_roots`); empty ⇒ - /// the session's implicit root is the connector `default_cwd`. - #[serde(default)] - roots: Vec, - }, - /// Gateway → connector: browse/read/write the agent's real workspace, confined - /// to `policy.workspace.allowed_roots`. Connector replies with `workspace_res` - /// correlated by `req_id`. `op` ∈ { "ls", "read", "write", "validate_cwd", - /// "git_status", "git_diff", "git_log", "git_show", "git_commit_files", - /// "workspace_meta", "watch", "unwatch" }. The git ops are READ-ONLY. - /// `workspace_meta` describes the workspace policy (allowed/effective roots, - /// default_cwd, git availability) without touching the filesystem. `watch` - /// starts a debounced recursive fs watcher on the resolved (clamped) dir and - /// streams unsolicited `workspace_event` frames; `unwatch` (by `watch_id`) - /// stops it. - #[serde(rename = "workspace_req")] - WorkspaceReq { - req_id: String, - op: String, - #[serde(default)] - path: String, - /// Which allowed root to resolve `path` against (absolute path string). - /// Defaults to the connector's default_cwd / first allowed root. - #[serde(default)] - root: Option, - /// base64 file bytes for `op == "write"`. - #[serde(default)] - content_b64: Option, - /// `op == "write"` precondition (safe remote writes). Absent/null ⇒ - /// unconditional overwrite (back-compat). `""` ⇒ create-only (fail if the - /// file already exists). A 64-char lowercase-hex SHA-256 ⇒ overwrite only - /// if the current file's bytes hash to it, else `E_CONFLICT`. - #[serde(default)] - if_etag: Option, - /// Optional session root set to scope this browse to (`cwd` + - /// `additionalDirectories`). Empty ⇒ the full `allowed_roots` (bot-wide - /// browse). When set, the effective roots are these ∩ `allowed_roots`. - #[serde(default)] - roots: Vec, - /// `op == "git_diff"`: diff the staged index (`--staged`) instead of the - /// working tree. - #[serde(default)] - staged: Option, - /// `op == "git_log"`: max commits to return (clamped to ≤100 by the - /// connector). - #[serde(default)] - limit: Option, - /// `op == "git_log"`: commits to skip before collecting (`--skip`), for - /// pagination (clamped to ≤100000 by the connector). - #[serde(default)] - skip: Option, - /// `op == "git_show" | "git_commit_files"`: the commit ref (a hex hash, as - /// emitted by `git_log`; validated `^[0-9a-fA-F]{7,64}$` before use as argv). - #[serde(default)] - commit: Option, - /// `op == "git_show"`: optional repo-root-relative path filter — limits the - /// commit diff to one file (as listed by `git_commit_files`). Validated - /// (relative, no `..`, no leading `-`/`:`) and passed as a `:(top)`-anchored - /// pathspec after `--`, never as a flag. - #[serde(default)] - commit_path: Option, - /// `op == "unwatch"`: the `watch_id` returned by a prior `watch` reply, - /// identifying the fs watcher to stop. - #[serde(default)] - watch_id: Option, - }, - #[serde(other)] - Unknown, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type")] -pub enum DataOutbound { - #[serde(rename = "auth")] - Auth { - #[serde(default = "default_bridge_protocol_version")] - v: u32, - token: String, - #[serde(default = "default_bridge_protocol_version")] - bridge_protocol_version: u32, - #[serde(default)] - connector: ConnectorInfo, - }, - #[serde(rename = "ping")] - Ping, - #[serde(rename = "resume")] - Resume { - #[serde(default = "default_bridge_protocol_version")] - v: u32, - last_event_seq: u64, - }, - #[serde(rename = "delta")] - Delta { - #[serde(default = "default_bridge_protocol_version")] - v: u32, - msg_id: String, - seq: u64, - delta: String, - #[serde(default)] - provider_session_key: Option, - #[serde(default)] - provider_session_id: Option, - #[serde(default)] - session_id: Option, - #[serde(default)] - acp_capability: Option, - }, - #[serde(rename = "done")] - Done { - #[serde(default = "default_bridge_protocol_version")] - v: u32, - client_msg_id: String, - msg_id: String, - #[serde(default)] - file_ids: Vec, - #[serde(default)] - mention_ids: Vec, - #[serde(default)] - content: Option, - #[serde(default)] - provider_session_key: Option, - #[serde(default)] - provider_session_id: Option, - #[serde(default)] - session_id: Option, - #[serde(default)] - acp_capability: Option, - }, - #[serde(rename = "error")] - Error { - #[serde(default = "default_bridge_protocol_version")] - v: u32, - client_msg_id: String, - msg_id: String, - message: String, - #[serde(default)] - provider_session_key: Option, - #[serde(default)] - provider_session_id: Option, - #[serde(default)] - session_id: Option, - #[serde(default)] - acp_capability: Option, - }, - #[serde(rename = "send")] - Send { - #[serde(default = "default_bridge_protocol_version")] - v: u32, - client_msg_id: String, - channel_id: String, - text: String, - #[serde(default)] - in_reply_to_msg_id: Option, - #[serde(default)] - file_ids: Vec, - #[serde(default)] - mention_ids: Vec, - #[serde(default)] - session_id: Option, - #[serde(default)] - provider_session_key: Option, - #[serde(default)] - provider_session_id: Option, - #[serde(default)] - acp_capability: Option, - }, - #[serde(rename = "file_upload")] - FileUpload { - #[serde(default = "default_bridge_protocol_version")] - v: u32, - client_file_id: String, - channel_id: String, - filename: String, - #[serde(default)] - content_type: Option, - data_b64: String, - }, - #[serde(rename = "resource_req")] - ResourceReq { - #[serde(default = "default_bridge_protocol_version")] - v: u32, - req_id: String, - resource: String, - #[serde(default)] - params: Option, - #[serde(default)] - encrypted: Option, - #[serde(default)] - encrypted_payload: Option, - #[serde(default)] - acp_capability: Option, - }, - /// Connector → gateway: reply to a `workspace_req`, correlated by `req_id`. - #[serde(rename = "workspace_res")] - WorkspaceRes { - #[serde(default = "default_bridge_protocol_version")] - v: u32, - req_id: String, - ok: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - data: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - error: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - code: Option, - }, - /// Connector → gateway: UNSOLICITED filesystem-change notification for an active - /// `watch`. Bot-scoped (no channel_id — the gateway maps bot → channels and - /// fans out to the workspace panels). `root` is the browse root the paths are - /// relative to; `paths` is the coalesced, de-duplicated, capped (≤50) set of - /// changed entries; `kind` is the change class (currently always "change"). - #[serde(rename = "workspace_event")] - WorkspaceEvent { - #[serde(default = "default_bridge_protocol_version")] - v: u32, - root: String, - paths: Vec, - kind: String, - }, - #[serde(rename = "permission_request")] - PermissionRequest { - #[serde(default = "default_bridge_protocol_version")] - v: u32, - client_msg_id: String, - channel_id: String, - request_id: String, - #[serde(default)] - task_id: Option, - #[serde(default)] - msg_id: Option, - #[serde(default)] - acp_session_id: Option, - #[serde(default)] - provider_session_key: Option, - #[serde(default)] - provider_session_id: Option, - #[serde(default)] - session_id: Option, - #[serde(default)] - title: Option, - body: String, - /// Structured tool detail derived from the ACP `toolCall` - /// (title / kind / raw_input / locations) so the channel card can show - /// WHAT is being approved + a risk badge. None when the agent sent no - /// toolCall (e.g. a plain message permission). - #[serde(default)] - tool: Option, - #[serde(default)] - options: Vec, - #[serde(default)] - acp_capability: Option, - }, - /// Tell the gateway a previously-forwarded permission request reached a - /// terminal state locally (timeout / agent cancel) with no human decision, - /// so the channel card stops hanging "pending" forever. - #[serde(rename = "permission_cancel")] - PermissionCancel { - #[serde(default = "default_bridge_protocol_version")] - v: u32, - request_id: String, - /// "timeout" | "cancelled" - reason: String, - }, - #[serde(rename = "session_update")] - SessionUpdate { - #[serde(default = "default_bridge_protocol_version")] - v: u32, - #[serde(default)] - provider_session_key: Option, - #[serde(default)] - provider_session_id: Option, - #[serde(default)] - metadata: Option, - #[serde(default)] - acp_capability: Option, - }, - #[serde(rename = "trace")] - Trace { - #[serde(default = "default_bridge_protocol_version")] - v: u32, - msg_id: String, - #[serde(default)] - task_id: Option, - #[serde(default)] - channel_id: Option, - #[serde(default)] - run_id: Option, - #[serde(default)] - session_key: Option, - #[serde(default)] - provider_session_key: Option, - #[serde(default)] - provider_session_id: Option, - #[serde(default)] - session_id: Option, - stream: String, - #[serde(default)] - seq: Option, - #[serde(default)] - ts: Option, - #[serde(default)] - phase: Option, - #[serde(default)] - status: Option, - #[serde(default)] - title: Option, - #[serde(default)] - message: Option, - #[serde(default)] - data: Option, - #[serde(default)] - acp_capability: Option, - }, - /// Generic ACP-event passthrough (docs/arch/ACP_EVENT_TAXONOMY.md): forwards an - /// ACP `session/update` verbatim so Cheers sees the full event surface. `name` - /// is the registry name (e.g. `session/update:tool_call`); `payload` is the raw - /// update. The connector stays ACP-generic — it never interprets the payload. - #[serde(rename = "acp_event")] - AcpEvent { - #[serde(default = "default_bridge_protocol_version")] - v: u32, - name: String, - #[serde(default)] - channel_id: Option, - #[serde(default)] - task_id: Option, - #[serde(default)] - msg_id: Option, - #[serde(default)] - session_id: Option, - #[serde(default)] - provider_session_key: Option, - payload: Value, - }, -} - pub struct BridgeWebSocket { stream: WebSocketStream>, } @@ -1484,7 +463,9 @@ impl BridgeWebSocket { .await .with_context(|| format!("failed to connect websocket: {url}"))?; let mut socket = Self { stream }; - socket.send_json(&AgentBridgeAuth::new(bot_token)).await?; + socket + .send_json(&AgentBridgeAuth::new(bot_token, local_connector_info())) + .await?; Ok(socket) } @@ -1521,3 +502,644 @@ impl BridgeWebSocket { Ok(None) } } + +/// Golden-fixture contract tests against `bridge-protocol/fixtures/` — the +/// same files the gateway's constructor tests are pinned to, so both ends +/// prove they agree on the exact wire bytes. +/// +/// - `*/to_connector/*`: written by the GATEWAY's regen (`CHEERS_REGEN_FIXTURES=1 +/// cargo test` in server/); here we prove they parse into the expected typed +/// variant (a rename/typo would fall through to `Unknown` and fail). +/// - `*/to_gateway/*`: written by THIS module's regen from typed values; the +/// assert mode proves parse→serialize round-trips to identical bytes. +/// - `tolerance/*`, `compat/*`: hand-frozen; only change with a version gate. +#[cfg(test)] +mod fixture_tests { + use super::*; + use serde_json::json; + use std::path::PathBuf; + + fn fixtures_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("bridge-protocol/fixtures") + } + + fn load(rel: &str) -> Value { + let path = fixtures_root().join(rel); + let raw = std::fs::read_to_string(&path).unwrap_or_else(|e| { + panic!( + "missing fixture {} ({e}); to_connector fixtures regen from server/ \ + (CHEERS_REGEN_FIXTURES=1 cargo test), to_gateway ones from this crate", + path.display() + ) + }); + serde_json::from_str(&raw).expect("fixture is valid JSON") + } + + /// to_gateway golden check: `frame` serializes to exactly the fixture, and + /// the fixture parses back to a value that re-serializes identically. + /// `CHEERS_REGEN_FIXTURES=1` rewrites the fixture from the typed value. + fn assert_round_trips(frame: &T, rel: &str) + where + T: Serialize + serde::de::DeserializeOwned, + { + let value = serde_json::to_value(frame).expect("frame serializes"); + let path = fixtures_root().join(rel); + if std::env::var_os("CHEERS_REGEN_FIXTURES").is_some() { + std::fs::create_dir_all(path.parent().unwrap()).expect("create fixtures dir"); + let pretty = serde_json::to_string_pretty(&value).expect("serialize fixture"); + std::fs::write(&path, format!("{pretty}\n")).expect("write fixture"); + return; + } + let expected = load(rel); + assert_eq!( + value, expected, + "typed frame drifted from fixture {rel}; if intentional, prove wire-safety and regen" + ); + let reparsed: T = serde_json::from_value(expected.clone()) + .unwrap_or_else(|e| panic!("fixture {rel} no longer parses: {e}")); + let reserialized = serde_json::to_value(&reparsed).expect("reserialize"); + assert_eq!(reserialized, expected, "fixture {rel} does not round-trip"); + } + + // ── to_connector: every gateway-emitted frame parses to its variant ────── + + #[test] + fn control_hello_fixture_parses() { + let frame: ControlInbound = + serde_json::from_value(load("control/to_connector/hello.json")).expect("hello parses"); + match frame { + ControlInbound::Hello { + bot_id, + memberships, + server_capabilities, + connector_config, + .. + } => { + assert_eq!(bot_id, "6f9619ff-8b86-4d01-b42d-00c04fc964ff"); + assert_eq!(memberships.len(), 1); + let caps = server_capabilities.expect("caps present"); + assert_eq!(caps.latest_connector_version.as_deref(), Some("0.1.27")); + assert!(connector_config.is_some()); + } + other => panic!("expected Hello, got {other:?}"), + } + } + + #[test] + fn task_fixture_parses() { + let frame: ControlInbound = + serde_json::from_value(load("control/to_connector/task.json")).expect("task parses"); + match frame { + ControlInbound::Task { + task_id, + trigger_msg_id, + msg_id, + cwd, + additional_dirs, + attachments, + pinned, + session, + session_policy, + session_id, + .. + } => { + assert_eq!(task_id, "99999999-aaaa-4bbb-8ccc-dddddddddddd"); + // wire-compat: msg_id duplicates trigger_msg_id by contract. + assert_eq!(msg_id.as_deref(), Some(trigger_msg_id.as_str())); + assert_eq!(cwd.as_deref(), Some("/workspace")); + assert_eq!(additional_dirs, vec!["/data".to_string()]); + assert_eq!(attachments.len(), 1); + assert_eq!(attachments[0].filename.as_deref(), Some("notes.md")); + assert_eq!(pinned.len(), 1); + assert!(session_id.is_none(), "fixture omits session_id"); + let policy = session_policy.expect("session_policy present"); + assert_eq!(policy.on_missing, "create"); + let session = session.expect("nested session ref present"); + assert!(session.provider_session_key.is_some()); + } + other => panic!("expected Task, got {other:?}"), + } + } + + #[test] + fn remaining_control_to_connector_fixtures_parse() { + for (rel, want) in [ + ("control/to_connector/cancel.json", "Cancel"), + ("control/to_connector/config_update.json", "ConfigUpdate"), + ( + "control/to_connector/config_option_set.json", + "ConfigOptionSet", + ), + ("control/to_connector/mode_set.json", "ModeSet"), + ( + "control/to_connector/permission_resolution.json", + "PermissionResolution", + ), + ] { + let frame: ControlInbound = serde_json::from_value(load(rel)) + .unwrap_or_else(|e| panic!("{rel} failed to parse: {e}")); + let got = match &frame { + ControlInbound::Cancel { .. } => "Cancel", + ControlInbound::ConfigUpdate { settings, .. } => { + assert!(settings.is_some(), "{rel}: settings parsed"); + "ConfigUpdate" + } + ControlInbound::ConfigOptionSet { .. } => "ConfigOptionSet", + ControlInbound::ModeSet { .. } => "ModeSet", + ControlInbound::PermissionResolution { resolution, .. } => { + assert_eq!(resolution.resolution, "allow", "{rel}"); + "PermissionResolution" + } + other => panic!("{rel}: unexpected variant {other:?}"), + }; + assert_eq!(got, want, "{rel}"); + } + } + + #[test] + fn data_to_connector_fixtures_parse() { + use DataInbound as D; + for (rel, want) in [ + ("data/to_connector/hello.json", "Hello"), + ("data/to_connector/pong.json", "Pong"), + ("data/to_connector/resume_ack.json", "ResumeAck"), + ("data/to_connector/send_ack_ok.json", "SendAck"), + ("data/to_connector/send_ack_err.json", "SendAck"), + ("data/to_connector/terminal_ack_ok.json", "TerminalAck"), + ("data/to_connector/terminal_ack_err.json", "TerminalAck"), + ("data/to_connector/error.json", "Error"), + ("data/to_connector/resource_res_ok.json", "ResourceRes"), + ("data/to_connector/resource_res_err.json", "ResourceRes"), + ("data/to_connector/realize_file.json", "RealizeFile"), + ("data/to_connector/workspace_req_read.json", "WorkspaceReq"), + ("data/to_connector/workspace_req_write.json", "WorkspaceReq"), + ( + "data/to_connector/workspace_req_git_log.json", + "WorkspaceReq", + ), + ("data/to_connector/workspace_req_watch.json", "WorkspaceReq"), + ] { + let frame: D = serde_json::from_value(load(rel)) + .unwrap_or_else(|e| panic!("{rel} failed to parse: {e}")); + let got = match &frame { + D::Hello { last_event_seq, .. } => { + assert_eq!(*last_event_seq, 0, "{rel}"); + "Hello" + } + D::Pong => "Pong", + D::ResumeAck { up_to_seq, .. } => { + assert_eq!(*up_to_seq, 42, "{rel}"); + "ResumeAck" + } + D::SendAck { .. } => "SendAck", + D::TerminalAck { .. } => "TerminalAck", + D::Error { error } => { + assert_eq!(error.code, "CAPABILITY_DENIED", "{rel}"); + "Error" + } + D::ResourceRes { .. } => "ResourceRes", + D::RealizeFile { roots, .. } => { + assert_eq!(roots.len(), 1, "{rel}"); + "RealizeFile" + } + D::WorkspaceReq { + op, + if_etag, + limit, + skip, + content_b64, + .. + } => { + match op.as_str() { + "write" => { + assert!(if_etag.is_some(), "{rel}: write carries if_etag"); + assert!(content_b64.is_some(), "{rel}: write carries content_b64"); + } + "git_log" => { + assert_eq!(*limit, Some(20), "{rel}"); + assert_eq!(*skip, Some(40), "{rel}"); + } + _ => {} + } + "WorkspaceReq" + } + other => panic!("{rel}: unexpected variant {other:?}"), + }; + assert_eq!(got, want, "{rel}"); + } + } + + // ── dormant to_connector frames (no gateway emitter today) ────────────── + + #[test] + fn dormant_control_frames_round_trip() { + assert_round_trips( + &ControlInbound::RuntimeSessionControl { + v: BRIDGE_PROTOCOL_VERSION, + request_id: "99999999-aaaa-4bbb-8ccc-dddddddddddd".into(), + action: "terminate".into(), + session: RuntimeSessionControlSession { + id: "eeeeeeee-ffff-4000-8111-222222222222".into(), + provider_session_key: + "cheers:channel:77777777-8888-4999-8aaa-bbbbbbbbbbbb:bot:6f9619ff-8b86-4d01-b42d-00c04fc964ff" + .into(), + primary_scope_type: Some("channel".into()), + primary_scope_id: Some("77777777-8888-4999-8aaa-bbbbbbbbbbbb".into()), + task_scope_id: None, + cwd: Some("/workspace".into()), + additional_dirs: vec![], + extra: Default::default(), + }, + runtime: RuntimeDescriptor { + protocol: "acp".into(), + name: None, + version: None, + provider_session_id: None, + config: None, + extra: Default::default(), + }, + reason: Some("user closed session".into()), + deadline_ms: None, + }, + "control/to_connector/runtime_session_control.json", + ); + assert_round_trips( + &ControlInbound::ChannelJoined { + channel: ChannelInfo { + channel_id: "77777777-8888-4999-8aaa-bbbbbbbbbbbb".into(), + channel_name: Some("general".into()), + channel_type: Some("public".into()), + workspace_id: Some("cccccccc-dddd-4eee-8fff-000000000000".into()), + joined_at: Some("2026-06-01T10:15:30Z".into()), + }, + invited_by: Some("33333333-4444-4555-8666-777777777777".into()), + }, + "control/to_connector/channel_joined.json", + ); + assert_round_trips( + &ControlInbound::ChannelLeft { + channel_id: "77777777-8888-4999-8aaa-bbbbbbbbbbbb".into(), + reason: "removed_by_admin".into(), + }, + "control/to_connector/channel_left.json", + ); + } + + // ── to_gateway: one round-trip fixture per outbound variant ───────────── + + const KEY: &str = + "cheers:channel:77777777-8888-4999-8aaa-bbbbbbbbbbbb:bot:6f9619ff-8b86-4d01-b42d-00c04fc964ff"; + + #[test] + fn control_to_gateway_fixtures_round_trip() { + assert_round_trips( + &ControlOutbound::Auth { + v: BRIDGE_PROTOCOL_VERSION, + token: "agb_fixture_token".into(), + bridge_protocol_version: BRIDGE_PROTOCOL_VERSION, + connector: ConnectorInfo { + name: "cce-acp-connector".into(), + version: "0.1.27".into(), + }, + }, + "control/to_gateway/auth.json", + ); + assert_round_trips( + &ControlOutbound::Ready { + v: BRIDGE_PROTOCOL_VERSION, + connector_version: Some("0.1.27".into()), + plugin_version: None, + runtime: Some(RuntimeDescriptor { + protocol: "acp".into(), + name: Some("claude-agent-acp".into()), + version: Some("1.4.2".into()), + provider_session_id: None, + config: None, + extra: Default::default(), + }), + connector_capabilities: Some(json!({ + "runtime_protocols": ["acp"], + "streaming": true, + })), + }, + "control/to_gateway/ready.json", + ); + assert_round_trips(&ControlOutbound::Ping, "control/to_gateway/ping.json"); + assert_round_trips( + &ControlOutbound::RuntimeSessionControlAck { + v: BRIDGE_PROTOCOL_VERSION, + request_id: "99999999-aaaa-4bbb-8ccc-dddddddddddd".into(), + action: "terminate".into(), + ok: true, + session: Some(RuntimeSessionAckSession { + id: Some("eeeeeeee-ffff-4000-8111-222222222222".into()), + session_id: None, + provider_session_key: Some(KEY.into()), + provider_session_id: Some("acp-session-1".into()), + status: Some("terminated".into()), + extra: Default::default(), + }), + applied_at: Some("2026-06-01T10:15:30+00:00".into()), + code: None, + error: None, + retryable: None, + }, + "control/to_gateway/runtime_session_control_ack.json", + ); + assert_round_trips( + &ControlOutbound::ConfigStatus { + v: BRIDGE_PROTOCOL_VERSION, + revision: Some(json!(3)), + ok: false, + applied: vec!["model".into()], + rejected: vec![ConfigStatusRejectedField { + field: "cwd".into(), + reason: "outside allowed_roots".into(), + }], + }, + "control/to_gateway/config_status.json", + ); + assert_round_trips( + &ControlOutbound::ConfigOptions { + v: BRIDGE_PROTOCOL_VERSION, + options: json!({ + "configOptions": [{"id": "model", "value": "claude-sonnet-5"}], + "currentModeId": "default", + }), + }, + "control/to_gateway/config_options.json", + ); + assert_round_trips( + &ControlOutbound::ConfigOptionStatus { + v: BRIDGE_PROTOCOL_VERSION, + request_id: "99999999-aaaa-4bbb-8ccc-dddddddddddd".into(), + ok: true, + session_id: None, + provider_session_key: Some(KEY.into()), + config_id: Some("model".into()), + value: Some("claude-sonnet-5".into()), + options: None, + error: None, + code: None, + }, + "control/to_gateway/config_option_status.json", + ); + } + + #[test] + fn data_to_gateway_fixtures_round_trip() { + assert_round_trips( + &DataOutbound::Auth { + v: BRIDGE_PROTOCOL_VERSION, + token: "agb_fixture_token".into(), + bridge_protocol_version: BRIDGE_PROTOCOL_VERSION, + connector: ConnectorInfo { + name: "cce-acp-connector".into(), + version: "0.1.27".into(), + }, + }, + "data/to_gateway/auth.json", + ); + assert_round_trips(&DataOutbound::Ping, "data/to_gateway/ping.json"); + assert_round_trips( + &DataOutbound::Resume { + v: BRIDGE_PROTOCOL_VERSION, + last_event_seq: 42, + }, + "data/to_gateway/resume.json", + ); + assert_round_trips( + &DataOutbound::Delta { + v: BRIDGE_PROTOCOL_VERSION, + msg_id: "33333333-4444-4555-8666-777777777777".into(), + seq: 7, + delta: "Hello, wor".into(), + provider_session_key: Some(KEY.into()), + provider_session_id: Some("acp-session-1".into()), + session_id: None, + acp_capability: None, + }, + "data/to_gateway/delta.json", + ); + assert_round_trips( + &DataOutbound::Done { + v: BRIDGE_PROTOCOL_VERSION, + client_msg_id: "client-msg-2".into(), + msg_id: "33333333-4444-4555-8666-777777777777".into(), + file_ids: vec!["file-1".into()], + mention_ids: vec![], + content: Some("Hello, world!".into()), + provider_session_key: Some(KEY.into()), + provider_session_id: Some("acp-session-1".into()), + session_id: None, + acp_capability: None, + }, + "data/to_gateway/done.json", + ); + assert_round_trips( + &DataOutbound::Error { + v: BRIDGE_PROTOCOL_VERSION, + client_msg_id: "client-msg-2".into(), + msg_id: "33333333-4444-4555-8666-777777777777".into(), + message: "prompt timed out".into(), + provider_session_key: Some(KEY.into()), + provider_session_id: None, + session_id: None, + acp_capability: None, + }, + "data/to_gateway/error.json", + ); + assert_round_trips( + &DataOutbound::Send { + v: BRIDGE_PROTOCOL_VERSION, + client_msg_id: "client-msg-3".into(), + channel_id: "77777777-8888-4999-8aaa-bbbbbbbbbbbb".into(), + text: "Proactive update: build finished.".into(), + in_reply_to_msg_id: None, + file_ids: vec![], + mention_ids: vec![], + session_id: None, + provider_session_key: Some(KEY.into()), + provider_session_id: None, + acp_capability: None, + }, + "data/to_gateway/send.json", + ); + assert_round_trips( + &DataOutbound::FileUpload { + v: BRIDGE_PROTOCOL_VERSION, + client_file_id: "client-file-1".into(), + channel_id: "77777777-8888-4999-8aaa-bbbbbbbbbbbb".into(), + filename: "report.pdf".into(), + content_type: Some("application/pdf".into()), + data_b64: "aGVsbG8=".into(), + }, + "data/to_gateway/file_upload.json", + ); + assert_round_trips( + &DataOutbound::ResourceReq { + v: BRIDGE_PROTOCOL_VERSION, + req_id: "req-1".into(), + resource: "channel.activity.read".into(), + params: Some(json!({"channel_id": "77777777-8888-4999-8aaa-bbbbbbbbbbbb"})), + encrypted: None, + encrypted_payload: None, + acp_capability: None, + }, + "data/to_gateway/resource_req.json", + ); + assert_round_trips( + &DataOutbound::WorkspaceRes { + v: BRIDGE_PROTOCOL_VERSION, + req_id: "req-2".into(), + ok: true, + data: Some( + json!({"etag": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}), + ), + error: None, + code: None, + }, + "data/to_gateway/workspace_res.json", + ); + assert_round_trips( + &DataOutbound::WorkspaceEvent { + v: BRIDGE_PROTOCOL_VERSION, + root: "/workspace".into(), + paths: vec!["src/main.rs".into()], + kind: "change".into(), + }, + "data/to_gateway/workspace_event.json", + ); + assert_round_trips( + &DataOutbound::PermissionRequest { + v: BRIDGE_PROTOCOL_VERSION, + client_msg_id: "client-msg-4".into(), + channel_id: "77777777-8888-4999-8aaa-bbbbbbbbbbbb".into(), + request_id: "99999999-aaaa-4bbb-8ccc-dddddddddddd".into(), + task_id: Some("99999999-aaaa-4bbb-8ccc-dddddddddddd".into()), + msg_id: Some("33333333-4444-4555-8666-777777777777".into()), + acp_session_id: Some("acp-session-1".into()), + provider_session_key: Some(KEY.into()), + provider_session_id: Some("acp-session-1".into()), + session_id: None, + title: Some("Run command".into()), + body: "The agent wants to run `cargo test`.".into(), + tool: Some(json!({"kind": "execute", "title": "cargo test"})), + options: vec![PermissionOption { + option_id: "allow_once".into(), + kind: Some("allow_once".into()), + name: Some("Allow once".into()), + description: None, + }], + acp_capability: None, + }, + "data/to_gateway/permission_request.json", + ); + assert_round_trips( + &DataOutbound::PermissionCancel { + v: BRIDGE_PROTOCOL_VERSION, + request_id: "99999999-aaaa-4bbb-8ccc-dddddddddddd".into(), + reason: "timeout".into(), + }, + "data/to_gateway/permission_cancel.json", + ); + assert_round_trips( + &DataOutbound::SessionUpdate { + v: BRIDGE_PROTOCOL_VERSION, + provider_session_key: Some(KEY.into()), + provider_session_id: Some("acp-session-1".into()), + metadata: Some(json!({"model": "claude-sonnet-5"})), + acp_capability: None, + }, + "data/to_gateway/session_update.json", + ); + assert_round_trips( + &DataOutbound::Trace { + v: BRIDGE_PROTOCOL_VERSION, + msg_id: "33333333-4444-4555-8666-777777777777".into(), + task_id: Some("99999999-aaaa-4bbb-8ccc-dddddddddddd".into()), + channel_id: Some("77777777-8888-4999-8aaa-bbbbbbbbbbbb".into()), + run_id: Some("run-1".into()), + session_key: None, + provider_session_key: Some(KEY.into()), + provider_session_id: None, + session_id: None, + stream: "progress".into(), + seq: Some(3), + ts: Some(1_780_000_000_000), + phase: Some("tool_call".into()), + status: Some("running".into()), + title: Some("Reading files".into()), + message: None, + data: None, + acp_capability: None, + }, + "data/to_gateway/trace.json", + ); + assert_round_trips( + &DataOutbound::AcpEvent { + v: BRIDGE_PROTOCOL_VERSION, + name: "session/update:plan".into(), + channel_id: Some("77777777-8888-4999-8aaa-bbbbbbbbbbbb".into()), + task_id: None, + msg_id: Some("33333333-4444-4555-8666-777777777777".into()), + session_id: None, + provider_session_key: Some(KEY.into()), + payload: json!({"entries": [{"content": "Fix the bug", "status": "pending"}]}), + }, + "data/to_gateway/acp_event.json", + ); + } + + // ── tolerance + compat (hand-frozen fixtures) ──────────────────────────── + + #[test] + fn unknown_frame_type_parses_to_unknown() { + let raw = load("tolerance/unknown_frame_type.json"); + let control: ControlInbound = + serde_json::from_value(raw.clone()).expect("control tolerates unknown type"); + assert!(matches!(control, ControlInbound::Unknown)); + let data: DataInbound = serde_json::from_value(raw).expect("data tolerates unknown type"); + assert!(matches!(data, DataInbound::Unknown)); + } + + #[test] + fn extra_unknown_field_is_ignored() { + let frame: ControlInbound = + serde_json::from_value(load("tolerance/extra_unknown_field.json")) + .expect("unknown fields are ignored"); + match frame { + ControlInbound::Cancel { msg_id, reason } => { + assert_eq!(msg_id, "33333333-4444-4555-8666-777777777777"); + assert_eq!(reason.as_deref(), Some("user_cancelled")); + } + other => panic!("expected Cancel, got {other:?}"), + } + } + + /// The retired TS connector's `ready` (plugin_version, no connector_version) + /// must keep parsing forever — the gateway's typed inbound parse (Phase 3) + /// relies on this. Frozen; only change with an explicit version gate. + #[test] + fn legacy_ready_plugin_version_parses() { + let frame: ControlOutbound = + serde_json::from_value(load("compat/ready_plugin_version.json")) + .expect("legacy ready parses"); + match frame { + ControlOutbound::Ready { + connector_version, + plugin_version, + runtime, + .. + } => { + assert!(connector_version.is_none()); + assert_eq!(plugin_version.as_deref(), Some("0.9.3")); + assert_eq!( + runtime.expect("legacy ready carries runtime").protocol, + "acp" + ); + } + other => panic!("expected Ready, got {other:?}"), + } + } +} 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 bf796a7d..11512bf5 100644 --- a/packages/cheers-acp-connector-rs/src/bridge_runtime/mod.rs +++ b/packages/cheers-acp-connector-rs/src/bridge_runtime/mod.rs @@ -48,9 +48,18 @@ use crate::config::{ }; use crate::loopback::{start_loopback, LoopbackHandle, LoopbackRequest, LoopbackResponse}; use crate::runtime_adapter::{PermissionOutcome, RuntimeEvent, SessionStartOptions}; +use crate::self_update::SelfUpdater; use crate::state::SessionStateStore; pub async fn run_connector(config: ConnectorConfig) -> anyhow::Result<()> { + // Self-update rollback rail first: if a freshly-swapped binary keeps failing + // to get this far, this call restores the previous one (and never returns). + let updater = SelfUpdater::new(config.update.clone(), &config.state_path); + updater.startup_gate()?; + // Replay last run's "newer release exists" reminder into the boot log — + // with self-update off this is the owner's manual-update prompt. + updater.startup_notice(); + let mut state = SessionStateStore::new(config.state_path.clone()); state.load().await?; let state = Arc::new(Mutex::new(state)); @@ -58,7 +67,12 @@ pub async fn run_connector(config: ConnectorConfig) -> anyhow::Result<()> { let mut join_set = tokio::task::JoinSet::new(); for (account_id, account) in config.accounts { let state = state.clone(); - join_set.spawn(async move { AccountRuntime::new(account_id, account, state).run().await }); + let updater = updater.clone(); + join_set.spawn(async move { + AccountRuntime::new(account_id, account, state, updater) + .run() + .await + }); } while let Some(result) = join_set.join_next().await { @@ -71,6 +85,7 @@ struct AccountRuntime { account_id: String, config: AccountConfig, state: Arc>, + updater: Arc, } impl AccountRuntime { @@ -78,11 +93,13 @@ impl AccountRuntime { account_id: String, config: AccountConfig, state: Arc>, + updater: Arc, ) -> Self { Self { account_id, config, state, + updater, } } @@ -112,6 +129,19 @@ impl AccountRuntime { self.config.advanced.send_ack_timeout_ms, ); let bridge = BridgeSession::connect(bridge_config.clone(), bridge_ready.clone()).await?; + // A healthy bridge connection is the self-update success signal (stops + // the rollback boot counter), and the gateway-advertised release version + // in the same hello is the update trigger. + self.updater.mark_healthy(); + if let Some(latest) = bridge + .control_hello() + .server_capabilities + .as_ref() + .and_then(|caps| caps.latest_connector_version.clone()) + { + self.updater + .maybe_start(latest, self.config.control_url.clone()); + } let initial_connector_config = bridge.control_hello().connector_config.clone(); // Capture the bot's own identity from the hello before `spawn_bridge_io` // consumes the session — it's injected into every prompt (see build_prompt). @@ -1790,6 +1820,9 @@ impl RuntimeContext { } async fn run_task(self: Arc, task: TaskCommand) -> anyhow::Result<()> { + // Held for the whole turn (queued included) — a staged self-update only + // swaps binaries and re-execs once no guard is alive. + let _busy = crate::self_update::BusyGuard::new(); if !self.config.policy.prompt.allow { let _ = self .io diff --git a/packages/cheers-acp-connector-rs/src/bridge_runtime/signing.rs b/packages/cheers-acp-connector-rs/src/bridge_runtime/signing.rs index 4a5b69d0..4fb8f26f 100644 --- a/packages/cheers-acp-connector-rs/src/bridge_runtime/signing.rs +++ b/packages/cheers-acp-connector-rs/src/bridge_runtime/signing.rs @@ -111,7 +111,8 @@ pub(super) fn signed_frame_type(frame: &DataOutbound) -> Option<&'static str> { | DataOutbound::WorkspaceEvent { .. } | DataOutbound::PermissionCancel { .. } | DataOutbound::AcpEvent { .. } - | DataOutbound::FileUpload { .. } => None, + | DataOutbound::FileUpload { .. } + | DataOutbound::Unknown => None, } } @@ -134,7 +135,8 @@ pub(super) fn attach_envelope(frame: &mut DataOutbound, envelope: AcpCapabilityE | DataOutbound::WorkspaceEvent { .. } | DataOutbound::PermissionCancel { .. } | DataOutbound::AcpEvent { .. } - | DataOutbound::FileUpload { .. } => {} + | DataOutbound::FileUpload { .. } + | DataOutbound::Unknown => {} } } diff --git a/packages/cheers-acp-connector-rs/src/bridge_session.rs b/packages/cheers-acp-connector-rs/src/bridge_session.rs index 63539842..24c3d003 100644 --- a/packages/cheers-acp-connector-rs/src/bridge_session.rs +++ b/packages/cheers-acp-connector-rs/src/bridge_session.rs @@ -74,7 +74,7 @@ pub struct BridgeReady { impl BridgeReady { pub fn acp(runtime_name: impl Into, runtime_version: Option) -> Self { Self { - connector: ConnectorInfo::default(), + connector: crate::bridge::local_connector_info(), runtime: RuntimeDescriptor { protocol: "acp".to_string(), name: Some(runtime_name.into()), @@ -287,8 +287,9 @@ pub async fn connect_control_stream( control .send_json(&ControlOutbound::Ready { v: BRIDGE_PROTOCOL_VERSION, - connector_version: ready.connector.version.clone(), - runtime: ready.runtime.clone(), + connector_version: Some(ready.connector.version.clone()), + plugin_version: None, + runtime: Some(ready.runtime.clone()), connector_capabilities: ready.connector_capabilities.clone(), }) .await diff --git a/packages/cheers-acp-connector-rs/src/cli.rs b/packages/cheers-acp-connector-rs/src/cli.rs index bb7334a3..d9e975d9 100644 --- a/packages/cheers-acp-connector-rs/src/cli.rs +++ b/packages/cheers-acp-connector-rs/src/cli.rs @@ -106,6 +106,7 @@ pub async fn run() -> anyhow::Result<()> { println!("config: {}", metadata.config_path.display()); println!("stdout: {}", metadata.stdout_log_path.display()); println!("stderr: {}", metadata.stderr_log_path.display()); + print_update_notice(&metadata.config_path).await; } else { println!("metadata: {}", status.paths.metadata_path.display()); } @@ -121,6 +122,31 @@ pub async fn run() -> anyhow::Result<()> { } } +/// `status` addendum: surface a persisted "newer release available" notice +/// (written by the running daemon when a gateway hello advertises one). Silent +/// on any failure — status must keep working with a broken or missing config. +async fn print_update_notice(config_path: &std::path::Path) { + let Ok(daemon_config) = crate::config::load_daemon_file_config(config_path).await else { + return; + }; + let Some(state_dir) = daemon_config.state_path.parent() else { + return; + }; + let Some(notice) = crate::self_update::available_update(state_dir) else { + return; + }; + println!( + "update available: {} (running {})", + notice.latest, + crate::self_update::current_version() + ); + if let Some(url) = notice.download_url() { + println!(" download: {url}"); + } + println!(" then: replace the binary and run `cce-acp-connector restart`"); + println!(" or: set `[update] auto = true` in the connector config"); +} + async fn run_foreground(config_path: Option) -> anyhow::Result<()> { let config_path = config_path.ok_or_else(|| anyhow!("--config is required"))?; let config = load_config(&config_path) diff --git a/packages/cheers-acp-connector-rs/src/config.rs b/packages/cheers-acp-connector-rs/src/config.rs index 17b5af74..57b48a47 100644 --- a/packages/cheers-acp-connector-rs/src/config.rs +++ b/packages/cheers-acp-connector-rs/src/config.rs @@ -17,6 +17,18 @@ pub struct ConnectorConfig { pub accounts: BTreeMap, pub state_path: PathBuf, pub log_dir: Option, + pub update: UpdateSettings, +} + +/// Opt-in self-update (`[update]`). `auto` is deliberately default-false: +/// updating means executing code fetched over the network, so the host owner +/// must turn it on explicitly. `public_key_pem` overrides the release-signing +/// key compiled into the binary — only needed by forks that publish their own +/// signed releases; the stock key verifies this repo's releases. +#[derive(Debug, Clone, Default)] +pub struct UpdateSettings { + pub auto: bool, + pub public_key_pem: Option, } #[derive(Debug, Clone)] @@ -257,9 +269,20 @@ struct RawConfig { version: Option, #[serde(default)] daemon: RawDaemon, + #[serde(default)] + update: RawUpdate, accounts: BTreeMap, } +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawUpdate { + #[serde(default)] + auto: bool, + #[serde(default)] + public_key_file: Option, +} + #[derive(Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] struct RawDaemon { @@ -664,6 +687,7 @@ pub async fn load_config(config_path: &Path) -> anyhow::Result return Err(anyhow!("config.accounts must include at least one account")); } let daemon_config = normalize_daemon_file_config(raw.daemon, &base_dir)?; + let update = normalize_update(raw.update, &base_dir).await?; let mut accounts = BTreeMap::new(); for (id, raw_account) in raw.accounts { accounts.insert( @@ -676,6 +700,23 @@ pub async fn load_config(config_path: &Path) -> anyhow::Result accounts, state_path: daemon_config.state_path, log_dir: daemon_config.log_dir, + update, + }) +} + +async fn normalize_update(raw: RawUpdate, base_dir: &Path) -> anyhow::Result { + let public_key_pem = match raw.public_key_file.as_deref() { + Some(value) => { + let path = resolve_path(value, base_dir)?; + Some(fs::read_to_string(&path).await.with_context(|| { + format!("failed to read update.public_key_file {}", path.display()) + })?) + } + None => None, + }; + Ok(UpdateSettings { + auto: raw.auto, + public_key_pem, }) } diff --git a/packages/cheers-acp-connector-rs/src/main.rs b/packages/cheers-acp-connector-rs/src/main.rs index 5c3c8f91..6a6b4768 100644 --- a/packages/cheers-acp-connector-rs/src/main.rs +++ b/packages/cheers-acp-connector-rs/src/main.rs @@ -8,6 +8,7 @@ mod config; mod daemon; mod loopback; mod runtime_adapter; +mod self_update; mod state; #[tokio::main] diff --git a/packages/cheers-acp-connector-rs/src/self_update.rs b/packages/cheers-acp-connector-rs/src/self_update.rs new file mode 100644 index 00000000..08d1e7e3 --- /dev/null +++ b/packages/cheers-acp-connector-rs/src/self_update.rs @@ -0,0 +1,817 @@ +//! Opt-in connector self-update. +//! +//! Trigger: the gateway hello advertises `server_capabilities.latest_connector_version` +//! (the release its download proxy serves). When `[update] auto = true` and that +//! version is newer than `CARGO_PKG_VERSION`, a background task downloads the +//! release's signed manifest through the gateway proxy, verifies the manifest's +//! ed25519 signature against the release key pinned in this binary (or the +//! `[update] public_key_file` override for forks), verifies each binary's sha256 +//! against the manifest, swaps the executables in place, and re-execs. +//! +//! Trust model: the gateway is only a transport. A connector never runs a byte +//! that isn't hash-listed in a manifest signed by the release key, so neither a +//! compromised gateway nor a tampering proxy can push code — they can at worst +//! withhold updates. +//! +//! Safety rails: +//! - Draining: the swap waits until no prompt turn is in flight (`BusyGuard`). +//! - Rollback: the previous binary is kept as `.old` and a marker file +//! tracks boot attempts of the new one; if it fails to reach a healthy bridge +//! connection `MAX_BOOT_ATTEMPTS` times, startup restores `.old` and +//! blocks that version from being retried. +//! - Containers never self-update (image rebuilds own that path), and +//! `CHEERS_ACP_NO_SELF_UPDATE=1` force-disables regardless of config. +//! +//! When self-update is OFF (or unavailable) the connector still tells the owner: +//! a newer advertised version is persisted to `self-update/available.json`, a +//! manual-update warning is logged both when the hello arrives and on every +//! subsequent startup, and `cce-acp-connector status` surfaces it. + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{anyhow, Context}; +use base64::engine::general_purpose::STANDARD as BASE64; +use base64::Engine; +use ed25519_dalek::pkcs8::DecodePublicKey; +use ed25519_dalek::{Signature, VerifyingKey}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::config::UpdateSettings; + +/// Release-signing public key baked into every build. The matching private key +/// lives only in CI (`CONNECTOR_SIGNING_KEY` secret) — see release-connector.yml. +const EMBEDDED_RELEASE_PUBKEY: &str = include_str!("../release-signing-pubkey.pem"); + +/// Boot attempts a freshly-swapped binary gets to reach a healthy bridge +/// connection before startup rolls back to `.old`. +const MAX_BOOT_ATTEMPTS: u32 = 3; + +const DRAIN_POLL: Duration = Duration::from_secs(5); + +/// Prompt turns currently in flight, process-wide. The updater refuses to swap +/// binaries while this is non-zero so an exec never cuts off a streaming reply. +static ACTIVE_PROMPTS: AtomicUsize = AtomicUsize::new(0); + +/// RAII busy marker held for the duration of one `run_task` (queued turns count +/// too — a swap mid-queue would drop them just as hard as a swap mid-stream). +pub struct BusyGuard; + +impl BusyGuard { + pub fn new() -> Self { + ACTIVE_PROMPTS.fetch_add(1, Ordering::SeqCst); + BusyGuard + } +} + +impl Drop for BusyGuard { + fn drop(&mut self) { + ACTIVE_PROMPTS.fetch_sub(1, Ordering::SeqCst); + } +} + +fn active_prompts() -> usize { + ACTIVE_PROMPTS.load(Ordering::SeqCst) +} + +// ── manifest ────────────────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +struct Manifest { + version: String, + assets: std::collections::BTreeMap, +} + +#[derive(Debug, Deserialize)] +struct ManifestAsset { + sha256: String, +} + +/// Verify `sig_b64` (base64 ed25519 signature over the exact manifest bytes) +/// against `pubkey_pem`, then parse. Everything the updater trusts flows +/// through here — no manifest field is used before this returns Ok. +fn verify_and_parse_manifest( + manifest_bytes: &[u8], + sig_b64: &str, + pubkey_pem: &str, +) -> anyhow::Result { + let key = VerifyingKey::from_public_key_pem(pubkey_pem) + .map_err(|e| anyhow!("invalid release-signing public key: {e}"))?; + let sig_bytes = BASE64 + .decode(sig_b64.trim()) + .context("manifest signature is not valid base64")?; + let sig = Signature::from_slice(&sig_bytes) + .map_err(|e| anyhow!("manifest signature has wrong length: {e}"))?; + key.verify_strict(manifest_bytes, &sig) + .map_err(|e| anyhow!("manifest signature verification FAILED: {e}"))?; + serde_json::from_slice(manifest_bytes).context("signed manifest is not valid JSON") +} + +fn sha256_hex(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|b| format!("{b:02x}")) + .collect() +} + +// ── version / platform / url helpers ───────────────────────────────────────── + +pub fn current_version() -> &'static str { + env!("CARGO_PKG_VERSION") +} + +/// Strict numeric semver-triple compare; anything unparsable is "not newer". +fn version_is_newer(candidate: &str, current: &str) -> bool { + fn triple(s: &str) -> Option<(u64, u64, u64)> { + let mut it = s.trim().trim_start_matches('v').splitn(3, '.'); + Some(( + it.next()?.parse().ok()?, + it.next()?.parse().ok()?, + it.next()?.parse().ok()?, + )) + } + match (triple(candidate), triple(current)) { + (Some(a), Some(b)) => a > b, + _ => false, + } +} + +/// Release-asset suffix for this build, matching the release-connector.yml +/// matrix. None on platforms the release doesn't ship (no self-update there). +fn platform_asset_suffix() -> Option<&'static str> { + match (std::env::consts::OS, std::env::consts::ARCH) { + ("macos", "x86_64") => Some("darwin-amd64"), + ("macos", "aarch64") => Some("darwin-arm64"), + ("linux", "x86_64") => Some("linux-amd64"), + ("linux", "aarch64") => Some("linux-arm64"), + _ => None, + } +} + +/// Derive the gateway's download-proxy base from the control WS URL: the API +/// and the bridge share one origin and path prefix, so +/// `wss://host[/prefix]/ws/agent-bridge/control` → `https://host[/prefix]/api/v1/connector/download`. +fn download_base_from_control_url(control_url: &str) -> Option { + let (scheme, rest) = if let Some(rest) = control_url.strip_prefix("wss://") { + ("https://", rest) + } else if let Some(rest) = control_url.strip_prefix("ws://") { + ("http://", rest) + } else { + return None; + }; + let path_start = rest.find('/')?; + let (host, path) = rest.split_at(path_start); + let prefix = path.strip_suffix("/ws/agent-bridge/control")?; + Some(format!("{scheme}{host}{prefix}/api/v1/connector/download")) +} + +// ── rollback marker ─────────────────────────────────────────────────────────── + +/// Persisted next to the session state, one generation of update history: +/// which version was just applied, where the previous binary went, and how many +/// times the new binary has booted without reaching a healthy connection. +#[derive(Debug, Default, Serialize, Deserialize)] +struct Marker { + version: String, + previous_exe: Option, + attempts: u32, + confirmed: bool, + /// Versions that were rolled back — never auto-retried, so a bad release + /// can't put the connector into a swap/rollback loop. + #[serde(default)] + blocked_versions: Vec, +} + +fn marker_path(state_dir: &Path) -> PathBuf { + state_dir.join("self-update").join("marker.json") +} + +// ── "update available" notice ───────────────────────────────────────────────── + +/// Persisted whenever a gateway advertises a release newer than this binary, so +/// the reminder survives to the next startup (before any gateway is reachable) +/// and `cce-acp-connector status` can show it without a config or a connection. +#[derive(Debug, Serialize, Deserialize)] +pub struct AvailableUpdate { + pub latest: String, + pub download_base: Option, +} + +impl AvailableUpdate { + /// Direct download URL for this platform's connector binary, when both the + /// gateway base and a prebuilt asset exist. + pub fn download_url(&self) -> Option { + let base = self.download_base.as_deref()?; + let suffix = platform_asset_suffix()?; + Some(format!("{base}/cce-acp-connector-{suffix}")) + } + + fn manual_hint(&self, reason: &str) -> String { + let how = if reason.contains("container") { + "rebuild/pull the bot image built from the new release".to_string() + } else { + let get = match self.download_url() { + Some(url) => { + format!("download {url} (and the matching cheers-mcp-server)") + } + None => "install the new release binaries".to_string(), + }; + format!( + "{get}, replace the installed binaries, then run `cce-acp-connector restart`; \ + or set `[update] auto = true` in the connector config to update automatically" + ) + }; + format!( + "connector {} is available (running {}; self-update off: {reason}). \ + To update: {how}.", + self.latest, + current_version() + ) + } +} + +fn available_path(state_dir: &Path) -> PathBuf { + state_dir.join("self-update").join("available.json") +} + +/// Read the persisted notice, dropping it once it's stale (the binary caught +/// up, e.g. via a manual update) so old reminders can't outlive their truth. +pub fn available_update(state_dir: &Path) -> Option { + let bytes = std::fs::read(available_path(state_dir)).ok()?; + let notice: AvailableUpdate = serde_json::from_slice(&bytes).ok()?; + if version_is_newer(¬ice.latest, current_version()) { + Some(notice) + } else { + let _ = std::fs::remove_file(available_path(state_dir)); + None + } +} + +fn write_available(state_dir: &Path, notice: &AvailableUpdate) -> anyhow::Result<()> { + let path = available_path(state_dir); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, serde_json::to_vec_pretty(notice)?)?; + std::fs::rename(&tmp, &path)?; + Ok(()) +} + +fn read_marker(state_dir: &Path) -> Option { + let bytes = std::fs::read(marker_path(state_dir)).ok()?; + serde_json::from_slice(&bytes).ok() +} + +fn write_marker(state_dir: &Path, marker: &Marker) -> anyhow::Result<()> { + let path = marker_path(state_dir); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, serde_json::to_vec_pretty(marker)?)?; + std::fs::rename(&tmp, &path)?; + Ok(()) +} + +// ── updater ─────────────────────────────────────────────────────────────────── + +pub struct SelfUpdater { + settings: UpdateSettings, + state_dir: PathBuf, + started: AtomicBool, + confirmed: AtomicBool, + /// Last version a manual-update warning was emitted for — one warning per + /// version per process, not one per reconnect/hello. + noticed: std::sync::Mutex>, +} + +impl SelfUpdater { + pub fn new(settings: UpdateSettings, state_path: &Path) -> Arc { + let state_dir = state_path + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")); + Arc::new(Self { + settings, + state_dir, + started: AtomicBool::new(false), + confirmed: AtomicBool::new(false), + noticed: std::sync::Mutex::new(None), + }) + } + + fn pubkey_pem(&self) -> &str { + self.settings + .public_key_pem + .as_deref() + .unwrap_or(EMBEDDED_RELEASE_PUBKEY) + } + + fn disabled_reason(&self) -> Option<&'static str> { + if !self.settings.auto { + return Some("[update] auto is not enabled"); + } + if std::env::var_os("CHEERS_ACP_NO_SELF_UPDATE").is_some() { + return Some("CHEERS_ACP_NO_SELF_UPDATE is set"); + } + // Containers get updates by image rebuild; an in-place swap would be + // lost on the next container restart and fights the image digest. + if Path::new("/.dockerenv").exists() + || std::env::var_os("KUBERNETES_SERVICE_HOST").is_some() + { + return Some("running in a container (update the image instead)"); + } + if platform_asset_suffix().is_none() { + return Some("no prebuilt release asset for this platform"); + } + None + } + + /// Startup half of the rollback rail. Called before connecting: counts this + /// boot against the pending marker and, once the new binary has burned + /// `MAX_BOOT_ATTEMPTS` boots without ever confirming, puts `.old` back + /// and execs it. Returns normally in every other case. + pub fn startup_gate(&self) -> anyhow::Result<()> { + let Some(mut marker) = read_marker(&self.state_dir) else { + return Ok(()); + }; + if marker.confirmed { + return Ok(()); + } + marker.attempts += 1; + if marker.attempts <= MAX_BOOT_ATTEMPTS { + write_marker(&self.state_dir, &marker)?; + return Ok(()); + } + let Some(previous) = marker.previous_exe.clone().filter(|p| p.exists()) else { + tracing::error!( + version = %marker.version, + "self-update looks unhealthy but no previous binary is kept — continuing" + ); + marker.confirmed = true; + write_marker(&self.state_dir, &marker)?; + return Ok(()); + }; + tracing::error!( + version = %marker.version, + attempts = marker.attempts, + "self-updated binary never reached a healthy connection — rolling back" + ); + let exe = std::env::current_exe().context("resolve current_exe for rollback")?; + let quarantined = exe.with_extension(format!("bad-{}", marker.version)); + let _ = std::fs::remove_file(&quarantined); + std::fs::rename(&exe, &quarantined).context("quarantine failing binary")?; + std::fs::rename(&previous, &exe).context("restore previous binary")?; + if !marker.blocked_versions.contains(&marker.version) { + marker.blocked_versions.push(marker.version.clone()); + } + marker.confirmed = true; // the restored binary must not count boots + marker.previous_exe = None; + write_marker(&self.state_dir, &marker)?; + exec_self(&exe) + } + + /// Health half of the rollback rail: the bridge connected, so the running + /// binary is good — stop counting boots and drop the kept `.old`. + pub fn mark_healthy(&self) { + if self.confirmed.swap(true, Ordering::SeqCst) { + return; + } + let Some(mut marker) = read_marker(&self.state_dir) else { + return; + }; + if marker.confirmed { + return; + } + marker.confirmed = true; + if let Some(previous) = marker.previous_exe.take() { + let _ = std::fs::remove_file(previous); + } + if let Err(err) = write_marker(&self.state_dir, &marker) { + tracing::warn!("failed to confirm self-update marker: {err}"); + } else { + tracing::info!(version = %marker.version, "self-update confirmed healthy"); + } + } + + /// Startup half of the notice rail: before any gateway is reachable, replay + /// the persisted "newer version exists" reminder from the previous run so an + /// owner reading the boot log knows an update is pending. (`available_update` + /// self-clears once the binary has caught up.) + pub fn startup_notice(&self) { + let Some(notice) = available_update(&self.state_dir) else { + return; + }; + if let Some(reason) = self.disabled_reason() { + self.notice_manual(¬ice, reason); + } else { + tracing::info!( + current = current_version(), + available = %notice.latest, + "connector update pending — will self-update after connecting" + ); + } + } + + /// Warn the owner that a manual update is needed — once per version per + /// process, so reconnect-time hellos don't turn the reminder into spam. + fn notice_manual(&self, notice: &AvailableUpdate, reason: &str) { + let mut noticed = self.noticed.lock().expect("noticed lock poisoned"); + if noticed.as_deref() == Some(notice.latest.as_str()) { + return; + } + *noticed = Some(notice.latest.clone()); + tracing::warn!("{}", notice.manual_hint(reason)); + } + + /// Trigger half: called with the gateway-advertised release version after + /// every hello. Persists (or clears) the update notice, and spawns at most + /// one update task per process lifetime when self-update is enabled. + pub fn maybe_start(self: &Arc, advertised: String, control_url: String) { + if !version_is_newer(&advertised, current_version()) { + // This gateway serves nothing newer — drop any stale reminder (a + // manual update landed, or the gateway was pinned back). + let _ = std::fs::remove_file(available_path(&self.state_dir)); + return; + } + let notice = AvailableUpdate { + latest: advertised.clone(), + download_base: download_base_from_control_url(&control_url), + }; + if let Err(err) = write_available(&self.state_dir, ¬ice) { + tracing::warn!("failed to persist update notice: {err}"); + } + if let Some(reason) = self.disabled_reason() { + self.notice_manual(¬ice, reason); + return; + } + if let Some(marker) = read_marker(&self.state_dir) { + if marker.blocked_versions.iter().any(|v| v == &advertised) { + tracing::warn!( + version = %advertised, + "skipping self-update: this version was previously rolled back" + ); + return; + } + } + if self.started.swap(true, Ordering::SeqCst) { + return; + } + let updater = self.clone(); + tokio::spawn(async move { + if let Err(err) = updater.run_update(&advertised, &control_url).await { + tracing::error!("self-update to {advertised} failed: {err}"); + // Allow a retry on a later reconnect/process restart. + updater.started.store(false, Ordering::SeqCst); + } + }); + } + + async fn run_update(&self, advertised: &str, control_url: &str) -> anyhow::Result<()> { + let base = download_base_from_control_url(control_url) + .ok_or_else(|| anyhow!("cannot derive download URL from control_url {control_url}"))?; + tracing::info!( + current = current_version(), + target = %advertised, + base = %base, + "self-update: downloading signed manifest" + ); + + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(300)) + .build()?; + let manifest_bytes = fetch(&client, &format!("{base}/connector-manifest.json")).await?; + let sig_b64 = String::from_utf8( + fetch(&client, &format!("{base}/connector-manifest.json.sig")).await?, + ) + .context("manifest signature is not UTF-8")?; + let manifest = verify_and_parse_manifest(&manifest_bytes, &sig_b64, self.pubkey_pem())?; + + // The advertised version is an unauthenticated hint; the signed manifest + // is the truth. Re-check so a stale/lying gateway can't downgrade us. + if !version_is_newer(&manifest.version, current_version()) { + return Err(anyhow!( + "signed manifest is for {} which is not newer than {} — refusing", + manifest.version, + current_version() + )); + } + + let suffix = platform_asset_suffix().expect("checked in disabled_reason"); + let exe = std::env::current_exe()?; + let exe_dir = exe + .parent() + .ok_or_else(|| anyhow!("current exe has no parent dir"))? + .to_path_buf(); + + // The MCP server ships in lockstep and is resolved next to the connector + // executable, so when that sibling exists it must be swapped in the same + // generation — a version-skewed pair is not a supported state. + let mut wanted: Vec<(String, PathBuf)> = + vec![(format!("cce-acp-connector-{suffix}"), exe.clone())]; + let mcp_sibling = exe_dir.join("cheers-mcp-server"); + if mcp_sibling.exists() { + wanted.push((format!("cheers-mcp-server-{suffix}"), mcp_sibling)); + } + + let staging = self + .state_dir + .join("self-update") + .join(format!("staged-{}", manifest.version)); + tokio::fs::create_dir_all(&staging).await?; + + let mut staged: Vec<(PathBuf, PathBuf)> = Vec::new(); // (staged file, install target) + for (asset, target) in &wanted { + let expected = &manifest + .assets + .get(asset) + .ok_or_else(|| anyhow!("manifest has no asset {asset}"))? + .sha256; + let bytes = fetch(&client, &format!("{base}/{asset}")).await?; + let actual = sha256_hex(&bytes); + if &actual != expected { + return Err(anyhow!( + "sha256 mismatch for {asset}: manifest {expected}, downloaded {actual}" + )); + } + let staged_path = staging.join(asset); + tokio::fs::write(&staged_path, &bytes).await?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + tokio::fs::set_permissions(&staged_path, std::fs::Permissions::from_mode(0o755)) + .await?; + } + staged.push((staged_path, target.clone())); + tracing::info!(asset = %asset, sha256 = %actual, "self-update: staged"); + } + + // Drain: wait for a quiet connector. Two consecutive zero readings + // narrow (but can't fully close) the race with a task frame that lands + // between the check and the exec — an acceptable residue, since the + // gateway already finalizes orphaned turns as "[bot offline]". + tracing::info!(version = %manifest.version, "self-update: staged, waiting for idle"); + let mut quiet = 0u32; + let mut waited = Duration::ZERO; + loop { + if active_prompts() == 0 { + quiet += 1; + if quiet >= 2 { + break; + } + } else { + quiet = 0; + } + tokio::time::sleep(DRAIN_POLL).await; + waited += DRAIN_POLL; + if waited.as_secs() % 300 == 0 { + tracing::info!( + in_flight = active_prompts(), + "self-update: still draining before restart" + ); + } + } + + self.apply_and_exec(&manifest.version, &staged) + } + + /// Swap every staged binary into place and exec the new connector. Copies + /// into the install dir first (staging may be another filesystem), then + /// uses two same-directory renames so each swap is atomic; the displaced + /// binary survives as `.old` until `mark_healthy`. + fn apply_and_exec(&self, version: &str, staged: &[(PathBuf, PathBuf)]) -> anyhow::Result<()> { + let exe = std::env::current_exe()?; + let mut previous_exe = None; + for (staged_path, target) in staged { + let incoming = target.with_extension("new"); + std::fs::copy(staged_path, &incoming) + .with_context(|| format!("copy staged update into {}", incoming.display()))?; + let old = target.with_extension("old"); + let _ = std::fs::remove_file(&old); + std::fs::rename(target, &old) + .with_context(|| format!("set aside {}", target.display()))?; + std::fs::rename(&incoming, target) + .with_context(|| format!("install {}", target.display()))?; + if target == &exe { + previous_exe = Some(old); + } + } + write_marker( + &self.state_dir, + &Marker { + version: version.to_string(), + previous_exe, + attempts: 0, + confirmed: false, + blocked_versions: read_marker(&self.state_dir) + .map(|m| m.blocked_versions) + .unwrap_or_default(), + }, + )?; + tracing::info!(version = %version, "self-update: binaries swapped, restarting"); + exec_self(&exe) + } +} + +async fn fetch(client: &reqwest::Client, url: &str) -> anyhow::Result> { + let resp = client + .get(url) + .send() + .await + .with_context(|| format!("GET {url}"))?; + if !resp.status().is_success() { + return Err(anyhow!("GET {url} returned HTTP {}", resp.status())); + } + Ok(resp.bytes().await?.to_vec()) +} + +/// Replace this process with `exe`, keeping argv (daemon metadata matches on +/// the command line) and the PID (launchd/systemd units keep tracking us). +#[cfg(unix)] +fn exec_self(exe: &Path) -> anyhow::Result<()> { + use std::os::unix::process::CommandExt; + let err = std::process::Command::new(exe) + .args(std::env::args_os().skip(1)) + .exec(); + Err(anyhow!("exec {} failed: {err}", exe.display())) +} + +#[cfg(not(unix))] +fn exec_self(_exe: &Path) -> anyhow::Result<()> { + Err(anyhow!( + "self-update restart is only supported on unix platforms" + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::pkcs8::spki::der::pem::LineEnding; + use ed25519_dalek::pkcs8::EncodePublicKey; + use ed25519_dalek::{Signer, SigningKey}; + + #[test] + fn version_compare_is_numeric() { + assert!(version_is_newer("0.1.27", "0.1.26")); + assert!(version_is_newer("0.1.100", "0.1.26")); + assert!(version_is_newer("v0.2.0", "0.1.99")); + assert!(!version_is_newer("0.1.26", "0.1.26")); + assert!(!version_is_newer("0.1.25", "0.1.26")); + assert!(!version_is_newer("latest", "0.1.26")); + } + + #[test] + fn download_base_derivation() { + assert_eq!( + download_base_from_control_url("wss://chat.example.com/ws/agent-bridge/control") + .as_deref(), + Some("https://chat.example.com/api/v1/connector/download") + ); + assert_eq!( + download_base_from_control_url("ws://localhost:30080/ws/agent-bridge/control") + .as_deref(), + Some("http://localhost:30080/api/v1/connector/download") + ); + assert_eq!( + download_base_from_control_url("wss://host.example.com/cheers/ws/agent-bridge/control") + .as_deref(), + Some("https://host.example.com/cheers/api/v1/connector/download") + ); + assert_eq!( + download_base_from_control_url("https://host/ws/agent-bridge/control"), + None + ); + assert_eq!( + download_base_from_control_url("wss://host/other/path"), + None + ); + } + + #[test] + fn manifest_roundtrip_verifies_and_rejects_tampering() { + let signing = SigningKey::from_bytes(&[7u8; 32]); + let pubkey_pem = signing + .verifying_key() + .to_public_key_pem(LineEnding::LF) + .expect("pem"); + let manifest = + br#"{"version":"0.1.27","assets":{"cce-acp-connector-linux-amd64":{"sha256":"abc"}}}"#; + let sig_b64 = BASE64.encode(signing.sign(manifest).to_bytes()); + + let parsed = verify_and_parse_manifest(manifest, &sig_b64, &pubkey_pem).expect("verify"); + assert_eq!(parsed.version, "0.1.27"); + + let mut tampered = manifest.to_vec(); + tampered[12] = b'9'; // version byte + assert!(verify_and_parse_manifest(&tampered, &sig_b64, &pubkey_pem).is_err()); + + let other_key = SigningKey::from_bytes(&[8u8; 32]) + .verifying_key() + .to_public_key_pem(LineEnding::LF) + .expect("pem"); + assert!(verify_and_parse_manifest(manifest, &sig_b64, &other_key).is_err()); + } + + #[test] + fn embedded_pubkey_parses() { + VerifyingKey::from_public_key_pem(EMBEDDED_RELEASE_PUBKEY) + .expect("embedded release pubkey must be a valid ed25519 SPKI PEM"); + } + + #[test] + fn busy_guard_counts() { + let before = active_prompts(); + { + let _a = BusyGuard::new(); + let _b = BusyGuard::new(); + assert_eq!(active_prompts(), before + 2); + } + assert_eq!(active_prompts(), before); + } + + #[test] + fn disabled_update_persists_notice_and_clears_when_caught_up() { + let dir = tempfile::tempdir().expect("tempdir"); + let state_path = dir.path().join("state.json"); + let updater = SelfUpdater::new(UpdateSettings::default(), &state_path); + let control = "wss://h.example/ws/agent-bridge/control".to_string(); + + // auto=false + newer advertised → no update task, but the notice (with + // a derived download base) is persisted for startup/status reminders. + updater.maybe_start("999.0.0".into(), control.clone()); + let notice = available_update(dir.path()).expect("notice persisted"); + assert_eq!(notice.latest, "999.0.0"); + assert_eq!( + notice.download_base.as_deref(), + Some("https://h.example/api/v1/connector/download") + ); + + // The gateway no longer advertises anything newer → reminder cleared. + updater.maybe_start(current_version().into(), control); + assert!(available_update(dir.path()).is_none()); + + // A stale persisted notice (binary caught up meanwhile) self-clears on read. + write_available( + dir.path(), + &AvailableUpdate { + latest: current_version().into(), + download_base: None, + }, + ) + .expect("write notice"); + assert!(available_update(dir.path()).is_none()); + assert!(!available_path(dir.path()).exists()); + } + + #[test] + fn startup_gate_rolls_back_after_max_attempts() { + let dir = tempfile::tempdir().expect("tempdir"); + let state_dir = dir.path(); + // No marker → no-op. + let updater = SelfUpdater::new(UpdateSettings::default(), &state_dir.join("state.json")); + updater.startup_gate().expect("no marker is fine"); + + // Pending marker: each gate call burns one attempt. + write_marker( + state_dir, + &Marker { + version: "9.9.9".into(), + previous_exe: None, + attempts: 0, + confirmed: false, + blocked_versions: vec![], + }, + ) + .expect("write marker"); + for expected in 1..=MAX_BOOT_ATTEMPTS { + updater.startup_gate().expect("counted boot"); + assert_eq!(read_marker(state_dir).unwrap().attempts, expected); + } + // Attempts exhausted with no previous binary kept → confirm and continue + // (nothing to roll back to), rather than dying in a loop. + updater.startup_gate().expect("continues without previous"); + let marker = read_marker(state_dir).unwrap(); + assert!(marker.confirmed); + + // mark_healthy is idempotent and confirms a fresh pending marker. + write_marker( + state_dir, + &Marker { + version: "9.9.10".into(), + previous_exe: None, + attempts: 1, + confirmed: false, + blocked_versions: vec![], + }, + ) + .expect("write marker"); + let updater = SelfUpdater::new(UpdateSettings::default(), &state_dir.join("state.json")); + updater.mark_healthy(); + assert!(read_marker(state_dir).unwrap().confirmed); + } +} diff --git a/scripts/redeploy.sh b/scripts/redeploy.sh index 067d1768..573fe4ab 100755 --- a/scripts/redeploy.sh +++ b/scripts/redeploy.sh @@ -45,7 +45,8 @@ esac images=() if $do_gateway; then echo "▸ [1/3] building $GATEWAY_IMAGE (Rust — this is the slow one)…" - docker build -t "$GATEWAY_IMAGE" server + # Root context: the gateway depends on packages/.../bridge-protocol (path dep). + docker build -t "$GATEWAY_IMAGE" -f server/Dockerfile . images+=("$GATEWAY_IMAGE") fi if $do_frontend; then diff --git a/server/Cargo.lock b/server/Cargo.lock index 691c1745..1118ab0e 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -683,6 +683,14 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "cheers-bridge-protocol" +version = "0.1.27" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "chrono" version = "0.4.44" @@ -2880,6 +2888,7 @@ dependencies = [ "axum", "base64", "bcrypt", + "cheers-bridge-protocol", "chrono", "dashmap", "dotenvy", diff --git a/server/Cargo.toml b/server/Cargo.toml index b4619e7e..9aab62c5 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -14,6 +14,10 @@ path = "src/main.rs" integration = [] [dependencies] +# Shared Agent Bridge frame types — same crate the connector uses, so frame +# drift between the two ends is a compile error. Path dep: the gateway docker +# build uses the repo-root context to include it (see server/Dockerfile). +cheers-bridge-protocol = { path = "../packages/cheers-acp-connector-rs/bridge-protocol" } # ── 运行时 ────────────────────────────────────────────────────────────────── tokio = { version = "1", features = ["full"] } axum = { version = "0.7", features = ["ws", "macros"] } diff --git a/server/Dockerfile b/server/Dockerfile index 50fe9816..8e4c8f7e 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,13 +1,18 @@ # ── build ──────────────────────────────────────────────────────────────────── +# Build context is the REPO ROOT (docker build -f server/Dockerfile .) because +# Cargo.toml has a path dependency on the shared bridge-protocol crate under +# packages/ — a server/-only context can't see it. FROM rust:1-bookworm AS builder -WORKDIR /app +WORKDIR /app/server # 依赖与源码(sqlx 用运行时查询,编译无需连库;migrations 由 migrate! 宏编译期内嵌) -COPY Cargo.toml Cargo.lock ./ -COPY src ./src -COPY migrations ./migrations +COPY server/Cargo.toml server/Cargo.lock ./ +COPY server/src ./src +COPY server/migrations ./migrations # assets/ holds files embedded via include_str! (e.g. the mode-2 install.sh). -COPY assets ./assets +COPY server/assets ./assets +# The shared Agent Bridge frame-types crate (path dep ../packages/...). +COPY packages/cheers-acp-connector-rs/bridge-protocol /app/packages/cheers-acp-connector-rs/bridge-protocol RUN cargo build --release # ── runtime ────────────────────────────────────────────────────────────────── @@ -16,7 +21,7 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates curl \ && rm -rf /var/lib/apt/lists/* WORKDIR /app -COPY --from=builder /app/target/release/server /usr/local/bin/gateway +COPY --from=builder /app/server/target/release/server /usr/local/bin/gateway EXPOSE 8000 # 启动即跑 sqlx 迁移(main.rs: sqlx::migrate!),无需独立 migrate 步骤。 # The binary is installed as `gateway` (renamed on COPY above), so run that. diff --git a/server/src/api/approval.rs b/server/src/api/approval.rs index bc371331..fcb4a6af 100644 --- a/server/src/api/approval.rs +++ b/server/src/api/approval.rs @@ -297,16 +297,14 @@ pub async fn resolve_permission( } else { "reject" }; - let frame = json!({ - "type": "permission_resolution", - "v": 1, - "request_id": request_id, - "message_id": pending.msg_id.to_string(), - "resolution": resolution, - "option_id": option_id, - "resolved_by": uid.to_string(), - "resolved_at": now, - }); + let frame = crate::gateway::bridge_frames::permission_resolution_frame( + &request_id, + &pending.msg_id.to_string(), + resolution, + &option_id, + &uid.to_string(), + &now, + ); let delivered = state.bot_locator.dispatch_task(pending.bot_id, frame).await; // Broadcast the resolved card so every client clears the pending state. diff --git a/server/src/api/auth.rs b/server/src/api/auth.rs index 0d8e7935..c5d5e34f 100644 --- a/server/src/api/auth.rs +++ b/server/src/api/auth.rs @@ -105,10 +105,7 @@ pub struct RegisterCodeRequest { /// Sign-up gate shared by request-code + register: open registration, or a live /// invite-link token. The token is only CHECKED here — its use is consumed later /// by `accept_invite_link`, once the account exists and actually joins. -async fn ensure_may_register( - state: &AppState, - invite_token: Option<&str>, -) -> Result<(), AppError> { +async fn ensure_may_register(state: &AppState, invite_token: Option<&str>) -> Result<(), AppError> { if state.config.open_registration { return Ok(()); } @@ -164,7 +161,9 @@ pub async fn register_request_code( .fetch_optional(&state.db) .await?; if taken.is_some() { - return Err(AppError::Conflict("that email is already registered".into())); + return Err(AppError::Conflict( + "that email is already registered".into(), + )); } let code = crate::infra::crypto::generate_email_code(); diff --git a/server/src/api/bot_permission.rs b/server/src/api/bot_permission.rs index 4dd10c76..b32b1c21 100644 --- a/server/src/api/bot_permission.rs +++ b/server/src/api/bot_permission.rs @@ -186,11 +186,12 @@ pub async fn set_posture( // L2 push to a live connector (best-effort). It re-clamps via L0 (both gates). let delivered = match bot_id.parse::() { Ok(uuid) => { - let frame = json!({ - "type": "config_update", - "v": 1, - "settings": { "agentNativePermissionMode": mode }, - }); + let frame = crate::gateway::bridge_frames::config_update_frame( + cheers_bridge_protocol::ConnectorControlSettings { + agent_native_permission_mode: Some(mode.clone()), + ..Default::default() + }, + ); state.bot_locator.dispatch_task(uuid, frame).await } Err(_) => false, @@ -292,11 +293,12 @@ pub async fn set_config_option( // via L0 allowed_config_options and applies per-session. let delivered = match bot_id.parse::() { Ok(uuid) => { - let frame = json!({ - "type": "config_update", - "v": 1, - "settings": { "configOptions": desired }, - }); + let frame = crate::gateway::bridge_frames::config_update_frame( + cheers_bridge_protocol::ConnectorControlSettings { + config_options: Some(desired.clone()), + ..Default::default() + }, + ); state.bot_locator.dispatch_task(uuid, frame).await } Err(_) => false, diff --git a/server/src/api/bots.rs b/server/src/api/bots.rs index 905702b5..8530361e 100644 --- a/server/src/api/bots.rs +++ b/server/src/api/bots.rs @@ -339,7 +339,8 @@ pub async fn get_bot_status( ensure_bot_visible(&state, &claims, &bot_id).await?; let row = sqlx::query( "SELECT bot_id, is_disabled, binding_type, created_by, - status_text, status_emoji, status_updated_at + status_text, status_emoji, status_updated_at, + binding_config->'connector_control'->>'connector_version' AS connector_version FROM bot_accounts WHERE bot_id = $1", ) .bind(&bot_id) @@ -396,6 +397,20 @@ pub async fn get_bot_status( .fetch_one(&state.db) .await?; + // Version pair for the settings UI: what the connector reported at its last + // `ready` vs. the release this gateway serves. `update_available` only turns + // true on a strict semver-triple increase, so a pinned-back gateway doesn't + // nag newer connectors to "update" downward. + let connector_version = row + .try_get::, _>("connector_version") + .ok() + .flatten(); + let latest_version = state.config.connector_release_version.clone(); + let update_available = match (connector_version.as_deref(), latest_version.as_deref()) { + (Some(cur), Some(latest)) => version_is_newer(latest, cur), + _ => false, + }; + Ok(Json(json!({ "bot_id": row.try_get::("bot_id").unwrap_or(bot_id), "is_disabled": is_disabled, @@ -409,6 +424,9 @@ pub async fn get_bot_status( "last_connected_at": last_connected_at.map(|t| t.to_rfc3339()), "last_disconnected_at": last_disconnected_at.map(|t| t.to_rfc3339()), "live_enrollment_codes": live_codes, + "connector_version": connector_version, + "latest_connector_version": latest_version, + "update_available": update_available, "status_text": row.try_get::, _>("status_text").ok().flatten(), "status_emoji": row.try_get::, _>("status_emoji").ok().flatten(), "status_updated_at": row @@ -993,7 +1011,10 @@ pub async fn broadcast_bot_member_update(state: &AppState, bot_id: &str) { data["channel_id"] = json!(cid); state .fanout - .broadcast_channel(channel_uuid, WireFrame::channel(channel_uuid, "member_updated", data)) + .broadcast_channel( + channel_uuid, + WireFrame::channel(channel_uuid, "member_updated", data), + ) .await; } } @@ -1111,3 +1132,38 @@ pub async fn refresh_bot_status( "msg_id": dto.msg_id, }))) } + +/// Strict semver-triple "is `candidate` newer than `current`" — used for the +/// connector `update_available` flag. Tolerates a leading `v`; anything that +/// isn't three dot-separated integers compares as "not newer" (fail quiet: +/// a garbled version must never nag every bot owner to update). +fn version_is_newer(candidate: &str, current: &str) -> bool { + fn triple(s: &str) -> Option<(u64, u64, u64)> { + let mut it = s.trim().trim_start_matches('v').splitn(3, '.'); + let major = it.next()?.parse().ok()?; + let minor = it.next()?.parse().ok()?; + let patch = it.next()?.parse().ok()?; + Some((major, minor, patch)) + } + match (triple(candidate), triple(current)) { + (Some(a), Some(b)) => a > b, + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::version_is_newer; + + #[test] + fn version_compare_is_numeric_not_lexicographic() { + assert!(version_is_newer("0.1.27", "0.1.26")); + assert!(version_is_newer("0.2.0", "0.1.99")); + assert!(version_is_newer("0.1.100", "0.1.26")); + assert!(version_is_newer("v0.1.27", "0.1.26")); + assert!(!version_is_newer("0.1.26", "0.1.26")); + assert!(!version_is_newer("0.1.25", "0.1.26")); + assert!(!version_is_newer("latest", "0.1.26")); + assert!(!version_is_newer("0.1.27", "unknown")); + } +} diff --git a/server/src/api/channels.rs b/server/src/api/channels.rs index d27bd4bc..461175d7 100644 --- a/server/src/api/channels.rs +++ b/server/src/api/channels.rs @@ -877,7 +877,9 @@ pub async fn add_channel_member( .try_get::("ok") .unwrap_or(false); if already_member { - return Err(AppError::BadRequest("user is already a channel member".into())); + return Err(AppError::BadRequest( + "user is already a channel member".into(), + )); } // Idempotent: a repeat invite before the first is answered is a no-op. diff --git a/server/src/api/enrollment.rs b/server/src/api/enrollment.rs index e1a266b5..6dd8fc22 100644 --- a/server/src/api/enrollment.rs +++ b/server/src/api/enrollment.rs @@ -438,9 +438,17 @@ pub async fn install_script( } /// Allowlisted release-asset names the gateway will proxy — exactly the -/// 2 products × 2 OS × 2 arches the release-connector workflow publishes. -/// Anything else 404s, so the endpoint can't be used as an open proxy. +/// 2 products × 2 OS × 2 arches the release-connector workflow publishes, +/// plus the ed25519-signed sha256 manifest pair the connector self-updater +/// verifies before swapping binaries. Anything else 404s, so the endpoint +/// can't be used as an open proxy. fn is_known_connector_asset(name: &str) -> bool { + if matches!( + name, + "connector-manifest.json" | "connector-manifest.json.sig" + ) { + return true; + } let Some(rest) = name .strip_prefix("cce-acp-connector-") .or_else(|| name.strip_prefix("cheers-mcp-server-")) diff --git a/server/src/api/files.rs b/server/src/api/files.rs index cc228414..efcc2bd1 100644 --- a/server/src/api/files.rs +++ b/server/src/api/files.rs @@ -555,23 +555,23 @@ pub async fn realize_file( // fall back to the scope-derived deterministic key. The connector re-clamps // against allowed_roots and falls back to default_cwd when this is empty // (docs/arch/SESSION_WORKDIR_ROOTSET.md, Phase 6). - let primary_key = crate::domain::sessions::resolve_primary_session(&state.db, bot_id, &channel_id) - .await - .ok() - .flatten() - .map(|(_, key)| key) - .unwrap_or_else(|| { - crate::domain::sessions::primary_provider_session_key(&channel_id, bot_id) - }); + let primary_key = + crate::domain::sessions::resolve_primary_session(&state.db, bot_id, &channel_id) + .await + .ok() + .flatten() + .map(|(_, key)| key) + .unwrap_or_else(|| { + crate::domain::sessions::primary_provider_session_key(&channel_id, bot_id) + }); let roots = crate::domain::sessions::session_root_set(&state.db, &primary_key).await; - let frame = serde_json::json!({ - "type": "realize_file", - "file_id": file_id, - "remote_ref": remote_ref, - "channel_id": channel_id, - "roots": roots, - }); + let frame = crate::gateway::bridge_frames::realize_file_frame( + &file_id, + &remote_ref, + &channel_id, + &roots, + ); let sent = state.bot_locator.send_data(bot_id, frame).await; if !sent { diff --git a/server/src/api/messages.rs b/server/src/api/messages.rs index 9b7cf131..1b13de8b 100644 --- a/server/src/api/messages.rs +++ b/server/src/api/messages.rs @@ -272,11 +272,7 @@ pub async fn cancel_message( let mut delivered = false; for (placeholder, target_bot) in &targets { - let frame = serde_json::json!({ - "type": "cancel", - "msg_id": placeholder, - "reason": reason, - }); + let frame = crate::gateway::bridge_frames::cancel_frame(*placeholder, reason); if state.bot_locator.dispatch_task(*target_bot, frame).await { delivered = true; } diff --git a/server/src/api/session_control.rs b/server/src/api/session_control.rs index aecc0754..bd5b2588 100644 --- a/server/src/api/session_control.rs +++ b/server/src/api/session_control.rs @@ -425,13 +425,8 @@ pub async fn set_session_mode( }) .await?; - let frame = json!({ - "type": "mode_set", - "v": 1, - "request_id": Uuid::new_v4().to_string(), - "provider_session_key": key, - "mode": mode, - }); + let frame = + crate::gateway::bridge_frames::mode_set_frame(&Uuid::new_v4().to_string(), &key, &mode); let delivered = state.bot_locator.dispatch_task(bot_id, frame).await; Ok(Json( json!({ "ok": true, "mode": mode, "delivered": delivered }), @@ -512,14 +507,12 @@ pub async fn set_session_config_option( }) .await?; - let frame = json!({ - "type": "config_option_set", - "v": 1, - "request_id": Uuid::new_v4().to_string(), - "provider_session_key": key, - "config_id": config_id, - "value": value, - }); + let frame = crate::gateway::bridge_frames::config_option_set_frame( + &Uuid::new_v4().to_string(), + &key, + &config_id, + &value, + ); let delivered = state.bot_locator.dispatch_task(bot_id, frame).await; Ok(Json( json!({ "ok": true, "config_id": config_id, "value": value, "delivered": delivered }), diff --git a/server/src/api/users.rs b/server/src/api/users.rs index d88716c2..245f9399 100644 --- a/server/src/api/users.rs +++ b/server/src/api/users.rs @@ -110,7 +110,11 @@ pub async fn update_me( "display_name too long (≤255 chars)".into(), )); } - if bio.value.as_deref().is_some_and(|s| s.chars().count() > 4000) { + if bio + .value + .as_deref() + .is_some_and(|s| s.chars().count() > 4000) + { return Err(AppError::BadRequest("bio too long (≤4000 chars)".into())); } if status_text diff --git a/server/src/api/workspace.rs b/server/src/api/workspace.rs index e5fabf97..e93dfaf9 100644 --- a/server/src/api/workspace.rs +++ b/server/src/api/workspace.rs @@ -426,23 +426,19 @@ async fn workspace_call( } let req_id = Uuid::new_v4().to_string(); let rx = state.workspace_rpc.register(req_id.clone()); - let mut frame = json!({ - "type": "workspace_req", - "req_id": req_id, - "op": op, - "path": path, - "root": root, - // `op == "write"` precondition; JSON null for every read/git op. - "if_etag": if_etag, - // Optional session root set to scope this browse (empty ⇒ full allowed_roots). - "roots": roots, - }); - // Merge op-specific fields into the frame as top-level keys. - if let (Value::Object(dst), Value::Object(src)) = (&mut frame, extra) { - for (k, v) in src { - dst.insert(k, v); - } - } + // Typed op-specific fields (deny_unknown_fields): a typo'd key in a caller + // is a loud 400 instead of a silently-dropped frame field. + let extra: crate::gateway::bridge_frames::WorkspaceReqExtra = serde_json::from_value(extra) + .map_err(|e| AppError::BadRequest(format!("bad workspace op field: {e}")))?; + let frame = crate::gateway::bridge_frames::workspace_req_frame( + &req_id, + op, + path, + root, + if_etag.as_deref(), + roots, + extra, + ); if !state.bot_locator.send_data(bot_id, frame).await { state.workspace_rpc.cancel(&req_id); return Err(AppError::BadRequest("bot connector is offline".into())); @@ -960,11 +956,12 @@ pub async fn get_session_workdirs( Query(q): Query, ) -> Result, AppError> { ensure_access(&state, &claims, channel_id, q.bot_id).await?; - let workdirs = crate::domain::sessions::channel_session_workdirs(&state.db, channel_id, q.bot_id) - .await - .into_iter() - .map(|(path, session_id)| json!({ "path": path, "session_id": session_id })) - .collect::>(); + let workdirs = + crate::domain::sessions::channel_session_workdirs(&state.db, channel_id, q.bot_id) + .await + .into_iter() + .map(|(path, session_id)| json!({ "path": path, "session_id": session_id })) + .collect::>(); Ok(Json(json!({ "workdirs": workdirs }))) } diff --git a/server/src/domain/acp_events.rs b/server/src/domain/acp_events.rs index 81d70766..49bebc0f 100644 --- a/server/src/domain/acp_events.rs +++ b/server/src/domain/acp_events.rs @@ -292,9 +292,7 @@ mod tests { // Regression: an unregistered name classifies to None and acp_policy:: // allows() short-circuits to Ok(true) — this action must stay gated. assert_eq!( - classify("cheers/session_set_primary") - .unwrap() - .event_class, + classify("cheers/session_set_primary").unwrap().event_class, Some("session_set_primary") ); } diff --git a/server/src/domain/approval.rs b/server/src/domain/approval.rs index 9051b3a0..00d43d3a 100644 --- a/server/src/domain/approval.rs +++ b/server/src/domain/approval.rs @@ -215,7 +215,8 @@ pub async fn list_audit( for r in rows { let event_type: String = r.try_get("event_type").unwrap_or_default(); - let request_id: Option = r.try_get::, _>("request_id").ok().flatten(); + let request_id: Option = + r.try_get::, _>("request_id").ok().flatten(); let bot_id: Option = r.try_get::, _>("bot_id").ok().flatten(); let detail: Option = r.try_get::, _>("detail").ok().flatten(); diff --git a/server/src/domain/bot_status_scheduler.rs b/server/src/domain/bot_status_scheduler.rs index b1641a05..e5eaa6d5 100644 --- a/server/src/domain/bot_status_scheduler.rs +++ b/server/src/domain/bot_status_scheduler.rs @@ -83,7 +83,8 @@ async fn prompt_one(state: &AppState, bot_id: &str, owner: &str) -> anyhow::Resu // Find-or-create the owner↔bot DM (race-safe via dm_key), so a refresh works // even if the owner never opened one by hand. - let channel_id = crate::domain::dms::find_or_create_dm(&state.db, owner_uuid, bot_id, true).await?; + let channel_id = + crate::domain::dms::find_or_create_dm(&state.db, owner_uuid, bot_id, true).await?; // INITIATE(prompt) gate — the same one `api::bots::refresh_bot_status` checks. // `create_message` silently skips waking the bot when this event is denied (it diff --git a/server/src/domain/connector_config.rs b/server/src/domain/connector_config.rs index d681adf4..4cfdd29e 100644 --- a/server/src/domain/connector_config.rs +++ b/server/src/domain/connector_config.rs @@ -308,6 +308,13 @@ name = {id_str} state_path = {state_path} log_dir = {log_dir} +# Optional: let the connector update itself when this server publishes a newer +# release (ed25519-signed manifest, sha256-verified binaries, auto-rollback). +# Kept commented out — executing downloaded code is the host owner's call, and +# connectors older than 0.1.27 reject configs containing this section. +# [update] +# auto = true + [accounts.{id}.bridge] control_url = {control} data_url = {data} diff --git a/server/src/domain/mentions.rs b/server/src/domain/mentions.rs index bf2efe0f..36ca045a 100644 --- a/server/src/domain/mentions.rs +++ b/server/src/domain/mentions.rs @@ -63,7 +63,8 @@ pub async fn validate_mention_ids( .await?; // member_id → MemberType(仅收录 user/bot;未知 type 视同不存在 → InvalidMember)。 - let mut type_by_id: std::collections::HashMap = std::collections::HashMap::new(); + let mut type_by_id: std::collections::HashMap = + std::collections::HashMap::new(); for row in &rows { let mid = row .try_get::("member_id") diff --git a/server/src/domain/messages.rs b/server/src/domain/messages.rs index 0ab3ed9c..aca5ce7d 100644 --- a/server/src/domain/messages.rs +++ b/server/src/domain/messages.rs @@ -102,9 +102,10 @@ pub async fn create_message( // GROUP_MENTION_CAP in resolve_mention_names — no per-channel budget here // (that guards *amplifying* bot→bot chains, and would wrongly throttle normal // 1:1 human↔bot chat). - let mut mentions = mentions::resolve_mention_names(db, params.channel_id, ¶ms.mention_names) - .await - .map_err(mention_parse_error_to_app)?; + let mut mentions = + mentions::resolve_mention_names(db, params.channel_id, ¶ms.mention_names) + .await + .map_err(mention_parse_error_to_app)?; let id_mentions = mentions::validate_mention_ids(db, params.channel_id, ¶ms.mention_ids) .await .map_err(mention_parse_error_to_app)?; @@ -239,8 +240,7 @@ pub async fn create_message( let chain_id: Option = if bots.is_empty() { None } else { - match crate::domain::task_chains::start_chain(db, params.channel_id, msg_id, msg_id).await - { + match crate::domain::task_chains::start_chain(db, params.channel_id, msg_id, msg_id).await { Ok(cid) => Some(cid.to_string()), Err(e) => { warn!(channel_id = %params.channel_id, err = %e, "start_chain failed; cascade will be un-cancelable"); @@ -302,14 +302,10 @@ pub async fn create_message( (key.clone(), Some(*sid)) } None => { - match sessions::resolve_primary_session( - db, - bot_id, - ¶ms.channel_id.to_string(), - ) - .await - .ok() - .flatten() + match sessions::resolve_primary_session(db, bot_id, ¶ms.channel_id.to_string()) + .await + .ok() + .flatten() { Some((sid, key)) => { let _ = sessions::touch_session(db, sid).await; diff --git a/server/src/domain/sessions.rs b/server/src/domain/sessions.rs index 692a77e9..83ecb8d8 100644 --- a/server/src/domain/sessions.rs +++ b/server/src/domain/sessions.rs @@ -306,7 +306,10 @@ pub async fn ensure_primary_session_workspace( // (re)bind the deterministic session as primary when no live primary binding // exists for this scope. When the deterministic session already IS the // primary, the upsert would be a no-op anyway. - if resolve_primary_session(db, bot_id, channel_id).await?.is_none() { + if resolve_primary_session(db, bot_id, channel_id) + .await? + .is_none() + { upsert_session_binding( db, &session_uuid, diff --git a/server/src/gateway/bridge_frames.rs b/server/src/gateway/bridge_frames.rs new file mode 100644 index 00000000..b5e04688 --- /dev/null +++ b/server/src/gateway/bridge_frames.rs @@ -0,0 +1,691 @@ +//! Every frame the gateway sends to a connector over the Agent Bridge WS, +//! as named constructors in one place. +//! +//! These used to be inline `json!` blocks scattered across ws/agent_bridge.rs, +//! dispatcher.rs and the api/ handlers, which is how field-name drift crept in +//! (the connector sent `ready.connector_version`, the gateway read +//! `plugin_version`). Each constructor is pinned to a golden fixture under +//! `packages/cheers-acp-connector-rs/bridge-protocol/fixtures/` — the same +//! files the connector's serde tests parse — so both ends agree on the exact +//! bytes. Change a frame ⇒ the fixture test fails ⇒ regen with +//! `CHEERS_REGEN_FIXTURES=1 cargo test` and prove wire-safety in the PR. +//! +//! Wire-compat notes (do NOT "normalize" without a fleet version floor): +//! - `cancel` / `realize_file` / `workspace_req` / `pong` carry no `v` field. +//! - `hello` carries both `v` and `bridge_protocol_version` (same value), and +//! `session_id` == `connection_id`. +//! - `config_update.settings` keys are camelCase — that IS the contract +//! (`ConnectorControlSettings` on the connector side). +//! - the `task` frame intentionally duplicates `msg_id`==`trigger_msg_id` and +//! repeats session identifiers in the nested `session{}` ref. + +use cheers_bridge_protocol as proto; +use serde_json::{json, Value}; +use uuid::Uuid; + +/// Serialize a typed protocol frame at the existing `Value` send seams +/// (`ws_send` / `dispatch_task` / `send_data` are unchanged). Infallible for +/// these types; the exact bytes are pinned by the fixture tests below. +pub(crate) fn frame_value(frame: &T) -> Value { + serde_json::to_value(frame).expect("bridge frame serializes") +} + +/// Agent Bridge protocol version stamped into (most) gateway frames and +/// validated against the connector's `auth` frame — re-exported from the +/// shared crate so both ends can only ever disagree at compile time. +pub(crate) use cheers_bridge_protocol::BRIDGE_PROTOCOL_VERSION; + +/// Control-stream `hello` — the first frame after auth, carrying the bot's +/// identity, channel membership snapshot, persisted connector_control config +/// and the gateway's capability advertisement. +#[allow(clippy::too_many_arguments)] +pub(crate) fn control_hello_frame( + bot_id: Uuid, + bot_username: &str, + bot_display_name: Option<&str>, + connection_id: Uuid, + memberships: Value, + connector_config: Option<&Value>, + server_capabilities: Value, + acp_security: Option<&Value>, +) -> Value { + let mut hello = json!({ + "type": "hello", + "v": BRIDGE_PROTOCOL_VERSION, + "bridge_protocol_version": BRIDGE_PROTOCOL_VERSION, + "stream": "control", + "bot_id": bot_id, + "bot_username": bot_username, + "bot_display_name": bot_display_name, + "connection_id": connection_id, + "session_id": connection_id, + "memberships": memberships, + "connector_config": connector_config, + "server_capabilities": server_capabilities, + }); + if let Some(acp_security) = acp_security { + hello["acp_security"] = acp_security.clone(); + } + hello +} + +/// Data-stream `hello`. `last_event_seq` is fixed 0 until event-log replay +/// exists (resume is ack-only, see `resume_ack_frame`). +pub(crate) fn data_hello_frame( + bot_id: Uuid, + connection_id: Uuid, + server_capabilities: Value, + acp_security: Option<&Value>, +) -> Value { + let mut hello = json!({ + "type": "hello", + "v": BRIDGE_PROTOCOL_VERSION, + "bridge_protocol_version": BRIDGE_PROTOCOL_VERSION, + "stream": "data", + "bot_id": bot_id, + "connection_id": connection_id, + "session_id": connection_id, + "last_event_seq": 0, + "server_capabilities": server_capabilities, + }); + if let Some(acp_security) = acp_security { + hello["acp_security"] = acp_security.clone(); + } + hello +} + +/// Bot-wide `config_update` push (owner posture / config-option overrides). +/// The settings' camelCase wire keys are the contract, encoded once in +/// `proto::ConnectorControlSettings`; the connector re-clamps via its L0 policy. +pub(crate) fn config_update_frame(settings: proto::ConnectorControlSettings) -> Value { + frame_value(&proto::ControlInbound::ConfigUpdate { + v: BRIDGE_PROTOCOL_VERSION, + revision: None, + settings: Some(settings), + updated_at: None, + }) +} + +/// App-level heartbeat reply. Deliberately version-less (wire-compat). +pub(crate) fn pong_frame() -> Value { + frame_value(&proto::DataInbound::Pong) +} + +/// Resume acknowledgement: no event-log replay exists, so `replayed` is always +/// 0 and the connector re-syncs via channel.activity.read. +pub(crate) fn resume_ack_frame(up_to_seq: i64) -> Value { + frame_value(&proto::DataInbound::ResumeAck { + v: BRIDGE_PROTOCOL_VERSION, + replayed: 0, + up_to_seq: up_to_seq.max(0) as u64, + }) +} + +pub(crate) fn send_ack_ok( + client_msg_id: &str, + message_id: Uuid, + finalized_placeholder: bool, +) -> Value { + frame_value(&proto::DataInbound::SendAck { + v: BRIDGE_PROTOCOL_VERSION, + client_msg_id: client_msg_id.to_string(), + ok: true, + message_id: Some(message_id.to_string()), + finalized_placeholder: Some(finalized_placeholder), + permission_resolution: None, + error: None, + code: None, + }) +} + +pub(crate) fn send_ack_err(client_msg_id: &str, code: &str, error: &str) -> Value { + frame_value(&proto::DataInbound::SendAck { + v: BRIDGE_PROTOCOL_VERSION, + client_msg_id: client_msg_id.to_string(), + ok: false, + message_id: None, + finalized_placeholder: None, + permission_resolution: None, + error: Some(error.to_string()), + code: Some(code.to_string()), + }) +} + +pub(crate) fn terminal_ack_ok(client_msg_id: &str, msg_id: Uuid) -> Value { + frame_value(&proto::DataInbound::TerminalAck { + v: BRIDGE_PROTOCOL_VERSION, + client_msg_id: client_msg_id.to_string(), + ok: true, + msg_id: Some(msg_id.to_string()), + queued: None, + job_id: None, + error: None, + code: None, + }) +} + +pub(crate) fn terminal_ack_err(client_msg_id: &str, code: &str, error: &str) -> Value { + frame_value(&proto::DataInbound::TerminalAck { + v: BRIDGE_PROTOCOL_VERSION, + client_msg_id: client_msg_id.to_string(), + ok: false, + msg_id: None, + queued: None, + job_id: None, + error: Some(error.to_string()), + code: Some(code.to_string()), + }) +} + +pub(crate) fn bridge_error(code: &str, detail: &str) -> Value { + frame_value(&proto::DataInbound::Error { + error: proto::BridgeErrorFrame { + v: BRIDGE_PROTOCOL_VERSION, + code: code.to_string(), + detail: Some(detail.to_string()), + request_id: None, + client_msg_id: None, + retryable: None, + }, + }) +} + +/// `resource_res` success reply, correlated by `req_id`. +pub(crate) fn resource_res_ok(req_id: &str, data: Value) -> Value { + frame_value(&proto::DataInbound::ResourceRes { + response: proto::ResourceResponse { + v: BRIDGE_PROTOCOL_VERSION, + req_id: req_id.to_string(), + ok: true, + data: Some(data), + error: None, + code: None, + }, + }) +} + +/// `resource_res` failure reply. +pub(crate) fn resource_res_err(req_id: &str, code: &str, msg: &str) -> Value { + frame_value(&proto::DataInbound::ResourceRes { + response: proto::ResourceResponse { + v: BRIDGE_PROTOCOL_VERSION, + req_id: req_id.to_string(), + ok: false, + data: None, + error: Some(msg.to_string()), + code: Some(code.to_string()), + }, + }) +} + +/// Interrupt an in-flight turn (⏹). Version-less by contract. +pub(crate) fn cancel_frame(placeholder_msg_id: Uuid, reason: &str) -> Value { + frame_value(&proto::ControlInbound::Cancel { + msg_id: placeholder_msg_id.to_string(), + reason: Some(reason.to_string()), + }) +} + +/// Session-targeted ACP `session/set_mode` (value-gated by the connector's L0 +/// allowed_modes envelope). +pub(crate) fn mode_set_frame(request_id: &str, provider_session_key: &str, mode: &str) -> Value { + frame_value(&proto::ControlInbound::ModeSet { + v: BRIDGE_PROTOCOL_VERSION, + request_id: request_id.to_string(), + session_id: None, + provider_session_key: Some(provider_session_key.to_string()), + mode: mode.to_string(), + updated_at: None, + }) +} + +/// Session-targeted ACP `session/set_config_option`. +pub(crate) fn config_option_set_frame( + request_id: &str, + provider_session_key: &str, + config_id: &str, + value: &str, +) -> Value { + frame_value(&proto::ControlInbound::ConfigOptionSet { + v: BRIDGE_PROTOCOL_VERSION, + request_id: request_id.to_string(), + session_id: None, + provider_session_key: Some(provider_session_key.to_string()), + config_id: config_id.to_string(), + value: value.to_string(), + updated_at: None, + }) +} + +/// Human decision on a forwarded `permission_request`, correlated by +/// `request_id`. +pub(crate) fn permission_resolution_frame( + request_id: &str, + message_id: &str, + resolution: &str, + option_id: &str, + resolved_by: &str, + resolved_at: &str, +) -> Value { + frame_value(&proto::ControlInbound::PermissionResolution { + v: BRIDGE_PROTOCOL_VERSION, + resolution: proto::PermissionResolution { + request_id: request_id.to_string(), + message_id: Some(message_id.to_string()), + resolution: resolution.to_string(), + option_id: Some(option_id.to_string()), + resolved_by: Some(resolved_by.to_string()), + resolved_at: Some(resolved_at.to_string()), + extra: Default::default(), + }, + }) +} + +/// Ask the connector to upload a staged workspace file to S3. Version-less by +/// contract. +pub(crate) fn realize_file_frame( + file_id: &str, + remote_ref: &str, + channel_id: &str, + roots: &[String], +) -> Value { + frame_value(&proto::DataInbound::RealizeFile { + file_id: file_id.to_string(), + remote_ref: remote_ref.to_string(), + channel_id: channel_id.to_string(), + roots: roots.to_vec(), + }) +} + +/// The op-specific keys a `workspace_req` may carry (`write` payloads, git +/// pagination, watch ids). `deny_unknown_fields`: a typo'd key from an api/ +/// caller is now a loud error instead of a silently-dropped field. +#[derive(Debug, Default, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct WorkspaceReqExtra { + content_b64: Option, + staged: Option, + limit: Option, + skip: Option, + commit: Option, + commit_path: Option, + watch_id: Option, +} + +/// Workspace RPC request, correlated by `req_id`; replies arrive as +/// `workspace_res`. Version-less by contract. +#[allow(clippy::too_many_arguments)] +pub(crate) fn workspace_req_frame( + req_id: &str, + op: &str, + path: &str, + root: Option<&str>, + if_etag: Option<&str>, + roots: &[String], + extra: WorkspaceReqExtra, +) -> Value { + frame_value(&proto::DataInbound::WorkspaceReq { + req_id: req_id.to_string(), + op: op.to_string(), + path: path.to_string(), + root: root.map(str::to_string), + content_b64: extra.content_b64, + if_etag: if_etag.map(str::to_string), + roots: roots.to_vec(), + staged: extra.staged, + limit: extra.limit, + skip: extra.skip, + commit: extra.commit, + commit_path: extra.commit_path, + watch_id: extra.watch_id, + }) +} + +// ── golden-fixture test infrastructure ──────────────────────────────────────── + +/// Shared by this module's tests and dispatcher.rs's task-frame test. +#[cfg(test)] +pub(crate) mod fixture { + use serde_json::Value; + use std::path::PathBuf; + + pub(crate) fn fixtures_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../packages/cheers-acp-connector-rs/bridge-protocol/fixtures") + } + + /// Golden check: `frame` must equal the fixture at `rel` as a + /// `serde_json::Value` (key order irrelevant). `volatile` keys (e.g. a + /// now() timestamp) are stripped before comparing. Run with + /// `CHEERS_REGEN_FIXTURES=1` to (re)write the fixture from the constructor + /// instead of asserting — any regen diff needs a wire-safety proof in the PR. + pub(crate) fn assert_matches_fixture(frame: &Value, rel: &str, volatile: &[&str]) { + let mut frame = frame.clone(); + if let Value::Object(map) = &mut frame { + for key in volatile { + map.remove(*key); + } + } + let path = fixtures_root().join(rel); + if std::env::var_os("CHEERS_REGEN_FIXTURES").is_some() { + std::fs::create_dir_all(path.parent().expect("fixture path has parent")) + .expect("create fixtures dir"); + let pretty = serde_json::to_string_pretty(&frame).expect("serialize fixture"); + std::fs::write(&path, format!("{pretty}\n")).expect("write fixture"); + return; + } + let raw = std::fs::read_to_string(&path).unwrap_or_else(|e| { + panic!( + "missing fixture {} ({e}); generate with CHEERS_REGEN_FIXTURES=1 cargo test", + path.display() + ) + }); + let expected: Value = serde_json::from_str(&raw).expect("fixture is valid JSON"); + assert_eq!( + frame, expected, + "frame drifted from fixture {rel}; if intentional, prove wire-safety in the PR and regen" + ); + } +} + +#[cfg(test)] +mod tests { + use super::fixture::assert_matches_fixture; + use super::*; + + fn bot_id() -> Uuid { + Uuid::parse_str("6f9619ff-8b86-4d01-b42d-00c04fc964ff").unwrap() + } + + fn conn_id() -> Uuid { + Uuid::parse_str("11111111-2222-4333-8444-555555555555").unwrap() + } + + /// Every frame the CURRENT connector emits (the to_gateway fixtures, regen'd + /// from its typed values) must parse through the shared enums on this side + /// too — the gateway's inbound view can never lag the connector's outbound + /// one without this test failing. + #[test] + fn to_gateway_fixtures_parse_typed() { + let root = super::fixture::fixtures_root(); + for (dir, is_control) in [("control/to_gateway", true), ("data/to_gateway", false)] { + let entries = std::fs::read_dir(root.join(dir)) + .unwrap_or_else(|e| panic!("fixtures dir {dir} missing: {e}")); + for entry in entries { + let path = entry.expect("dir entry").path(); + let raw = std::fs::read_to_string(&path).expect("read fixture"); + let value: Value = serde_json::from_str(&raw).expect("fixture is JSON"); + let name = path.display(); + if is_control { + let parsed: proto::ControlOutbound = serde_json::from_value(value) + .unwrap_or_else(|e| panic!("{name} failed typed parse: {e}")); + assert!( + !matches!(parsed, proto::ControlOutbound::Unknown), + "{name} fell through to Unknown" + ); + } else { + let parsed: proto::DataOutbound = serde_json::from_value(value) + .unwrap_or_else(|e| panic!("{name} failed typed parse: {e}")); + assert!( + !matches!(parsed, proto::DataOutbound::Unknown), + "{name} fell through to Unknown" + ); + } + } + } + // The frozen legacy TS ready keeps parsing with the plugin_version alias. + let legacy: Value = serde_json::from_str( + &std::fs::read_to_string(root.join("compat/ready_plugin_version.json")) + .expect("compat fixture"), + ) + .expect("compat fixture is JSON"); + match serde_json::from_value(legacy).expect("legacy ready parses") { + proto::ControlOutbound::Ready { + connector_version, + plugin_version, + .. + } => { + assert!(connector_version.is_none()); + assert_eq!(plugin_version.as_deref(), Some("0.9.3")); + } + other => panic!("expected Ready, got {other:?}"), + } + } + + #[test] + fn control_hello_matches_fixture() { + let frame = control_hello_frame( + bot_id(), + "helper", + Some("Helper"), + conn_id(), + json!([{ + "channel_id": "77777777-8888-4999-8aaa-bbbbbbbbbbbb", + "channel_name": "general", + "channel_type": "public", + "workspace_id": "cccccccc-dddd-4eee-8fff-000000000000", + "joined_at": "2026-06-01T10:15:30Z", + }]), + Some(&json!({"agentNativePermissionMode": "default"})), + json!({ + "auth": ["authorization_bearer", "auth_frame"], + "task_stream": "control", + "runtime_session_control": true, + "resource_req": true, + "send_ack": true, + "terminal_ack": true, + "resume": "ack_only", + "file_upload": false, + "acp_security": true, + "latest_connector_version": "0.1.27", + }), + None, + ); + assert_matches_fixture(&frame, "control/to_connector/hello.json", &[]); + } + + #[test] + fn data_hello_matches_fixture() { + let frame = data_hello_frame( + bot_id(), + conn_id(), + json!({ + "auth": ["authorization_bearer", "auth_frame"], + "task_stream": "control", + "runtime_session_control": true, + "resource_req": true, + "send_ack": true, + "terminal_ack": true, + "resume": "ack_only", + "file_upload": false, + "acp_security": true, + "latest_connector_version": "0.1.27", + }), + None, + ); + assert_matches_fixture(&frame, "data/to_connector/hello.json", &[]); + } + + #[test] + fn config_update_matches_fixture() { + let frame = config_update_frame(proto::ConnectorControlSettings { + agent_native_permission_mode: Some("acceptEdits".to_string()), + ..Default::default() + }); + assert_matches_fixture(&frame, "control/to_connector/config_update.json", &[]); + } + + #[test] + fn pong_matches_fixture() { + assert_matches_fixture(&pong_frame(), "data/to_connector/pong.json", &[]); + } + + #[test] + fn resume_ack_matches_fixture() { + assert_matches_fixture( + &resume_ack_frame(42), + "data/to_connector/resume_ack.json", + &[], + ); + } + + #[test] + fn send_ack_ok_matches_fixture() { + let frame = send_ack_ok("client-msg-1", bot_id(), true); + assert_matches_fixture(&frame, "data/to_connector/send_ack_ok.json", &[]); + } + + #[test] + fn send_ack_err_matches_fixture() { + let frame = send_ack_err("client-msg-1", "SEND_FAILED", "channel not writable"); + assert_matches_fixture(&frame, "data/to_connector/send_ack_err.json", &[]); + } + + #[test] + fn terminal_ack_ok_matches_fixture() { + let frame = terminal_ack_ok("client-msg-2", bot_id()); + assert_matches_fixture(&frame, "data/to_connector/terminal_ack_ok.json", &[]); + } + + #[test] + fn terminal_ack_err_matches_fixture() { + let frame = terminal_ack_err("client-msg-2", "TERMINAL_REJECTED", "not the owner of msg"); + assert_matches_fixture(&frame, "data/to_connector/terminal_ack_err.json", &[]); + } + + #[test] + fn bridge_error_matches_fixture() { + let frame = bridge_error("CAPABILITY_DENIED", "signature verification failed"); + assert_matches_fixture(&frame, "data/to_connector/error.json", &[]); + } + + #[test] + fn resource_res_ok_matches_fixture() { + let frame = resource_res_ok("req-1", json!({"messages": []})); + assert_matches_fixture(&frame, "data/to_connector/resource_res_ok.json", &[]); + } + + #[test] + fn resource_res_err_matches_fixture() { + let frame = resource_res_err("req-1", "E_FORBIDDEN", "SEE denied for this event"); + assert_matches_fixture(&frame, "data/to_connector/resource_res_err.json", &[]); + } + + #[test] + fn cancel_matches_fixture() { + let frame = cancel_frame(conn_id(), "user_cancelled"); + assert_matches_fixture(&frame, "control/to_connector/cancel.json", &[]); + } + + #[test] + fn mode_set_matches_fixture() { + let frame = mode_set_frame( + "99999999-aaaa-4bbb-8ccc-dddddddddddd", + "cheers:channel:77777777-8888-4999-8aaa-bbbbbbbbbbbb:bot:6f9619ff-8b86-4d01-b42d-00c04fc964ff", + "acceptEdits", + ); + assert_matches_fixture(&frame, "control/to_connector/mode_set.json", &[]); + } + + #[test] + fn config_option_set_matches_fixture() { + let frame = config_option_set_frame( + "99999999-aaaa-4bbb-8ccc-dddddddddddd", + "cheers:channel:77777777-8888-4999-8aaa-bbbbbbbbbbbb:bot:6f9619ff-8b86-4d01-b42d-00c04fc964ff", + "model", + "claude-sonnet-5", + ); + assert_matches_fixture(&frame, "control/to_connector/config_option_set.json", &[]); + } + + #[test] + fn permission_resolution_matches_fixture() { + let frame = permission_resolution_frame( + "99999999-aaaa-4bbb-8ccc-dddddddddddd", + "eeeeeeee-ffff-4000-8111-222222222222", + "allow", + "allow_once", + "33333333-4444-4555-8666-777777777777", + "2026-06-01T10:15:30+00:00", + ); + assert_matches_fixture( + &frame, + "control/to_connector/permission_resolution.json", + &[], + ); + } + + #[test] + fn realize_file_matches_fixture() { + let frame = realize_file_frame( + "file-1", + "/workspace/report.pdf", + "77777777-8888-4999-8aaa-bbbbbbbbbbbb", + &["/workspace".to_string()], + ); + assert_matches_fixture(&frame, "data/to_connector/realize_file.json", &[]); + } + + #[test] + fn workspace_req_read_matches_fixture() { + let frame = workspace_req_frame( + "req-1", + "read", + "src/main.rs", + Some("/workspace"), + None, + &["/workspace".to_string()], + WorkspaceReqExtra::default(), + ); + assert_matches_fixture(&frame, "data/to_connector/workspace_req_read.json", &[]); + } + + #[test] + fn workspace_req_write_matches_fixture() { + let frame = workspace_req_frame( + "req-2", + "write", + "notes.md", + Some("/workspace"), + Some("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"), + &["/workspace".to_string()], + WorkspaceReqExtra { + content_b64: Some("aGVsbG8=".to_string()), + ..Default::default() + }, + ); + assert_matches_fixture(&frame, "data/to_connector/workspace_req_write.json", &[]); + } + + #[test] + fn workspace_req_git_log_matches_fixture() { + let frame = workspace_req_frame( + "req-3", + "git_log", + "", + Some("/workspace"), + None, + &[], + WorkspaceReqExtra { + limit: Some(20), + skip: Some(40), + ..Default::default() + }, + ); + assert_matches_fixture(&frame, "data/to_connector/workspace_req_git_log.json", &[]); + } + + #[test] + fn workspace_req_watch_matches_fixture() { + let frame = workspace_req_frame( + "req-4", + "watch", + "", + Some("/workspace"), + None, + &["/workspace".to_string()], + WorkspaceReqExtra::default(), + ); + assert_matches_fixture(&frame, "data/to_connector/workspace_req_watch.json", &[]); + } +} diff --git a/server/src/gateway/dispatcher.rs b/server/src/gateway/dispatcher.rs index 454748f5..04fa2609 100644 --- a/server/src/gateway/dispatcher.rs +++ b/server/src/gateway/dispatcher.rs @@ -69,10 +69,7 @@ impl MediaCache { return hit; } let blocks = load_pinned_context(db, channel_id).await; - self.pinned - .lock() - .await - .insert(channel_id, blocks.clone()); + self.pinned.lock().await.insert(channel_id, blocks.clone()); blocks } } @@ -538,7 +535,11 @@ async fn fetch_transcript(row: &AttachmentRow, cache: &MediaCache) -> Option(a) + .expect("attachment objects fit AttachmentInfo (flatten extra)") + }) + .collect(), + pinned: task_context.pinned, // Per-session ACP root set. The connector re-validates against its - // allowed_roots and uses default_cwd when cwd is null (ACP: cwd is a pure - // session/new argument, immutable for the session's lifetime). - "cwd": cwd, - "additional_dirs": additional_dirs, - "session": { - "id": session_id, - "provider_session_key": provider_session_key, - "task_scope_id": task_id, - }, - "enqueued_at": chrono::Utc::now().to_rfc3339(), + // allowed_roots and uses default_cwd when cwd is absent (ACP: cwd is a + // pure session/new argument, immutable for the session's lifetime). + cwd, + additional_dirs, + binding_config: None, + session: Some(proto::RuntimeSessionRef { + id: session_id.map(|id| id.to_string()), + provider_session_key: Some(provider_session_key.to_string()), + provider_session_id: None, + provider_account_id: None, + provider_agent_id: None, + primary_scope_type: None, + primary_scope_id: None, + task_scope_id: Some(task_id.to_string()), + extra: Default::default(), + }), + enqueued_at: Some(chrono::Utc::now().to_rfc3339()), }) } #[cfg(test)] mod tests { use super::*; + use crate::gateway::bridge_frames::fixture::assert_matches_fixture; + + /// The `task` frame is pinned to the shared golden fixture (the same file + /// the connector parses in its serde tests). `enqueued_at` is now() — + /// stripped before comparing. + #[test] + fn task_frame_matches_fixture() { + let frame = build_task_frame( + Uuid::parse_str("99999999-aaaa-4bbb-8ccc-dddddddddddd").unwrap(), + Uuid::parse_str("77777777-8888-4999-8aaa-bbbbbbbbbbbb").unwrap(), + Uuid::parse_str("eeeeeeee-ffff-4000-8111-222222222222").unwrap(), + 42, + 0, + Uuid::parse_str("33333333-4444-4555-8666-777777777777").unwrap(), + "cheers:channel:77777777-8888-4999-8aaa-bbbbbbbbbbbb:bot:6f9619ff-8b86-4d01-b42d-00c04fc964ff", + None, + TaskContext { + trigger_message: json!({ + "msg_id": "eeeeeeee-ffff-4000-8111-222222222222", + "content": "hello bot", + }), + attachments: vec![json!({ + "file_id": "file-1", + "filename": "notes.md", + "content_type": "text/markdown", + "size_bytes": 12, + })], + pinned: vec!["Always answer in English.".to_string()], + }, + (Some("/workspace".to_string()), vec!["/data".to_string()]), + ); + assert_matches_fixture(&frame, "control/to_connector/task.json", &["enqueued_at"]); + } /// I4:同一 (trigger, bot) 必派生同一占位 id(重投收敛同一占位)。 #[test] diff --git a/server/src/gateway/mod.rs b/server/src/gateway/mod.rs index 7231166e..b0b4b071 100644 --- a/server/src/gateway/mod.rs +++ b/server/src/gateway/mod.rs @@ -1,4 +1,5 @@ pub mod approval_sweeper; +pub mod bridge_frames; pub mod connection_event_reaper; pub mod conversion_worker; pub mod dispatcher; diff --git a/server/src/gateway/stream.rs b/server/src/gateway/stream.rs index b1cb9ee0..ae08c54a 100644 --- a/server/src/gateway/stream.rs +++ b/server/src/gateway/stream.rs @@ -387,8 +387,17 @@ pub async fn handle_done( // chain_id 从本条回复继承,下一跳共享同一条可取消链(§8 gate 也在内部)。 // spawn 出去,避免下一跳的 dispatch(S3 拉取 + base64)阻塞本 connector 的读循环。 spawn_trigger_bot_replies( - db, fanout, registry, bot_locator, channel_id, msg_id, channel_seq, depth, bot_id, - mentions, chain_id, + db, + fanout, + registry, + bot_locator, + channel_id, + msg_id, + channel_seq, + depth, + bot_id, + mentions, + chain_id, ); // finalize_session 保持 inline:单条快速 UPDATE,且 spawn 会扩大与 acquire_scope_session @@ -655,7 +664,16 @@ pub async fn handle_send( let chain_id = chain_for_proactive_send(db, channel_id, bot_id, msg_id, &mentions).await; // spawn:同 handle_done,避免下一跳 dispatch 阻塞 connector 读循环。 spawn_trigger_bot_replies( - db, fanout, registry, bot_locator, channel_id, msg_id, channel_seq, 0, bot_id, mentions, + db, + fanout, + registry, + bot_locator, + channel_id, + msg_id, + channel_seq, + 0, + bot_id, + mentions, chain_id, ); @@ -677,7 +695,8 @@ async fn chain_for_proactive_send( if mentions.is_empty() { return None; } - match crate::domain::task_chains::chain_of_active_bot_task(db, channel_id, author_bot_id).await { + match crate::domain::task_chains::chain_of_active_bot_task(db, channel_id, author_bot_id).await + { Ok(Some(cid)) => Some(cid), _ => crate::domain::task_chains::start_chain(db, channel_id, msg_id, msg_id) .await @@ -736,8 +755,17 @@ pub async fn broadcast_and_trigger_created_message( let chain_id = chain_for_proactive_send(db, channel_id, author_bot_id, msg_id, &mentions).await; // spawn:同 handle_done / handle_send,避免下一跳 dispatch 阻塞 connector 读循环。 Some(spawn_trigger_bot_replies( - db, fanout, registry, bot_locator, channel_id, msg_id, channel_seq, 0, author_bot_id, - mentions, chain_id, + db, + fanout, + registry, + bot_locator, + channel_id, + msg_id, + channel_seq, + 0, + author_bot_id, + mentions, + chain_id, )) } diff --git a/server/src/gateway/ws/agent_bridge.rs b/server/src/gateway/ws/agent_bridge.rs index c1ce4549..55591c06 100644 --- a/server/src/gateway/ws/agent_bridge.rs +++ b/server/src/gateway/ws/agent_bridge.rs @@ -32,19 +32,24 @@ use crate::{ }; use sqlx::PgPool; -// ── 关闭码(与 WIRE_PROTOCOL 对齐)────────────────────────────────────────── -const CLOSE_AUTH_FAIL: u16 = 4401; -const CLOSE_BOT_UNAVAILABLE: u16 = 4403; -const CLOSE_SUPERSEDED: u16 = 4402; -/// A connector that keeps sending unparseable frames is buggy/hostile — close it -/// with a protocol-error code after this many CONSECUTIVE malformed frames so it -/// can't spin the read loop / hammer logs. A good frame resets the counter. -const CLOSE_PROTOCOL_ERROR: u16 = 4400; +// ── 关闭码(与 WIRE_PROTOCOL 对齐;共享常量在 cheers-bridge-protocol)───────── +use cheers_bridge_protocol as proto; +use cheers_bridge_protocol::{ + WS_CLOSE_AUTH_FAIL as CLOSE_AUTH_FAIL, WS_CLOSE_BOT_UNAVAILABLE as CLOSE_BOT_UNAVAILABLE, + WS_CLOSE_SUPERSEDED as CLOSE_SUPERSEDED, WS_CLOSE_UNSUPPORTED_PROTOCOL as CLOSE_PROTOCOL_ERROR, +}; /// Heartbeat idle close (WIRE_PROTOCOL §10.1): a connector whose process died /// without a clean TCP FIN would otherwise stay "online" until the OS notices. +/// Gateway-local (connectors treat it as retryable), so not in the shared crate. const CLOSE_IDLE_TIMEOUT: u16 = 4409; +/// A connector that keeps sending unparseable frames is buggy/hostile — close it +/// with a protocol-error code after this many CONSECUTIVE malformed frames so it +/// can't spin the read loop / hammer logs. A good frame resets the counter. const MAX_MALFORMED_FRAMES: u32 = 20; -const BRIDGE_PROTOCOL_VERSION: u32 = 1; +use crate::gateway::bridge_frames::{ + self, bridge_error, send_ack_err, send_ack_ok, terminal_ack_err, terminal_ack_ok, + BRIDGE_PROTOCOL_VERSION, +}; /// Gateway-side liveness: probe with a WS ping every PROBE_INTERVAL (the peer's /// WS stack auto-pongs, so this needs no connector cooperation), reap after @@ -98,23 +103,16 @@ async fn handle_control(mut socket: WebSocket, state: AppState, header_token: Op // ── 3. 发 hello 帧(membership snapshot)───────────────────────────── let memberships = load_memberships(&state.db, bot.bot_id).await; - let mut hello = json!({ - "type": "hello", - "v": BRIDGE_PROTOCOL_VERSION, - "bridge_protocol_version": BRIDGE_PROTOCOL_VERSION, - "stream": "control", - "bot_id": bot.bot_id, - "bot_username": bot.username, - "bot_display_name": bot.display_name, - "connection_id": connection_id, - "session_id": connection_id, - "memberships": memberships, - "connector_config": bot.connector_config, - "server_capabilities": server_capabilities(), - }); - if let Some(acp_security) = &bot.acp_security { - hello["acp_security"] = acp_security.clone(); - } + let hello = bridge_frames::control_hello_frame( + bot.bot_id, + &bot.username, + bot.display_name.as_deref(), + connection_id, + Value::Array(memberships), + bot.connector_config.as_ref(), + server_capabilities(&state), + bot.acp_security.as_ref(), + ); if ws_send(&mut socket, &hello).await.is_err() { return; } @@ -128,11 +126,11 @@ async fn handle_control(mut socket: WebSocket, state: AppState, header_token: Op .and_then(|c| c.get("agentNativePermissionMode")) .and_then(Value::as_str) { - let cfg = json!({ - "type": "config_update", - "v": BRIDGE_PROTOCOL_VERSION, - "settings": { "agentNativePermissionMode": mode }, - }); + let cfg = + bridge_frames::config_update_frame(cheers_bridge_protocol::ConnectorControlSettings { + agent_native_permission_mode: Some(mode.to_string()), + ..Default::default() + }); let _ = ws_send(&mut socket, &cfg).await; } @@ -145,11 +143,11 @@ async fn handle_control(mut socket: WebSocket, state: AppState, header_token: Op .and_then(|c| c.get("configOptions")) .filter(|v| v.as_object().is_some_and(|m| !m.is_empty())) { - let cfg = json!({ - "type": "config_update", - "v": BRIDGE_PROTOCOL_VERSION, - "settings": { "configOptions": opts }, - }); + let cfg = + bridge_frames::config_update_frame(cheers_bridge_protocol::ConnectorControlSettings { + config_options: Some(opts.clone()), + ..Default::default() + }); let _ = ws_send(&mut socket, &cfg).await; } @@ -252,34 +250,67 @@ async fn handle_control(mut socket: WebSocket, state: AppState, header_token: Op async fn handle_control_frame(frame: &Value, state: &AppState, bot: &BotInfo) { let bot_id = bot.bot_id; - let ftype = frame.get("type").and_then(|v| v.as_str()).unwrap_or(""); - match ftype { - "ping" => {} // pong 由 WS 层处理 - "ready" => { - tracing::info!(bot_id = %bot_id, version = ?frame.get("plugin_version"), "bot ready"); + // Typed parse — the shared enum is the single schema both ends compile + // against, so a field rename can no longer silently read as None (the + // plugin_version bug class). Handlers that persist or tolerate legacy + // shapes still receive the raw `frame` for their payload. + let parsed: proto::ControlOutbound = match serde_json::from_value(frame.clone()) { + Ok(parsed) => parsed, + Err(err) => { + tracing::warn!( + bot_id = %bot_id, + frame_type = frame.get("type").and_then(|v| v.as_str()).unwrap_or(""), + %err, + "control frame failed typed parse" + ); + return; + } + }; + match parsed { + proto::ControlOutbound::Ping => {} // pong 由 WS 层处理 + proto::ControlOutbound::Ready { + connector_version, + plugin_version, + connector_capabilities, + .. + } => { + // The Rust connector reports `connector_version`; the retired TS + // connector used `plugin_version` — both are first-class fields on + // the shared Ready variant (pinned by fixtures/compat/). + let connector_version = connector_version.or(plugin_version); + tracing::info!(bot_id = %bot_id, version = ?connector_version, "bot ready"); // Persist the connector's advertised capabilities (e.g. whether the // downstream agent accepts audio/image prompts) so the platform can // consult them offline — the composer warns before sending voice to // a bot that can't hear it, and the dispatcher skips inlining bytes // the agent would only discard. Refreshed on every (re)connect, so - // a connector upgrade updates them automatically. - if let Some(caps) = frame.get("connector_capabilities") { - let caps_str = serde_json::to_string(caps).unwrap_or_else(|_| "{}".into()); + // a connector upgrade updates them automatically. The version rides + // along so bot status can report update availability while offline. + let caps = connector_capabilities; + if caps.is_some() || connector_version.is_some() { + let caps_str = serde_json::to_string(&caps.unwrap_or(Value::Null)) + .unwrap_or_else(|_| "null".into()); let result = sqlx::query( "UPDATE bot_accounts SET binding_config = COALESCE(binding_config, '{}'::jsonb) || jsonb_build_object( 'connector_control', COALESCE(binding_config->'connector_control', '{}'::jsonb) - || jsonb_build_object( - 'capabilities', $2::jsonb, - 'capabilities_updated_at', to_jsonb(NOW()) - ) + || CASE WHEN $2::jsonb IS NOT NULL AND $2::jsonb <> 'null'::jsonb + THEN jsonb_build_object( + 'capabilities', $2::jsonb, + 'capabilities_updated_at', to_jsonb(NOW()) + ) + ELSE '{}'::jsonb END + || CASE WHEN $3::text IS NOT NULL + THEN jsonb_build_object('connector_version', $3::text) + ELSE '{}'::jsonb END ) WHERE bot_id = $1", ) .bind(bot_id.to_string()) .bind(&caps_str) + .bind(connector_version.as_deref()) .execute(&state.db) .await; if let Err(e) = result { @@ -287,7 +318,9 @@ async fn handle_control_frame(frame: &Value, state: &AppState, bot: &BotInfo) { } } } - "runtime_session_control_ack" => { + proto::ControlOutbound::RuntimeSessionControlAck { .. } => { + // The handler keeps the raw frame: it tolerates the legacy shape + // where session fields live at the top level instead of `session{}`. match handle_runtime_session_control_ack(frame, state, bot).await { Ok(session_id) => { tracing::info!( @@ -305,24 +338,19 @@ async fn handle_control_frame(frame: &Value, state: &AppState, bot: &BotInfo) { } } } - "config_options" => { + proto::ControlOutbound::ConfigOptions { options, v } => { // Advertised-options snapshot (configOptions / modes / models / // availableCommands / currentModeId / currentModelId). PATCH-merge: // each report carries only the fields its source event had (e.g. an // available_commands_update has no configOptions), so incoming // fields overlay the stored snapshot instead of replacing it — // otherwise a later commands update would null out the model list. - let mut incoming = frame - .get("options") - .and_then(Value::as_object) - .cloned() - .unwrap_or_default(); + let mut incoming = options.as_object().cloned().unwrap_or_default(); // Defense against older connectors that still send explicit nulls. incoming.retain(|_, v| !v.is_null()); let incoming_str = serde_json::to_string(&Value::Object(incoming)).unwrap_or_else(|_| "{}".into()); - let v_str = serde_json::to_string(&frame.get("v").cloned().unwrap_or(json!(1))) - .unwrap_or_else(|_| "1".into()); + let v_str = v.to_string(); let result = sqlx::query( "UPDATE bot_accounts SET binding_config = jsonb_set( jsonb_set( @@ -348,12 +376,14 @@ async fn handle_control_frame(frame: &Value, state: &AppState, bot: &BotInfo) { tracing::warn!(bot_id = %bot_id, err = %e, "config options merge failed"); } } - ftype @ ("config_status" | "config_option_status") => { + kind @ (proto::ControlOutbound::ConfigStatus { .. } + | proto::ControlOutbound::ConfigOptionStatus { .. }) => { // connector 上报配置状态,统一写入 binding_config.connector_control.* - let config_key = match ftype { - "config_status" => "last_status", - "config_option_status" => "last_option_status", - _ => return, + // Raw `frame` is persisted (minus type) so fields beyond the typed + // schema survive in the stored blob. + let config_key = match kind { + proto::ControlOutbound::ConfigStatus { .. } => "last_status", + _ => "last_option_status", }; let mut payload = frame.clone(); // 去掉 type 字段,只存业务数据 @@ -380,8 +410,12 @@ async fn handle_control_frame(frame: &Value, state: &AppState, bot: &BotInfo) { tracing::warn!(bot_id = %bot_id, key = config_key, err = %e, "config persist failed"); } } - other => { - tracing::debug!(bot_id = %bot_id, frame_type = other, "unknown control frame"); + proto::ControlOutbound::Auth { .. } | proto::ControlOutbound::Unknown => { + tracing::debug!( + bot_id = %bot_id, + frame_type = frame.get("type").and_then(|v| v.as_str()).unwrap_or(""), + "unhandled control frame" + ); } } } @@ -478,20 +512,12 @@ async fn handle_data(mut socket: WebSocket, state: AppState, header_token: Optio // ── 3. 发 hello 帧 ────────────────────────────────────────────────── // last_event_seq: 最后一次事件的 seq(重连重放用,暂返回 0) - let mut hello = json!({ - "type": "hello", - "v": BRIDGE_PROTOCOL_VERSION, - "bridge_protocol_version": BRIDGE_PROTOCOL_VERSION, - "stream": "data", - "bot_id": bot.bot_id, - "connection_id": connection_id, - "session_id": connection_id, - "last_event_seq": 0, - "server_capabilities": server_capabilities(), - }); - if let Some(acp_security) = &bot.acp_security { - hello["acp_security"] = acp_security.clone(); - } + let hello = bridge_frames::data_hello_frame( + bot.bot_id, + connection_id, + server_capabilities(&state), + bot.acp_security.as_ref(), + ); if ws_send(&mut socket, &hello).await.is_err() { return; } @@ -651,6 +677,14 @@ async fn handle_data(mut socket: WebSocket, state: AppState, header_token: Optio crate::gateway::presence::broadcast_bot_presence(&state, bot.bot_id).await; } +/// Data-stream dispatch stays STRING-routed on purpose (unlike the typed +/// control dispatch): several readers deliberately tolerate legacy shapes a +/// strict `proto::DataOutbound` parse would reject — `delta` accepts +/// `delta|content`, `done`/`reply`/`send` accept `content|text`, and `reply` +/// isn't a variant at all (retired TS connector frame). Those aliases are +/// wire-compat contracts, not drift; the typed schema both ends COMPILE +/// against plus the golden fixtures already pin the canonical shapes (see +/// bridge_frames::tests::to_gateway_fixtures_parse_typed). async fn handle_data_frame(frame: &Value, state: &AppState, bot: &BotInfo, socket: &mut WebSocket) { let ftype = frame.get("type").and_then(|v| v.as_str()).unwrap_or(""); @@ -823,8 +857,7 @@ async fn handle_data_frame(frame: &Value, state: &AppState, bot: &BotInfo, socke if frame.get("resource").and_then(Value::as_str) == Some("bot.status.write") && resp.get("ok").and_then(Value::as_bool) == Some(true) { - crate::api::bots::broadcast_bot_member_update(state, &bot.bot_id.to_string()) - .await; + crate::api::bots::broadcast_bot_member_update(state, &bot.bot_id.to_string()).await; // Traceability (audit items 3 + 9): record the self-status write to // acp_event_log so status changes are auditable alongside every other // ACP event. Summary ONLY — which fields were set and their char @@ -923,7 +956,7 @@ async fn handle_data_frame(frame: &Value, state: &AppState, bot: &BotInfo, socke } "ping" => { - let _ = ws_send(socket, &json!({ "type": "pong" })).await; + let _ = ws_send(socket, &bridge_frames::pong_frame()).await; } "resume" => { @@ -936,16 +969,7 @@ async fn handle_data_frame(frame: &Value, state: &AppState, bot: &BotInfo, socke .and_then(Value::as_i64) .unwrap_or(0) .max(0); - let _ = ws_send( - socket, - &json!({ - "type": "resume_ack", - "v": BRIDGE_PROTOCOL_VERSION, - "replayed": 0, - "up_to_seq": up_to_seq, - }), - ) - .await; + let _ = ws_send(socket, &bridge_frames::resume_ack_frame(up_to_seq)).await; } // ── agent 进度(trace):记录 + 转发给浏览器,让 UI 显示「思考中」状态 ── @@ -1188,10 +1212,10 @@ async fn allowed_seers( // for this event_class, and the membership default for `See` is always allow. So // with no matching `see` rule, everyone online is a seer — skip the channel-role // query and the per-online-user `matched_groups` probes entirely (the common case). - if !rules - .iter() - .any(|r| r.event_class == event_class && r.capability == crate::domain::bot_event_policy::Capability::See.as_str()) - { + if !rules.iter().any(|r| { + r.event_class == event_class + && r.capability == crate::domain::bot_event_policy::Capability::See.as_str() + }) { return online; } let mut chan_role: std::collections::HashMap = std::collections::HashMap::new(); @@ -1618,60 +1642,11 @@ fn client_msg_id(frame: &Value) -> Option { .map(ToString::to_string) } -fn send_ack_ok(client_msg_id: &str, message_id: Uuid, finalized_placeholder: bool) -> Value { - json!({ - "type": "send_ack", - "v": BRIDGE_PROTOCOL_VERSION, - "client_msg_id": client_msg_id, - "ok": true, - "message_id": message_id, - "finalized_placeholder": finalized_placeholder, - }) -} +// send_ack / terminal_ack / error constructors live in gateway::bridge_frames +// so their bytes are pinned by the shared golden fixtures. -fn send_ack_err(client_msg_id: &str, code: &str, error: &str) -> Value { - json!({ - "type": "send_ack", - "v": BRIDGE_PROTOCOL_VERSION, - "client_msg_id": client_msg_id, - "ok": false, - "code": code, - "error": error, - }) -} - -fn terminal_ack_ok(client_msg_id: &str, msg_id: Uuid) -> Value { - json!({ - "type": "terminal_ack", - "v": BRIDGE_PROTOCOL_VERSION, - "client_msg_id": client_msg_id, - "ok": true, - "msg_id": msg_id, - }) -} - -fn terminal_ack_err(client_msg_id: &str, code: &str, error: &str) -> Value { - json!({ - "type": "terminal_ack", - "v": BRIDGE_PROTOCOL_VERSION, - "client_msg_id": client_msg_id, - "ok": false, - "code": code, - "error": error, - }) -} - -fn bridge_error(code: &str, detail: &str) -> Value { - json!({ - "type": "error", - "v": BRIDGE_PROTOCOL_VERSION, - "code": code, - "detail": detail, - }) -} - -fn server_capabilities() -> Value { - json!({ +fn server_capabilities(state: &AppState) -> Value { + let mut caps = json!({ "auth": ["authorization_bearer", "auth_frame"], "task_stream": "control", "runtime_session_control": true, @@ -1681,7 +1656,15 @@ fn server_capabilities() -> Value { "resume": "ack_only", "file_upload": false, "acp_security": true, - }) + }); + // Advertise the release version this gateway serves via its download proxy + // so an opted-in connector can self-update. Absent when the operator pins + // nothing (proxy then tracks "latest", whose version the gateway can't know + // without polling GitHub — connectors treat absence as "no update signal"). + if let Some(v) = &state.config.connector_release_version { + caps["latest_connector_version"] = json!(v); + } + caps } // ── 鉴权:Authorization Bearer 或首帧 auth ───────────────────────────────────── @@ -1752,6 +1735,36 @@ async fn auth_bot( return None; } + // Protocol negotiation, checked BEFORE the token hits the DB: the + // auth frame states the connector's bridge protocol version + // (absent ⇒ v1, the legacy default). Reject anything else with a + // 4400 close whose reason names the supported version — the + // connector-facing half of the handshake; the connector already + // strictly validates the gateway's hello (ensure_supported_version). + // NOTE: header-Bearer auth carries no auth frame; those connectors + // are covered by their own hello-side check. + let peer_version = frame + .get("bridge_protocol_version") + .or_else(|| frame.get("v")) + .and_then(Value::as_u64) + .unwrap_or(BRIDGE_PROTOCOL_VERSION as u64); + if peer_version != BRIDGE_PROTOCOL_VERSION as u64 { + tracing::warn!( + version = peer_version, + connector = ?frame.get("connector"), + "rejecting connector with unsupported bridge protocol version" + ); + close( + socket, + CLOSE_PROTOCOL_ERROR, + &format!( + "unsupported bridge protocol version {peer_version}; supported: {BRIDGE_PROTOCOL_VERSION}" + ), + ) + .await; + return None; + } + let token = match frame.get("token").and_then(Value::as_str) { Some(t) => t.to_string(), None => { diff --git a/server/src/infra/email.rs b/server/src/infra/email.rs index 6ec4e59d..9a703b96 100644 --- a/server/src/infra/email.rs +++ b/server/src/infra/email.rs @@ -44,7 +44,14 @@ pub async fn send_password_reset_code(config: &Config, to_email: &str, code: &st /// Send the code to `to_email`, preferring Brevo and falling back to log delivery so /// the flow still works in dev (or if the provider is briefly unreachable). `kind` /// is a short label for the logs (e.g. `registration`). -async fn deliver(config: &Config, to_email: &str, subject: &str, intro: &str, code: &str, kind: &str) { +async fn deliver( + config: &Config, + to_email: &str, + subject: &str, + intro: &str, + code: &str, + kind: &str, +) { let text = format!( "{intro}\n\n {code}\n\n{CODE_TTL_TEXT}\nIf you didn't request this, you can ignore this email." ); diff --git a/server/src/resource/bot_status.rs b/server/src/resource/bot_status.rs index bec64e1b..08de121d 100644 --- a/server/src/resource/bot_status.rs +++ b/server/src/resource/bot_status.rs @@ -17,11 +17,7 @@ use sqlx::PgPool; use super::{db_err, resource_error, Principal, PrincipalType, ResourceResult}; -pub async fn handle_write( - db: &PgPool, - principal: &Principal, - params: &Value, -) -> ResourceResult { +pub async fn handle_write(db: &PgPool, principal: &Principal, params: &Value) -> ResourceResult { // Bot-only: the verb writes the CALLER's own card. Users edit bot profiles // through the authed REST profile editor (owner/admin-gated), not here. if principal.principal_type != PrincipalType::Bot { @@ -72,12 +68,19 @@ pub async fn handle_write( // Input caps (status_text ≤140, status_emoji ≤32, info/description ≤1000 chars) // live inside persist_bot_self_status — the choke point shared with the REST // /self-status path (audit item 1) — so both write paths validate identically. - crate::api::bots::persist_bot_self_status(db, &bot_id, &status_text, &status_emoji, info_provided, &info) - .await - .map_err(|e| match e { - crate::api::bots::PersistStatusError::Invalid(msg) => resource_error("INVALID_PARAMS", msg), - crate::api::bots::PersistStatusError::Db(e) => db_err("bot.status.write")(e), - })?; + crate::api::bots::persist_bot_self_status( + db, + &bot_id, + &status_text, + &status_emoji, + info_provided, + &info, + ) + .await + .map_err(|e| match e { + crate::api::bots::PersistStatusError::Invalid(msg) => resource_error("INVALID_PARAMS", msg), + crate::api::bots::PersistStatusError::Db(e) => db_err("bot.status.write")(e), + })?; // Persist succeeded — commit the rate-limit interval (see `peek` above). crate::infra::ratelimit::bot_status_limiter().record(&bot_id); diff --git a/server/src/resource/mod.rs b/server/src/resource/mod.rs index 2423aa5b..05c78a07 100644 --- a/server/src/resource/mod.rs +++ b/server/src/resource/mod.rs @@ -11,7 +11,7 @@ pub mod plan; pub mod sessions; pub mod usage; -use serde_json::{json, Value}; +use serde_json::Value; use sqlx::{PgPool, Row}; use uuid::Uuid; @@ -167,15 +167,7 @@ async fn require_channel_admin( } } -/// 构造成功的 resource_res 帧。 -fn ok_res(req_id: &str, data: Value) -> Value { - json!({ "type": "resource_res", "v": 1, "req_id": req_id, "ok": true, "data": data }) -} - -/// 构造失败的 resource_res 帧。 -fn err_res(req_id: &str, code: &str, msg: &str) -> Value { - json!({ "type": "resource_res", "v": 1, "req_id": req_id, "ok": false, "code": code, "error": msg }) -} +use crate::gateway::bridge_frames::{resource_res_err as err_res, resource_res_ok as ok_res}; // ── 辅助函数 ────────────────────────────────────────────────────────────────── diff --git a/server/tests/flows.rs b/server/tests/flows.rs index 1aa70d99..f4218bd5 100644 --- a/server/tests/flows.rs +++ b/server/tests/flows.rs @@ -1586,7 +1586,13 @@ async fn phasea_sessions_primary_falls_back_after_promoted_session_closes(db: Pg .expect("acquire deterministic primary"); let other = server::domain::sessions::create_channel_session( - &db, bot, "acct1", &cid, "other", None, &[], + &db, + bot, + "acct1", + &cid, + "other", + None, + &[], ) .await .expect("create other session"); @@ -1598,7 +1604,10 @@ async fn phasea_sessions_primary_falls_back_after_promoted_session_closes(db: Pg .await .expect("resolve primary") .expect("a primary is bound"); - assert_eq!(primary_id, other.session_id, "promoted session should be primary"); + assert_eq!( + primary_id, other.session_id, + "promoted session should be primary" + ); // The promoted primary handles a turn before it's closed — finalize_session // detaches its binding (COALESCE'd, idempotent) while it stays live/addressable. @@ -1615,13 +1624,12 @@ async fn phasea_sessions_primary_falls_back_after_promoted_session_closes(db: Pg // it was already detached by finalize_session — `uq_cheers_session_binding_primary` // only cares about role, so a stale 'primary' row here would block any future // promotion for this bot+scope forever. - let closed_role: String = sqlx::query_scalar( - "SELECT role FROM cheers_session_bindings WHERE session_id = $1", - ) - .bind(other.session_id.to_string()) - .fetch_one(&db) - .await - .expect("closed session still has a binding row"); + let closed_role: String = + sqlx::query_scalar("SELECT role FROM cheers_session_bindings WHERE session_id = $1") + .bind(other.session_id.to_string()) + .fetch_one(&db) + .await + .expect("closed session still has a binding row"); assert_eq!( closed_role, "other", "closed session's binding must be demoted off 'primary' even when already detached" @@ -2249,7 +2257,9 @@ async fn members_marks_caller_is_self(db: PgPool) { assert_eq!(members.len(), 3); for m in members { let id = m["member_id"].as_str().unwrap(); - let is_self = m["is_self"].as_bool().expect("is_self present on every row"); + let is_self = m["is_self"] + .as_bool() + .expect("is_self present on every row"); assert_eq!( is_self, id == bot_a.to_string(), @@ -2326,7 +2336,11 @@ async fn resolve_group_mention_tokens_expand_by_scope(db: PgPool) { let people = resolve_mention_names(&db, ch, &[tok.to_string()]) .await .unwrap(); - assert_eq!(ids(&people), sorted(vec![user]), "token {tok:?} = people only"); + assert_eq!( + ids(&people), + sorted(vec![user]), + "token {tok:?} = people only" + ); } // A real member name is not a group token → resolves to that one member. let one = resolve_mention_names(&db, ch, &["Helper".to_string()]) @@ -2647,8 +2661,15 @@ async fn cancel_chain_blocks_downstream_and_is_idempotent(db: PgPool) { // Cancel → status cancelled, returns A's in-flight placeholder, gate closes. let inflight = task_chains::cancel_chain(&db, &chain, user).await.unwrap(); - assert_eq!(inflight, vec![(a_placeholder, bot_a)], "cancel returns in-flight bots"); - assert!(!task_chains::is_active(&db, &chain).await, "gate is closed after cancel"); + assert_eq!( + inflight, + vec![(a_placeholder, bot_a)], + "cancel returns in-flight bots" + ); + assert!( + !task_chains::is_active(&db, &chain).await, + "gate is closed after cancel" + ); // A downstream hop on this (now cancelled) chain is dropped by the gate. let counter = Arc::new(CountingBotLocator::default()); @@ -2740,7 +2761,11 @@ async fn proactive_post_inherits_active_bot_chain(db: PgPool) { h.await.unwrap(); } - assert_eq!(counter.dispatched.load(Ordering::SeqCst), 1, "B is triggered"); + assert_eq!( + counter.dispatched.load(Ordering::SeqCst), + 1, + "B is triggered" + ); // B's placeholder inherits A's chain (not a fresh one). let b_chain: Option = sqlx::query_scalar( "SELECT chain_id FROM messages WHERE sender_id = $1 AND is_partial = TRUE", @@ -2797,9 +2822,11 @@ async fn invite_link_liveness_lifecycle(db: PgPool) { .unwrap()); // 未知 token → not live。 - assert!(!server::api::invite_links::token_is_live(&db, "cinv_missing") - .await - .unwrap()); + assert!( + !server::api::invite_links::token_is_live(&db, "cinv_missing") + .await + .unwrap() + ); // 已过期 → not live。 seed_invite_link( @@ -2811,9 +2838,11 @@ async fn invite_link_liveness_lifecycle(db: PgPool) { Some(chrono::Duration::hours(-1)), ) .await; - assert!(!server::api::invite_links::token_is_live(&db, "cinv_expired") - .await - .unwrap()); + assert!( + !server::api::invite_links::token_is_live(&db, "cinv_expired") + .await + .unwrap() + ); // 用尽(use_count == max_uses)→ not live。 seed_invite_link(&db, ws, creator, "cinv_spent", Some(1), None).await; @@ -2831,9 +2860,11 @@ async fn invite_link_liveness_lifecycle(db: PgPool) { .execute(&db) .await .unwrap(); - assert!(!server::api::invite_links::token_is_live(&db, "cinv_revoked") - .await - .unwrap()); + assert!( + !server::api::invite_links::token_is_live(&db, "cinv_revoked") + .await + .unwrap() + ); } /// accept 的「占用一次使用」是条件 UPDATE:max_uses=1 时第二次占用必须 0 行 ——