**English** | [简体中文](README.md)
dsh-cherry-bridge
# dsh-cherry-bridge **Bridge DeepSeek Harness to Cherry Studio desktops** — a DSH plugin Drive Cherry Studio agents on your LAN straight from a DSH session: pair, list devices/sessions, read history, send messages and wait for a terminal state, answer approval and question cards, cancel executions — plus a same-origin settings page for device and tool-scope management. ```mermaid flowchart TB T["DSH sessions
14 cherry_* tools"] --> P W["Settings page
devices · pairing · scope"] --> P P["dsh-cherry-bridge
pool · subscriptions · scope"] P <-->|"ws:// · Noise XX"| C["Cherry Studio desktop
QR pairing · approvals"] ``` - Package: `dsh-cherry-bridge` - Plugin id: `cherry-remote` (echoing the `remoteDiscoveryType` mDNS service identity) - Transport: `ws://` + Noise XX (encryption lives in the Noise record layer; no TLS termination required) - Tool face: 14 `cherry_*` tools - Scope: the 14 tools are enabled **per workspace**, and by default **nobody has them** (see “Scope” below) - HTTP face: same-origin prefix `/cherry-remote/api` on the host web server ## Install ```bash # From GitHub (recommended) npx @deepseek-ai/dsh plugin --profile web add github:SAXEM1997/dsh-cherry-bridge # Pinned to a version tag (recommended for production) npx @deepseek-ai/dsh plugin --profile web add github:SAXEM1997/dsh-cherry-bridge#v0.1.0 ``` ```bash # Or from the npm registry dsh plugin --profile add dsh-cherry-bridge ``` Alternatively merge it into a profile via a bundle patch — this package ships `cordis.patch.yml` declaring the `cherry-remote` insertion line and its default config (see “Configuration” below). `package.json`'s `dsh.bundle.patch` points at that file; dsh-plugin-manager inserts the plugin line from it. The manual profile line is equivalent to: ```yaml - id: cherry-remote name: 'dsh-cherry-bridge' config: perSessionCacheBytes: 2097152 rateBudgetPerSecond: 32 ``` ### First pairing 1. On the Cherry Studio desktop open **remote access → add device**: that screen shows a **QR code**; the pairing information (address, port, and the one-time invitation) only appears on the page the code leads to. 2. **Scan the QR code** (WeChat, any browser, …) and copy the pairing information the scanned page displays. 3. In DSH call `cherry_pair` (the settings-page wizard works too) and paste that pairing information. 4. **Cross-check the 6-digit verification code both ways**: `cherry_pair` and the settings page both display it prominently — compare it by eye against the desktop's number before approving on the desktop side. 5. After approval the device archive is written to the plugin data directory (`devices.json`, mode `0600`) with the `desktopIdentity` **pinned**; every later connection verifies the Noise peer static key against the pin, so a hijacked QR code cannot be traded for a man-in-the-middle substitute. ## Configuration | Key | Type | Default | Notes | | --- | --- | --- | --- | | `dataDir` | string | `$DSH_HOME/cherry-remote` (fallback `~/.dsh/cherry-remote`) | Plugin data directory holding `identity.key` and `devices.json` (both `0600`). | | `perSessionCacheBytes` | number | `2097152` (2 MiB) | Per-session cap on the **local** subscription event cache. Beyond it the oldest events are dropped LRU-style with a `warn`. | | `rateBudgetPerSecond` | number | `32` | Per-device second-level smoothed request budget (req/s). The desktop's hard limit is 64 req/s with **no negotiation channel** (since 2.1.5 `agent.content.read` has its own 512 req/s window); exceeding it terminates the whole session with close `1011` — hence the conservative value. | > ⚠️ `cordis.patch.yml`'s `config` is **whole-replace**, not merged with the > bundle defaults. The assembly layer (`resolveBridgeConfig`) falls back to the > defaults above for missing keys — removing a key gets you the **plugin > default**, not some other value from the profile. A missing key and an > invalid value are different things: a key that exists but is unusable (`0`, > negative, a string) falls back with a named `warn`. ## Tools | Tool | What it does | | --- | --- | | `cherry_devices` | List every paired device and its connection state (`connected`/`connecting`/`offline`/`disabled`/`identity-mismatch`). | | `cherry_agents` | List the agents available on a device (paginated). | | `cherry_workspaces` | List an agent's **registered** workspaces, and whether a system workspace exists. | | `cherry_sessions` | List an agent's sessions, optionally filtered by workspace. | | `cherry_read` | Read a session's message-history snapshot (two-level baseline: `agent.sessions.get` + `agent.messages.list`/`agent.parts.list`; parts are read under **each message's own** revision). Attachment parts come back as `name`/`mediaType`/`byteLength` with an **empty** `content` — the bytes are never fetched. | | `cherry_events` | Read the session's buffered events newer than `cursor`, acking them after delivery. | | `cherry_create` | Create a session in an agent workspace (workspace selection is **required**; identity is read back via `agent.sessions.get`). | | `cherry_send` | Send a message to a session, by default waiting for a terminal state and returning the assistant text; `wait: false` returns the command receipt immediately. | | `cherry_wait` | Wait only for the session's in-flight execution to settle (sends nothing). | | `cherry_interactions` | List the session's **pending** interaction cards (questions / approvals). | | `cherry_respond` | Answer an interaction card: questions must be answered in full; approvals take `decision`. | | `cherry_cancel` | Cancel the session's in-flight execution (`expectedExecutionId` required). | | `cherry_pair` | Pair with a desktop using "the pairing information shown by the scanned page". | | `cherry_unpair` | Unpair: tear the connection down and delete the device archive entry. | The device selector (the `device` argument) accepts an **exact `deviceId`** or a **unique display-name prefix**; a prefix matching several devices returns a structured ambiguity error — it never picks one arbitrarily. `cherry_send(wait: true)` / `cherry_wait` results carry one of **8 `status` values**: the four protocol terminal states (`completed`/`failed`/`cancelled`/`interrupted`) plus four waiter verdicts of its own — `awaiting-interaction` (a soft terminal: a pending interaction card appeared while the run is still alive), `timeout` (the window ran out, **not a failure** — the partial content and a resume cursor come back), `idle` (`cherry_wait` met a session with nothing in flight and returned the current snapshot immediately), and `subscription-lost` (the event subscription was lost mid-wait and could not be rebuilt, so the outcome is **unknowable** from here: unlike `timeout`, it ships guidance to repair the subscription or re-read the run by id). ## Settings page (web client) The plugin is also a **web client bundle** (`package.json`'s `dsh.client` declares `platform: web` and exports `./client`). It registers a **`cherry-remote`** namespace page through the host settings page's `settings.section` slot, providing: - device cards: display name, addresses, connection state light, enable/disable, remove; - a pairing wizard: paste the pairing information from the scanned page → show the 6-digit verification code → show the terminal verdict; - a scope panel: `Global` / `Selected workspaces only` plus a workspace multi-select (title + path) — see “Scope” below; - live state: device states and pairing progress stream over the same-origin SSE feed (`/cherry-remote/api/events`). For development run `npm run dev:ui` (the Vite entry under `ui/`). **This requires a DSH host already running at `http://127.0.0.1:23023`**: the dev server serves only the page itself; the `/cherry-remote/api` calls it makes are forwarded to the host by a **dev-only proxy** in `ui/vite.config.ts` (SSE included, unbuffered). Without a host the page renders but the device list and event stream error out. If the host lives on another port, override with `CHERRY_DEV_HOST=http://127.0.0.1: npm run dev:ui` — no code changes needed. ## Scope The 14 `cherry_*` tools appear **per workspace**, not in every session. The settings page's **scope panel** (namespace `cherry-remote`, nav label “Cherry Studio Bridge”) edits it. The mechanism is **hiding, not refusing**: in an out-of-scope session the tools never enter the tool table (saving context) rather than being visible-but-erroring. - **Default is nobody**: when the scope document does not exist it is treated as `selected` + an **empty list** — **no workspace** gets the tools. That is the fail-closed side: a corrupt document, invalid fields, or an unreadable one all land on the stricter side; there is **no** "unreadable → treat as global" escape hatch. - **`Selected workspaces only` (`selected`)**: only sessions inside ticked workspaces can ever see the tools; sessions with **no workspace** (whose `cwd` belongs to no registered workspace) never do (fail-closed). - **`Global` (`global`)**: visible in every workspace; the ticks then play no part in the decision (they matter again after switching back to `selected`). - **Saves take effect immediately, in running sessions too**: a successful save re-installs / releases the restriction layer for **every live agent**, effective from the **next turn** — no DSH restart, no new session; un-ticking likewise removes them from the next turn. - **Scope governs tools only**: the settings page and `/cherry-remote/api` are **never** scope-gated — an excluded workspace must still be able to rescue itself (pair, change the scope). - **No pretending when capability is missing**: while `host.state` is `pending` (the injection callback has not arrived) the panel shows a neutral wait note and does **not** disable save; when it is `unsupported` (any of `tools.register` / `tools.restrict` / `tools.schemas` statically missing) it shows a read-only warning naming the **real missing cause** and disables save. Under both states the tools stay **unregistered** (fail-closed) — never a fallback to "globally visible". > **Wording boundary**: the panel only claims "this plugin does not shadow > these tools". Upstream `allow`-shaped filtering (a sub-agent's > `toolFilter`, for example) can still keep them out of a session — that is > accepted behavior, and this plugin promises nothing about layers above it. ### The scope document and endpoints The scope document lives in the plugin data directory: `/scope.json` (default `$DSH_HOME/cherry-remote/scope.json`), mode `0600`, written atomically, with a monotonically increasing `revision` — the **single exception** being a document that fails to parse or has invalid fields: it is rebuilt on the stricter side (`selected` + empty list) with `revision` reset to 0, and the file remains writable; it deliberately does **not** live in profile YAML (no second source of truth). `GET /cherry-remote/api/scope` returns the whole state in one body: ```json { "scope": { "mode": "selected", "workspaceIds": [], "revision": 0 }, "workspaces": [{ "id": "…", "title": "…", "path": "…" }], "staleWorkspaceIds": [], "pathUnmatched": [{ "agentId": "…", "cwd": "…" }], "host": { "state": "ready", "capable": true, "reason": "…", "degraded": [] } } ``` - `scope` — the document's three writable fields (`mode` / `workspaceIds` / `revision`). - `workspaces` — the registry's workspaces (projected to `id`/`title`/`path`; the browser cannot reach the registry itself). - `staleWorkspaceIds` — ids ticked in the document that the registry no longer has: a `PUT` prune is **the only path that clears them**; the UI reports the result via `pruned` instead of crying "validation failed". - `pathUnmatched` — sessions whose **cwd does not match the current canonical path table** (`agentId` + `cwd`). Both kinds land here: a cwd that resolves but (after realpath normalization) belongs to no registered workspace (a spelling difference, a symlink, or a genuinely unregistered directory — indistinguishable here); and, when the host lacks `resolveByPath` (see the `path-normalization` source below), a cwd compared only by case-sensitive string equality that missed. They are treated as "workspace-less": under `selected` those sessions do not see the 14 tools; under `global` everything stays visible regardless of workspace registration, so this entry only explains "ticked but no tools" under `selected`. It is **not** a degradation, changes neither the ticks nor the `mode`, and triggers no write. - `host` — the capability triple (`pending` / `unsupported` / `ready`, `capable === (state === 'ready')`) and the `degraded` array (`{source, reason}`; one latest entry per source, different sources coexist). The five sources: - `install` — a hide-layer application once failed; `reason` names which of the four sites: parameterless `schemas()` unreadable (this round installs and releases nothing), a layer release threw (an old layer may still be in force), the agent lacks `agent.ctx.tools.restrict`, or the host refused `restrict()`. It only **accumulates** (a newer entry replaces the same source's older one) and no success path clears it — after recovery it still only says "failed once in this session". - `workspace-list` — the workspace registry is unreadable (read failure or an empty table): decisions go fail-closed and writes are refused; the only source of the five that a later successful read clears. - `visibility-only` — the host lacks `tools.guard` or refused to install it: `restrict` still removes the 14 tools at the assembly layer (not a render-layer fake filter), but the **execution-side backstop** is gone — no layer rejects calls to these tools anymore, which degrades **visibility only**, not the execution boundary. If some agent cannot even take a `restrict` (another site of the same `install` source), that agent's target tools stay visible and the guard passes: the plugin cannot hide them and can only record the fact in `degraded`. - `scope-version` — the document's `version` disagrees with what this plugin supports (higher or lower, same guard): read-only degradation with writes locked (so a differently-shaped document is never overwritten). - `path-normalization` — the host lacks `resolveByPath`: cwd comparison degrades to case-sensitive string equality (no realpath), so symlinks or spelling differences may miss under `selected`. `PUT /cherry-remote/api/scope` is a read-modify-write: the body is `{mode, workspaceIds, expectedRevision}`, and `expectedRevision` is **required** — it is the `revision` you last read. Success returns `200 {scope, pruned}`: `scope` is the post-write canonical document (pruned ids are gone), `pruned` lists the stale ids pruned by this write, and the new `revision` returned is the next write's baseline. Failures return `{error, message?}` with a stable `error` code: | HTTP | `error` | Meaning | | --- | --- | --- | | 400 | `invalid-scope` | Invalid fields: `mode` is not `global`/`selected`, or `workspaceIds` is not a string array. | | 400 | `missing-expected-revision` | The request lacks the baseline `expectedRevision`, **or it is not a finite number** (type and value judged by the same guard). | | 409 | `scope-revision-conflict` | The baseline `revision` is stale (the document changed elsewhere); `message` carries the current `revision`. | | 503 | `workspace-list-unavailable` | The workspace registry is unreadable: the write is refused and the stored ticks and `mode` move **not one bit** (nothing pruned either). | | 503 | `scope-version-unsupported` | The scope document's `version` disagrees with the supported one (higher or lower, same guard): read-only degradation; **re-reading returns the same document**. | > A request body that cannot be read **at all** (invalid JSON) still goes > through this module's existing `400 invalid-request` — that is not a > scope-field verdict, hence not in the table above. ## HTTP face (same-origin) Mounted under the host web server's `/cherry-remote/api` prefix — **no extra listener, no LAN exposure, no separate credentials**: | Method & path | Purpose | | --- | --- | | `GET /cherry-remote/api/devices` | Device list. | | `PATCH /cherry-remote/api/devices/:id` | Update display name / enabled / addresses / port (`hosts` is **whole-replace**). | | `DELETE /cherry-remote/api/devices/:id` | Remove the device and tear down its connection. | | `GET /cherry-remote/api/scope` | Scope: the document + the registry workspace list + `host` diagnostics (see “Scope”). | | `PUT /cherry-remote/api/scope` | Save the scope (read-modify-write, `expectedRevision` required). | | `POST /cherry-remote/api/pair` | Start a pairing run; returns `202 {claimId}` at once, progress streams over SSE. | | `GET /cherry-remote/api/events` | SSE: `device-state` / `pairing-progress` (each `pairing-progress` frame carries the `claimId` that produced it; the heartbeat is a payload-free comment line). | | `GET /cherry-remote/api/diagnostics` | Operations diagnostics report (below). | > The minimal `pairing-progress` frame shape is `{stage, detail?}`; this > plugin **additionally** attaches the optional `claimId` (a verdict extension > for cross-run isolation, not part of the planned minimal field set) — > consumers must tolerate its absence. **Trust fence**: every route re-runs the same-origin checks before acting, four branches in order: (1) a non-loopback, undeclared `Host` → refuse; (2) `sec-fetch-site: cross-site` → refuse; (3) an `Origin` present and not same-origin with `Host` → refuse; (4) **missing `Origin` → allow** (that is a non-browser client like curl, not a cross-site page). ### `GET /diagnostics` Aggregates device states / connection statistics / a recent-error digest, **redacted per the §5 rules**: - May appear: `deviceId`, display name, state, addresses, port, a **12-char digest** of `desktopIdentity`, the **effective post-hello limits** (`heartbeatMs`/`subscriptions`/`pageItems`/`inFlightRequests` — **only when a live connection exists**: this endpoint never dials, so a disconnected device reports no limits and is not opportunistically woken up, rather than passing off client fallback values), `sessionId` and session cursors (from the subscription layer's current state; unsubscribed sessions have none), `stats` counters (`connections`/`sockets`/`connectionsBuilt`/`subscriptions`), the two `config` keys the assembly resolved (`rateBudgetPerSecond`/`perSessionCacheBytes` — the same numbers the logs report), and error reason/message (**message truncated to 200 chars at the endpoint boundary**, see `ERROR_MESSAGE_MAX`); - Never appears: `accessToken`, `tokenExpiresAt`, `invitationSecret`, the raw `desktopIdentity`, message text, part text. > `stats.connectionsBuilt` counts **connections the pool has built since the > last `apply`** — it is not an RPC request count; request counting belongs to > the transport layer, and this endpoint deliberately publishes no same-named > metric to avoid misreads. Counters and the error ring are zeroed on every > `apply`: the statistics describe **the current** assembly only. Redaction is a **whitelist projection**, not blacklist filtering: the report is built field-by-field into a fresh object, so a credential field added to the device archive in the future is excluded **by construction** — it never depends on someone remembering to update a deny-list. ## Logs Level conventions (spec §5): - **error** — connection failure / authentication failure / protocol errors; - **warn** — reconnect backoff / cache eviction / subscription LRU eviction; - **info** — pairing progress / subscription establish & recovery / device state transitions. At info level only **stage names and identifiers** are logged (`deviceId`/`sessionId`/pairing stage) — never payloads. Sensitive fields (tokens, `invitationSecret`, `accessToken`, message and part text) **never enter the logs**; `deviceId`, `desktopIdentity` digests, and `sessionId` may. The contract suite asserts log redaction over a full pair → connect → send → interact flow (`tests/contract/contract.test.ts`). ## Development ```bash npm test # full vitest run (incl. tests/contract/) npm run typecheck # server + UI projects npm run build # tsdown → dist/index.mjs + dist/index.d.mts npm run build:client # vite → lib/client.js npm run dev:ui # settings-page dev server (host must run at 127.0.0.1:23023, see above) npm run prepublishOnly # pre-publish gate: typecheck + test + build + build:client ``` > **`dist/` and `lib/` are committed build artifacts, not gitignored > outputs.** After changing `src/` or `ui/` you must re-run > `npm run build && npm run build:client` before committing, or the committed > artifacts go stale. There is deliberately **no** `prepare` hook: `prepare` > also runs on `npm install`, while `npm install --omit=dev` prunes > devDependencies first — the hook would then die (127) unable to find > `tsdown`/`vite`, taking the whole install down with it. With artifacts > committed, installs from git, `--omit=dev`, and packing a bare clone all > work with zero build steps. The “clean clone → pack” case in > `tests/contract/contract.test.ts` gates this: it clones this repo (without > `node_modules`), asserts `dist/index.mjs`, `dist/index.d.mts`, and > `lib/client.js` are **already present** in the clone and also inside the > `npm pack --ignore-scripts` tarball. What guards against silent artifact > rot is `prepublishOnly`: on the publish path it runs typecheck and the full > suite first, then rebuilds both artifact sides. `@cherrystudio/remote-protocol@0.5.0` and `@cherrystudio/remote-transport@0.2.1` are pinned at **exact versions** (no `^`) with tests asserting the pin — upgrades must re-run `tests/contract/contract.test.ts` against schema drift. The contract suite also models the **six behavioral differences** between this repo's loopback fake peer and a real Cherry Studio, each mapped to a manual verification step in [`docs/e2e-checklist.md`](docs/e2e-checklist.md). ### Cherry Studio version compatibility The verified desktop range is **2.1.4 → 2.1.6** (including post-2.1.6 commits on `main`), based on a face-by-face diff of upstream `src/main/services/remoteAccess/` and `packages/remote-{protocol,transport}`: | Upstream change | Impact | | --- | --- | | Since 2.1.5 `connection.hello` adds `agentUploadsVersion`/`agentUploadChecksum`/`agentAttachmentSelections` and 5 `agentUpload*` limits | **Additive**, harmless: hello has no strict validation and limits are read by name (regression: `tests/integration/connection.test.ts` ①c) | | Since 2.1.5 record framing introduces a binary marker (first byte `1`) for upload chunks | **Additive**: JSON record encoding is unchanged both ways; this plugin neither sends nor receives binary | | Since 2.1.5 attachments project as `{kind:'file', name, ref}` (top-level `ref`), bytes fetchable via `agent.content.read` | Only **metadata** is taken (`name`/`mediaType`/`byteLength`); **bytes never** — the read path is a text channel (`tests/integration/toolsRead.test.ts` ④k/④l) | | Since 2.1.5 `agent.messages.send` requires non-empty trimmed `text` | `cherry_send` rejects blank text locally as `text-empty`, never sending it (`tests/integration/toolsSendGuard.test.ts`) | | `agent.content.read` relaxed to its own 512 req/s; other business methods stay 64 req/s | This plugin's 32 req/s budget stays as is — still safe | | Since 2.1.5 keychain-less Linux stores the device identity as plaintext 0600; identity is **never regenerated** | Upgrades **do not require re-pairing**; keychain-less Linux desktops go from "unusable" to "usable" | Pairing payload, mDNS advertisement, protocol versions (`[1]`), and the Noise profile saw **zero changes** in that upstream window. Upstream also has `5db8ab7` (the agent runtime swapping DSH for Pi, deleting its bundled `packages/dsh-bridge`) — that bridge runs in the **opposite direction** (Cherry driving DSH) and is unrelated to the remote-access face this plugin depends on. > `zod` is a **runtime** dependency, not a removable dead one: this package's > `dist` externals `@cherrystudio/remote-protocol`, and that protocol's > runtime output `import`s zod everywhere (peer `^4.3.4`; the protocol > package's own dev pin moved to 4.4.3, which does not affect the range's > lower bound). Demoting or removing it would make the protocol package fail > to load its schemas at runtime. It is pinned exactly at `4.3.4` — the peer > range's lower bound and the protocol package's test baseline. > TypeScript is pinned to `~7.0.2` (patch digit only). **Accepted risk**: > `tsdown`'s `dist/index.d.mts` generation rides TS 7's experimental dts API, > a surface that may change across minors; restricting to a patch-only range > means a minor cannot silently change it inside the range — an upgrade must > explicitly touch `package.json` and re-run `npm run build` plus the full > suite. Type artifacts are committed, so one bad upgrade shows up > immediately in the **byte-exact artifact gate** instead of ambushing a > downstream consumer: `tests/unit/artifactFreshness.test.ts` rebuilds > `dist/index.mjs`, `dist/index.d.mts`, and `lib/client.js` from current > source and compares them **byte for byte** against the committed versions > (the contract suite's “clean clone → pack” case only asserts the three > files **exist**; it cannot see byte-level rot). > Note: `tests/loopback/fakeCherry.ts` in this repo is an in-test loopback > fake peer, not a published artifact; the `Task N`, `spec §x`, > `controller ruling`, `UltraReview` labels in docs and code comments point at > this project's internal SDD process documents (design/task/review records) > that are **not published** with the package — they just keep the provenance > of "why this rule looks like this" in place. In the published artifact > `docs/` contains only > [`docs/e2e-checklist.md`](docs/e2e-checklist.md) (`files` lists it > explicitly, not the `docs` directory itself); `docs/superpowers/**` internal > process documents are **deliberately excluded** from the tarball. > `src` and `ui` in `files` are **published on purpose**: host-side > troubleshooting (reading the original implementation against `dist`/`lib` > behavior) and fork development need them; at runtime the `exports`-pointed > `dist/*` and `lib/client.js` are what actually resolves — `src` is never > loaded. Before a release, walk `docs/e2e-checklist.md`: Electron-side approvals cannot be CI'd; the real-device flows (pairing / send / interaction answers / disconnect recovery / multi-device coexistence) must be exercised by hand. ## Known limitations - `cherry_respond` lets a DSH agent approve Cherry-side approval cards on the user's behalf — an abuse surface. The MVP adds no interception layer and trusts DSH's own tool-call approval system to bound agent behavior; a possible phase-2 gate: approval-type answers must pass a DSH-side `ask_user_question` confirmation. - Only the `agent` capability is requested per device, never `configuration`. - The MVP uses `ws://` transport only (encryption by the Noise record layer); no direct TLS. ## License MIT