Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion com.claude.sync-credentials.plist
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/raulneiva/conductor/workspaces/claude-code-proxy/worcester/scripts/sync-credentials.sh</string>
<string>/Users/raulneiva/.claude/sync-credentials.sh</string>
</array>
<key>StartInterval</key>
<integer>300</integer>
Expand Down
85 changes: 65 additions & 20 deletions scripts/setup-credential-sync.sh
Original file line number Diff line number Diff line change
@@ -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" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>$LABEL</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>$INSTALLED_SCRIPT</string>
</array>
<key>StartInterval</key>
<integer>300</integer>
<key>RunAtLoad</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/claude-sync-credentials.log</string>
<key>StandardErrorPath</key>
<string>/tmp/claude-sync-credentials.err</string>
</dict>
</plist>
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."
89 changes: 73 additions & 16 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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<string, unknown>).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<string, unknown>).content;
if (!Array.isArray(content)) return false;
return content.some(
(block) =>
!!block &&
typeof block === 'object' &&
(block as Record<string, unknown>).type === 'tool_result'
);
}

/**
* Apply context window management to request body if enabled
*/
Expand Down Expand Up @@ -1561,32 +1613,37 @@ export class ClaudeCodeProxy {
* Build request headers for a specific provider
*/
private buildProviderHeaders(provider: 'anthropic' | 'zai' | 'openrouter', reqHeaders: Record<string, string>): Record<string, string> {
if (provider === 'zai') {
const headers: Record<string, string> = {
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<string, string>) => {
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<string, string> = {
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);
Expand Down
84 changes: 84 additions & 0 deletions tests/context-window.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
19 changes: 18 additions & 1 deletion tests/proxy.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",
);
Expand All @@ -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 () => {
Expand Down
Loading