diff --git a/README.md b/README.md index 0a6b9249..a2392d40 100644 --- a/README.md +++ b/README.md @@ -537,6 +537,30 @@ Requests flow through the Claude Code adapter which: - Leaves the SDK subprocess cwd on the proxy host (Claude Code's local paths don't exist there). - Runs in passthrough mode by default — Claude Code executes its own tools on the machine it runs on; Meridian just forwards tool_use blocks. +### Cherry Studio + +[Cherry Studio](https://github.com/CherryHQ/cherry-studio) is a desktop chat client (Electron) that talks to any Anthropic-compatible endpoint. It's the first **chat-client** adapter in Meridian — the existing adapters (OpenCode, Crush, Pi, ForgeCode, Claude Code, Droid) are all CLI coding agents with their own MCP tool runtimes, but Cherry Studio is a pure chat UI that wants Claude's server-side tools (especially `WebSearch` and `WebFetch`) to work natively. + +In Cherry Studio's provider settings, add a new Anthropic-compatible provider: + +- **API URL:** `http://127.0.0.1:3456` +- **API Key:** any string when `MERIDIAN_API_KEY` is unset, or your key value otherwise +- **Custom request headers:** `x-meridian-agent: cherry-studio` + +Cherry Studio doesn't send a stable User-Agent ([CherryHQ#10209](https://github.com/CherryHQ/cherry-studio/issues/10209)) so the header is required for the adapter to fire — without it, requests fall through to whatever `MERIDIAN_DEFAULT_AGENT` is (default OpenCode), which blocks `WebSearch`/`WebFetch` in favor of OpenCode's MCP equivalents and breaks Cherry Studio's web search. + +If Cherry Studio is the *only* tool pointing at this Meridian instance, you can skip the header and set the env var instead: + +```bash +MERIDIAN_DEFAULT_AGENT=cherry-studio meridian +``` + +The Cherry Studio adapter: +- **Allows** `WebSearch` and `WebFetch` (the whole point — chat clients have no MCP equivalent and need Claude's built-in web access). +- **Blocks** filesystem and shell tools by default (`Read`, `Write`, `Edit`, `Bash`, `Glob`, `Grep`). A chat-style LLM shouldn't enumerate files on the proxy host unsupervised, even on localhost. If you want broader access, point at a coding-agent adapter where tool calls are surveilled by the calling tool. +- Runs the SDK's tools internally (no passthrough) so results land inline in the assistant turn — exactly what a chat UI wants to render. +- Supports `thinking` / `thinkingPassthrough` toggles via the settings UI at `/settings`, same as the coding-agent adapters. + ### Any Anthropic-compatible tool ```bash diff --git a/src/__tests__/cherry-studio-adapter.test.ts b/src/__tests__/cherry-studio-adapter.test.ts new file mode 100644 index 00000000..5e4b968a --- /dev/null +++ b/src/__tests__/cherry-studio-adapter.test.ts @@ -0,0 +1,171 @@ +/** + * Tests for the Cherry Studio chat-client adapter. + * + * The load-bearing assertion below — that WebSearch / WebFetch are NOT in + * the adapter's blocked / incompatible lists — is the actual fix for the + * symptom Ben reported in #481 ("WebSearch tool not exposed in the + * session"). When this assertion regresses, Cherry Studio's web search + * silently breaks again. + */ +import { describe, it, expect } from "bun:test" +import { cherryStudioAdapter } from "../proxy/adapters/cherrystudio" + +describe("cherryStudioAdapter — identity", () => { + it("has name 'cherry-studio'", () => { + expect(cherryStudioAdapter.name).toBe("cherry-studio") + }) +}) + +describe("cherryStudioAdapter.getSessionId", () => { + it("always returns undefined — Cherry Studio has no session-affinity header", () => { + const ctx = { req: { header: () => "anything" } } + expect(cherryStudioAdapter.getSessionId(ctx as any)).toBeUndefined() + }) +}) + +describe("cherryStudioAdapter.extractWorkingDirectory", () => { + it("returns undefined for any body — chat client has no CWD concept", () => { + expect(cherryStudioAdapter.extractWorkingDirectory({})).toBeUndefined() + expect(cherryStudioAdapter.extractWorkingDirectory({ system: "anything" })).toBeUndefined() + }) +}) + +describe("cherryStudioAdapter — tool blocking (regression for #481)", () => { + // The whole point of this adapter: chat clients have no MCP equivalent for + // WebSearch / WebFetch and no client-side web access. If we block them, the + // user sees "tool not exposed" and the fix is silently undone. + const blocked = new Set([ + ...cherryStudioAdapter.getBlockedBuiltinTools(), + ...cherryStudioAdapter.getAgentIncompatibleTools(), + ]) + + it("does NOT block WebSearch", () => { + expect(blocked.has("WebSearch")).toBe(false) + }) + + it("does NOT block WebFetch", () => { + expect(blocked.has("WebFetch")).toBe(false) + }) + + it("blocks filesystem tools (Read/Write/Edit/Bash) — chat clients shouldn't poke the proxy host", () => { + expect(blocked.has("Read")).toBe(true) + expect(blocked.has("Write")).toBe(true) + expect(blocked.has("Edit")).toBe(true) + expect(blocked.has("Bash")).toBe(true) + expect(blocked.has("Glob")).toBe(true) + expect(blocked.has("Grep")).toBe(true) + }) + + it("blocks Claude-Code-only orchestration tools", () => { + for (const name of ["CronCreate", "EnterPlanMode", "EnterWorktree", "Skill", "Agent"]) { + expect(blocked.has(name)).toBe(true) + } + }) +}) + +describe("cherryStudioAdapter — chat-client behavior", () => { + it("usesPassthrough returns false — SDK runs tools, returns results inline", () => { + expect(cherryStudioAdapter.usesPassthrough?.()).toBe(false) + }) + + it("supportsThinking returns true — Cherry Studio renders thinking when enabled", () => { + expect(cherryStudioAdapter.supportsThinking?.()).toBe(true) + }) + + it("shouldTrackFileChanges returns false — chat clients don't render diff blocks", () => { + expect(cherryStudioAdapter.shouldTrackFileChanges?.()).toBe(false) + }) + + it("buildSdkAgents returns empty — no subagent routing", () => { + expect(cherryStudioAdapter.buildSdkAgents?.({}, [])).toEqual({}) + }) + + it("getAllowedMcpTools returns empty — no MCP server-side tools", () => { + expect(cherryStudioAdapter.getAllowedMcpTools()).toEqual([]) + }) + + it("buildSdkHooks returns undefined — no PreToolUse hook needed", () => { + expect(cherryStudioAdapter.buildSdkHooks?.({}, {})).toBeUndefined() + }) + + it("buildSystemContextAddendum returns empty string", () => { + expect(cherryStudioAdapter.buildSystemContextAddendum?.({}, {})).toBe("") + }) +}) + +// --------------------------------------------------------------------------- +// Detection — Cherry Studio has no stable User-Agent (CherryHQ#10209), so +// it must be selected via header or env var. These tests pin that contract. +// --------------------------------------------------------------------------- +describe("Cherry Studio detection via x-meridian-agent header", () => { + it("x-meridian-agent: cherry-studio routes to cherryStudioAdapter", async () => { + const { detectAdapter } = await import("../proxy/adapters/detect") + const ctx = { + req: { + header: (name: string) => (name === "x-meridian-agent" ? "cherry-studio" : undefined), + }, + } + expect(detectAdapter(ctx as any).name).toBe("cherry-studio") + }) + + it("x-meridian-agent: cherrystudio (no hyphen) also routes — alias", async () => { + const { detectAdapter } = await import("../proxy/adapters/detect") + const ctx = { + req: { + header: (name: string) => (name === "x-meridian-agent" ? "cherrystudio" : undefined), + }, + } + expect(detectAdapter(ctx as any).name).toBe("cherry-studio") + }) + + it("ignores case in x-meridian-agent value", async () => { + const { detectAdapter } = await import("../proxy/adapters/detect") + const ctx = { + req: { + header: (name: string) => (name === "x-meridian-agent" ? "Cherry-Studio" : undefined), + }, + } + expect(detectAdapter(ctx as any).name).toBe("cherry-studio") + }) +}) + +// --------------------------------------------------------------------------- +// Audit: every adapter registered for detection must also have a UI label. +// Without this, the next adapter we register for detection-only (like +// claude-code originally was) ends up invisible in the settings page — +// users can't see or change its feature toggles. This is the symptom Ben +// described as "It'd be nice to customize things like Client Prompt, +// Thinking Passthrough, Thinking like other harnesses." +// --------------------------------------------------------------------------- +describe("adapter list is single-sourced (regression guard)", () => { + it("every canonical adapter in ADAPTER_LABELS is reachable via ADAPTER_MAP", async () => { + const { ADAPTER_MAP, ADAPTER_LABELS } = await import("../proxy/adapters/detect") + for (const name of Object.keys(ADAPTER_LABELS)) { + expect(ADAPTER_MAP[name]).toBeDefined() + expect(ADAPTER_MAP[name]?.name).toBeDefined() + } + }) + + it("getAllFeatureConfigs returns one entry per ADAPTER_LABELS key", async () => { + const { ADAPTER_LABELS } = await import("../proxy/adapters/detect") + const { getAllFeatureConfigs } = await import("../proxy/sdkFeatures") + const cfg = getAllFeatureConfigs() + for (const name of Object.keys(ADAPTER_LABELS)) { + expect(cfg[name]).toBeDefined() + } + }) + + it("includes cherry-studio specifically (the new entry)", async () => { + const { ADAPTER_LABELS } = await import("../proxy/adapters/detect") + const { getAllFeatureConfigs } = await import("../proxy/sdkFeatures") + expect(ADAPTER_LABELS["cherry-studio"]).toBe("Cherry Studio") + expect(getAllFeatureConfigs()["cherry-studio"]).toBeDefined() + }) + + it("includes claude-code (latent gap fixed by this change)", async () => { + const { ADAPTER_LABELS } = await import("../proxy/adapters/detect") + const { getAllFeatureConfigs } = await import("../proxy/sdkFeatures") + expect(ADAPTER_LABELS["claude-code"]).toBe("Claude Code") + expect(getAllFeatureConfigs()["claude-code"]).toBeDefined() + }) +}) diff --git a/src/proxy/adapters/cherrystudio.ts b/src/proxy/adapters/cherrystudio.ts new file mode 100644 index 00000000..9f52e8d1 --- /dev/null +++ b/src/proxy/adapters/cherrystudio.ts @@ -0,0 +1,133 @@ +/** + * Cherry Studio chat-client adapter. + * + * Cherry Studio (CherryHQ/cherry-studio) is a desktop Electron chat client + * that talks to Anthropic-compatible APIs. Unlike Meridian's coding-agent + * adapters (OpenCode, Crush, ForgeCode, etc.), it has no local tool runtime + * and no MCP integration — it's a pure chat UI that wants Claude to use + * server-side tools natively, especially web search. + * + * Key differences from the coding-agent adapters: + * - WebSearch and WebFetch NOT blocked. Chat clients have no MCP equivalent + * and no client-side web access — Claude's built-ins are the whole point + * of using Meridian + Max OAuth here. Verified independently that Max + * OAuth runs WebSearch successfully when allowed. + * - Filesystem / shell tools blocked by default. Chat clients shouldn't + * read the proxy host's filesystem unsupervised — even on localhost, an + * unsuspecting user could let an LLM enumerate `~/.ssh` etc. Operators + * who genuinely want filesystem access can use a coding-agent adapter + * (where the agent supervises tool calls) or set MERIDIAN_DEFAULT_AGENT + * to one with broader permissions. + * - usesPassthrough = false. Cherry Studio has no tool-execution loop; + * the SDK executes tools internally and returns results inline. + * - No MCP server, no subagent routing. + * + * Detection: Cherry Studio doesn't send a stable User-Agent — see upstream + * issue CherryHQ/cherry-studio#10209 (custom UA gets overridden). Use: + * - `x-meridian-agent: cherry-studio` header (per request) + * - `MERIDIAN_DEFAULT_AGENT=cherry-studio` env var (global default) + * + * Closes #481. + */ + +import type { Context } from "hono" +import type { AgentAdapter } from "../adapter" +import { normalizeContent } from "../messages" + +const CHERRY_STUDIO_NAME = "cherry-studio" + +/** + * Tools the SDK should refuse to invoke for Cherry Studio. + * + * Two categories: + * 1. Filesystem / shell — kept off by default so an unsupervised chat-style + * LLM can't enumerate files on the proxy host. + * 2. Claude-Code-only orchestration tools (cron, plan/worktree mode + * toggles, etc.) that have no useful meaning outside the Claude Code + * CLI runtime. + * + * Web tools are deliberately absent. So is `TodoWrite` — chat clients are + * fine with the SDK's built-in todo view since they have no equivalent. + */ +const CHERRY_STUDIO_BLOCKED: readonly string[] = [ + "Read", "Write", "Edit", "MultiEdit", + "Bash", "Glob", "Grep", "NotebookEdit", + "CronCreate", "CronDelete", "CronList", + "EnterPlanMode", "ExitPlanMode", + "EnterWorktree", "ExitWorktree", + "Monitor", "PushNotification", "RemoteTrigger", "ScheduleWakeup", + "Skill", "Agent", "TaskOutput", "TaskStop", + "AskUserQuestion", +] + +export const cherryStudioAdapter: AgentAdapter = { + name: CHERRY_STUDIO_NAME, + + /** No session-affinity header from Cherry Studio — fingerprint-based resume. */ + getSessionId(_c: Context): string | undefined { + return undefined + }, + + /** Chat client runs on the same host as the proxy in the typical setup. */ + extractWorkingDirectory(_body: any): string | undefined { + return undefined + }, + + normalizeContent(content: any): string { + return normalizeContent(content) + }, + + /** + * No SDK built-in tools to block beyond the chat-client list. Returning [] + * here and putting the full list in getAgentIncompatibleTools() keeps the + * blocking story in one place — both lists land in the SDK's + * `--disallowedTools` arg either way. + */ + getBlockedBuiltinTools(): readonly string[] { + return [] + }, + + getAgentIncompatibleTools(): readonly string[] { + return CHERRY_STUDIO_BLOCKED + }, + + /** No MCP integration — chat client speaks plain Anthropic Messages API. */ + getMcpServerName(): string { + return CHERRY_STUDIO_NAME + }, + + getAllowedMcpTools(): readonly string[] { + return [] + }, + + buildSdkAgents(_body: any, _mcpToolNames: readonly string[]): Record { + return {} + }, + + buildSdkHooks(_body: any, _sdkAgents: Record): undefined { + return undefined + }, + + buildSystemContextAddendum(_body: any, _sdkAgents: Record): string { + return "" + }, + + /** + * Chat client has no client-side tool loop. The SDK runs tools and folds + * results into the assistant turn for us — that's exactly what a chat UI + * wants to render. + */ + usesPassthrough(): boolean { + return false + }, + + /** Cherry Studio renders thinking blocks when the user enables them. */ + supportsThinking(): boolean { + return true + }, + + /** No filesystem-edit summary; chat clients neither edit files nor render that block. */ + shouldTrackFileChanges(): boolean { + return false + }, +} diff --git a/src/proxy/adapters/detect.ts b/src/proxy/adapters/detect.ts index 3ad77209..71c6cf3a 100644 --- a/src/proxy/adapters/detect.ts +++ b/src/proxy/adapters/detect.ts @@ -14,8 +14,9 @@ import { passthroughAdapter } from "./passthrough" import { piAdapter } from "./pi" import { forgeCodeAdapter } from "./forgecode" import { claudeCodeAdapter } from "./claudecode" +import { cherryStudioAdapter } from "./cherrystudio" -const ADAPTER_MAP: Record = { +export const ADAPTER_MAP: Record = { opencode: openCodeAdapter, droid: droidAdapter, crush: crushAdapter, @@ -24,8 +25,34 @@ const ADAPTER_MAP: Record = { forgecode: forgeCodeAdapter, "claude-code": claudeCodeAdapter, claudecode: claudeCodeAdapter, + "cherry-studio": cherryStudioAdapter, + cherrystudio: cherryStudioAdapter, } +/** + * Canonical adapter names with their human-readable labels. The settings UI + * and per-adapter config code consume this so adding an adapter to + * ADAPTER_MAP automatically wires it everywhere — no hardcoded lists in + * sdkFeatures.ts or settingsPage.ts to drift out of sync. + * + * Aliases (e.g. `claudecode` → `claude-code`, `cherrystudio` → `cherry-studio`) + * exist in ADAPTER_MAP for detection convenience but are intentionally absent + * here: each adapter has exactly one canonical name in the UI. + */ +export const ADAPTER_LABELS: Record = { + opencode: "OpenCode", + crush: "Crush", + forgecode: "ForgeCode", + pi: "Pi", + droid: "Droid", + passthrough: "LiteLLM / Passthrough", + "claude-code": "Claude Code", + "cherry-studio": "Cherry Studio", +} + +/** Canonical adapter names — keys of ADAPTER_LABELS, in stable UI order. */ +export const ADAPTER_NAMES: readonly string[] = Object.keys(ADAPTER_LABELS) + const envDefault = process.env.MERIDIAN_DEFAULT_AGENT || "" if (envDefault && !ADAPTER_MAP[envDefault]) { console.warn( @@ -53,6 +80,7 @@ function isLiteLLMRequest(c: Context): boolean { * * Detection rules (evaluated in order): * 1. x-meridian-agent header → explicit adapter override + * e.g. "cherry-studio", "claude-code", "opencode", etc. * 2. x-opencode-session or x-session-affinity header → OpenCode adapter * 3. User-Agent starts with "opencode/" → OpenCode adapter * 4. User-Agent starts with "factory-cli/" → Droid adapter @@ -60,6 +88,11 @@ function isLiteLLMRequest(c: Context): boolean { * 6. User-Agent starts with "claude-cli/" → Claude Code adapter * 7. litellm/* UA or x-litellm-* headers → LiteLLM passthrough adapter * 8. Default → MERIDIAN_DEFAULT_AGENT env var, or OpenCode + * + * Cherry Studio (and other chat clients with no stable User-Agent) must use + * the explicit `x-meridian-agent: cherry-studio` header or set + * MERIDIAN_DEFAULT_AGENT — there is no auto-detection rule, by design. + * See cherrystudio.ts for the chat-client adapter shape. */ export function detectAdapter(c: Context): AgentAdapter { const agentOverride = c.req.header("x-meridian-agent")?.toLowerCase() diff --git a/src/proxy/sdkFeatures.ts b/src/proxy/sdkFeatures.ts index a1250ec3..5fac09d9 100644 --- a/src/proxy/sdkFeatures.ts +++ b/src/proxy/sdkFeatures.ts @@ -130,12 +130,17 @@ export function getFeaturesForAdapter(adapterName: string): AdapterFeatures { } /** - * Get the full config for all adapters (for the settings UI). + * Get the full config for all adapters (for the settings UI). Sources its + * adapter list from `ADAPTER_NAMES` so adding an entry in + * `adapters/detect.ts` automatically surfaces here. Previously a hardcoded + * list that drifted — `claude-code` was added to `ADAPTER_MAP` but never + * here, so its feature toggles had no UI representation. */ export function getAllFeatureConfigs(): Record { - const adapters = ["opencode", "crush", "forgecode", "pi", "droid", "passthrough"] + // Lazy require — avoids any chance of import cycle on the leaf module path. + const { ADAPTER_NAMES } = require("./adapters/detect") as typeof import("./adapters/detect") const result: Record = {} - for (const name of adapters) { + for (const name of ADAPTER_NAMES) { result[name] = getFeaturesForAdapter(name) } return result diff --git a/src/telemetry/settingsPage.ts b/src/telemetry/settingsPage.ts index 84cc54cd..c8803939 100644 --- a/src/telemetry/settingsPage.ts +++ b/src/telemetry/settingsPage.ts @@ -1,9 +1,16 @@ /** * SDK Features settings page — per-adapter toggle UI. * Same dark theme as the telemetry dashboard. No framework, no CDN. + * + * The list of adapters rendered here is interpolated from + * `proxy/adapters/detect.ADAPTER_LABELS` so adding an adapter to + * `ADAPTER_MAP` automatically surfaces its toggles in the UI. Previously + * this file held a hardcoded list that drifted (the `claude-code` adapter + * was registered for detection but never rendered its config here). */ import { profileBarCss, profileBarHtml, profileBarJs, themeCss } from "./profileBar" +import { ADAPTER_LABELS as ADAPTER_LABELS_SOURCE } from "../proxy/adapters/detect" export const settingsPageHtml = ` @@ -126,14 +133,7 @@ const FEATURES = [ { key: 'additionalDirectories', label: 'Additional Directories', desc: 'Comma-separated extra paths Claude can access (monorepo libs, etc.)', type: 'text' }, ]; -const ADAPTER_LABELS = { - opencode: 'OpenCode', - crush: 'Crush', - forgecode: 'ForgeCode', - pi: 'Pi', - droid: 'Droid', - passthrough: 'LiteLLM / Passthrough', -}; +const ADAPTER_LABELS = ${JSON.stringify(ADAPTER_LABELS_SOURCE)}; let currentConfig = {};