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." diff --git a/src/index.ts b/src/index.ts index 33b15b9..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 */ @@ -1561,32 +1613,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/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); 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 () => {