From acf75d1280e345982a4a5829d5ab2ad556b424c1 Mon Sep 17 00:00:00 2001 From: kskada <120260513+kskadart@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:29:58 +0300 Subject: [PATCH] Add Claude Code regression test for local agent auth provider hook --- .../local-agent-auth-provider-hook.test.mjs | 125 +++++++++++++++++- 1 file changed, 124 insertions(+), 1 deletion(-) diff --git a/packages/core/test/unit/gateway/local-agent-auth-provider-hook.test.mjs b/packages/core/test/unit/gateway/local-agent-auth-provider-hook.test.mjs index 2ece12b8..45ddc968 100644 --- a/packages/core/test/unit/gateway/local-agent-auth-provider-hook.test.mjs +++ b/packages/core/test/unit/gateway/local-agent-auth-provider-hook.test.mjs @@ -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;