Skip to content

Commit bab8694

Browse files
authored
fix: gate OpenCode listener (port 10004) on explicit AWF_ENABLE_OPENCODE flag (#2337)
* Initial plan * Gate OpenCode listener on AWF_ENABLE_OPENCODE env var * Address review feedback: validate --enable-opencode requires --enable-api-proxy; add AWF_ENABLE_OPENCODE=true test * feat: add enableOpenCode to AwfFileConfig (stdin/file config spec) --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent dfd8f0e commit bab8694

9 files changed

Lines changed: 128 additions & 8 deletions

File tree

containers/api-proxy/server.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ function resolveCopilotAuthToken(env = process.env) {
8383
const COPILOT_AUTH_TOKEN = resolveCopilotAuthToken(process.env);
8484
const COPILOT_INTEGRATION_ID = process.env.COPILOT_INTEGRATION_ID || 'copilot-developer-cli';
8585
const GEMINI_API_KEY = (process.env.GEMINI_API_KEY || '').trim() || undefined;
86+
const ENABLE_OPENCODE = process.env.AWF_ENABLE_OPENCODE === 'true';
8687

8788
/**
8889
* Normalizes an API target value to a bare hostname.
@@ -1504,7 +1505,7 @@ function writeModelsJson(logDir = MODELS_LOG_DIR) {
15041505
* @returns {{ endpoints: Array<object>, models_fetch_complete: boolean, model_aliases: Record<string, string[]>|null }}
15051506
*/
15061507
function reflectEndpoints() {
1507-
const opencodeConfigured = !!(OPENAI_API_KEY || ANTHROPIC_API_KEY || COPILOT_AUTH_TOKEN);
1508+
const opencodeConfigured = ENABLE_OPENCODE && !!(OPENAI_API_KEY || ANTHROPIC_API_KEY || COPILOT_AUTH_TOKEN);
15081509
return {
15091510
endpoints: [
15101511
{
@@ -1610,7 +1611,7 @@ if (require.main === module) {
16101611
if (ANTHROPIC_API_KEY) expectedListeners++;
16111612
if (COPILOT_AUTH_TOKEN) expectedListeners++;
16121613
if (GEMINI_API_KEY) expectedListeners++;
1613-
if (OPENAI_API_KEY || ANTHROPIC_API_KEY || COPILOT_AUTH_TOKEN) expectedListeners++; // OpenCode (10004)
1614+
if (ENABLE_OPENCODE && (OPENAI_API_KEY || ANTHROPIC_API_KEY || COPILOT_AUTH_TOKEN)) expectedListeners++; // OpenCode (10004)
16141615
let readyListeners = 0;
16151616
function onListenerReady() {
16161617
readyListeners++;
@@ -1827,6 +1828,8 @@ if (require.main === module) {
18271828
}
18281829

18291830
// OpenCode API proxy (port 10004) — dynamic provider routing
1831+
// Only started when AWF_ENABLE_OPENCODE=true, so it doesn't activate
1832+
// unconditionally whenever any credential is present (e.g. Copilot-only runs).
18301833
// Defaults to Copilot/OpenAI routing (OPENAI_API_KEY), with Anthropic as a BYOK fallback.
18311834
// OpenCode gets a separate port from Claude (10001) and Codex (10000) for per-engine
18321835
// rate limiting and metrics isolation.
@@ -1836,6 +1839,7 @@ if (require.main === module) {
18361839
// 2. ANTHROPIC_API_KEY → Anthropic BYOK route (ANTHROPIC_API_TARGET)
18371840
// 3. COPILOT_GITHUB_TOKEN/API_KEY → Copilot route (COPILOT_API_TARGET),
18381841
// resolved internally to COPILOT_AUTH_TOKEN
1842+
if (ENABLE_OPENCODE) {
18391843
const opencodeStartupRoute = resolveOpenCodeRoute(
18401844
OPENAI_API_KEY, ANTHROPIC_API_KEY, COPILOT_AUTH_TOKEN,
18411845
OPENAI_API_TARGET, ANTHROPIC_API_TARGET, COPILOT_API_TARGET,
@@ -1912,6 +1916,7 @@ if (require.main === module) {
19121916
onListenerReady();
19131917
});
19141918
}
1919+
} // end if (ENABLE_OPENCODE)
19151920

19161921
// Graceful shutdown
19171922
process.on('SIGTERM', async () => {

containers/api-proxy/server.test.js

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1639,15 +1639,33 @@ describe('reflectEndpoints', () => {
16391639
expect(urlMap.opencode).toBeNull();
16401640
});
16411641

1642-
it('should report opencode as configured when openai key is present', () => {
1643-
// The module-level OPENAI_API_KEY is whatever process.env had at import time.
1644-
// We reflect the real configured state — just verify the shape is correct.
1642+
it('should report opencode as not configured when AWF_ENABLE_OPENCODE is not set', () => {
1643+
// ENABLE_OPENCODE is false at module load time (AWF_ENABLE_OPENCODE not set in test env),
1644+
// so opencode.configured must always be false regardless of other credentials.
16451645
const result = reflectEndpoints();
16461646
const opencode = result.endpoints.find((e) => e.provider === 'opencode');
1647-
expect(typeof opencode.configured).toBe('boolean');
1647+
expect(opencode.configured).toBe(false);
16481648
expect(opencode.models).toBeNull();
16491649
expect(opencode.models_url).toBeNull();
16501650
});
1651+
1652+
it('should report opencode as configured when AWF_ENABLE_OPENCODE=true and a credential is present', () => {
1653+
let isolatedReflect;
1654+
jest.isolateModules(() => {
1655+
process.env.AWF_ENABLE_OPENCODE = 'true';
1656+
process.env.OPENAI_API_KEY = 'sk-test-isolated';
1657+
try {
1658+
// eslint-disable-next-line @typescript-eslint/no-var-requires
1659+
({ reflectEndpoints: isolatedReflect } = require('./server'));
1660+
} finally {
1661+
delete process.env.AWF_ENABLE_OPENCODE;
1662+
delete process.env.OPENAI_API_KEY;
1663+
}
1664+
});
1665+
const result = isolatedReflect();
1666+
const opencode = result.endpoints.find((e) => e.provider === 'opencode');
1667+
expect(opencode.configured).toBe(true);
1668+
});
16511669
});
16521670

16531671
// ── healthResponse ─────────────────────────────────────────────────────────

src/cli.test.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Command } from 'commander';
2-
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';
2+
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';
33
import { redactSecrets } from './redact-secrets';
44
import * as fs from 'fs';
55
import * as path from 'path';
@@ -1545,6 +1545,23 @@ describe('cli', () => {
15451545
});
15461546
});
15471547

1548+
describe('validateEnableOpenCodeFlag', () => {
1549+
it('should pass when both --enable-opencode and --enable-api-proxy are set', () => {
1550+
expect(validateEnableOpenCodeFlag(true, true)).toEqual({ valid: true });
1551+
});
1552+
it('should pass when --enable-opencode is false', () => {
1553+
expect(validateEnableOpenCodeFlag(false, false)).toEqual({ valid: true });
1554+
});
1555+
it('should pass when --enable-opencode is false and --enable-api-proxy is true', () => {
1556+
expect(validateEnableOpenCodeFlag(true, false)).toEqual({ valid: true });
1557+
});
1558+
it('should fail when --enable-opencode is true without --enable-api-proxy', () => {
1559+
const r = validateEnableOpenCodeFlag(false, true);
1560+
expect(r.valid).toBe(false);
1561+
expect(r.error).toContain('--enable-api-proxy');
1562+
});
1563+
});
1564+
15481565
describe('hasRateLimitOptions', () => {
15491566
it('should return false when no rate limit options set', () => {
15501567
expect(hasRateLimitOptions({})).toBe(false);

src/cli.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -707,6 +707,16 @@ export function validateRateLimitFlags(enableApiProxy: boolean, options: {
707707
return { valid: true };
708708
}
709709

710+
/**
711+
* Validates that --enable-opencode is not used without --enable-api-proxy.
712+
*/
713+
export function validateEnableOpenCodeFlag(enableApiProxy: boolean, enableOpenCode: boolean): FlagValidationResult {
714+
if (enableOpenCode && !enableApiProxy) {
715+
return { valid: false, error: '--enable-opencode requires --enable-api-proxy' };
716+
}
717+
return { valid: true };
718+
}
719+
710720
/**
711721
* Result of validating flag combinations
712722
*/
@@ -1513,6 +1523,12 @@ program
15131523
'--gemini-api-base-path <path>',
15141524
'Base path prefix for Gemini API requests',
15151525
)
1526+
.option(
1527+
'--enable-opencode',
1528+
'Enable OpenCode API proxy listener on port 10004 (requires --enable-api-proxy).\n' +
1529+
' Only start this when the workflow uses the OpenCode engine.',
1530+
false
1531+
)
15161532
.option(
15171533
'--rate-limit-rpm <n>',
15181534
'Max requests per minute per provider (requires --enable-api-proxy)',
@@ -1966,6 +1982,7 @@ program
19661982
enableDlp: options.enableDlp,
19671983
allowedUrls,
19681984
enableApiProxy: options.enableApiProxy,
1985+
enableOpenCode: options.enableOpencode,
19691986
modelAliases,
19701987
openaiApiKey: process.env.OPENAI_API_KEY,
19711988
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
@@ -2017,6 +2034,13 @@ program
20172034
process.exit(1);
20182035
}
20192036

2037+
// Error if --enable-opencode is used without --enable-api-proxy
2038+
const enableOpenCodeValidation = validateEnableOpenCodeFlag(config.enableApiProxy ?? false, config.enableOpenCode ?? false);
2039+
if (!enableOpenCodeValidation.valid) {
2040+
logger.error(enableOpenCodeValidation.error!);
2041+
process.exit(1);
2042+
}
2043+
20202044
// Warn if --env-all is used
20212045
if (config.envAll) {
20222046
logger.warn('⚠️ Using --env-all: All host environment variables will be passed to container');

src/config-file.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,16 @@ describe('config-file', () => {
8080
expect(errors).toContain('config.apiProxy.enabled must be a boolean');
8181
});
8282

83+
it('accepts boolean apiProxy.enableOpenCode', () => {
84+
expect(validateAwfFileConfig({ apiProxy: { enableOpenCode: true } })).toEqual([]);
85+
expect(validateAwfFileConfig({ apiProxy: { enableOpenCode: false } })).toEqual([]);
86+
});
87+
88+
it('rejects non-boolean apiProxy.enableOpenCode', () => {
89+
const errors = validateAwfFileConfig({ apiProxy: { enableOpenCode: 'yes' } });
90+
expect(errors).toContain('config.apiProxy.enableOpenCode must be a boolean');
91+
});
92+
8393
it('rejects non-object apiProxy.targets', () => {
8494
const errors = validateAwfFileConfig({ apiProxy: { targets: 'invalid' } });
8595
expect(errors).toContain('config.apiProxy.targets must be an object');
@@ -497,6 +507,7 @@ describe('config-file', () => {
497507
it('maps all API proxy target fields', () => {
498508
const result = mapAwfFileConfigToCliOptions({
499509
apiProxy: {
510+
enableOpenCode: true,
500511
targets: {
501512
openai: { host: 'api.openai.com', basePath: '/v1' },
502513
copilot: { host: 'api.githubcopilot.com' },
@@ -505,6 +516,7 @@ describe('config-file', () => {
505516
},
506517
});
507518

519+
expect(result.enableOpencode).toBe(true);
508520
expect(result.openaiApiTarget).toBe('api.openai.com');
509521
expect(result.openaiApiBasePath).toBe('/v1');
510522
expect(result.copilotApiTarget).toBe('api.githubcopilot.com');

src/config-file.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export interface AwfFileConfig {
1212
};
1313
apiProxy?: {
1414
enabled?: boolean;
15+
enableOpenCode?: boolean;
1516
targets?: {
1617
openai?: { host?: string; basePath?: string };
1718
anthropic?: { host?: string; basePath?: string };
@@ -150,10 +151,13 @@ export function validateAwfFileConfig(config: unknown): string[] {
150151
if (!isRecord(config.apiProxy)) {
151152
errors.push('config.apiProxy must be an object');
152153
} else {
153-
validateKnownKeys(config.apiProxy, ['enabled', 'targets', 'models'], 'config.apiProxy', errors);
154+
validateKnownKeys(config.apiProxy, ['enabled', 'enableOpenCode', 'targets', 'models'], 'config.apiProxy', errors);
154155
if (config.apiProxy.enabled !== undefined && typeof config.apiProxy.enabled !== 'boolean') {
155156
errors.push('config.apiProxy.enabled must be a boolean');
156157
}
158+
if (config.apiProxy.enableOpenCode !== undefined && typeof config.apiProxy.enableOpenCode !== 'boolean') {
159+
errors.push('config.apiProxy.enableOpenCode must be a boolean');
160+
}
157161
if (config.apiProxy.targets !== undefined) {
158162
if (!isRecord(config.apiProxy.targets)) {
159163
errors.push('config.apiProxy.targets must be an object');
@@ -355,6 +359,7 @@ export function mapAwfFileConfigToCliOptions(config: AwfFileConfig): Record<stri
355359
upstreamProxy: config.network?.upstreamProxy,
356360

357361
enableApiProxy: config.apiProxy?.enabled,
362+
enableOpencode: config.apiProxy?.enableOpenCode,
358363
openaiApiTarget: config.apiProxy?.targets?.openai?.host,
359364
openaiApiBasePath: config.apiProxy?.targets?.openai?.basePath,
360365
anthropicApiTarget: config.apiProxy?.targets?.anthropic?.host,

src/docker-manager.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2783,6 +2783,30 @@ describe('docker-manager', () => {
27832783
expect(env.AWF_RATE_LIMIT_BYTES_PM).toBeUndefined();
27842784
});
27852785

2786+
it('should set AWF_ENABLE_OPENCODE=true in api-proxy when enableOpenCode is true', () => {
2787+
const configWithOpenCode = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key', enableOpenCode: true };
2788+
const result = generateDockerCompose(configWithOpenCode, mockNetworkConfigWithProxy);
2789+
const proxy = result.services['api-proxy'];
2790+
const env = proxy.environment as Record<string, string>;
2791+
expect(env.AWF_ENABLE_OPENCODE).toBe('true');
2792+
});
2793+
2794+
it('should not set AWF_ENABLE_OPENCODE in api-proxy when enableOpenCode is false', () => {
2795+
const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key', enableOpenCode: false };
2796+
const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy);
2797+
const proxy = result.services['api-proxy'];
2798+
const env = proxy.environment as Record<string, string>;
2799+
expect(env.AWF_ENABLE_OPENCODE).toBeUndefined();
2800+
});
2801+
2802+
it('should not set AWF_ENABLE_OPENCODE in api-proxy when enableOpenCode is undefined', () => {
2803+
const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key' };
2804+
const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy);
2805+
const proxy = result.services['api-proxy'];
2806+
const env = proxy.environment as Record<string, string>;
2807+
expect(env.AWF_ENABLE_OPENCODE).toBeUndefined();
2808+
});
2809+
27862810
it('should set OPENAI_API_TARGET in api-proxy when openaiApiTarget is provided', () => {
27872811
const configWithProxy = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-test-key', openaiApiTarget: 'custom.openai-router.internal' };
27882812
const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy);

src/docker-manager.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1806,6 +1806,8 @@ export function generateDockerCompose(
18061806
...(config.modelAliases && {
18071807
AWF_MODEL_ALIASES: JSON.stringify({ models: config.modelAliases }),
18081808
}),
1809+
// Enable OpenCode listener only when explicitly requested
1810+
...(config.enableOpenCode && { AWF_ENABLE_OPENCODE: 'true' }),
18091811
},
18101812
healthcheck: {
18111813
test: ['CMD', 'curl', '-f', `http://localhost:${API_PROXY_HEALTH_PORT}/health`],

src/types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -715,6 +715,19 @@ export interface WrapperConfig {
715715
*/
716716
geminiApiKey?: string;
717717

718+
/**
719+
* Enable the OpenCode API proxy listener on port 10004
720+
*
721+
* When true, the api-proxy sidecar starts the OpenCode listener (port 10004) that
722+
* dynamically routes requests to whichever LLM credential is available.
723+
* When false (the default), the listener is not started even if other API keys
724+
* are present, preventing unnecessary port exposure in workflows that do not use
725+
* the OpenCode engine.
726+
*
727+
* @default false
728+
*/
729+
enableOpenCode?: boolean;
730+
718731
/**
719732
* Target hostname for GitHub Copilot API requests (used by API proxy sidecar)
720733
*

0 commit comments

Comments
 (0)