From 19ddd1239e24411d055ab6e8f619c7f5667d9d3a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 03:03:19 +0000 Subject: [PATCH 1/4] Initial plan From f7a6633f6d4490f48d607b9cde566b09a6bee4dc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 03:08:32 +0000 Subject: [PATCH 2/4] Gate OpenCode listener on AWF_ENABLE_OPENCODE env var --- containers/api-proxy/server.js | 9 +++++++-- containers/api-proxy/server.test.js | 8 ++++---- src/cli.ts | 7 +++++++ src/docker-manager.test.ts | 24 ++++++++++++++++++++++++ src/docker-manager.ts | 2 ++ src/types.ts | 13 +++++++++++++ 6 files changed, 57 insertions(+), 6 deletions(-) diff --git a/containers/api-proxy/server.js b/containers/api-proxy/server.js index 2819d672b..1972d2729 100644 --- a/containers/api-proxy/server.js +++ b/containers/api-proxy/server.js @@ -81,6 +81,7 @@ function resolveCopilotAuthToken(env = process.env) { const COPILOT_AUTH_TOKEN = resolveCopilotAuthToken(process.env); const COPILOT_INTEGRATION_ID = process.env.COPILOT_INTEGRATION_ID || 'copilot-developer-cli'; const GEMINI_API_KEY = (process.env.GEMINI_API_KEY || '').trim() || undefined; +const ENABLE_OPENCODE = process.env.AWF_ENABLE_OPENCODE === 'true'; /** * Normalizes an API target value to a bare hostname. @@ -1434,7 +1435,7 @@ async function fetchStartupModels(overrides = {}) { * @returns {{ endpoints: Array, models_fetch_complete: boolean, model_aliases: Record|null }} */ function reflectEndpoints() { - const opencodeConfigured = !!(OPENAI_API_KEY || ANTHROPIC_API_KEY || COPILOT_AUTH_TOKEN); + const opencodeConfigured = ENABLE_OPENCODE && !!(OPENAI_API_KEY || ANTHROPIC_API_KEY || COPILOT_AUTH_TOKEN); return { endpoints: [ { @@ -1540,7 +1541,7 @@ if (require.main === module) { if (ANTHROPIC_API_KEY) expectedListeners++; if (COPILOT_AUTH_TOKEN) expectedListeners++; if (GEMINI_API_KEY) expectedListeners++; - if (OPENAI_API_KEY || ANTHROPIC_API_KEY || COPILOT_AUTH_TOKEN) expectedListeners++; // OpenCode (10004) + if (ENABLE_OPENCODE && (OPENAI_API_KEY || ANTHROPIC_API_KEY || COPILOT_AUTH_TOKEN)) expectedListeners++; // OpenCode (10004) let readyListeners = 0; function onListenerReady() { readyListeners++; @@ -1754,6 +1755,8 @@ if (require.main === module) { } // OpenCode API proxy (port 10004) — dynamic provider routing + // Only started when AWF_ENABLE_OPENCODE=true, so it doesn't activate + // unconditionally whenever any credential is present (e.g. Copilot-only runs). // Defaults to Copilot/OpenAI routing (OPENAI_API_KEY), with Anthropic as a BYOK fallback. // OpenCode gets a separate port from Claude (10001) and Codex (10000) for per-engine // rate limiting and metrics isolation. @@ -1763,6 +1766,7 @@ if (require.main === module) { // 2. ANTHROPIC_API_KEY → Anthropic BYOK route (ANTHROPIC_API_TARGET) // 3. COPILOT_GITHUB_TOKEN/API_KEY → Copilot route (COPILOT_API_TARGET), // resolved internally to COPILOT_AUTH_TOKEN + if (ENABLE_OPENCODE) { const opencodeStartupRoute = resolveOpenCodeRoute( OPENAI_API_KEY, ANTHROPIC_API_KEY, COPILOT_AUTH_TOKEN, OPENAI_API_TARGET, ANTHROPIC_API_TARGET, COPILOT_API_TARGET, @@ -1839,6 +1843,7 @@ if (require.main === module) { onListenerReady(); }); } + } // end if (ENABLE_OPENCODE) // Graceful shutdown process.on('SIGTERM', async () => { diff --git a/containers/api-proxy/server.test.js b/containers/api-proxy/server.test.js index f5c36244f..cbadbd9bb 100644 --- a/containers/api-proxy/server.test.js +++ b/containers/api-proxy/server.test.js @@ -1639,12 +1639,12 @@ describe('reflectEndpoints', () => { expect(urlMap.opencode).toBeNull(); }); - it('should report opencode as configured when openai key is present', () => { - // The module-level OPENAI_API_KEY is whatever process.env had at import time. - // We reflect the real configured state — just verify the shape is correct. + it('should report opencode as not configured when AWF_ENABLE_OPENCODE is not set', () => { + // ENABLE_OPENCODE is false at module load time (AWF_ENABLE_OPENCODE not set in test env), + // so opencode.configured must always be false regardless of other credentials. const result = reflectEndpoints(); const opencode = result.endpoints.find((e) => e.provider === 'opencode'); - expect(typeof opencode.configured).toBe('boolean'); + expect(opencode.configured).toBe(false); expect(opencode.models).toBeNull(); expect(opencode.models_url).toBeNull(); }); diff --git a/src/cli.ts b/src/cli.ts index 0cda2f69f..ecf40d9c4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1513,6 +1513,12 @@ program '--gemini-api-base-path ', 'Base path prefix for Gemini API requests', ) + .option( + '--enable-opencode', + 'Enable OpenCode API proxy listener on port 10004 (requires --enable-api-proxy).\n' + + ' Only start this when the workflow uses the OpenCode engine.', + false + ) .option( '--rate-limit-rpm ', 'Max requests per minute per provider (requires --enable-api-proxy)', @@ -1966,6 +1972,7 @@ program enableDlp: options.enableDlp, allowedUrls, enableApiProxy: options.enableApiProxy, + enableOpenCode: options.enableOpencode, modelAliases, openaiApiKey: process.env.OPENAI_API_KEY, anthropicApiKey: process.env.ANTHROPIC_API_KEY, diff --git a/src/docker-manager.test.ts b/src/docker-manager.test.ts index 4a75d4258..f993a3e19 100644 --- a/src/docker-manager.test.ts +++ b/src/docker-manager.test.ts @@ -2783,6 +2783,30 @@ describe('docker-manager', () => { expect(env.AWF_RATE_LIMIT_BYTES_PM).toBeUndefined(); }); + it('should set AWF_ENABLE_OPENCODE=true in api-proxy when enableOpenCode is true', () => { + const configWithOpenCode = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key', enableOpenCode: true }; + const result = generateDockerCompose(configWithOpenCode, mockNetworkConfigWithProxy); + const proxy = result.services['api-proxy']; + const env = proxy.environment as Record; + expect(env.AWF_ENABLE_OPENCODE).toBe('true'); + }); + + it('should not set AWF_ENABLE_OPENCODE in api-proxy when enableOpenCode is false', () => { + const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key', enableOpenCode: false }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const proxy = result.services['api-proxy']; + const env = proxy.environment as Record; + expect(env.AWF_ENABLE_OPENCODE).toBeUndefined(); + }); + + it('should not set AWF_ENABLE_OPENCODE in api-proxy when enableOpenCode is undefined', () => { + const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' }; + const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); + const proxy = result.services['api-proxy']; + const env = proxy.environment as Record; + expect(env.AWF_ENABLE_OPENCODE).toBeUndefined(); + }); + it('should set OPENAI_API_TARGET in api-proxy when openaiApiTarget is provided', () => { const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key', openaiApiTarget: 'custom.openai-router.internal' }; const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy); diff --git a/src/docker-manager.ts b/src/docker-manager.ts index 946b12c52..9efd4031c 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -1806,6 +1806,8 @@ export function generateDockerCompose( ...(config.modelAliases && { AWF_MODEL_ALIASES: JSON.stringify({ models: config.modelAliases }), }), + // Enable OpenCode listener only when explicitly requested + ...(config.enableOpenCode && { AWF_ENABLE_OPENCODE: 'true' }), }, healthcheck: { test: ['CMD', 'curl', '-f', `http://localhost:${API_PROXY_HEALTH_PORT}/health`], diff --git a/src/types.ts b/src/types.ts index 8048ec92e..6f4b146d1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -715,6 +715,19 @@ export interface WrapperConfig { */ geminiApiKey?: string; + /** + * Enable the OpenCode API proxy listener on port 10004 + * + * When true, the api-proxy sidecar starts the OpenCode listener (port 10004) that + * dynamically routes requests to whichever LLM credential is available. + * When false (the default), the listener is not started even if other API keys + * are present, preventing unnecessary port exposure in workflows that do not use + * the OpenCode engine. + * + * @default false + */ + enableOpenCode?: boolean; + /** * Target hostname for GitHub Copilot API requests (used by API proxy sidecar) * From db88f489d86622b3a30dc791c41d64a2bfcca6d4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 03:22:57 +0000 Subject: [PATCH 3/4] Address review feedback: validate --enable-opencode requires --enable-api-proxy; add AWF_ENABLE_OPENCODE=true test --- containers/api-proxy/server.test.js | 18 ++++++++++++++++++ src/cli.test.ts | 19 ++++++++++++++++++- src/cli.ts | 17 +++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/containers/api-proxy/server.test.js b/containers/api-proxy/server.test.js index cbadbd9bb..551fa520e 100644 --- a/containers/api-proxy/server.test.js +++ b/containers/api-proxy/server.test.js @@ -1648,6 +1648,24 @@ describe('reflectEndpoints', () => { expect(opencode.models).toBeNull(); expect(opencode.models_url).toBeNull(); }); + + it('should report opencode as configured when AWF_ENABLE_OPENCODE=true and a credential is present', () => { + let isolatedReflect; + jest.isolateModules(() => { + process.env.AWF_ENABLE_OPENCODE = 'true'; + process.env.OPENAI_API_KEY = 'sk-test-isolated'; + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + ({ reflectEndpoints: isolatedReflect } = require('./server')); + } finally { + delete process.env.AWF_ENABLE_OPENCODE; + delete process.env.OPENAI_API_KEY; + } + }); + const result = isolatedReflect(); + const opencode = result.endpoints.find((e) => e.provider === 'opencode'); + expect(opencode.configured).toBe(true); + }); }); // ── healthResponse ───────────────────────────────────────────────────────── diff --git a/src/cli.test.ts b/src/cli.test.ts index e18a6315b..9f28c2af7 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,5 +1,5 @@ import { Command } from 'commander'; -import { parseEnvironmentVariables, parseDomains, parseDomainsFile, escapeShellArg, joinShellArgs, parseVolumeMounts, isValidIPv4, isValidIPv6, parseDnsServers, parseDnsOverHttps, validateAgentImage, isAgentImagePreset, AGENT_IMAGE_PRESETS, processAgentImageOption, processLocalhostKeyword, validateSkipPullWithBuildLocal, validateAllowHostPorts, validateAllowHostServicePorts, applyHostServicePortsConfig, parseMemoryLimit, validateFormat, validateApiProxyConfig, buildRateLimitConfig, validateRateLimitFlags, hasRateLimitOptions, collectRulesetFile, validateApiTargetInAllowedDomains, DEFAULT_OPENAI_API_TARGET, DEFAULT_ANTHROPIC_API_TARGET, DEFAULT_COPILOT_API_TARGET, DEFAULT_GEMINI_API_TARGET, emitApiProxyTargetWarnings, emitCliProxyStatusLogs, warnClassicPATWithCopilotModel, formatItem, program, parseAgentTimeout, applyAgentTimeout, handlePredownloadAction, resolveApiTargetsToAllowedDomains, extractGhesDomainsFromEngineApiTarget, extractGhecDomainsFromServerUrl, checkDockerHost } from './cli'; +import { parseEnvironmentVariables, parseDomains, parseDomainsFile, escapeShellArg, joinShellArgs, parseVolumeMounts, isValidIPv4, isValidIPv6, parseDnsServers, parseDnsOverHttps, validateAgentImage, isAgentImagePreset, AGENT_IMAGE_PRESETS, processAgentImageOption, processLocalhostKeyword, validateSkipPullWithBuildLocal, validateAllowHostPorts, validateAllowHostServicePorts, applyHostServicePortsConfig, parseMemoryLimit, validateFormat, validateApiProxyConfig, buildRateLimitConfig, validateRateLimitFlags, validateEnableOpenCodeFlag, hasRateLimitOptions, collectRulesetFile, validateApiTargetInAllowedDomains, DEFAULT_OPENAI_API_TARGET, DEFAULT_ANTHROPIC_API_TARGET, DEFAULT_COPILOT_API_TARGET, DEFAULT_GEMINI_API_TARGET, emitApiProxyTargetWarnings, emitCliProxyStatusLogs, warnClassicPATWithCopilotModel, formatItem, program, parseAgentTimeout, applyAgentTimeout, handlePredownloadAction, resolveApiTargetsToAllowedDomains, extractGhesDomainsFromEngineApiTarget, extractGhecDomainsFromServerUrl, checkDockerHost } from './cli'; import { redactSecrets } from './redact-secrets'; import * as fs from 'fs'; import * as path from 'path'; @@ -1545,6 +1545,23 @@ describe('cli', () => { }); }); + describe('validateEnableOpenCodeFlag', () => { + it('should pass when both --enable-opencode and --enable-api-proxy are set', () => { + expect(validateEnableOpenCodeFlag(true, true)).toEqual({ valid: true }); + }); + it('should pass when --enable-opencode is false', () => { + expect(validateEnableOpenCodeFlag(false, false)).toEqual({ valid: true }); + }); + it('should pass when --enable-opencode is false and --enable-api-proxy is true', () => { + expect(validateEnableOpenCodeFlag(true, false)).toEqual({ valid: true }); + }); + it('should fail when --enable-opencode is true without --enable-api-proxy', () => { + const r = validateEnableOpenCodeFlag(false, true); + expect(r.valid).toBe(false); + expect(r.error).toContain('--enable-api-proxy'); + }); + }); + describe('hasRateLimitOptions', () => { it('should return false when no rate limit options set', () => { expect(hasRateLimitOptions({})).toBe(false); diff --git a/src/cli.ts b/src/cli.ts index ecf40d9c4..63a0db597 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -707,6 +707,16 @@ export function validateRateLimitFlags(enableApiProxy: boolean, options: { return { valid: true }; } +/** + * Validates that --enable-opencode is not used without --enable-api-proxy. + */ +export function validateEnableOpenCodeFlag(enableApiProxy: boolean, enableOpenCode: boolean): FlagValidationResult { + if (enableOpenCode && !enableApiProxy) { + return { valid: false, error: '--enable-opencode requires --enable-api-proxy' }; + } + return { valid: true }; +} + /** * Result of validating flag combinations */ @@ -2024,6 +2034,13 @@ program process.exit(1); } + // Error if --enable-opencode is used without --enable-api-proxy + const enableOpenCodeValidation = validateEnableOpenCodeFlag(config.enableApiProxy ?? false, config.enableOpenCode ?? false); + if (!enableOpenCodeValidation.valid) { + logger.error(enableOpenCodeValidation.error!); + process.exit(1); + } + // Warn if --env-all is used if (config.envAll) { logger.warn('⚠️ Using --env-all: All host environment variables will be passed to container'); From c4b517748b6f1c373b4abfa5f740f0d149667aca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 03:34:52 +0000 Subject: [PATCH 4/4] feat: add enableOpenCode to AwfFileConfig (stdin/file config spec) --- src/config-file.test.ts | 12 ++++++++++++ src/config-file.ts | 7 ++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/config-file.test.ts b/src/config-file.test.ts index 6021d55e1..1f4edd468 100644 --- a/src/config-file.test.ts +++ b/src/config-file.test.ts @@ -80,6 +80,16 @@ describe('config-file', () => { expect(errors).toContain('config.apiProxy.enabled must be a boolean'); }); + it('accepts boolean apiProxy.enableOpenCode', () => { + expect(validateAwfFileConfig({ apiProxy: { enableOpenCode: true } })).toEqual([]); + expect(validateAwfFileConfig({ apiProxy: { enableOpenCode: false } })).toEqual([]); + }); + + it('rejects non-boolean apiProxy.enableOpenCode', () => { + const errors = validateAwfFileConfig({ apiProxy: { enableOpenCode: 'yes' } }); + expect(errors).toContain('config.apiProxy.enableOpenCode must be a boolean'); + }); + it('rejects non-object apiProxy.targets', () => { const errors = validateAwfFileConfig({ apiProxy: { targets: 'invalid' } }); expect(errors).toContain('config.apiProxy.targets must be an object'); @@ -497,6 +507,7 @@ describe('config-file', () => { it('maps all API proxy target fields', () => { const result = mapAwfFileConfigToCliOptions({ apiProxy: { + enableOpenCode: true, targets: { openai: { host: 'api.openai.com', basePath: '/v1' }, copilot: { host: 'api.githubcopilot.com' }, @@ -505,6 +516,7 @@ describe('config-file', () => { }, }); + expect(result.enableOpencode).toBe(true); expect(result.openaiApiTarget).toBe('api.openai.com'); expect(result.openaiApiBasePath).toBe('/v1'); expect(result.copilotApiTarget).toBe('api.githubcopilot.com'); diff --git a/src/config-file.ts b/src/config-file.ts index 5de232c13..f56fe7b5d 100644 --- a/src/config-file.ts +++ b/src/config-file.ts @@ -12,6 +12,7 @@ export interface AwfFileConfig { }; apiProxy?: { enabled?: boolean; + enableOpenCode?: boolean; targets?: { openai?: { host?: string; basePath?: string }; anthropic?: { host?: string; basePath?: string }; @@ -150,10 +151,13 @@ export function validateAwfFileConfig(config: unknown): string[] { if (!isRecord(config.apiProxy)) { errors.push('config.apiProxy must be an object'); } else { - validateKnownKeys(config.apiProxy, ['enabled', 'targets', 'models'], 'config.apiProxy', errors); + validateKnownKeys(config.apiProxy, ['enabled', 'enableOpenCode', 'targets', 'models'], 'config.apiProxy', errors); if (config.apiProxy.enabled !== undefined && typeof config.apiProxy.enabled !== 'boolean') { errors.push('config.apiProxy.enabled must be a boolean'); } + if (config.apiProxy.enableOpenCode !== undefined && typeof config.apiProxy.enableOpenCode !== 'boolean') { + errors.push('config.apiProxy.enableOpenCode must be a boolean'); + } if (config.apiProxy.targets !== undefined) { if (!isRecord(config.apiProxy.targets)) { errors.push('config.apiProxy.targets must be an object'); @@ -355,6 +359,7 @@ export function mapAwfFileConfigToCliOptions(config: AwfFileConfig): Record