diff --git a/containers/api-proxy/providers/copilot.js b/containers/api-proxy/providers/copilot.js index 789349b6e..260a8ddbd 100644 --- a/containers/api-proxy/providers/copilot.js +++ b/containers/api-proxy/providers/copilot.js @@ -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'); /** @@ -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; @@ -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. @@ -290,6 +291,7 @@ function createCopilotAdapter(env, deps = {}) { _apiKey: apiKey, _integrationId: integrationId, _rawTarget: rawTarget, + _basePath: basePath, }; } diff --git a/containers/api-proxy/server.test.js b/containers/api-proxy/server.test.js index 61be2bee7..52cef3fae 100644 --- a/containers/api-proxy/server.test.js +++ b/containers/api-proxy/server.test.js @@ -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', () => { diff --git a/src/cli.test.ts b/src/cli.test.ts index fc35eefc7..e8d764f75 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -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', () => { @@ -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(); diff --git a/src/cli.ts b/src/cli.ts index 4b6ad4f81..2fefe5a07 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -128,6 +128,91 @@ import { processAgentImageOption } from './domain-utils'; 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 = 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; + } 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 = 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 = { @@ -639,10 +724,28 @@ program } } + 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) { @@ -891,9 +994,10 @@ program 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, diff --git a/src/services/agent-service.test.ts b/src/services/agent-service.test.ts index 7c95bed69..17496d4c0 100644 --- a/src/services/agent-service.test.ts +++ b/src/services/agent-service.test.ts @@ -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; + 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; + 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; + 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); diff --git a/src/services/agent-service.ts b/src/services/agent-service.ts index 6a5da01b5..c77751ed8 100644 --- a/src/services/agent-service.ts +++ b/src/services/agent-service.ts @@ -77,10 +77,14 @@ export function buildAgentEnvironment(params: AgentEnvironmentParams): Record { 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; + 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; + 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); diff --git a/src/services/api-proxy-service.ts b/src/services/api-proxy-service.ts index e1a53a469..530267093 100644 --- a/src/services/api-proxy-service.ts +++ b/src/services/api-proxy-service.ts @@ -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) }), diff --git a/src/types/config.ts b/src/types/config.ts index 6f7d118e9..f1d9729c7 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -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) *