Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
171 changes: 171 additions & 0 deletions src/__tests__/cherry-studio-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>([
...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()
})
})
133 changes: 133 additions & 0 deletions src/proxy/adapters/cherrystudio.ts
Original file line number Diff line number Diff line change
@@ -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<string, any> {
return {}
},

buildSdkHooks(_body: any, _sdkAgents: Record<string, any>): undefined {
return undefined
},

buildSystemContextAddendum(_body: any, _sdkAgents: Record<string, any>): 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
},
}
Loading
Loading