mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-08-29 03:12:10 +08:00
Merge pull request #1705 from kskadart/fix/claude-code-auth-hook-regression
fix(core): cover Claude Code path in local agent auth provider hook
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
@@ -105,6 +105,57 @@ test("Grok local agent auth hook refreshes live login state before authenticatin
|
||||
});
|
||||
});
|
||||
|
||||
// Regression for musistudio/claude-code-router#1628: the imported plugin's
|
||||
// auth.headers carry a token snapshot from import time, but the hook must
|
||||
// resolve the current on-disk token on every call so a rotation picked up by
|
||||
// the interactive Claude Code CLI is honored without a gateway restart.
|
||||
test("Claude Code local agent auth hook re-reads the on-disk access token on every request", { skip: process.platform === "win32" }, async () => {
|
||||
await withClaudeCodeHome(async (home) => {
|
||||
await withPlatform("darwin", async () => {
|
||||
await withFakeSecurityFailure(async () => {
|
||||
writeClaudeCredentials(home, {
|
||||
accessToken: "stale-imported-access-token",
|
||||
refreshToken: "stale-refresh-token"
|
||||
});
|
||||
|
||||
const [hook] = createGatewayPlugin({
|
||||
config: {
|
||||
providerPlugins: [claudeCodeOauthProviderPlugin()]
|
||||
}
|
||||
}).providerHooks;
|
||||
assert.equal(hook.key, "config:ccr-local-agent-claude-code-api-claude-code-oauth");
|
||||
|
||||
const upstreamRequest = {
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-api-key": "client-key"
|
||||
},
|
||||
method: "POST",
|
||||
url: "https://api.anthropic.com/v1/messages"
|
||||
};
|
||||
|
||||
const staleAuth = await hook.authenticate({ upstreamRequest });
|
||||
assert.equal(staleAuth.ok, true);
|
||||
assert.equal(staleAuth.value.headers.authorization, "Bearer stale-imported-access-token");
|
||||
assert.equal(staleAuth.value.headers["x-api-key"], undefined);
|
||||
assert.equal(upstreamRequest.headers["x-api-key"], "client-key");
|
||||
|
||||
// Simulate the interactive Claude Code CLI rotating the shared token
|
||||
// family on disk -- no gateway restart, no re-import.
|
||||
writeClaudeCredentials(home, {
|
||||
accessToken: "rotated-access-token",
|
||||
refreshToken: "rotated-refresh-token"
|
||||
});
|
||||
|
||||
const rotatedAuth = await hook.authenticate({ upstreamRequest });
|
||||
assert.equal(rotatedAuth.ok, true);
|
||||
assert.equal(rotatedAuth.value.headers.authorization, "Bearer rotated-access-token");
|
||||
assert.equal(rotatedAuth.value.headers["anthropic-beta"], "oauth-2025-04-20");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("Grok local agent request hook removes unsupported Responses tools and stale tool choice", () => {
|
||||
const [hook] = createGatewayPlugin({
|
||||
config: {
|
||||
@@ -232,6 +283,78 @@ function grokOauthProviderPlugin() {
|
||||
};
|
||||
}
|
||||
|
||||
function claudeCodeOauthProviderPlugin() {
|
||||
return {
|
||||
auth: {
|
||||
headers: {
|
||||
authorization: "Bearer stale-imported-access-token",
|
||||
"anthropic-beta": "oauth-2025-04-20"
|
||||
},
|
||||
removeHeaders: ["x-api-key"],
|
||||
strict: true
|
||||
},
|
||||
key: "ccr-local-agent-claude-code-api-claude-code-oauth",
|
||||
providerName: "Claude Code API"
|
||||
};
|
||||
}
|
||||
|
||||
async function withClaudeCodeHome(run) {
|
||||
const home = mkdtempSync(path.join(os.tmpdir(), "ccr-claude-code-hook-test-"));
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = home;
|
||||
try {
|
||||
await run(home);
|
||||
} finally {
|
||||
restoreEnv("HOME", previousHome);
|
||||
rmSync(home, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function withPlatform(platform, run) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
Object.defineProperty(process, "platform", {
|
||||
configurable: true,
|
||||
value: platform
|
||||
});
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
Object.defineProperty(process, "platform", descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
// Forces the macOS Keychain lookup to miss so the scan falls back to the file
|
||||
// credentials this test controls, regardless of the host's real Keychain state.
|
||||
async function withFakeSecurityFailure(run) {
|
||||
await withFakeSecurityScript("exit 44\n", run);
|
||||
}
|
||||
|
||||
async function withFakeSecurityScript(body, run) {
|
||||
const binDir = mkdtempSync(path.join(os.tmpdir(), "ccr-claude-code-security-bin-"));
|
||||
const securityPath = path.join(binDir, "security");
|
||||
const previousPath = process.env.PATH;
|
||||
const previousUser = process.env.USER;
|
||||
writeFileSync(securityPath, `#!/bin/sh\n${body}`);
|
||||
chmodSync(securityPath, 0o755);
|
||||
process.env.PATH = `${binDir}${path.delimiter}${previousPath ?? ""}`;
|
||||
process.env.USER = "ccr-test-user";
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
restoreEnv("PATH", previousPath);
|
||||
restoreEnv("USER", previousUser);
|
||||
rmSync(binDir, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function writeClaudeCredentials(home, credentials) {
|
||||
const directory = path.join(home, ".claude");
|
||||
const credentialFile = path.join(directory, ".credentials.json");
|
||||
mkdirSync(directory, { recursive: true });
|
||||
writeFileSync(credentialFile, JSON.stringify(credentials, null, 2));
|
||||
return credentialFile;
|
||||
}
|
||||
|
||||
async function withGrokHome(t, run) {
|
||||
const previousGrokHome = process.env.GROK_HOME;
|
||||
const previousGrokAuthFile = process.env.GROK_AUTH_FILE;
|
||||
|
||||
Reference in New Issue
Block a user