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
8 changes: 5 additions & 3 deletions containers/api-proxy/providers/copilot.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@
* Auth: Bearer token (COPILOT_GITHUB_TOKEN or COPILOT_API_KEY)
* Credentials: COPILOT_GITHUB_TOKEN (GitHub OAuth, higher trust) or COPILOT_API_KEY (BYOK)
* Target: COPILOT_API_TARGET (auto-derived from GITHUB_SERVER_URL if not set)
* Base path: none (Copilot inference API manages its own path layout)
* Base path: optional `COPILOT_API_BASE_PATH` for prefixed BYOK routers
*
* Special routing: GET /models (and /models/*) always uses COPILOT_GITHUB_TOKEN
* regardless of which auth mode is active, because the /models endpoint only
* accepts OAuth tokens, not API keys.
*/

const { normalizeApiTarget } = require('../proxy-utils');
const { normalizeApiTarget, normalizeBasePath } = require('../proxy-utils');
const { URL } = require('url');

/**
Expand Down Expand Up @@ -150,6 +150,7 @@ function createCopilotAdapter(env, deps = {}) {
const authToken = resolveCopilotAuthToken(env);
const integrationId = env.COPILOT_INTEGRATION_ID || 'copilot-developer-cli';
const rawTarget = deriveCopilotApiTarget(env);
const basePath = normalizeBasePath(env.COPILOT_API_BASE_PATH);

const bodyTransform = deps.bodyTransform || null;

Expand All @@ -172,7 +173,7 @@ function createCopilotAdapter(env, deps = {}) {

isEnabled() { return !!authToken; },
getTargetHost() { return rawTarget; },
getBasePath() { return ''; },
getBasePath() { return basePath; },

/**
* Build Copilot auth headers for this request.
Expand Down Expand Up @@ -290,6 +291,7 @@ function createCopilotAdapter(env, deps = {}) {
_apiKey: apiKey,
_integrationId: integrationId,
_rawTarget: rawTarget,
_basePath: basePath,
};
}

Expand Down
13 changes: 13 additions & 0 deletions containers/api-proxy/server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1048,6 +1048,19 @@ describe('createCopilotAdapter — BYOK getAuthHeaders', () => {
const headers = adapter.getAuthHeaders(fakeReq);
expect(headers['Copilot-Integration-Id']).toBe('my-custom-integration');
});

it('uses COPILOT_API_BASE_PATH when configured', () => {
const adapter = createCopilotAdapter({
COPILOT_API_KEY: 'sk-or-v1-abc123',
COPILOT_API_BASE_PATH: '/api/v1/',
});
expect(adapter.getBasePath()).toBe('/api/v1');
});

it('defaults to empty base path when COPILOT_API_BASE_PATH is not set', () => {
const adapter = createCopilotAdapter({ COPILOT_API_KEY: 'sk-or-v1-abc123' });
expect(adapter.getBasePath()).toBe('');
});
});

describe('resolveOpenCodeRoute', () => {
Expand Down
54 changes: 53 additions & 1 deletion src/cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { Command } from 'commander';
import { validateFormat, program, handlePredownloadAction } from './cli';
import {
validateFormat,
program,
handlePredownloadAction,
resolveCopilotApiKey,
deriveCopilotApiTargetFromProviderBaseUrl,
deriveCopilotApiBasePathFromProviderBaseUrl,
resolveCopilotApiRouting,
} from './cli';
import { redactSecrets } from './redact-secrets';

describe('cli', () => {
Expand Down Expand Up @@ -225,6 +233,50 @@ describe('cli', () => {
});
});

describe('Copilot BYOK env resolution', () => {
it('prefers COPILOT_API_KEY and falls back to COPILOT_PROVIDER_API_KEY', () => {
expect(resolveCopilotApiKey({
COPILOT_API_KEY: 'primary-key',
COPILOT_PROVIDER_API_KEY: 'fallback-key',
})).toBe('primary-key');

expect(resolveCopilotApiKey({
COPILOT_PROVIDER_API_KEY: 'fallback-key',
})).toBe('fallback-key');
});

it('derives copilot target hostname from COPILOT_PROVIDER_BASE_URL', () => {
expect(deriveCopilotApiTargetFromProviderBaseUrl('https://openrouter.ai/api/v1')).toBe('openrouter.ai');
expect(deriveCopilotApiTargetFromProviderBaseUrl('openrouter.ai/api/v1')).toBe('openrouter.ai');
expect(deriveCopilotApiTargetFromProviderBaseUrl(' http://router.example.com:8443/v2 ')).toBe('router.example.com');
expect(deriveCopilotApiTargetFromProviderBaseUrl('example.com:8080')).toBe('example.com');
expect(deriveCopilotApiTargetFromProviderBaseUrl('192.168.1.10:9000')).toBe('192.168.1.10');
expect(deriveCopilotApiTargetFromProviderBaseUrl('[2001:db8::1]:8443')).toBe('[2001:db8::1]');
expect(deriveCopilotApiTargetFromProviderBaseUrl(' ')).toBeUndefined();
expect(deriveCopilotApiTargetFromProviderBaseUrl(undefined)).toBeUndefined();
expect(deriveCopilotApiTargetFromProviderBaseUrl('not a valid url')).toBeUndefined();
});

it('derives copilot base path from COPILOT_PROVIDER_BASE_URL', () => {
expect(deriveCopilotApiBasePathFromProviderBaseUrl('https://openrouter.ai/api/v1')).toBe('/api/v1');
expect(deriveCopilotApiBasePathFromProviderBaseUrl('openrouter.ai/api/v1/')).toBe('/api/v1');
expect(deriveCopilotApiBasePathFromProviderBaseUrl('https://openrouter.ai')).toBeUndefined();
expect(deriveCopilotApiBasePathFromProviderBaseUrl(' ')).toBeUndefined();
expect(deriveCopilotApiBasePathFromProviderBaseUrl(undefined)).toBeUndefined();
});

it('resolves provider-derived Copilot routing for allowlist/config wiring', () => {
const resolved = resolveCopilotApiRouting(
{ copilotApiTarget: undefined },
{ COPILOT_PROVIDER_BASE_URL: 'https://openrouter.ai/api/v1' }
);
expect(resolved).toEqual({
copilotApiTarget: 'openrouter.ai',
copilotApiBasePath: '/api/v1',
});
});
});

describe('help text formatting', () => {
it('should include section headers in help output', () => {
const help = program.helpInformation();
Expand Down
110 changes: 107 additions & 3 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,91 @@

export const program = new Command();

/**
* Resolve the Copilot BYOK key from supported environment variables.
* COPILOT_API_KEY takes precedence over COPILOT_PROVIDER_API_KEY.
*/
export function resolveCopilotApiKey(
env: Record<string, string | undefined> = process.env
): string | undefined {
return env.COPILOT_API_KEY || env.COPILOT_PROVIDER_API_KEY;
}

/**
* Derive a Copilot API target hostname from COPILOT_PROVIDER_BASE_URL.
* Returns undefined when the value is empty or not a valid URL/host.
*/
export function deriveCopilotApiTargetFromProviderBaseUrl(
providerBaseUrl: string | undefined
): string | undefined {
const trimmed = providerBaseUrl?.trim();
if (!trimmed) return undefined;

const candidate = trimmed.includes('://')
? trimmed
: `https://${trimmed}`;

try {
return new URL(candidate).hostname || undefined;
Comment on lines +151 to +156
} catch {
return undefined;
}
}

/**
* Derive a Copilot API base-path prefix from COPILOT_PROVIDER_BASE_URL.
* Returns undefined when the value is empty, invalid, or has no path.
*/
export function deriveCopilotApiBasePathFromProviderBaseUrl(
providerBaseUrl: string | undefined
): string | undefined {
const trimmed = providerBaseUrl?.trim();
if (!trimmed) return undefined;

const candidate = trimmed.includes('://')
? trimmed
: `https://${trimmed}`;

try {
const pathname = new URL(candidate).pathname.replace(/\/+$/, '');
if (!pathname || pathname === '/') return undefined;
return pathname.startsWith('/') ? pathname : `/${pathname}`;
} catch {
return undefined;
}
}

/**
* Resolve Copilot target/base-path routing for BYOK provider-style env vars.
*
* Target precedence:
* 1. --copilot-api-target
* 2. COPILOT_API_TARGET
* 3. Hostname from COPILOT_PROVIDER_BASE_URL
*
* Base path precedence:
* 1. COPILOT_API_BASE_PATH
* 2. Pathname from COPILOT_PROVIDER_BASE_URL
*/
export function resolveCopilotApiRouting(
options: { copilotApiTarget?: string },
env: Record<string, string | undefined> = process.env
): { copilotApiTarget?: string; copilotApiBasePath?: string } {
const providerBaseUrl = env.COPILOT_PROVIDER_BASE_URL;
const copilotApiTargetFromProviderBaseUrl = deriveCopilotApiTargetFromProviderBaseUrl(providerBaseUrl);
const copilotApiBasePathFromProviderBaseUrl = deriveCopilotApiBasePathFromProviderBaseUrl(providerBaseUrl);

return {
copilotApiTarget:
options.copilotApiTarget ||
env.COPILOT_API_TARGET ||
copilotApiTargetFromProviderBaseUrl,
copilotApiBasePath:
env.COPILOT_API_BASE_PATH ||
copilotApiBasePathFromProviderBaseUrl,
};
}

// Option group markers used by the custom help formatter to insert section headers.
// Each key is the long flag name of the first option in a group.
const optionGroupHeaders: Record<string, string> = {
Expand Down Expand Up @@ -185,9 +270,9 @@
const flags = helper.optionTerm(opt);
const optDesc = helper.optionDescription(opt);
const longFlag = opt.long?.replace(/^--/, '');
if (longFlag && optionGroupHeaders[longFlag]) {

Check warning on line 273 in src/cli.ts

View workflow job for this annotation

GitHub Actions / ESLint

Generic Object Injection Sink

Check warning on line 273 in src/cli.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Generic Object Injection Sink

Check warning on line 273 in src/cli.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Generic Object Injection Sink
output.push('');
output.push(` ${optionGroupHeaders[longFlag]}`);

Check warning on line 275 in src/cli.ts

View workflow job for this annotation

GitHub Actions / ESLint

Generic Object Injection Sink

Check warning on line 275 in src/cli.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Generic Object Injection Sink

Check warning on line 275 in src/cli.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Generic Object Injection Sink
}
output.push(formatItem(flags, optDesc, termWidth, itemIndent + 2, itemSep, helpWidth));
}
Expand Down Expand Up @@ -639,10 +724,28 @@
}
}

