Skip to content

fix: register claude-code transforms so tool calls pass through - #735

Closed
wayniacal wants to merge 1 commit into
rynfar:mainfrom
wayniacal:fix/claudecode-transforms
Closed

fix: register claude-code transforms so tool calls pass through#735
wayniacal wants to merge 1 commit into
rynfar:mainfrom
wayniacal:fix/claudecode-transforms

Conversation

@wayniacal

Copy link
Copy Markdown
Contributor

ADAPTER_TRANSFORMS has no entry for claude-code, so getAdapterTransforms("claude-code") returns [] and a Claude Code client keeps the createRequestContext defaults: blockedTools: [], passthrough: undefined. The SDK subprocess then runs the client's task itself, on the proxy host, with its own built-in Read/Write/Bash, while the CLI executes the same tool_use blocks locally. Every side effect happens twice.

claudeCodeAdapter.usesPassthrough() already returns resolvePassthrough(true), but nothing calls it. server.ts reads pipelineCtx.*. MERIDIAN_PASSTHROUGH=1 does not reach this path either.

Same class as #546, where openai had to be added to the transform's adapters array for the same reason.

How it surfaced

A scheduled agent on 1.57.0 that edits a git repo and posts a summary through an HTTP API. One run produced three posts:

12:02:10.431  post A   CLI executed the post command
12:02:10.444  post B   SDK subprocess ran the identical command, 13 ms later
12:02:17.383  post C   second SDK session, its own wording

Proxy log for that run:

[PROXY] 4ab4f7fb adapter=claude-code model=opus[1m] tools=27 lineage=new session=new
[PROXY] 652b45ff adapter=claude-code model=opus[1m] tools=0  lineage=new session=new

652b45ff is the CLI's tools=0 structured-output request (conversation title). With no transform, that one also became a full agent with built-in tools, so it did the entire task independently: its own file edits, its own commits, a racing push to the same branch. Its transcript records it reading the other session's concurrent edits and concluding a linter had reformatted the file, then deleting 21 lines to "dedupe". It emitted the title at the end, after the side effects.

Anything a Claude Code client does through Meridian is currently doubled this way. Writes, commits, HTTP calls.

Fix

transforms/claudecode.ts mirrors claudeCodeAdapter, registered under adapter.name ("claude-code", not "claudecode"). Core tool names stay PascalCase, since Claude Code's toolkit is Read/Write/Edit/Bash/Glob/Grep; reusing openCodeTransforms verbatim would hand the deferral logic OpenCode's lowercase names and defer the tools it means to load eagerly.

Tests

Parity suite extended for claude-code, including a check that the registry entry exists at all, since that is the failure here rather than a wrong value. Full suite passes (2106 + the isolated files, 0 failures).

Verified against the live proxy with an agent asked to append one line to a file:

before   2 lines written, meridian-side session tool calls: {'Bash': 1, …}
after    1 line written,  meridian-side session tool calls: {'mcp__oc__Bash': 1}
         [PROXY] deferred=23/27 tools (core: Read,Write,Edit,Bash,Glob,Grep)

server.ts reads pipelineCtx.*, never the adapter methods, and
ADAPTER_TRANSFORMS had no entry for adapter "claude-code". Claude Code
clients therefore got the createRequestContext defaults, blockedTools
empty and passthrough undefined, so the SDK subprocess ran the client's
task internally on the proxy host with its own built-in Read/Write/Bash
while the CLI executed the same tool_use blocks locally. Every side
effect happened twice, milliseconds apart.

Observed on a scheduled agent: one run produced three near identical
API posts and two racing git pushes to the same branch, from three
agents (the CLI, the SDK mirror of its turn, and a second SDK session
spawned by the CLI's tools=0 structured-output request).

Mirrors claudeCodeAdapter, held in sync by the parity tests. Same class
of bug as rynfar#546.
@rynfar

rynfar commented Jul 31, 2026

Copy link
Copy Markdown
Owner

I can review this tonight or tomorrow thanks for the submission

@rynfar

rynfar commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Merged as 94ba52f (#743), cherry-picked so the commit keeps your authorship. Thank you — this is a serious bug and the write-up made it fast to confirm.

The diagnosis is exactly right, including the part that's easy to miss: claudeCodeAdapter.usesPassthrough() looks like it covers this, but server.ts resolves from pipelineCtx.* and never calls the adapter methods, so it was dead code. Registering under adapter.name ("claude-code", not "claudecode") is the other trap, and I kept your comment on it — the file is claudecode.ts, so that mismatch is precisely the kind of thing someone "tidies" into a regression later.

Your PascalCase reasoning is also right and worth stating for the record: reusing openCodeTransforms verbatim would hand the deferral logic OpenCode's lowercase names and defer the very tools it means to load eagerly.

One correction

The PR says MERIDIAN_PASSTHROUGH=1 "does not reach this path either". It does — envBool("PASSTHROUGH") is the final fallback in the resolution chain:

const passthrough = adapter.instancePassthrough !== undefined
  ? adapter.instancePassthrough
  : pipelineCtx.passthrough !== undefined
    ? pipelineCtx.passthrough
    : envBool("PASSTHROUGH")

That narrows the blast radius to default installs rather than everyone, and it explains why this went unnoticed on my side: this machine sets both MERIDIAN_PASSTHROUGH=1 and MERIDIAN_DEFAULT_AGENT=pi, so it was insulated twice over. Doesn't change the fix — the default should match the adapter rather than depend on an env var — but the scoping matters for anyone reading this later.

On reproducing the doubling

I confirmed the precondition on a default install: mode: internal, adapter=claude-code, and an empty disallowedTools reaching the SDK.

I did not see two writes in a single live run — that time the model used the bridged tool. The doubling is possible rather than guaranteed: with built-ins unblocked, the SDK may act host-side in addition to delegating, and which path it takes varies per run. That makes your transcript more valuable, not less — you caught the case where it took both, and the unblocked-built-ins state that allows it is deterministic, which is what the tests now pin.

Added on top

A server-level test in proxy-tool-blocking.test.ts. Your parity tests assert the transform's values, and all of them would have passed while this was broken — the defect was the wiring between the registry and the request. The new cases drive a claude-cli User-Agent under default-install conditions and assert the SDK actually receives blocked built-ins, including one named for the exact broken state (disallowedTools empty). Verified load-bearing: removing the registry entry fails all three, alongside your registration check.

Your "is registered under the adapter's own name" test was the right instinct, by the way — asserting the entry exists rather than only that its values are correct is what catches this class.

Filed separately

Verifying this turned up something adjacent: a claude-code client is told the proxy's working directory, so the model can build absolute paths that write to the wrong machine's tree — #744. Pre-existing and not caused by your change (extractClientWorkingDirectory doesn't branch on passthrough), but easier to hit now that tool execution correctly happens client-side.

Closing this PR since the commit is on mainmain requires signed commits, so fork PRs can't be merged directly and cherry-picking is the path in. Not a rejection of the work; it's all here.

@rynfar rynfar closed this Aug 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants