diff --git a/src/__tests__/transform-parity.test.ts b/src/__tests__/transform-parity.test.ts index 3d4f5cc8..3f86e665 100644 --- a/src/__tests__/transform-parity.test.ts +++ b/src/__tests__/transform-parity.test.ts @@ -12,6 +12,9 @@ import { droidAdapter } from "../proxy/adapters/droid" import { piAdapter } from "../proxy/adapters/pi" import { forgeCodeAdapter } from "../proxy/adapters/forgecode" import { passthroughAdapter } from "../proxy/adapters/passthrough" +import { claudeCodeTransforms } from "../proxy/transforms/claudecode" +import { claudeCodeAdapter } from "../proxy/adapters/claudecode" +import { getAdapterTransforms } from "../proxy/transforms/registry" function makeCtx(adapter: string, body: any = {}) { return createRequestContext({ @@ -229,3 +232,72 @@ describe("Passthrough (LiteLLM) transform parity", () => { expect([...ctx.blockedTools]).toEqual([...passthroughAdapter.getBlockedBuiltinTools()]) }) }) + +// The Claude Code CLI drives its own tool loop. If registry.ts has no entry for +// adapter "claude-code" (or the transform's `adapters` array omits it), the SDK +// runs the client's task internally on the proxy host with built-ins unblocked +// — every Bash/Edit executes twice, once per side. Same class as #546. +describe("Claude Code transform parity", () => { + let savedMP: string | undefined + let savedCP: string | undefined + beforeEach(() => { + savedMP = process.env.MERIDIAN_PASSTHROUGH + savedCP = process.env.CLAUDE_PROXY_PASSTHROUGH + delete process.env.MERIDIAN_PASSTHROUGH + delete process.env.CLAUDE_PROXY_PASSTHROUGH + }) + afterEach(() => { + if (savedMP !== undefined) process.env.MERIDIAN_PASSTHROUGH = savedMP + else delete process.env.MERIDIAN_PASSTHROUGH + if (savedCP !== undefined) process.env.CLAUDE_PROXY_PASSTHROUGH = savedCP + else delete process.env.CLAUDE_PROXY_PASSTHROUGH + }) + + it("is registered under the adapter's own name", () => { + expect(getAdapterTransforms(claudeCodeAdapter.name)).toBe(claudeCodeTransforms) + }) + + it("matches blockedTools", () => { + const ctx = runTransformHook(claudeCodeTransforms, "onRequest", makeCtx("claude-code"), "claude-code") + expect([...ctx.blockedTools]).toEqual([...claudeCodeAdapter.getBlockedBuiltinTools()]) + expect(ctx.blockedTools.length).toBeGreaterThan(0) + }) + + it("matches incompatibleTools and allowedMcpTools", () => { + const ctx = runTransformHook(claudeCodeTransforms, "onRequest", makeCtx("claude-code"), "claude-code") + expect([...ctx.incompatibleTools]).toEqual([...claudeCodeAdapter.getAgentIncompatibleTools()]) + expect([...ctx.allowedMcpTools]).toEqual([...claudeCodeAdapter.getAllowedMcpTools()]) + }) + + it("matches coreToolNames (PascalCase, not OpenCode's lowercase)", () => { + const ctx = runTransformHook(claudeCodeTransforms, "onRequest", makeCtx("claude-code"), "claude-code") + expect([...ctx.coreToolNames!]).toEqual([...claudeCodeAdapter.getCoreToolNames!()]) + }) + + it("matches passthrough (default on)", () => { + const ctx = runTransformHook(claudeCodeTransforms, "onRequest", makeCtx("claude-code"), "claude-code") + expect(ctx.passthrough).toBe(claudeCodeAdapter.usesPassthrough!()) + expect(ctx.passthrough).toBe(true) + }) + + it("matches passthrough (env off → false)", () => { + process.env.MERIDIAN_PASSTHROUGH = "0" + const ctx = runTransformHook(claudeCodeTransforms, "onRequest", makeCtx("claude-code"), "claude-code") + expect(ctx.passthrough).toBe(claudeCodeAdapter.usesPassthrough!()) + expect(ctx.passthrough).toBe(false) + }) + + it("matches supportsThinking and shouldTrackFileChanges", () => { + const ctx = runTransformHook(claudeCodeTransforms, "onRequest", makeCtx("claude-code"), "claude-code") + expect(ctx.supportsThinking).toBe(claudeCodeAdapter.supportsThinking!()) + expect(ctx.shouldTrackFileChanges).toBe(claudeCodeAdapter.shouldTrackFileChanges!()) + }) + + it("matches file change extraction for Edit and Bash", () => { + const ctx = runTransformHook(claudeCodeTransforms, "onRequest", makeCtx("claude-code"), "claude-code") + expect(ctx.extractFileChangesFromToolUse!("Edit", { file_path: "/a.ts" })) + .toEqual(claudeCodeAdapter.extractFileChangesFromToolUse!("Edit", { file_path: "/a.ts" })) + expect(ctx.extractFileChangesFromToolUse!("Bash", { command: "echo hi > /tmp/a" })) + .toEqual(claudeCodeAdapter.extractFileChangesFromToolUse!("Bash", { command: "echo hi > /tmp/a" })) + }) +}) diff --git a/src/proxy/transforms/claudecode.ts b/src/proxy/transforms/claudecode.ts new file mode 100644 index 00000000..095a74f2 --- /dev/null +++ b/src/proxy/transforms/claudecode.ts @@ -0,0 +1,58 @@ +import type { Transform, RequestContext } from "../transform" +import { extractFileChangesFromBash, type FileChange } from "../fileChanges" +import { BLOCKED_BUILTIN_TOOLS, CLAUDE_CODE_ONLY_TOOLS, ALLOWED_MCP_TOOLS } from "../tools" +import { resolvePassthrough } from "../../env" + +/** + * Claude Code transform — supplies the SDK tool config at request time. + * + * server.ts reads `pipelineCtx.*`, never the adapter methods, so without an + * entry here a Claude Code client gets the createRequestContext defaults: + * `blockedTools: []` and `passthrough: undefined`. That means the SDK + * subprocess runs the client's task with its own built-in Read/Write/Bash on + * the proxy host while the client executes the same tool calls locally — + * every side effect happens twice (#546, same failure mode as OpenCode's). + * + * Values mirror claudeCodeAdapter (adapters/claudecode.ts); the parity tests + * hold the two in sync. Core tool names are PascalCase here — Claude Code's + * toolkit is Read/Write/Edit/Bash/Glob/Grep, not OpenCode's lowercase names. + */ +export const claudeCodeTransforms: Transform[] = [ + { + name: "claudecode-core", + adapters: ["claude-code"], + + onRequest(ctx: RequestContext): RequestContext { + const extractFileChangesFromToolUse = (toolName: string, toolInput: unknown): FileChange[] => { + const input = toolInput as Record | null | undefined + const filePath = input?.file_path ?? input?.filePath ?? input?.path + const lowerName = toolName.toLowerCase() + if (lowerName === "write" && filePath) { + return [{ operation: "wrote", path: String(filePath) }] + } + if ((lowerName === "edit" || lowerName === "multiedit") && filePath) { + return [{ operation: "edited", path: String(filePath) }] + } + if (lowerName === "bash" && input?.command) { + return extractFileChangesFromBash(String(input.command)) + } + return [] + } + + return { + ...ctx, + blockedTools: BLOCKED_BUILTIN_TOOLS, + incompatibleTools: CLAUDE_CODE_ONLY_TOOLS, + allowedMcpTools: ALLOWED_MCP_TOOLS, + coreToolNames: ["Read", "Write", "Edit", "Bash", "Glob", "Grep"], + // Claude Code owns tool execution client-side. Mirrors + // claudeCodeAdapter.usesPassthrough(). + passthrough: resolvePassthrough(true), + supportsThinking: true, + // Claude Code surfaces its own file edits; don't duplicate them. + shouldTrackFileChanges: false, + extractFileChangesFromToolUse, + } + }, + }, +] diff --git a/src/proxy/transforms/registry.ts b/src/proxy/transforms/registry.ts index fe63a329..06c8b241 100644 --- a/src/proxy/transforms/registry.ts +++ b/src/proxy/transforms/registry.ts @@ -7,6 +7,7 @@ import { forgeCodeTransforms } from "./forgecode" import { passthroughTransforms } from "./passthrough" import { cherryTransforms } from "./cherry" import { codexTransforms } from "./codex" +import { claudeCodeTransforms } from "./claudecode" const ADAPTER_TRANSFORMS: Record = { opencode: openCodeTransforms, @@ -16,6 +17,10 @@ const ADAPTER_TRANSFORMS: Record = { forgecode: forgeCodeTransforms, passthrough: passthroughTransforms, cherry: cherryTransforms, + // Keyed by adapter.name ("claude-code", not "claudecode"). Without this the + // Claude Code CLI falls through to the empty default — built-ins unblocked, + // passthrough off — and the SDK re-executes every tool call on the proxy host. + "claude-code": claudeCodeTransforms, // The OpenAI-compatible endpoint reuses OpenCode's transforms verbatim so // tool/passthrough behaviour is identical; only the preset default differs // (see sdkFeatures.ADAPTER_DEFAULTS.openai).