const {
copilotApiTarget: resolvedCopilotApiTarget,
copilotApiBasePath: resolvedCopilotApiBasePath,
} = resolveCopilotApiRouting(
{ copilotApiTarget: options.copilotApiTarget },
process.env
);

// Automatically add API target values to allowlist when specified
// This ensures that when engine.api-target is set in GitHub Agentic Workflows,
// the target domain is automatically accessible through the firewall
resolveApiTargetsToAllowedDomains(options, allowedDomains, process.env, logger.debug.bind(logger));
resolveApiTargetsToAllowedDomains(
{
copilotApiTarget: resolvedCopilotApiTarget,
openaiApiTarget: options.openaiApiTarget,
anthropicApiTarget: options.anthropicApiTarget,
geminiApiTarget: options.geminiApiTarget,
},
allowedDomains,
process.env,
logger.debug.bind(logger)
);

// Validate all domains and patterns
for (const domain of allowedDomains) {
Expand Down Expand Up @@ -699,7 +802,7 @@

// Validate --env-file path if provided
if (options.envFile) {
if (!fs.existsSync(options.envFile)) {

Check warning on line 805 in src/cli.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found existsSync from package "fs" with non literal argument at index 0

Check warning on line 805 in src/cli.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found existsSync from package "fs" with non literal argument at index 0

Check warning on line 805 in src/cli.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found existsSync from package "fs" with non literal argument at index 0
logger.error(`--env-file: file not found: ${options.envFile}`);
process.exit(1);
}
Expand Down Expand Up @@ -891,9 +994,10 @@
openaiApiKey: process.env.OPENAI_API_KEY,
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
copilotGithubToken: process.env.COPILOT_GITHUB_TOKEN,
copilotApiKey: process.env.COPILOT_API_KEY,
copilotApiKey: resolveCopilotApiKey(process.env),
geminiApiKey: process.env.GEMINI_API_KEY,
copilotApiTarget: options.copilotApiTarget || process.env.COPILOT_API_TARGET,
copilotApiTarget: resolvedCopilotApiTarget,
copilotApiBasePath: resolvedCopilotApiBasePath,
openaiApiTarget: options.openaiApiTarget || process.env.OPENAI_API_TARGET,
openaiApiBasePath: options.openaiApiBasePath || process.env.OPENAI_API_BASE_PATH,
anthropicApiTarget: options.anthropicApiTarget || process.env.ANTHROPIC_API_TARGET,
Expand Down Expand Up @@ -1029,11 +1133,11 @@
if (typeof candidate !== 'string' || candidate.trim() === '') continue;
try {
const envFilePath = path.isAbsolute(candidate) ? candidate : path.resolve(process.cwd(), candidate);
const envFileContents = fs.readFileSync(envFilePath, 'utf8');

Check warning on line 1136 in src/cli.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found readFileSync from package "fs" with non literal argument at index 0

Check warning on line 1136 in src/cli.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found readFileSync from package "fs" with non literal argument at index 0

Check warning on line 1136 in src/cli.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found readFileSync from package "fs" with non literal argument at index 0
for (const line of envFileContents.split(/\r?\n/)) {
const trimmedLine = line.trim();
if (!trimmedLine || trimmedLine.startsWith('#')) continue;
if (/^(?:export\s+)?COPILOT_MODEL\s*=/.test(trimmedLine)) {

Check warning on line 1140 in src/cli.ts

View workflow job for this annotation

GitHub Actions / ESLint

Unsafe Regular Expression

Check warning on line 1140 in src/cli.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Unsafe Regular Expression

Check warning on line 1140 in src/cli.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Unsafe Regular Expression
return true;
}
}
Expand All @@ -1060,7 +1164,7 @@
const redactedConfig: Record<string, unknown> = {};
for (const [key, value] of Object.entries(config)) {
if (key === 'openaiApiKey' || key === 'anthropicApiKey' || key === 'copilotGithubToken' || key === 'copilotApiKey' || key === 'geminiApiKey') continue;
redactedConfig[key] = key === 'agentCommand' ? redactSecrets(value as string) : value;

Check warning on line 1167 in src/cli.ts

View workflow job for this annotation

GitHub Actions / ESLint

Generic Object Injection Sink

Check warning on line 1167 in src/cli.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Generic Object Injection Sink

Check warning on line 1167 in src/cli.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Generic Object Injection Sink
}
logger.debug('Configuration:', JSON.stringify(redactedConfig, null, 2));
logger.info(`Allowed domains: ${allowedDomains.join(', ')}`);
Expand Down
33 changes: 33 additions & 0 deletions src/services/agent-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,39 @@ describe('agent service', () => {
delete process.env.COPILOT_API_KEY;
});

it('should not forward COPILOT_PROVIDER_API_KEY to agent from --env-all when api-proxy is enabled', () => {
const providerApiKey = 'sk-real-provider-key';
process.env.COPILOT_PROVIDER_API_KEY = providerApiKey;
const configWithProxy = { ...mockConfig, enableApiProxy: true, envAll: true };
const proxyNetworkConfig = { ...mockNetworkConfig, proxyIp: '172.30.0.30' };
const result = generateDockerCompose(configWithProxy, proxyNetworkConfig);
const env = result.services.agent.environment as Record<string, string>;
expect(env.COPILOT_PROVIDER_API_KEY).toBeUndefined();
delete process.env.COPILOT_PROVIDER_API_KEY;
});

it('should keep COPILOT_PROVIDER_API_KEY placeholder when api-proxy is enabled with copilotApiKey and --env-all', () => {
const providerApiKey = 'sk-real-provider-key';
const copilotApiKey = 'cpat-config-byok-key';
process.env.COPILOT_PROVIDER_API_KEY = providerApiKey;
const configWithProxy = { ...mockConfig, enableApiProxy: true, envAll: true, copilotApiKey };
const proxyNetworkConfig = { ...mockNetworkConfig, proxyIp: '172.30.0.30' };
const result = generateDockerCompose(configWithProxy, proxyNetworkConfig);
const env = result.services.agent.environment as Record<string, string>;
expect(env.COPILOT_PROVIDER_API_KEY).toBe('placeholder-token-for-credential-isolation');
delete process.env.COPILOT_PROVIDER_API_KEY;
});

it('should keep COPILOT_API_KEY placeholder when api-proxy is enabled with copilotApiKey and --env-all', () => {
process.env.COPILOT_API_KEY = 'cpat-host-value';
const configWithProxy = { ...mockConfig, enableApiProxy: true, envAll: true, copilotApiKey: 'cpat-config-byok-key' };
const proxyNetworkConfig = { ...mockNetworkConfig, proxyIp: '172.30.0.30' };
const result = generateDockerCompose(configWithProxy, proxyNetworkConfig);
const env = result.services.agent.environment as Record<string, string>;
expect(env.COPILOT_API_KEY).toBe('placeholder-token-for-credential-isolation');
delete process.env.COPILOT_API_KEY;
});

it('should forward AWF_ONE_SHOT_TOKEN_DEBUG when set', () => {
process.env.AWF_ONE_SHOT_TOKEN_DEBUG = '1';
const result = generateDockerCompose(mockConfig, mockNetworkConfig);
Expand Down
6 changes: 5 additions & 1 deletion src/services/agent-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,14 @@ export function buildAgentEnvironment(params: AgentEnvironmentParams): Record<st
EXCLUDED_ENV_VARS.add('CODEX_API_KEY');
EXCLUDED_ENV_VARS.add('ANTHROPIC_API_KEY');
EXCLUDED_ENV_VARS.add('CLAUDE_API_KEY');
EXCLUDED_ENV_VARS.add('COPILOT_GITHUB_TOKEN');
EXCLUDED_ENV_VARS.add('COPILOT_API_KEY');
EXCLUDED_ENV_VARS.add('COPILOT_PROVIDER_API_KEY');
EXCLUDED_ENV_VARS.add('GEMINI_API_KEY');
EXCLUDED_ENV_VARS.add('GOOGLE_GEMINI_BASE_URL');
EXCLUDED_ENV_VARS.add('GEMINI_API_BASE_URL');
// COPILOT_GITHUB_TOKEN and COPILOT_API_KEY get placeholders (not excluded), protected by one-shot-token
// Copilot credential vars are excluded from inherited env passthrough. When needed for
// compatibility, placeholder values are set explicitly below and protected by one-shot-token.
// GITHUB_API_URL is intentionally NOT excluded: the Copilot CLI needs it to know the
// GitHub API base URL. Copilot-specific API calls (inference and token exchange) go
// through COPILOT_API_URL → api-proxy regardless of GITHUB_API_URL being set.
Expand Down
21 changes: 21 additions & 0 deletions src/services/api-proxy-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,27 @@ describe('API proxy sidecar', () => {
expect(env.COPILOT_API_TARGET).toBeUndefined();
});

it('should set COPILOT_API_BASE_PATH in api-proxy when copilotApiBasePath is provided', () => {
const configWithProxy = {
...mockConfig,
enableApiProxy: true,
copilotApiKey: 'cpat_test_byok_key',
copilotApiBasePath: '/api/v1',
};
const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy);
const proxy = result.services['api-proxy'];
const env = proxy.environment as Record<string, string>;
expect(env.COPILOT_API_BASE_PATH).toBe('/api/v1');
});

it('should not set COPILOT_API_BASE_PATH in api-proxy when copilotApiBasePath is not provided', () => {
const configWithProxy = { ...mockConfig, enableApiProxy: true, copilotApiKey: 'cpat_test_byok_key' };
const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy);
const proxy = result.services['api-proxy'];
const env = proxy.environment as Record<string, string>;
expect(env.COPILOT_API_BASE_PATH).toBeUndefined();
});

it('should pass COPILOT_API_KEY to api-proxy env when copilotApiKey is provided', () => {
const configWithProxy = { ...mockConfig, enableApiProxy: true, copilotApiKey: 'cpat_test_byok_key' };
const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy);
Expand Down
1 change: 1 addition & 0 deletions src/services/api-proxy-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export function buildApiProxyService(params: ApiProxyServiceParams): ApiProxyBui
// stripping here prevents a scheme-prefixed hostname from reaching the
// container at all (belt-and-suspenders for gh-aw#25137).
...(config.copilotApiTarget && { COPILOT_API_TARGET: stripScheme(config.copilotApiTarget) }),
...(config.copilotApiBasePath && { COPILOT_API_BASE_PATH: config.copilotApiBasePath }),
...(config.openaiApiTarget && { OPENAI_API_TARGET: stripScheme(config.openaiApiTarget) }),
...(config.openaiApiBasePath && { OPENAI_API_BASE_PATH: config.openaiApiBasePath }),
...(config.anthropicApiTarget && { ANTHROPIC_API_TARGET: stripScheme(config.anthropicApiTarget) }),
Expand Down
16 changes: 16 additions & 0 deletions src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -750,6 +750,22 @@ export interface WrapperConfig {
*/
copilotApiTarget?: string;

/**
* Base path prefix for GitHub Copilot API requests (used by API proxy sidecar)
*
* When set, this path is prepended to upstream Copilot requests. This enables
* BYOK providers that expose Copilot-compatible APIs behind a prefixed endpoint
* (for example, `https://router.example.com/api/v1`).
*
* Can be set via:
* - Environment variable: `COPILOT_API_BASE_PATH`
* - Auto-derived from `COPILOT_PROVIDER_BASE_URL` path when present
*
* @default ''
* @example '/api/v1'
*/
copilotApiBasePath?: string;

/**
* Target hostname for OpenAI API requests (used by API proxy sidecar)
*
Expand Down
Loading