From 87bd24a7baca48d180ace783cb5611750d14192f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:31:49 +0000 Subject: [PATCH 1/3] Initial plan From 54a39f497684d0be25399cd524683e4a80a89b38 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:47:23 +0000 Subject: [PATCH 2/3] refactor: extract credential resolver --- src/commands/build-config.ts | 162 +++++++++++------------ src/commands/resolve-credentials.test.ts | 113 ++++++++++++++++ src/commands/resolve-credentials.ts | 93 +++++++++++++ 3 files changed, 280 insertions(+), 88 deletions(-) create mode 100644 src/commands/resolve-credentials.test.ts create mode 100644 src/commands/resolve-credentials.ts diff --git a/src/commands/build-config.ts b/src/commands/build-config.ts index c85a245b4..13823ddb8 100644 --- a/src/commands/build-config.ts +++ b/src/commands/build-config.ts @@ -1,5 +1,5 @@ import { WrapperConfig, LogLevel, UpstreamProxyConfig } from '../types'; -import { OPENAI_ENV, ANTHROPIC_ENV, GEMINI_ENV, COPILOT_ENV, OIDC_AUTH_ENV_MAPPING } from '../api-proxy-env-constants'; +import { resolveApiCredentials } from './resolve-credentials'; /** * Inputs required to assemble a {@link WrapperConfig}. @@ -39,8 +39,6 @@ interface BuildConfigInputs { * Assembles a {@link WrapperConfig} from pre-parsed and pre-validated inputs. * * This function performs no validation — callers must validate before calling. - * API keys are resolved from the process environment here to keep credential - * access centralised in one place. */ export function buildConfig(inputs: BuildConfigInputs): WrapperConfig { const { @@ -72,43 +70,12 @@ export function buildConfig(inputs: BuildConfigInputs): WrapperConfig { dockerHostPathPrefix, } = inputs; - const chrootIdentityUid = parseOptionalIntegerOption(options.chrootIdentityUid); - const chrootIdentityGid = parseOptionalIntegerOption(options.chrootIdentityGid); - const chrootIdentity = ( - options.chrootIdentityHome !== undefined || - options.chrootIdentityUser !== undefined || - chrootIdentityUid !== undefined || - chrootIdentityGid !== undefined - ) - ? { - home: options.chrootIdentityHome as string | undefined, - user: options.chrootIdentityUser as string | undefined, - uid: chrootIdentityUid, - gid: chrootIdentityGid, - } - : undefined; - const dind = ( - options.dindPreStageDirs !== undefined || - options.dindWorkDir !== undefined || - options.dindStagingImage !== undefined || - options.dindStageEngineBinaryPath !== undefined || - options.dindStageEngineBinaryTargetPath !== undefined - ) - ? { - preStageDirs: options.dindPreStageDirs as boolean | undefined, - workDir: options.dindWorkDir as string | undefined, - stagingImage: options.dindStagingImage as string | undefined, - stageEngineBinary: ( - options.dindStageEngineBinaryPath !== undefined || - options.dindStageEngineBinaryTargetPath !== undefined - ) - ? { - path: options.dindStageEngineBinaryPath as string | undefined, - targetPath: options.dindStageEngineBinaryTargetPath as string | undefined, - } - : undefined, - } - : undefined; + const chrootIdentity = buildChrootIdentity(options); + const dind = buildDindConfig(options); + const apiCredentials = resolveApiCredentials(options, { + resolvedCopilotApiTarget, + resolvedCopilotApiBasePath, + }); return { allowedDomains, @@ -151,7 +118,8 @@ export function buildConfig(inputs: BuildConfigInputs): WrapperConfig { enableDlp: options.enableDlp as boolean, allowedUrls, enableApiProxy: options.enableApiProxy as boolean, - modelFallback: options.modelFallback as { enabled?: boolean; strategy?: 'middle_power' } | undefined, + modelFallback: + options.modelFallback as { enabled?: boolean; strategy?: 'middle_power' } | undefined, requestedModel: options.requestedModel as string | undefined, anthropicAutoCache: options.anthropicAutoCache as boolean, anthropicCacheTailTtl: options.anthropicCacheTailTtl as '5m' | '1h' | undefined, @@ -165,61 +133,28 @@ export function buildConfig(inputs: BuildConfigInputs): WrapperConfig { maxPermissionDenied, maxCacheMisses, enableTokenSteering: options.enableTokenSteering as boolean, - debugTokens: (options.debugTokens as boolean | undefined) ?? (process.env.AWF_DEBUG_TOKENS === '1' ? true : undefined), - tokenLogDir: (options.tokenLogDir as string | undefined) ?? (process.env.AWF_TOKEN_LOG_DIR?.trim() || undefined), - captureBlockedRequests: (options.captureBlockedRequests as boolean | 'summary' | 'redacted' | 'full' | undefined) ?? + debugTokens: + (options.debugTokens as boolean | undefined) ?? + (process.env.AWF_DEBUG_TOKENS === '1' ? true : undefined), + tokenLogDir: + (options.tokenLogDir as string | undefined) ?? + (process.env.AWF_TOKEN_LOG_DIR?.trim() || undefined), + captureBlockedRequests: + (options.captureBlockedRequests as boolean | 'summary' | 'redacted' | 'full' | undefined) ?? (process.env.AWF_CAPTURE_BLOCKED_LLM_REQUESTS ? (process.env.AWF_CAPTURE_BLOCKED_LLM_REQUESTS as 'summary' | 'redacted' | 'full') : undefined), - maxCapturedBytes: (options.maxCapturedBytes as number | undefined) ?? - (process.env.AWF_MAX_BLOCKED_CAPTURE_BYTES ? Number(process.env.AWF_MAX_BLOCKED_CAPTURE_BYTES) : undefined), - openaiApiKey: process.env[OPENAI_ENV.KEY], - anthropicApiKey: process.env[ANTHROPIC_ENV.KEY], - copilotGithubToken: process.env[COPILOT_ENV.GITHUB_TOKEN], - copilotProviderApiKey: process.env[COPILOT_ENV.PROVIDER_API_KEY], - copilotProviderType: - (options.copilotProviderType as string | undefined) || process.env[COPILOT_ENV.PROVIDER_TYPE], - copilotProviderBaseUrl: - (options.copilotProviderBaseUrl as string | undefined) || process.env[COPILOT_ENV.PROVIDER_BASE_URL], - geminiApiKey: process.env[GEMINI_ENV.KEY], - copilotApiTarget: resolvedCopilotApiTarget, - copilotApiBasePath: resolvedCopilotApiBasePath, + maxCapturedBytes: + (options.maxCapturedBytes as number | undefined) ?? + (process.env.AWF_MAX_BLOCKED_CAPTURE_BYTES + ? Number(process.env.AWF_MAX_BLOCKED_CAPTURE_BYTES) + : undefined), + ...apiCredentials, copilotByokExtraHeaders: options.copilotByokExtraHeaders as Record | undefined, copilotByokExtraBodyFields: options.copilotByokExtraBodyFields as Record | undefined, copilotByokSessionId: options.copilotByokSessionId as string | undefined, - openaiApiTarget: - (options.openaiApiTarget as string | undefined) || process.env[OPENAI_ENV.TARGET], - openaiApiBasePath: - (options.openaiApiBasePath as string | undefined) || process.env[OPENAI_ENV.BASE_PATH], - anthropicApiTarget: - (options.anthropicApiTarget as string | undefined) || process.env[ANTHROPIC_ENV.TARGET], - anthropicApiBasePath: - (options.anthropicApiBasePath as string | undefined) || process.env[ANTHROPIC_ENV.BASE_PATH], - openaiApiAuthHeader: - (options.openaiApiAuthHeader as string | undefined) || process.env[OPENAI_ENV.AUTH_HEADER], - anthropicApiAuthHeader: - (options.anthropicApiAuthHeader as string | undefined) || process.env[ANTHROPIC_ENV.AUTH_HEADER], - anthropicTokenUrl: - (options.anthropicTokenUrl as string | undefined) || process.env.AWF_AUTH_ANTHROPIC_TOKEN_URL, - authType: (options.authType as string | undefined) || process.env.AWF_AUTH_TYPE, - authProvider: (options.authProvider as string | undefined) || process.env.AWF_AUTH_PROVIDER, - authOidcAudience: (options.authOidcAudience as string | undefined) || process.env.AWF_AUTH_OIDC_AUDIENCE, - ...Object.fromEntries( - OIDC_AUTH_ENV_MAPPING - .filter(m => !['authType', 'authProvider', 'authOidcAudience'].includes(m.configKey)) - .map(({ configKey, envVar }) => [ - configKey, - (options[configKey] as string | undefined) || process.env[envVar], - ]) - .filter(([, v]) => v !== undefined) - ), - geminiApiTarget: - (options.geminiApiTarget as string | undefined) || process.env[GEMINI_ENV.TARGET], - geminiApiBasePath: - (options.geminiApiBasePath as string | undefined) || process.env[GEMINI_ENV.BASE_PATH], difcProxyHost: options.difcProxyHost as string | undefined, difcProxyCaCert: options.difcProxyCaCert as string | undefined, - githubToken: process.env.GITHUB_TOKEN || process.env.GH_TOKEN, diagnosticLogs: (options.diagnosticLogs as boolean) || false, awfDockerHost: options.dockerHost as string | undefined, upstreamProxy, @@ -232,6 +167,57 @@ export function buildConfig(inputs: BuildConfigInputs): WrapperConfig { }; } +function buildChrootIdentity( + options: Record +): WrapperConfig['chrootIdentity'] { + const uid = parseOptionalIntegerOption(options.chrootIdentityUid); + const gid = parseOptionalIntegerOption(options.chrootIdentityGid); + + if ( + options.chrootIdentityHome === undefined + && options.chrootIdentityUser === undefined + && uid === undefined + && gid === undefined + ) { + return undefined; + } + + return { + home: options.chrootIdentityHome as string | undefined, + user: options.chrootIdentityUser as string | undefined, + uid, + gid, + }; +} + +function buildDindConfig(options: Record): WrapperConfig['dind'] { + const stageEngineBinary = ( + options.dindStageEngineBinaryPath !== undefined + || options.dindStageEngineBinaryTargetPath !== undefined + ) + ? { + path: options.dindStageEngineBinaryPath as string | undefined, + targetPath: options.dindStageEngineBinaryTargetPath as string | undefined, + } + : undefined; + + if ( + options.dindPreStageDirs === undefined + && options.dindWorkDir === undefined + && options.dindStagingImage === undefined + && stageEngineBinary === undefined + ) { + return undefined; + } + + return { + preStageDirs: options.dindPreStageDirs as boolean | undefined, + workDir: options.dindWorkDir as string | undefined, + stagingImage: options.dindStagingImage as string | undefined, + stageEngineBinary, + }; +} + function parseOptionalIntegerOption(value: unknown): number | undefined { if (typeof value === 'number' && Number.isInteger(value) && value > 0) { return value; diff --git a/src/commands/resolve-credentials.test.ts b/src/commands/resolve-credentials.test.ts new file mode 100644 index 000000000..81267d486 --- /dev/null +++ b/src/commands/resolve-credentials.test.ts @@ -0,0 +1,113 @@ +import { resolveApiCredentials } from './resolve-credentials'; + +const ENV_KEYS = [ + 'OPENAI_API_KEY', + 'ANTHROPIC_API_KEY', + 'COPILOT_GITHUB_TOKEN', + 'COPILOT_PROVIDER_API_KEY', + 'COPILOT_PROVIDER_TYPE', + 'COPILOT_PROVIDER_BASE_URL', + 'GEMINI_API_KEY', + 'OPENAI_API_TARGET', + 'OPENAI_API_BASE_PATH', + 'ANTHROPIC_API_TARGET', + 'ANTHROPIC_API_BASE_PATH', + 'AWF_OPENAI_AUTH_HEADER', + 'AWF_ANTHROPIC_AUTH_HEADER', + 'AWF_AUTH_ANTHROPIC_TOKEN_URL', + 'AWF_AUTH_TYPE', + 'AWF_AUTH_PROVIDER', + 'AWF_AUTH_OIDC_AUDIENCE', + 'AWF_AUTH_AZURE_TENANT_ID', + 'AWF_AUTH_GCP_SCOPE', + 'GEMINI_API_TARGET', + 'GEMINI_API_BASE_PATH', + 'GITHUB_TOKEN', + 'GH_TOKEN', +] as const; + +describe('resolveApiCredentials', () => { + let savedEnv: Partial>; + + beforeEach(() => { + savedEnv = {}; + for (const key of ENV_KEYS) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = savedEnv[key]; + } + } + }); + + it('reads provider API keys directly from the environment', () => { + process.env.OPENAI_API_KEY = 'sk-openai'; + process.env.ANTHROPIC_API_KEY = 'sk-anthropic'; + process.env.COPILOT_GITHUB_TOKEN = 'gh-copilot'; + process.env.COPILOT_PROVIDER_API_KEY = 'sk-provider'; + process.env.GEMINI_API_KEY = 'sk-gemini'; + + const credentials = resolveApiCredentials({}); + + expect(credentials.openaiApiKey).toBe('sk-openai'); + expect(credentials.anthropicApiKey).toBe('sk-anthropic'); + expect(credentials.copilotGithubToken).toBe('gh-copilot'); + expect(credentials.copilotProviderApiKey).toBe('sk-provider'); + expect(credentials.geminiApiKey).toBe('sk-gemini'); + }); + + it('prefers explicit options over environment fallbacks', () => { + process.env.COPILOT_PROVIDER_TYPE = 'env-type'; + process.env.COPILOT_PROVIDER_BASE_URL = 'https://env-router.example.com/v1'; + process.env.OPENAI_API_TARGET = 'https://env-openai.example.com'; + process.env.AWF_AUTH_TYPE = 'env-auth'; + process.env.AWF_AUTH_AZURE_TENANT_ID = 'env-tenant'; + + const credentials = resolveApiCredentials({ + copilotProviderType: 'azure', + copilotProviderBaseUrl: 'https://config-router.example.com/v1', + openaiApiTarget: 'https://config-openai.example.com', + authType: 'github-oidc', + authAzureTenantId: 'config-tenant', + }); + + expect(credentials.copilotProviderType).toBe('azure'); + expect(credentials.copilotProviderBaseUrl).toBe('https://config-router.example.com/v1'); + expect(credentials.openaiApiTarget).toBe('https://config-openai.example.com'); + expect(credentials.authType).toBe('github-oidc'); + expect(credentials.authAzureTenantId).toBe('config-tenant'); + }); + + it('resolves oidc-related environment mappings', () => { + process.env.AWF_AUTH_PROVIDER = 'gcp'; + process.env.AWF_AUTH_OIDC_AUDIENCE = 'https://github.com/github'; + process.env.AWF_AUTH_GCP_SCOPE = 'https://www.googleapis.com/auth/cloud-platform'; + + const credentials = resolveApiCredentials({}); + + expect(credentials.authProvider).toBe('gcp'); + expect(credentials.authOidcAudience).toBe('https://github.com/github'); + expect(credentials.authGcpScope).toBe('https://www.googleapis.com/auth/cloud-platform'); + }); + + it('passes through resolved copilot endpoints and github token precedence', () => { + process.env.GITHUB_TOKEN = 'github-token'; + process.env.GH_TOKEN = 'gh-token'; + + const credentials = resolveApiCredentials({}, { + resolvedCopilotApiTarget: 'https://copilot.example.com', + resolvedCopilotApiBasePath: '/v1/chat/completions', + }); + + expect(credentials.copilotApiTarget).toBe('https://copilot.example.com'); + expect(credentials.copilotApiBasePath).toBe('/v1/chat/completions'); + expect(credentials.githubToken).toBe('github-token'); + }); +}); diff --git a/src/commands/resolve-credentials.ts b/src/commands/resolve-credentials.ts new file mode 100644 index 000000000..ce6a9c428 --- /dev/null +++ b/src/commands/resolve-credentials.ts @@ -0,0 +1,93 @@ +import { OPENAI_ENV, ANTHROPIC_ENV, GEMINI_ENV, COPILOT_ENV, OIDC_AUTH_ENV_MAPPING } from '../api-proxy-env-constants'; +import type { WrapperConfig } from '../types'; + +interface ResolveApiCredentialsInputs { + resolvedCopilotApiTarget?: string; + resolvedCopilotApiBasePath?: string; +} + +type OidcConfigKey = (typeof OIDC_AUTH_ENV_MAPPING)[number]['configKey']; + +type ApiCredentials = Pick; + +/** + * Resolves API proxy credentials and related auth settings from config options + * and environment variables. + */ +export function resolveApiCredentials( + options: Record, + inputs: ResolveApiCredentialsInputs = {} +): ApiCredentials { + const oidcCredentials = Object.fromEntries( + OIDC_AUTH_ENV_MAPPING + .map(({ configKey, envVar }) => [ + configKey, + resolveOptionOrEnv(options, configKey, envVar), + ]) + .filter(([, value]) => value !== undefined) + ) as Pick; + + return { + openaiApiKey: process.env[OPENAI_ENV.KEY], + anthropicApiKey: process.env[ANTHROPIC_ENV.KEY], + copilotGithubToken: process.env[COPILOT_ENV.GITHUB_TOKEN], + copilotProviderApiKey: process.env[COPILOT_ENV.PROVIDER_API_KEY], + copilotProviderType: resolveOptionOrEnv(options, 'copilotProviderType', COPILOT_ENV.PROVIDER_TYPE), + copilotProviderBaseUrl: resolveOptionOrEnv( + options, + 'copilotProviderBaseUrl', + COPILOT_ENV.PROVIDER_BASE_URL + ), + geminiApiKey: process.env[GEMINI_ENV.KEY], + copilotApiTarget: inputs.resolvedCopilotApiTarget, + copilotApiBasePath: inputs.resolvedCopilotApiBasePath, + openaiApiTarget: resolveOptionOrEnv(options, 'openaiApiTarget', OPENAI_ENV.TARGET), + openaiApiBasePath: resolveOptionOrEnv(options, 'openaiApiBasePath', OPENAI_ENV.BASE_PATH), + anthropicApiTarget: resolveOptionOrEnv(options, 'anthropicApiTarget', ANTHROPIC_ENV.TARGET), + anthropicApiBasePath: resolveOptionOrEnv( + options, + 'anthropicApiBasePath', + ANTHROPIC_ENV.BASE_PATH + ), + openaiApiAuthHeader: resolveOptionOrEnv(options, 'openaiApiAuthHeader', OPENAI_ENV.AUTH_HEADER), + anthropicApiAuthHeader: resolveOptionOrEnv( + options, + 'anthropicApiAuthHeader', + ANTHROPIC_ENV.AUTH_HEADER + ), + anthropicTokenUrl: (options.anthropicTokenUrl as string | undefined) || process.env.AWF_AUTH_ANTHROPIC_TOKEN_URL, + ...oidcCredentials, + geminiApiTarget: resolveOptionOrEnv(options, 'geminiApiTarget', GEMINI_ENV.TARGET), + geminiApiBasePath: resolveOptionOrEnv(options, 'geminiApiBasePath', GEMINI_ENV.BASE_PATH), + githubToken: process.env.GITHUB_TOKEN || process.env.GH_TOKEN, + }; +} + +function resolveOptionOrEnv( + options: Record, + optionKey: string, + envVar: string +): string | undefined { + return (options[optionKey] as string | undefined) || process.env[envVar]; +} From e61d967702b04d4e2955d40217aae957b8b2d73d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Jul 2026 19:21:08 +0000 Subject: [PATCH 3/3] fix: use nullish coalescing in resolveOptionOrEnv --- src/commands/resolve-credentials.test.ts | 16 ++++++++++++++++ src/commands/resolve-credentials.ts | 4 ++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/commands/resolve-credentials.test.ts b/src/commands/resolve-credentials.test.ts index 81267d486..82e64d1dd 100644 --- a/src/commands/resolve-credentials.test.ts +++ b/src/commands/resolve-credentials.test.ts @@ -97,6 +97,22 @@ describe('resolveApiCredentials', () => { expect(credentials.authGcpScope).toBe('https://www.googleapis.com/auth/cloud-platform'); }); + it('treats explicitly provided empty string as authoritative over env var', () => { + process.env.OPENAI_API_BASE_PATH = '/env-base-path'; + process.env.ANTHROPIC_API_BASE_PATH = '/env-anthropic-base'; + process.env.GEMINI_API_BASE_PATH = '/env-gemini-base'; + + const credentials = resolveApiCredentials({ + openaiApiBasePath: '', + anthropicApiBasePath: '', + geminiApiBasePath: '', + }); + + expect(credentials.openaiApiBasePath).toBe(''); + expect(credentials.anthropicApiBasePath).toBe(''); + expect(credentials.geminiApiBasePath).toBe(''); + }); + it('passes through resolved copilot endpoints and github token precedence', () => { process.env.GITHUB_TOKEN = 'github-token'; process.env.GH_TOKEN = 'gh-token'; diff --git a/src/commands/resolve-credentials.ts b/src/commands/resolve-credentials.ts index ce6a9c428..020a6b9b4 100644 --- a/src/commands/resolve-credentials.ts +++ b/src/commands/resolve-credentials.ts @@ -76,7 +76,7 @@ export function resolveApiCredentials( 'anthropicApiAuthHeader', ANTHROPIC_ENV.AUTH_HEADER ), - anthropicTokenUrl: (options.anthropicTokenUrl as string | undefined) || process.env.AWF_AUTH_ANTHROPIC_TOKEN_URL, + anthropicTokenUrl: (options.anthropicTokenUrl as string | undefined) ?? process.env.AWF_AUTH_ANTHROPIC_TOKEN_URL, ...oidcCredentials, geminiApiTarget: resolveOptionOrEnv(options, 'geminiApiTarget', GEMINI_ENV.TARGET), geminiApiBasePath: resolveOptionOrEnv(options, 'geminiApiBasePath', GEMINI_ENV.BASE_PATH), @@ -89,5 +89,5 @@ function resolveOptionOrEnv( optionKey: string, envVar: string ): string | undefined { - return (options[optionKey] as string | undefined) || process.env[envVar]; + return (options[optionKey] as string | undefined) ?? process.env[envVar]; }