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
162 changes: 74 additions & 88 deletions src/commands/build-config.ts
Original file line number Diff line number Diff line change
@@ -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}.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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<string, string> | undefined,
copilotByokExtraBodyFields: options.copilotByokExtraBodyFields as Record<string, string> | 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,
Expand All @@ -232,6 +167,57 @@ export function buildConfig(inputs: BuildConfigInputs): WrapperConfig {
};
}

function buildChrootIdentity(
options: Record<string, unknown>
): 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<string, unknown>): 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;
Expand Down
129 changes: 129 additions & 0 deletions src/commands/resolve-credentials.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
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<Record<(typeof ENV_KEYS)[number], string | undefined>>;

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('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';

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');
});
});
Loading
Loading