diff --git a/build/esbuild.config.mjs b/build/esbuild.config.mjs index e92ec79a..2011adcd 100644 --- a/build/esbuild.config.mjs +++ b/build/esbuild.config.mjs @@ -67,6 +67,7 @@ export const cssInput = path.join(rendererRoot, "styles", "globals.css"); export const cssOutput = path.join(rendererAssetsDir, "main.css"); export const webClientBridgeOutput = path.join(rendererAssetsDir, "web-client-bridge.js"); export const electronUndiciProxyAgentInput = path.join(coreSourceRoot, "proxy", "undici-proxy-agent.ts"); +export const localAgentAuthProviderHookInput = path.join(coreSourceRoot, "gateway", "core-runtime", "local-agent-auth-provider-hook.ts"); export const upstreamHeaderSanitizerInput = path.join(coreSourceRoot, "gateway", "core-runtime", "upstream-header-sanitizer.ts"); const lightweightMcpBundleNames = ["browser-web-search-proxy-mcp.js", "fusion-vision-mcp.js", "fusion-tool-fallback-mcp.js", "media-tools-proxy-mcp.js"]; const lightweightMcpBundleMaxBytes = 128 * 1024; @@ -234,6 +235,7 @@ export function createMainBuildOptions({ mode = "production", plugins = [] } = { path.join(coreSourceRoot, "mcp", "toolhub-mcp.ts"), path.join(coreSourceRoot, "observability", "request-log-worker.ts"), path.join(coreSourceRoot, "routing", "route-script-worker.ts"), + localAgentAuthProviderHookInput, upstreamHeaderSanitizerInput, electronUndiciProxyAgentInput, path.join(electronSourceRoot, "main", "preload.ts") @@ -266,6 +268,7 @@ export function createCliBuildOptions({ mode = "production", plugins = [] } = {} path.join(coreSourceRoot, "mcp", "toolhub-mcp.ts"), path.join(coreSourceRoot, "observability", "request-log-worker.ts"), path.join(coreSourceRoot, "routing", "route-script-worker.ts"), + localAgentAuthProviderHookInput, upstreamHeaderSanitizerInput ], external: nodeExternals.filter((moduleName) => moduleName !== "electron"), @@ -295,6 +298,7 @@ export function createCoreServerBuildOptions({ mode = "production", plugins = [] path.join(coreSourceRoot, "mcp", "toolhub-mcp.ts"), path.join(coreSourceRoot, "observability", "request-log-worker.ts"), path.join(coreSourceRoot, "routing", "route-script-worker.ts"), + localAgentAuthProviderHookInput, upstreamHeaderSanitizerInput ], external: nodeExternals.filter((moduleName) => moduleName !== "electron"), diff --git a/build/test.mjs b/build/test.mjs index 81ac359f..2402335c 100644 --- a/build/test.mjs +++ b/build/test.mjs @@ -27,6 +27,7 @@ const testProjects = { runtimeEntryPoints: { "runtime/fusion-vision-mcp": path.join(packageRoots.core, "mcp", "fusion-vision-mcp.ts"), "runtime/gateway-bootstrap": path.join(packageRoots.core, "gateway", "core-runtime", "gateway-bootstrap.ts"), + "runtime/local-agent-auth-provider-hook": path.join(packageRoots.core, "gateway", "core-runtime", "local-agent-auth-provider-hook.ts"), "runtime/media-tools-proxy-mcp": path.join(packageRoots.core, "mcp", "media-tools-proxy-mcp.ts"), "runtime/request-log-worker": path.join(packageRoots.core, "observability", "request-log-worker.ts"), "runtime/route-script-worker": path.join(packageRoots.core, "routing", "route-script-worker.ts"), diff --git a/packages/core/src/gateway/core-runtime/config-compiler.ts b/packages/core/src/gateway/core-runtime/config-compiler.ts index b2c2ca77..ff89a762 100644 --- a/packages/core/src/gateway/core-runtime/config-compiler.ts +++ b/packages/core/src/gateway/core-runtime/config-compiler.ts @@ -13,14 +13,16 @@ import { mediaToolsMcpServer } from "@ccr/core/mcp/grok-media-config"; import { resolveGatewayPublicModelId } from "@ccr/core/gateway/features/model-discovery"; import { activeProviderCredentials, inferProtocol, normalizedProviderCapabilities, normalizeProviderProtocol, providerCapabilityForClientProtocol, providerCapabilityInternalName, providerCapabilityNameMatches, providerCredentialInternalName, providerProtocolForClientProtocol, sortProviderCredentialsForConfig, toCoreGatewayProviders } from "@ccr/core/providers/runtime-topology"; import { buildRawTraceConfig } from "@ccr/core/observability/raw-trace-sync"; -import { endpoint, resolveUndiciProxyAgentModule, resolveUpstreamHeaderSanitizerEntry, writeGatewayProxyPreloadFile } from "@ccr/core/gateway/core-runtime/supervisor"; +import { endpoint, resolveLocalAgentAuthProviderHookEntry, resolveUndiciProxyAgentModule, resolveUpstreamHeaderSanitizerEntry, writeGatewayProxyPreloadFile } from "@ccr/core/gateway/core-runtime/supervisor"; import { billingUsageSyncHeader, billingUsageSyncPath, claudeCodeOauthBetaHeader, claudeCodeOauthRequiredBeta, coreGatewayAuthHeader, coreGatewayAuthTokenEnv } from "@ccr/core/gateway/internal/shared"; import type { BrowserWebSearchMcpIntegration, CoreGatewayProvider } from "@ccr/core/gateway/internal/shared"; import { uniqueStrings } from "@ccr/core/gateway/internal/collections"; import { isLocalClaudeCodeOauthProviderPlugin, mergeAnthropicBetaValues } from "@ccr/core/providers/oauth-plugin"; +import { isLocalAgentOauthProviderPlugin } from "@ccr/core/gateway/core-runtime/local-agent-auth-provider-hook"; import { resolveConfiguredProviderModelSelector, resolveUniqueConfiguredProviderModelSelector } from "@ccr/core/routing/model-resolution"; const upstreamHeaderSanitizerPluginKey = "ccr-upstream-header-sanitizer"; +const localAgentAuthProviderHookPluginKey = "ccr-local-agent-auth-provider-hooks"; export const unlimitedVirtualModelToolCalls = Number.MAX_SAFE_INTEGER; export const unlimitedVirtualModelToolTurns = Number.MAX_SAFE_INTEGER; @@ -36,7 +38,8 @@ export async function compileCoreGatewayConfig( const pluginCoreGatewayConfig = pluginService.getCoreGatewayConfig(); const configuredGatewayPlugins = Array.isArray(pluginCoreGatewayConfig.plugins) ? pluginCoreGatewayConfig.plugins.filter((plugin) => - !isRecord(plugin) || stringValue(plugin.key) !== upstreamHeaderSanitizerPluginKey + !isRecord(plugin) || + ![localAgentAuthProviderHookPluginKey, upstreamHeaderSanitizerPluginKey].includes(stringValue(plugin.key) ?? "") ) : []; const pluginBillingConfig = isRecord(pluginCoreGatewayConfig.billing) ? pluginCoreGatewayConfig.billing : {}; @@ -82,6 +85,7 @@ export async function compileCoreGatewayConfig( .filter((provider): provider is CoreGatewayProvider => Boolean(provider)), ...builtinToolArtifacts.providers ]; + const localAgentAuthProviderHookPlugin = localAgentAuthProviderHookPluginConfig(providerPluginsWithCapabilityAliases); const pluginAgentConfig = isRecord(pluginCoreGatewayConfig.agent) ? pluginCoreGatewayConfig.agent : {}; const pluginMcpServers = Array.isArray(pluginAgentConfig.mcpServers) ? pluginAgentConfig.mcpServers : []; const externalMcpServers = [ @@ -138,6 +142,7 @@ export async function compileCoreGatewayConfig( port: config.gateway.corePort, plugins: [ ...configuredGatewayPlugins, + ...(localAgentAuthProviderHookPlugin ? [localAgentAuthProviderHookPlugin] : []), { enabled: true, key: upstreamHeaderSanitizerPluginKey, @@ -156,6 +161,17 @@ export async function compileCoreGatewayConfig( }; } +function localAgentAuthProviderHookPluginConfig(providerPlugins: unknown[]): Record | undefined { + if (!providerPlugins.some(isLocalAgentOauthProviderPlugin)) { + return undefined; + } + return { + enabled: true, + key: localAgentAuthProviderHookPluginKey, + modulePath: resolveLocalAgentAuthProviderHookEntry() + }; +} + function withProviderCapabilityPluginAliases( providerPlugins: unknown[], providers: GatewayProviderConfig[] @@ -518,6 +534,7 @@ async function withGrokOauthRuntimeDefaults(providerPlugins: unknown[]): Promise const currentHeaders = isRecord(currentAuth.headers) ? currentAuth.headers : {}; const currentRequest = isRecord(plugin.request) ? plugin.request : {}; const currentRequestHeaders = isRecord(currentRequest.headers) ? currentRequest.headers : {}; + const currentBodyRemove = Array.isArray(currentRequest.bodyRemove) ? currentRequest.bodyRemove : []; return { ...plugin, auth: { @@ -529,6 +546,12 @@ async function withGrokOauthRuntimeDefaults(providerPlugins: unknown[]): Promise }, request: { ...currentRequest, + bodyRemove: uniqueStrings([ + ...currentBodyRemove + .map((value) => stringValue(value)) + .filter((value): value is string => Boolean(value)), + "external_web_access" + ]), headers: { ...currentRequestHeaders, "x-grok-client-identifier": "xai-grok-cli", diff --git a/packages/core/src/gateway/core-runtime/local-agent-auth-provider-hook.ts b/packages/core/src/gateway/core-runtime/local-agent-auth-provider-hook.ts new file mode 100644 index 00000000..e0c4d32d --- /dev/null +++ b/packages/core/src/gateway/core-runtime/local-agent-auth-provider-hook.ts @@ -0,0 +1,438 @@ +import { readClaudeCodeOauth, readGrokAuth, readKimiAuth, resolveGrokAuth, resolveKimiAuth } from "@ccr/core/agents/local-providers/service"; +import { grokAccessTokenExpired, grokClientVersion } from "@ccr/core/agents/local-providers/grok"; +import { kimiAccessTokenExpired, kimiIdentityHeaders } from "@ccr/core/agents/local-providers/kimi"; +import { transformCodexApplyPatchBridgeRequestBody } from "@ccr/core/gateway/features/codex-patch-bridge"; +import { claudeCodeOauthBetaHeader, claudeCodeOauthRequiredBeta } from "@ccr/core/gateway/internal/shared"; +import { isRecord, stringValue } from "@ccr/core/gateway/internal/value"; +import { mergeAnthropicBetaValues } from "@ccr/core/providers/oauth-plugin"; + +const configProviderPluginKeyPrefix = "config:"; +const localAgentProviderPluginKeyPrefix = "ccr-local-agent-"; + +type HeaderRecord = Record; + +type UpstreamRequest = { + body?: unknown; + bodyEncoding?: "bytes" | "form" | "json" | "none" | "text"; + headers?: HeaderRecord; + method?: string; + url: string; +}; + +type ProviderPluginInput = { + model?: string; + request?: { + headers?: Record; + }; + upstreamRequest: UpstreamRequest; +}; + +type ProviderHookResult = { + ok: true; + value: UpstreamRequest; +} | { + error: string; + ok: false; +}; + +type ProviderHook = { + authenticate?: (input: ProviderPluginInput) => Promise | ProviderHookResult; + key: string; + provider?: string; + providerName?: string; + transformRequest?: (input: ProviderPluginInput) => Promise | ProviderHookResult; +}; + +type LocalAgentOauthKind = "claude-code" | "grok" | "kimi"; + +export function createGatewayPlugin(input: { config?: Record } = {}) { + return { + providerHooks: localAgentOauthProviderHooks(input.config) + }; +} + +export function localAgentOauthProviderHooks(config: Record | undefined): ProviderHook[] { + const providerPlugins = Array.isArray(config?.providerPlugins) ? config.providerPlugins : []; + return providerPlugins + .map(localAgentOauthProviderHook) + .filter((hook): hook is ProviderHook => Boolean(hook)); +} + +export function isLocalAgentOauthProviderPlugin(value: unknown): boolean { + if (!isRecord(value)) { + return false; + } + return localAgentOauthKind(value) !== undefined; +} + +function localAgentOauthProviderHook(plugin: unknown): ProviderHook | undefined { + if (!isRecord(plugin)) { + return undefined; + } + const kind = localAgentOauthKind(plugin); + const key = stringValue(plugin.key); + if (!kind || !key) { + return undefined; + } + + const hook: ProviderHook = { + key: `${configProviderPluginKeyPrefix}${key}`, + provider: stringValue(plugin.provider), + providerName: stringValue(plugin.providerName) + }; + + if (kind === "grok") { + hook.authenticate = (input) => authenticateWithBearer(input, () => resolveLiveGrokAccessToken(plugin), plugin, "Grok CLI access token was not found."); + hook.transformRequest = (input) => transformGrokRequest(input, plugin); + return hook; + } + + if (kind === "kimi") { + hook.authenticate = (input) => authenticateWithBearer(input, () => resolveLiveKimiAccessToken(plugin), plugin, "Kimi CLI access token was not found."); + hook.transformRequest = (input) => transformWithHeaders(input, kimiIdentityHeaders()); + return hook; + } + + hook.authenticate = (input) => authenticateClaudeCode(input, plugin); + return hook; +} + +function localAgentOauthKind(plugin: Record): LocalAgentOauthKind | undefined { + const key = stringValue(plugin.key)?.toLowerCase(); + if (!key?.startsWith(localAgentProviderPluginKeyPrefix)) { + return undefined; + } + if (key.includes("grok-cli-oauth")) { + return "grok"; + } + if (key.includes("kimi-cli-oauth")) { + return "kimi"; + } + if (key.includes("claude-code-oauth")) { + return "claude-code"; + } + return undefined; +} + +async function authenticateWithBearer( + input: ProviderPluginInput, + tokenResolver: () => Promise | string | undefined, + plugin: Record, + missingTokenError: string +): Promise { + const token = await tokenResolver() || originalBearerToken(plugin); + if (!token) { + return { error: missingTokenError, ok: false }; + } + return { + ok: true, + value: { + ...input.upstreamRequest, + headers: withBearerAuth(input.upstreamRequest.headers, token, originalRemoveHeaders(plugin)) + } + }; +} + +async function authenticateClaudeCode( + input: ProviderPluginInput, + plugin: Record +): Promise { + const token = readClaudeCodeOauth()?.accessToken || originalBearerToken(plugin); + if (!token) { + return { error: "Claude Code access token was not found.", ok: false }; + } + const headers = withBearerAuth(input.upstreamRequest.headers, token, originalRemoveHeaders(plugin)); + headers[claudeCodeOauthBetaHeader] = mergeAnthropicBetaValues( + requestHeader(input.request?.headers, claudeCodeOauthBetaHeader), + originalAnthropicBetaDefault(plugin), + claudeCodeOauthRequiredBeta + ); + return { + ok: true, + value: { + ...input.upstreamRequest, + headers + } + }; +} + +async function resolveLiveGrokAccessToken(plugin: Record): Promise { + const auth = await resolveGrokAuth().catch(() => readGrokAuth()); + if (auth?.accessToken && !grokAccessTokenExpired(auth)) { + return auth.accessToken; + } + return originalBearerToken(plugin); +} + +async function resolveLiveKimiAccessToken(plugin: Record): Promise { + const reference = kimiOauthReference(plugin); + const auth = await resolveKimiAuth(reference).catch(() => readKimiAuth(reference)); + if (auth?.accessToken && !kimiAccessTokenExpired(auth)) { + return auth.accessToken; + } + return originalBearerToken(plugin); +} + +function transformWithHeaders(input: ProviderPluginInput, headers: HeaderRecord): ProviderHookResult { + return { + ok: true, + value: { + ...input.upstreamRequest, + headers: { + ...(input.upstreamRequest.headers ?? {}), + ...headers + } + } + }; +} + +function transformGrokRequest(input: ProviderPluginInput, plugin: Record): ProviderHookResult { + const headers = { + ...(input.upstreamRequest.headers ?? {}), + "x-grok-client-identifier": "xai-grok-cli", + "x-grok-client-version": grokClientVersion(), + "x-grok-model-override": input.model || originalRequestHeader(plugin, "x-grok-model-override") || "" + }; + const body = transformGrokResponsesBody(input.upstreamRequest.body); + return { + ok: true, + value: { + ...input.upstreamRequest, + body: body.changed ? body.value : input.upstreamRequest.body, + headers + } + }; +} + +function transformGrokResponsesBody(value: unknown): { value: unknown; changed: boolean } { + if (!isRecord(value)) { + return { value, changed: false }; + } + const patched = transformCodexApplyPatchBridgeRequestBody(value); + const sanitizedOptions = sanitizeGrokUnsupportedResponsesOptions(patched.body); + const sanitized = sanitizeGrokResponsesTools(sanitizedOptions.value); + return { + value: sanitized.value, + changed: patched.changed || sanitizedOptions.changed || sanitized.changed + }; +} + +function sanitizeGrokUnsupportedResponsesOptions(body: Record): { + value: Record; + changed: boolean; +} { + let next: Record | undefined; + for (const key of grokUnsupportedResponsesOptions) { + if (Object.hasOwn(body, key)) { + next ??= { ...body }; + delete next[key]; + } + } + return next ? { value: next, changed: true } : { value: body, changed: false }; +} + +function sanitizeGrokResponsesTools(body: Record): { value: Record; changed: boolean } { + if (!Array.isArray(body.tools)) { + return { value: body, changed: false }; + } + + let changed = false; + const removedToolNames = new Set(); + const tools = body.tools.filter((tool) => { + const supported = isGrokSupportedResponsesTool(tool); + if (!supported) { + changed = true; + const name = responseToolName(tool); + if (name) { + removedToolNames.add(name); + } + } + return supported; + }); + + if (!changed) { + return { value: body, changed: false }; + } + + const next = { ...body }; + if (tools.length > 0) { + next.tools = tools; + } else { + delete next.tools; + delete next.parallel_tool_calls; + } + if (tools.length === 0 || grokToolChoiceNamesRemovedTool(next.tool_choice, removedToolNames)) { + delete next.tool_choice; + } + return { value: next, changed: true }; +} + +function isGrokSupportedResponsesTool(value: unknown): boolean { + if (!isRecord(value)) { + return false; + } + return grokSupportedResponsesToolTypes.has(stringValue(value.type) ?? ""); +} + +function responseToolName(value: unknown): string | undefined { + if (!isRecord(value)) { + return undefined; + } + return stringValue(value.name) || stringValue(value.server_label); +} + +function grokToolChoiceNamesRemovedTool(value: unknown, removedToolNames: Set): boolean { + if (removedToolNames.size === 0) { + return false; + } + if (!isRecord(value)) { + return false; + } + const name = stringValue(value.name) || stringValue(value.server_label); + return name ? removedToolNames.has(name) : false; +} + +function withBearerAuth( + headers: HeaderRecord | undefined, + token: string, + removeHeaders: string[] +): HeaderRecord { + const next = { ...(headers ?? {}) }; + for (const name of removeHeaders) { + deleteHeader(next, name); + } + setHeader(next, "authorization", `Bearer ${token}`); + return next; +} + +function originalRemoveHeaders(plugin: Record): string[] { + const auth = isRecord(plugin.auth) ? plugin.auth : undefined; + const removeHeaders = Array.isArray(auth?.removeHeaders) ? auth.removeHeaders : []; + return uniqueStrings([ + ...removeHeaders + .map((value) => stringValue(value)) + .filter((value): value is string => Boolean(value)), + "x-api-key" + ]); +} + +function originalBearerToken(plugin: Record): string | undefined { + const authorization = originalAuthHeader(plugin, "authorization"); + const prefix = "Bearer "; + return authorization?.startsWith(prefix) ? authorization.slice(prefix.length).trim() || undefined : undefined; +} + +function originalAuthHeader(plugin: Record, name: string): string | undefined { + const auth = isRecord(plugin.auth) ? plugin.auth : undefined; + const headers = isRecord(auth?.headers) ? auth.headers : undefined; + return recordHeader(headers, name); +} + +function originalRequestHeader(plugin: Record, name: string): string | undefined { + const request = isRecord(plugin.request) ? plugin.request : undefined; + const headers = isRecord(request?.headers) ? request.headers : undefined; + return recordHeader(headers, name); +} + +function originalAnthropicBetaDefault(plugin: Record): string | undefined { + const value = originalAuthHeaderValue(plugin, claudeCodeOauthBetaHeader); + if (typeof value === "string") { + return value; + } + if (isRecord(value)) { + return stringValue(value.default); + } + return undefined; +} + +function originalAuthHeaderValue(plugin: Record, name: string): unknown { + const auth = isRecord(plugin.auth) ? plugin.auth : undefined; + const headers = isRecord(auth?.headers) ? auth.headers : undefined; + if (!headers) { + return undefined; + } + const normalizedName = name.toLowerCase(); + return Object.entries(headers).find(([key]) => key.trim().toLowerCase() === normalizedName)?.[1]; +} + +function recordHeader(headers: Record | undefined, name: string): string | undefined { + const value = recordHeaderValue(headers, name); + return typeof value === "string" ? value : undefined; +} + +function recordHeaderValue(headers: Record | undefined, name: string): unknown { + if (!headers) { + return undefined; + } + const normalizedName = name.toLowerCase(); + return Object.entries(headers).find(([key]) => key.trim().toLowerCase() === normalizedName)?.[1]; +} + +function requestHeader( + headers: Record | undefined, + name: string +): string | undefined { + if (!headers) { + return undefined; + } + const normalizedName = name.toLowerCase(); + const value = Object.entries(headers).find(([key]) => key.trim().toLowerCase() === normalizedName)?.[1]; + if (Array.isArray(value)) { + return value.join(","); + } + return value; +} + +function setHeader(headers: HeaderRecord, name: string, value: string): void { + deleteHeader(headers, name); + headers[name] = value; +} + +function deleteHeader(headers: HeaderRecord, name: string): void { + const normalizedName = name.toLowerCase(); + for (const key of Object.keys(headers)) { + if (key.trim().toLowerCase() === normalizedName) { + delete headers[key]; + } + } +} + +function kimiOauthReference(plugin: Record): { key?: string; oauthHost?: string } | undefined { + const oauth = isRecord(plugin.kimiOauth) ? plugin.kimiOauth : undefined; + if (!oauth) { + return undefined; + } + const key = stringValue(oauth.key); + const oauthHost = stringValue(oauth.oauthHost) || stringValue(oauth.oauth_host); + return key || oauthHost ? { key, oauthHost } : undefined; +} + +const grokSupportedResponsesToolTypes = new Set([ + "function", + "x_search", + "image_generation", + "collections_search", + "file_search", + "code_execution", + "code_interpreter", + "mcp", + "shell" +]); + +const grokUnsupportedResponsesOptions = new Set([ + "external_web_access" +]); + +function uniqueStrings(values: string[]): string[] { + const seen = new Set(); + const unique: string[] = []; + for (const value of values) { + const key = value.trim().toLowerCase(); + if (!key || seen.has(key)) { + continue; + } + seen.add(key); + unique.push(value); + } + return unique; +} diff --git a/packages/core/src/gateway/core-runtime/supervisor.ts b/packages/core/src/gateway/core-runtime/supervisor.ts index 15ae9e81..3cb63e26 100644 --- a/packages/core/src/gateway/core-runtime/supervisor.ts +++ b/packages/core/src/gateway/core-runtime/supervisor.ts @@ -184,6 +184,20 @@ export function resolveUpstreamHeaderSanitizerEntry(): string { ].find((candidate) => existsSync(candidate)) ?? pathJoin(__dirname, "upstream-header-sanitizer.js"); } +export function resolveLocalAgentAuthProviderHookEntry(): string { + const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath; + return [ + pathJoin(__dirname, "local-agent-auth-provider-hook.js"), + pathJoin(process.cwd(), ".test-dist", "core", "runtime", "local-agent-auth-provider-hook.js"), + ...(resourcesPath + ? [ + pathJoin(resourcesPath, "app.asar", "dist", "main", "local-agent-auth-provider-hook.js"), + pathJoin(resourcesPath, "app", "dist", "main", "local-agent-auth-provider-hook.js") + ] + : []) + ].find((candidate) => existsSync(candidate)) ?? pathJoin(__dirname, "local-agent-auth-provider-hook.js"); +} + function resolveGatewayEntry(): string { const override = process.env[gatewayEntryOverrideEnv]?.trim(); if (override) { 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 new file mode 100644 index 00000000..2ece12b8 --- /dev/null +++ b/packages/core/test/unit/gateway/local-agent-auth-provider-hook.test.mjs @@ -0,0 +1,265 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { createDefaultAppConfig } from "@ccr/core/config/default-config.ts"; +import { compileCoreGatewayConfig } from "@ccr/core/gateway/core-runtime/config-compiler.ts"; +import { + createGatewayPlugin, + isLocalAgentOauthProviderPlugin +} from "@ccr/core/gateway/core-runtime/local-agent-auth-provider-hook.ts"; +import { virtualApplyPatchToolName } from "@ccr/core/gateway/internal/shared.ts"; + +test("Grok local agent auth hook refreshes live login state before authenticating upstream requests", async (t) => { + await withGrokHome(t, async (grokHome) => { + writeGrokAuth(grokHome, { + expires_at: "2000-01-01T00:00:00Z", + key: "expired-grok-access-token", + oidc_client_id: "grok-client-id", + oidc_issuer: "https://auth.x.ai", + refresh_token: "grok-refresh-token" + }); + + const previousFetch = globalThis.fetch; + const previousTokenEndpoint = process.env.GROK_OIDC_TOKEN_ENDPOINT; + process.env.GROK_OIDC_TOKEN_ENDPOINT = "http://127.0.0.1/grok/oauth/token"; + globalThis.fetch = async (input, init) => { + assert.equal(String(input), "http://127.0.0.1/grok/oauth/token"); + assert.equal(String(init?.body ?? ""), "client_id=grok-client-id&grant_type=refresh_token&refresh_token=grok-refresh-token"); + return new Response(JSON.stringify({ + access_token: "refreshed-grok-access-token", + expires_in: 3600, + refresh_token: "refreshed-grok-refresh-token" + }), { headers: { "content-type": "application/json" }, status: 200 }); + }; + t.after(() => { + globalThis.fetch = previousFetch; + restoreEnv("GROK_OIDC_TOKEN_ENDPOINT", previousTokenEndpoint); + }); + + const [hook] = createGatewayPlugin({ + config: { + providerPlugins: [grokOauthProviderPlugin()] + } + }).providerHooks; + assert.equal(hook.key, "config:ccr-local-agent-grok-cli-api-grok-cli-oauth"); + + const patch = "*** Begin Patch\n*** Add File: grok.txt\n+hi\n*** End Patch\n"; + const upstreamRequest = { + body: { + external_web_access: true, + input: [ + { type: "custom_tool_call", call_id: "call_patch", name: "apply_patch", input: patch }, + { type: "custom_tool_call_output", call_id: "call_patch", output: "Success" } + ], + model: "wrong-model", + parallel_tool_calls: true, + tool_choice: "auto", + tools: [ + { type: "function", name: "exec_command" }, + { type: "custom", name: "apply_patch", format: { type: "grammar", syntax: "lark", definition: "start: begin_patch" } }, + { type: "namespace", name: "multi_agent_v1" }, + { type: "web_search" } + ] + }, + headers: { + "content-type": "application/json", + "x-api-key": "client-key" + }, + method: "POST", + url: "https://cli-chat-proxy.grok.com/v1/responses" + }; + const authResult = await hook.authenticate({ + model: "grok-4.5", + upstreamRequest + }); + + assert.equal(authResult.ok, true); + assert.equal(authResult.value.headers.authorization, "Bearer refreshed-grok-access-token"); + assert.equal(authResult.value.headers["x-api-key"], undefined); + assert.equal(upstreamRequest.headers["x-api-key"], "client-key"); + + const requestResult = await hook.transformRequest({ + model: "grok-4.5", + upstreamRequest: authResult.value + }); + assert.equal(requestResult.ok, true); + assert.equal(requestResult.value.headers["x-grok-client-identifier"], "xai-grok-cli"); + assert.equal(requestResult.value.headers["x-grok-client-version"], "0.2.93"); + assert.equal(requestResult.value.headers["x-grok-model-override"], "grok-4.5"); + assert.equal(requestResult.value.body.external_web_access, undefined); + assert.deepEqual(requestResult.value.body.tools.map((tool) => tool.type), ["function", "function"]); + assert.equal(requestResult.value.body.tools[1].name, virtualApplyPatchToolName); + assert.equal(requestResult.value.body.tools.some((tool) => tool.type === "custom" || tool.type === "namespace"), false); + assert.equal(requestResult.value.body.tool_choice, "auto"); + assert.equal(requestResult.value.body.parallel_tool_calls, true); + assert.match(requestResult.value.body.instructions, /When modifying files, call virtual_apply_patch/); + assert.equal(requestResult.value.body.input[0].type, "function_call"); + assert.equal(requestResult.value.body.input[0].name, virtualApplyPatchToolName); + assert.deepEqual(JSON.parse(requestResult.value.body.input[0].arguments), { patch }); + assert.equal(requestResult.value.body.input[1].type, "function_call_output"); + + const persisted = JSON.parse(readFileSync(path.join(grokHome, "auth.json"), "utf8")); + assert.equal(persisted["https://auth.x.ai::test-account"].key, "refreshed-grok-access-token"); + }); +}); + +test("Grok local agent request hook removes unsupported Responses tools and stale tool choice", () => { + const [hook] = createGatewayPlugin({ + config: { + providerPlugins: [grokOauthProviderPlugin()] + } + }).providerHooks; + const requestResult = hook.transformRequest({ + model: "grok-4.5", + upstreamRequest: { + body: { + parallel_tool_calls: true, + tool_choice: { type: "tool", name: "multi_agent_v1" }, + tools: [ + { type: "namespace", name: "multi_agent_v1" }, + { type: "function", name: "exec_command" } + ] + }, + headers: {}, + method: "POST", + url: "https://cli-chat-proxy.grok.com/v1/responses" + } + }); + + assert.equal(requestResult.ok, true); + assert.deepEqual(requestResult.value.body.tools, [{ type: "function", name: "exec_command" }]); + assert.equal(requestResult.value.body.tool_choice, undefined); + assert.equal(requestResult.value.body.parallel_tool_calls, true); +}); + +test("core gateway config installs the local agent dynamic auth runtime hook when OAuth plugins are present", async () => { + const config = createDefaultAppConfig(); + config.providerPlugins = [grokOauthProviderPlugin()]; + config.Providers = [ + { + api_base_url: "https://cli-chat-proxy.grok.com/v1", + api_key: "ccr-local-agent-login", + id: "grok-cli-api", + models: ["grok-4.5"], + name: "Grok CLI API", + type: "openai_responses" + } + ]; + + const compiled = await compileCoreGatewayConfig( + config, + "raw-trace-token", + "billing-usage-token", + "core-auth-token" + ); + const plugins = Array.isArray(compiled.plugins) ? compiled.plugins : []; + const localAgentAuthPlugin = plugins.find((plugin) => plugin.key === "ccr-local-agent-auth-provider-hooks"); + + assert.ok(localAgentAuthPlugin); + assert.match(localAgentAuthPlugin.modulePath, /local-agent-auth-provider-hook\.js$/); +}); + +test("core gateway config removes Grok unsupported Responses options through declarative request transforms", async (t) => { + await withGrokHome(t, async (grokHome) => { + writeGrokAuth(grokHome, { + expires_at: "2099-01-01T00:00:00Z", + key: "live-grok-access-token", + oidc_client_id: "grok-client-id", + oidc_issuer: "https://auth.x.ai", + refresh_token: "grok-refresh-token" + }); + const plugin = grokOauthProviderPlugin(); + plugin.request.bodyRemove = ["metadata"]; + + const config = createDefaultAppConfig(); + config.providerPlugins = [plugin]; + config.Providers = [ + { + api_base_url: "https://cli-chat-proxy.grok.com/v1", + api_key: "ccr-local-agent-login", + id: "grok-cli-api", + models: ["grok-4.5"], + name: "Grok CLI API", + type: "openai_responses" + } + ]; + + const compiled = await compileCoreGatewayConfig( + config, + "raw-trace-token", + "billing-usage-token", + "core-auth-token" + ); + const providerPlugins = Array.isArray(compiled.providerPlugins) ? compiled.providerPlugins : []; + const grokPlugin = providerPlugins.find((value) => value.key === "ccr-local-agent-grok-cli-api-grok-cli-oauth"); + + assert.ok(grokPlugin); + assert.deepEqual(grokPlugin.request.bodyRemove, ["metadata", "external_web_access"]); + }); +}); + +test("local agent OAuth plugin detector only matches managed OAuth imports", () => { + assert.equal(isLocalAgentOauthProviderPlugin(grokOauthProviderPlugin()), true); + assert.equal(isLocalAgentOauthProviderPlugin({ + key: "ccr-local-agent-grok-cli-api-grok-cli-api-key" + }), false); + assert.equal(isLocalAgentOauthProviderPlugin({ + key: "external-grok-cli-oauth" + }), false); +}); + +function grokOauthProviderPlugin() { + return { + auth: { + headers: { + authorization: "Bearer imported-stale-token" + }, + removeHeaders: ["x-api-key"], + strict: true + }, + key: "ccr-local-agent-grok-cli-api-grok-cli-oauth", + providerName: "Grok CLI API", + request: { + headers: { + "x-grok-client-identifier": "xai-grok-cli", + "x-grok-client-version": "0.2.93", + "x-grok-model-override": "{{ model }}" + }, + strict: true + } + }; +} + +async function withGrokHome(t, run) { + const previousGrokHome = process.env.GROK_HOME; + const previousGrokAuthFile = process.env.GROK_AUTH_FILE; + const previousGrokCliVersion = process.env.GROK_CLI_VERSION; + const grokHome = mkdtempSync(path.join(os.tmpdir(), "ccr-grok-hook-test-")); + process.env.GROK_HOME = grokHome; + process.env.GROK_CLI_VERSION = "0.2.93"; + delete process.env.GROK_AUTH_FILE; + try { + await run(grokHome); + } finally { + restoreEnv("GROK_HOME", previousGrokHome); + restoreEnv("GROK_AUTH_FILE", previousGrokAuthFile); + restoreEnv("GROK_CLI_VERSION", previousGrokCliVersion); + rmSync(grokHome, { force: true, recursive: true }); + } +} + +function restoreEnv(name, value) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } +} + +function writeGrokAuth(grokHome, auth) { + writeFileSync(path.join(grokHome, "auth.json"), JSON.stringify({ + "https://auth.x.ai::test-account": auth + }, null, 2)); +}