From 6ccfc703f08d6835507b617fe11c29bdeb80ad3b Mon Sep 17 00:00:00 2001 From: 0xPuncker <22941237+0xPuncker@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:02:56 -0300 Subject: [PATCH 1/3] fix(openrouter): stop leaking Anthropic auth headers to fallback providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildProviderHeaders forwarded all of Claude Code's incoming headers to OpenRouter/Z.AI except 4 hop-by-hop ones, including x-api-key. OpenRouter treats a stray x-api-key as a restricted data-policy request and returns '404: No endpoints available matching your guardrail restrictions' — so the OpenRouter fallback was dead for every real (Claude Code) request, even though the model slug and endpoint were correct. Strip Anthropic-specific routing/auth headers (x-api-key, anthropic-version, anthropic-beta, anthropic-dangerous-direct-browser-access) and content-length (recomputed after cleanBody) from the cross-provider passthrough. Verified end-to-end: OpenRouter now returns 200 with full Opus via ~anthropic/claude-opus-latest. --- src/index.ts | 37 +++++++++++++++++++++---------------- tests/proxy.test.js | 19 ++++++++++++++++++- 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/src/index.ts b/src/index.ts index 33b15b9..b640acc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1561,32 +1561,37 @@ export class ClaudeCodeProxy { * Build request headers for a specific provider */ private buildProviderHeaders(provider: 'anthropic' | 'zai' | 'openrouter', reqHeaders: Record): Record { - if (provider === 'zai') { - const headers: Record = { - host: new URL(this.config.zai.baseUrl).host, - authorization: `Bearer ${this.config.zai.apiKey}`, - }; - // Copy allowed headers + // Headers that must NOT be forwarded to a non-Anthropic upstream. Beyond the + // hop-by-hop/auth headers, Anthropic-specific routing headers — especially + // `x-api-key` — make OpenRouter route the request into a restricted data-policy + // path and return `404: No endpoints available matching your guardrail + // restrictions`. `content-length` is recomputed after cleanBody, so drop the + // client's value too. + const blockedForwardHeaders = [ + "authorization", "transfer-encoding", "connection", "host", "content-length", + "x-api-key", "anthropic-version", "anthropic-beta", "anthropic-dangerous-direct-browser-access", + ]; + const copyAllowed = (headers: Record) => { for (const [key, value] of Object.entries(reqHeaders)) { - if (!["authorization", "transfer-encoding", "connection", "host"].includes(key)) { + if (!blockedForwardHeaders.includes(key.toLowerCase())) { headers[key] = value; } } return headers; + }; + + if (provider === 'zai') { + return copyAllowed({ + host: new URL(this.config.zai.baseUrl).host, + authorization: `Bearer ${this.config.zai.apiKey}`, + }); } else if (provider === 'openrouter') { - const headers: Record = { + return copyAllowed({ host: new URL(this.config.openrouter.baseUrl).host, authorization: `Bearer ${this.config.openrouter.apiKey}`, "HTTP-Referer": "https://claude.ai/code", "X-Title": "Claude Code", - }; - // Copy allowed headers - for (const [key, value] of Object.entries(reqHeaders)) { - if (!["authorization", "transfer-encoding", "connection", "host"].includes(key)) { - headers[key] = value; - } - } - return headers; + }); } else { // Anthropic headers return this.cleanHeaders(reqHeaders); diff --git a/tests/proxy.test.js b/tests/proxy.test.js index 0e50c3d..b8ca48c 100644 --- a/tests/proxy.test.js +++ b/tests/proxy.test.js @@ -557,10 +557,12 @@ describe("Claude Code Proxy provider request normalization", () => { const proxy = createProxy(); let capturedUrl = ""; let capturedBody = ""; + let capturedHeaders = {}; proxy.httpRequest = async (url, options) => { capturedUrl = url; capturedBody = String(options.body); + capturedHeaders = options.headers; return { status: 200, headers: options.headers, @@ -577,7 +579,14 @@ describe("Claude Code Proxy provider request normalization", () => { metadata: { source: "test" }, extra_field: "should-be-stripped", }), - { "content-type": "application/json" }, + { + "content-type": "application/json", + // Claude Code always sends these Anthropic-specific headers; forwarding + // x-api-key to OpenRouter triggers a guardrail 404. + "x-api-key": "sk-ant-should-not-leak", + "anthropic-version": "2023-06-01", + "anthropic-beta": "claude-code-20250219", + }, "/v1/messages?beta=true", "POST", ); @@ -588,6 +597,14 @@ describe("Claude Code Proxy provider request normalization", () => { assert.equal(parsedBody.model, "~anthropic/claude-sonnet-latest"); assert.equal(parsedBody.extra_field, undefined); assert.deepEqual(parsedBody.metadata, { source: "test" }); + + // Anthropic-specific headers must NOT reach OpenRouter (cause guardrail 404). + assert.equal(capturedHeaders["x-api-key"], undefined); + assert.equal(capturedHeaders["anthropic-version"], undefined); + assert.equal(capturedHeaders["anthropic-beta"], undefined); + // OpenRouter's own auth + attribution headers are present. + assert.equal(capturedHeaders.authorization, "Bearer openrouter-test-key"); + assert.equal(capturedHeaders["HTTP-Referer"], "https://claude.ai/code"); }); it("normalizes OpenRouter message responses to strict Anthropic shape", async () => { From 766e5079cee661912ee5e9abf05040cb239dbb29 Mon Sep 17 00:00:00 2001 From: 0xPuncker <22941237+0xPuncker@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:06:43 -0300 Subject: [PATCH 2/3] fix(context-window): drop orphaned tool_result at truncation boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a conversation exceeded the context-window threshold, truncation kept a contiguous suffix of the most recent messages but could leave a `user` turn whose leading `tool_result` referenced a `tool_use` from an assistant message that was just dropped. Anthropic rejected this with: messages.0.content.0: unexpected `tool_use_id` found in `tool_result` blocks ... Each `tool_result` block must have a corresponding `tool_use` block in the previous message. The subscription request 400'd and both fallbacks (Anthropic API / Z.AI) also failed, surfacing a hard error to the client on long agentic sessions. Fix: after selecting the kept window, trim from the front until it starts on a valid leading turn (a `user` message with no orphaned tool_result). Front-only trimming is safe — every tool_use/tool_result pair inside the suffix stays intact; only the boundary can be orphaned. Guard the degenerate empty-window case by skipping truncation rather than emitting empty messages. Adds tests for orphan repair and pair integrity within the kept window. --- src/index.ts | 52 ++++++++++++++++++++++ tests/context-window.test.js | 84 ++++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) diff --git a/src/index.ts b/src/index.ts index b640acc..e8d199f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -945,6 +945,34 @@ export class ClaudeCodeProxy { runningTotal += msgTokens; } + // Repair the front boundary. The kept window is a contiguous suffix of + // the original messages, so every tool_use/tool_result pair *inside* it + // is intact — but the first kept message may be a `user` turn whose + // `tool_result` blocks reference a `tool_use` from an assistant message + // that was just truncated away. Anthropic rejects that with + // "messages.0.content.0: unexpected tool_use_id found in tool_result + // blocks". Drop leading messages until the window starts on a valid turn + // (a `user` message carrying no orphaned tool_result). + let trimmed = 0; + while ( + truncatedMessages.length > 0 && + !this.isValidLeadingMessage(truncatedMessages[0]) + ) { + truncatedMessages.shift(); + trimmed++; + } + if (trimmed > 0) { + this.logger.warn(`Context window: dropped ${trimmed} leading message(s) to avoid orphaned tool_result at the truncation boundary`); + } + + // If front-repair emptied the window (no clean user turn fit within the + // budget), truncating would produce an invalid empty `messages` array. + // Leave the body untouched and let the upstream provider decide. + if (truncatedMessages.length === 0) { + this.logger.warn('Context window: no valid leading message fits within budget — skipping truncation'); + return bodyStr; + } + truncatedBody.messages = truncatedMessages; const truncated = JSON.stringify(truncatedBody); @@ -956,6 +984,30 @@ export class ClaudeCodeProxy { } } + /** + * Whether a message can legally be the first entry in the `messages` array + * sent to Anthropic: it must be a `user` turn and must not carry a + * `tool_result` block (which would require a preceding assistant `tool_use`). + */ + private isValidLeadingMessage(msg: unknown): boolean { + if (!msg || typeof msg !== 'object') return false; + if ((msg as Record).role !== 'user') return false; + return !this.messageContainsToolResult(msg); + } + + /** True if any content block of the message is a `tool_result`. */ + private messageContainsToolResult(msg: unknown): boolean { + if (!msg || typeof msg !== 'object') return false; + const content = (msg as Record).content; + if (!Array.isArray(content)) return false; + return content.some( + (block) => + !!block && + typeof block === 'object' && + (block as Record).type === 'tool_result' + ); + } + /** * Apply context window management to request body if enabled */ diff --git a/tests/context-window.test.js b/tests/context-window.test.js index 4eaca89..6ab4f81 100644 --- a/tests/context-window.test.js +++ b/tests/context-window.test.js @@ -126,6 +126,90 @@ describe('Context Window Management', () => { assert.deepStrictEqual(lastTruncated, lastOriginal); }); + it('should not leave an orphaned tool_result as the first message after truncation', () => { + proxy = new ClaudeCodeProxy(); + const truncateMessagesToFit = proxy.truncateMessagesToFit.bind(proxy); + + // Simulate a Claude Code agentic conversation: alternating + // user → assistant(tool_use) → user(tool_result) → ... that overflows. + const filler = 'context filler text to grow the token count. '.repeat(8); + const messages = []; + for (let i = 0; i < 40; i++) { + messages.push({ role: 'user', content: `turn ${i} ${filler}` }); + messages.push({ + role: 'assistant', + content: [{ type: 'tool_use', id: `toolu_${i}`, name: 'Read', input: {} }] + }); + messages.push({ + role: 'user', + content: [{ type: 'tool_result', tool_use_id: `toolu_${i}`, content: filler }] + }); + } + + const requestBody = JSON.stringify({ + model: 'claude-opus-4-6', + messages, + max_tokens: 4096 + }); + + const truncated = truncateMessagesToFit(requestBody, 6000, 0.5); + const truncatedBody = JSON.parse(truncated); + + // Truncation must have occurred... + assert.ok(truncatedBody.messages.length > 0); + assert.ok(truncatedBody.messages.length < messages.length); + + // ...and the first kept message must be a valid leading turn: a `user` + // message with no orphaned tool_result block (the exact 400 we hit). + const first = truncatedBody.messages[0]; + assert.strictEqual(first.role, 'user'); + const firstHasToolResult = + Array.isArray(first.content) && + first.content.some((b) => b && b.type === 'tool_result'); + assert.strictEqual(firstHasToolResult, false); + }); + + it('should keep tool_use/tool_result pairs intact within the kept window', () => { + proxy = new ClaudeCodeProxy(); + const truncateMessagesToFit = proxy.truncateMessagesToFit.bind(proxy); + + const filler = 'pair integrity filler. '.repeat(8); + const messages = []; + for (let i = 0; i < 30; i++) { + messages.push({ role: 'user', content: `turn ${i} ${filler}` }); + messages.push({ + role: 'assistant', + content: [{ type: 'tool_use', id: `toolu_${i}`, name: 'Read', input: {} }] + }); + messages.push({ + role: 'user', + content: [{ type: 'tool_result', tool_use_id: `toolu_${i}`, content: filler }] + }); + } + + const truncated = truncateMessagesToFit( + JSON.stringify({ model: 'claude-opus-4-6', messages, max_tokens: 4096 }), + 6000, + 0.5 + ); + const kept = JSON.parse(truncated).messages; + + // Every tool_result in the kept window has its matching tool_use earlier. + const seenToolUseIds = new Set(); + for (const msg of kept) { + if (!Array.isArray(msg.content)) continue; + for (const block of msg.content) { + if (block?.type === 'tool_use') seenToolUseIds.add(block.id); + if (block?.type === 'tool_result') { + assert.ok( + seenToolUseIds.has(block.tool_use_id), + `orphaned tool_result ${block.tool_use_id} in kept window` + ); + } + } + } + }); + it('should not truncate when within context window limit', () => { proxy = new ClaudeCodeProxy(); const truncateMessagesToFit = proxy.truncateMessagesToFit.bind(proxy); From 86054af2fc629f4fcdaacaacde2a7de9af814a05 Mon Sep 17 00:00:00 2001 From: 0xPuncker <22941237+0xPuncker@users.noreply.github.com> Date: Thu, 25 Jun 2026 05:29:48 -0300 Subject: [PATCH 3/3] fix(credential-sync): install to stable ~/.claude path, not workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The LaunchAgent was pointed at the sync script inside a Conductor workspace (.../worcester/scripts/sync-credentials.sh). When that workspace was deleted the script vanished, the agent failed every 5 min (exit 127), and the mounted credentials file went stale — the subscription OAuth refresh token rotated on the host and Anthropic invalidated the proxy's copy (invalid_grant), taking the subscription provider offline. - setup-credential-sync.sh now installs sync-credentials.sh to ~/.claude/ (workspace-independent) and generates the plist pointing there, instead of sed-rewriting a hardcoded per-user workspace path. Uses bootstrap/bootout with load/unload fallback and kickstarts once to verify. - Update the committed plist template to the stable ~/.claude path. Survives workspace deletion; the proxy reads the file per-request so refreshed tokens are picked up with no restart. --- com.claude.sync-credentials.plist | 2 +- scripts/setup-credential-sync.sh | 85 +++++++++++++++++++++++-------- 2 files changed, 66 insertions(+), 21 deletions(-) diff --git a/com.claude.sync-credentials.plist b/com.claude.sync-credentials.plist index 4ffbb7d..86f282b 100644 --- a/com.claude.sync-credentials.plist +++ b/com.claude.sync-credentials.plist @@ -7,7 +7,7 @@ ProgramArguments /bin/bash - /Users/raulneiva/conductor/workspaces/claude-code-proxy/worcester/scripts/sync-credentials.sh + /Users/raulneiva/.claude/sync-credentials.sh StartInterval 300 diff --git a/scripts/setup-credential-sync.sh b/scripts/setup-credential-sync.sh index 84262b6..90e1218 100755 --- a/scripts/setup-credential-sync.sh +++ b/scripts/setup-credential-sync.sh @@ -1,40 +1,85 @@ #!/usr/bin/env bash -# Install LaunchAgent to auto-sync Claude credentials every 5 minutes +# Install a LaunchAgent that auto-syncs the Claude OAuth token from the macOS +# Keychain to ~/.claude/claude-credentials.json every 5 minutes, so the proxy +# container (which can't read the Keychain) always has a fresh token. +# +# The sync script and the LaunchAgent are installed to STABLE, workspace- +# independent locations under $HOME — never inside a Conductor workspace. +# Pointing the agent at a workspace path is what broke this before: the +# workspace was deleted, the script vanished, and the token went stale. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -PLIST_FILE="$PROJECT_ROOT/com.claude.sync-credentials.plist" +SOURCE_SCRIPT="$SCRIPT_DIR/sync-credentials.sh" + +# Stable install locations (independent of any workspace) +INSTALL_DIR="$HOME/.claude" +INSTALLED_SCRIPT="$INSTALL_DIR/sync-credentials.sh" LAUNCH_AGENTS_DIR="$HOME/Library/LaunchAgents" -TARGET_PLIST="$LAUNCH_AGENTS_DIR/com.claude.sync-credentials.plist" +LABEL="com.claude.sync-credentials" +TARGET_PLIST="$LAUNCH_AGENTS_DIR/$LABEL.plist" echo "📦 Installing Claude credential sync LaunchAgent..." -# Ensure LaunchAgents directory exists -mkdir -p "$LAUNCH_AGENTS_DIR" +mkdir -p "$INSTALL_DIR" "$LAUNCH_AGENTS_DIR" + +# Install the sync script to the stable location so it survives workspace +# deletion. The script itself has no workspace dependencies. +install -m 0755 "$SOURCE_SCRIPT" "$INSTALLED_SCRIPT" -# Copy plist to LaunchAgents directory -cp "$PLIST_FILE" "$TARGET_PLIST" +# Generate the plist pointing at the stable script path. +cat > "$TARGET_PLIST" < + + + + Label + $LABEL + ProgramArguments + + /bin/bash + $INSTALLED_SCRIPT + + StartInterval + 300 + RunAtLoad + + StandardOutPath + /tmp/claude-sync-credentials.log + StandardErrorPath + /tmp/claude-sync-credentials.err + + +PLIST -# Update the script path in the plist to use the absolute path -/usr/bin/sed -i '' "s|/Users/raulneiva/conductor/workspaces/claude-code-proxy/worcester/scripts/sync-credentials.sh|$SCRIPT_DIR/sync-credentials.sh|g" "$TARGET_PLIST" +# (Re)load the LaunchAgent. Prefer the modern bootstrap API, fall back to +# load/unload on older macOS. +GUI_DOMAIN="gui/$(id -u)" +if /bin/launchctl bootout "$GUI_DOMAIN/$LABEL" 2>/dev/null; then :; fi +if ! /bin/launchctl bootstrap "$GUI_DOMAIN" "$TARGET_PLIST" 2>/dev/null; then + /bin/launchctl unload "$TARGET_PLIST" 2>/dev/null || true + /bin/launchctl load "$TARGET_PLIST" +fi -# Load the LaunchAgent -/bin/launchctl unload "$TARGET_PLIST" 2>/dev/null || true -/bin/launchctl load "$TARGET_PLIST" +# Run it once immediately and confirm it succeeded. +/bin/launchctl kickstart -k "$GUI_DOMAIN/$LABEL" 2>/dev/null || true +sleep 2 echo "✅ LaunchAgent installed and started!" echo "" echo "Details:" -echo " - Sync interval: Every 5 minutes" -echo " - Log file: /tmp/claude-sync-credentials.log" -echo " - Error log: /tmp/claude-sync-credentials.err" +echo " - Script: $INSTALLED_SCRIPT" +echo " - Plist: $TARGET_PLIST" +echo " - Sync interval: Every 5 minutes (+ at login)" +echo " - Log file: /tmp/claude-sync-credentials.log" +echo " - Error log: /tmp/claude-sync-credentials.err" echo "" echo "Commands:" -echo " - Start: launchctl load $TARGET_PLIST" -echo " - Stop: launchctl unload $TARGET_PLIST" -echo " - Status: launchctl list | grep com.claude" +echo " - Status: launchctl list | grep $LABEL (Status 0 = last run OK)" +echo " - Run now: launchctl kickstart -k $GUI_DOMAIN/$LABEL" +echo " - Stop: launchctl bootout $GUI_DOMAIN/$LABEL" echo " - Logs: tail -f /tmp/claude-sync-credentials.log" echo "" -echo "The proxy container will automatically pick up credential changes." +echo "The proxy container reads the credentials file on every request, so it" +echo "picks up refreshed tokens automatically — no container restart needed."