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 () => {