Skip to content

Commit 2aac716

Browse files
CopilotlpcoxCopilot
authored
feat: add Copilot BYOK support via COPILOT_API_KEY (#1918)
* Initial plan * feat: add Copilot BYOK support via COPILOT_API_KEY env var Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/048378d6-92ce-40bd-bf1b-e668cf5ceb38 * test: improve warning message assertion for COPILOT_API_KEY Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/048378d6-92ce-40bd-bf1b-e668cf5ceb38 * fix: address review feedback on BYOK support - Reword docker-manager comment to clarify placeholder behavior when api-proxy is enabled - Update github-copilot example to accept either COPILOT_GITHUB_TOKEN or COPILOT_API_KEY instead of hard-requiring COPILOT_API_KEY - Extract resolveCopilotAuthToken() as a testable function with JSDoc and export it from server.js - Add 8 unit tests covering precedence, fallback, empty/whitespace handling, and trimming behavior Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Landon Cox <landon.cox@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 501daee commit 2aac716

11 files changed

Lines changed: 174 additions & 24 deletions

File tree

containers/agent/api-proxy-health-check.sh

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,16 @@ if [ -n "$COPILOT_API_URL" ]; then
107107
echo "[health-check] ✓ COPILOT_GITHUB_TOKEN is placeholder value (correct)"
108108
fi
109109

110+
# Verify COPILOT_API_KEY (BYOK) is placeholder when api-proxy is enabled (if present)
111+
if [ -n "$COPILOT_API_KEY" ]; then
112+
if [ "$COPILOT_API_KEY" != "placeholder-token-for-credential-isolation" ]; then
113+
echo "[health-check][ERROR] COPILOT_API_KEY contains non-placeholder value!"
114+
echo "[health-check][ERROR] Token should be 'placeholder-token-for-credential-isolation'"
115+
exit 1
116+
fi
117+
echo "[health-check] ✓ COPILOT_API_KEY is placeholder value (correct)"
118+
fi
119+
110120
# Verify COPILOT_TOKEN is placeholder (if present)
111121
if [ -n "$COPILOT_TOKEN" ]; then
112122
if [ "$COPILOT_TOKEN" != "placeholder-token-for-credential-isolation" ]; then

containers/api-proxy/server.js

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,21 @@ function shouldStripHeader(name) {
6262
const OPENAI_API_KEY = (process.env.OPENAI_API_KEY || '').trim() || undefined;
6363
const ANTHROPIC_API_KEY = (process.env.ANTHROPIC_API_KEY || '').trim() || undefined;
6464
const COPILOT_GITHUB_TOKEN = (process.env.COPILOT_GITHUB_TOKEN || '').trim() || undefined;
65+
const COPILOT_API_KEY = (process.env.COPILOT_API_KEY || '').trim() || undefined;
66+
67+
/**
68+
* Resolves the Copilot auth token from environment variables.
69+
* COPILOT_GITHUB_TOKEN (GitHub OAuth) takes precedence over COPILOT_API_KEY (direct key).
70+
* @param {Record<string, string|undefined>} env - Environment variables to inspect
71+
* @returns {string|undefined} The resolved auth token, or undefined if neither is set
72+
*/
73+
function resolveCopilotAuthToken(env = process.env) {
74+
const githubToken = (env.COPILOT_GITHUB_TOKEN || '').trim() || undefined;
75+
const apiKey = (env.COPILOT_API_KEY || '').trim() || undefined;
76+
return githubToken || apiKey;
77+
}
78+
79+
const COPILOT_AUTH_TOKEN = resolveCopilotAuthToken(process.env);
6580
const GEMINI_API_KEY = (process.env.GEMINI_API_KEY || '').trim() || undefined;
6681

6782
/**
@@ -213,7 +228,9 @@ logRequest('info', 'startup', {
213228
openai: !!OPENAI_API_KEY,
214229
anthropic: !!ANTHROPIC_API_KEY,
215230
gemini: !!GEMINI_API_KEY,
216-
copilot: !!COPILOT_GITHUB_TOKEN,
231+
copilot: !!COPILOT_AUTH_TOKEN,
232+
copilot_github_token: !!COPILOT_GITHUB_TOKEN,
233+
copilot_api_key: !!COPILOT_API_KEY,
217234
},
218235
});
219236

@@ -752,7 +769,7 @@ function healthResponse() {
752769
openai: !!OPENAI_API_KEY,
753770
anthropic: !!ANTHROPIC_API_KEY,
754771
gemini: !!GEMINI_API_KEY,
755-
copilot: !!COPILOT_GITHUB_TOKEN,
772+
copilot: !!COPILOT_AUTH_TOKEN,
756773
},
757774
metrics_summary: metrics.getSummary(),
758775
rate_limits: limiter.getAllStatus(),
@@ -857,7 +874,9 @@ if (require.main === module) {
857874

858875

859876
// GitHub Copilot API proxy (port 10002)
860-
if (COPILOT_GITHUB_TOKEN) {
877+
// Supports COPILOT_GITHUB_TOKEN (GitHub OAuth) and COPILOT_API_KEY (BYOK direct key).
878+
// COPILOT_GITHUB_TOKEN takes precedence when both are set.
879+
if (COPILOT_AUTH_TOKEN) {
861880
const copilotServer = http.createServer((req, res) => {
862881
// Health check endpoint
863882
if (req.url === '/health' && req.method === 'GET') {
@@ -870,13 +889,13 @@ if (require.main === module) {
870889
if (checkRateLimit(req, res, 'copilot', contentLength)) return;
871890

872891
proxyRequest(req, res, COPILOT_API_TARGET, {
873-
'Authorization': `Bearer ${COPILOT_GITHUB_TOKEN}`,
892+
'Authorization': `Bearer ${COPILOT_AUTH_TOKEN}`,
874893
}, 'copilot');
875894
});
876895

877896
copilotServer.on('upgrade', (req, socket, head) => {
878897
proxyWebSocket(req, socket, head, COPILOT_API_TARGET, {
879-
'Authorization': `Bearer ${COPILOT_GITHUB_TOKEN}`,
898+
'Authorization': `Bearer ${COPILOT_AUTH_TOKEN}`,
880899
}, 'copilot');
881900
});
882901

@@ -992,4 +1011,4 @@ if (require.main === module) {
9921011
}
9931012

9941013
// Export for testing
995-
module.exports = { normalizeApiTarget, deriveCopilotApiTarget, normalizeBasePath, buildUpstreamPath, proxyWebSocket };
1014+
module.exports = { normalizeApiTarget, deriveCopilotApiTarget, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken };

containers/api-proxy/server.test.js

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
const http = require('http');
66
const tls = require('tls');
77
const { EventEmitter } = require('events');
8-
const { normalizeApiTarget, deriveCopilotApiTarget, normalizeBasePath, buildUpstreamPath, proxyWebSocket } = require('./server');
8+
const { normalizeApiTarget, deriveCopilotApiTarget, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken } = require('./server');
99

1010
describe('normalizeApiTarget', () => {
1111
it('should strip https:// prefix', () => {
@@ -620,3 +620,43 @@ describe('proxyWebSocket', () => {
620620
});
621621
});
622622

623+
describe('resolveCopilotAuthToken', () => {
624+
it('should return COPILOT_GITHUB_TOKEN when only it is set', () => {
625+
expect(resolveCopilotAuthToken({ COPILOT_GITHUB_TOKEN: 'gho_abc123' })).toBe('gho_abc123');
626+
});
627+
628+
it('should return COPILOT_API_KEY when only it is set', () => {
629+
expect(resolveCopilotAuthToken({ COPILOT_API_KEY: 'sk-byok-key' })).toBe('sk-byok-key');
630+
});
631+
632+
it('should prefer COPILOT_GITHUB_TOKEN over COPILOT_API_KEY when both are set', () => {
633+
expect(resolveCopilotAuthToken({
634+
COPILOT_GITHUB_TOKEN: 'gho_abc123',
635+
COPILOT_API_KEY: 'sk-byok-key',
636+
})).toBe('gho_abc123');
637+
});
638+
639+
it('should return undefined when neither is set', () => {
640+
expect(resolveCopilotAuthToken({})).toBeUndefined();
641+
});
642+
643+
it('should return undefined for empty strings', () => {
644+
expect(resolveCopilotAuthToken({ COPILOT_GITHUB_TOKEN: '', COPILOT_API_KEY: '' })).toBeUndefined();
645+
});
646+
647+
it('should return undefined for whitespace-only values', () => {
648+
expect(resolveCopilotAuthToken({ COPILOT_GITHUB_TOKEN: ' ', COPILOT_API_KEY: ' \n' })).toBeUndefined();
649+
});
650+
651+
it('should trim whitespace from token values', () => {
652+
expect(resolveCopilotAuthToken({ COPILOT_API_KEY: ' sk-byok-key ' })).toBe('sk-byok-key');
653+
});
654+
655+
it('should fall back to COPILOT_API_KEY when COPILOT_GITHUB_TOKEN is whitespace-only', () => {
656+
expect(resolveCopilotAuthToken({
657+
COPILOT_GITHUB_TOKEN: ' ',
658+
COPILOT_API_KEY: 'sk-byok-key',
659+
})).toBe('sk-byok-key');
660+
});
661+
});
662+

docs-site/src/content/docs/reference/cli-reference.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -675,6 +675,7 @@ sudo -E awf --enable-api-proxy \
675675
| `OPENAI_API_KEY` | OpenAI / Codex |
676676
| `ANTHROPIC_API_KEY` | Anthropic / Claude |
677677
| `COPILOT_GITHUB_TOKEN` | GitHub Copilot |
678+
| `COPILOT_API_KEY` | GitHub Copilot (BYOK) |
678679

679680
**Sidecar ports:**
680681

docs/api-proxy-sidecar.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ The API proxy sidecar receives **real credentials** and routing configuration:
123123
| `OPENAI_API_KEY` | Real API key | `--enable-api-proxy` and env set | OpenAI API key (injected into requests) |
124124
| `ANTHROPIC_API_KEY` | Real API key | `--enable-api-proxy` and env set | Anthropic API key (injected into requests) |
125125
| `COPILOT_GITHUB_TOKEN` | Real token | `--enable-api-proxy` and env set | GitHub Copilot token (injected into requests) |
126+
| `COPILOT_API_KEY` | Real API key | `--enable-api-proxy` and env set | GitHub Copilot BYOK key (injected into requests) |
126127
| `HTTP_PROXY` | `http://172.30.0.10:3128` | Always | Routes through Squid for domain filtering |
127128
| `HTTPS_PROXY` | `http://172.30.0.10:3128` | Always | Routes through Squid for domain filtering |
128129

@@ -140,9 +141,10 @@ The agent container receives **redacted placeholders** and proxy URLs:
140141
| `ANTHROPIC_BASE_URL` | `http://172.30.0.30:10001` | `ANTHROPIC_API_KEY` provided to host | Redirects Anthropic SDK to proxy |
141142
| `ANTHROPIC_AUTH_TOKEN` | `placeholder-token-for-credential-isolation` | `ANTHROPIC_API_KEY` provided to host | Placeholder token (real auth via BASE_URL) |
142143
| `CLAUDE_CODE_API_KEY_HELPER` | `/usr/local/bin/get-claude-key.sh` | `ANTHROPIC_API_KEY` provided to host | Helper script for Claude Code CLI |
143-
| `COPILOT_API_URL` | `http://172.30.0.30:10002` | `COPILOT_GITHUB_TOKEN` provided to host | Redirects Copilot CLI to proxy |
144-
| `COPILOT_TOKEN` | `placeholder-token-for-credential-isolation` | `COPILOT_GITHUB_TOKEN` provided to host | Placeholder token (real auth via API_URL) |
144+
| `COPILOT_API_URL` | `http://172.30.0.30:10002` | `COPILOT_GITHUB_TOKEN` or `COPILOT_API_KEY` provided to host | Redirects Copilot CLI to proxy |
145+
| `COPILOT_TOKEN` | `placeholder-token-for-credential-isolation` | `COPILOT_GITHUB_TOKEN` or `COPILOT_API_KEY` provided to host | Placeholder token (real auth via API_URL) |
145146
| `COPILOT_GITHUB_TOKEN` | `placeholder-token-for-credential-isolation` | `COPILOT_GITHUB_TOKEN` provided to host | Placeholder token protected by one-shot-token |
147+
| `COPILOT_API_KEY` | `placeholder-token-for-credential-isolation` | `COPILOT_API_KEY` provided to host | BYOK placeholder token protected by one-shot-token |
146148
| `OPENAI_API_KEY` | Not set | `--enable-api-proxy` | Excluded from agent (held in api-proxy) |
147149
| `ANTHROPIC_API_KEY` | Not set | `--enable-api-proxy` | Excluded from agent (held in api-proxy) |
148150
| `HTTP_PROXY` | `http://172.30.0.10:3128` | Always | Routes through Squid proxy |

examples/github-copilot.sh

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
#
77
# Prerequisites:
88
# - GitHub Copilot CLI installed: npm install -g @github/copilot
9-
# - COPILOT_API_KEY environment variable set (for API proxy)
9+
# - COPILOT_GITHUB_TOKEN or COPILOT_API_KEY environment variable set (for API proxy)
1010
# - GITHUB_TOKEN environment variable set (for GitHub API access)
1111
#
1212
# Usage: sudo -E ./examples/github-copilot.sh
@@ -16,10 +16,12 @@ set -e
1616
echo "=== AWF GitHub Copilot CLI Example (with API Proxy) ==="
1717
echo ""
1818

19-
# Check for COPILOT_API_KEY
20-
if [ -z "$COPILOT_API_KEY" ]; then
21-
echo "Error: COPILOT_API_KEY environment variable is not set"
22-
echo "Set it with: export COPILOT_API_KEY='your_copilot_api_key'"
19+
# Check for Copilot credential (COPILOT_GITHUB_TOKEN or COPILOT_API_KEY)
20+
if [ -z "$COPILOT_GITHUB_TOKEN" ] && [ -z "$COPILOT_API_KEY" ]; then
21+
echo "Error: No Copilot credential set"
22+
echo "Set one of:"
23+
echo " export COPILOT_GITHUB_TOKEN='your_github_token'"
24+
echo " export COPILOT_API_KEY='your_copilot_api_key'"
2325
exit 1
2426
fi
2527

@@ -37,7 +39,8 @@ echo "Running GitHub Copilot CLI with API proxy and debug logging enabled..."
3739
echo ""
3840

3941
# Run Copilot CLI with API proxy enabled
40-
# Use sudo -E to preserve environment variables (COPILOT_GITHUB_TOKEN, GITHUB_TOKEN, AWF_ONE_SHOT_TOKEN_DEBUG)
42+
# Use sudo -E to preserve environment variables (COPILOT_GITHUB_TOKEN, COPILOT_API_KEY, GITHUB_TOKEN, AWF_ONE_SHOT_TOKEN_DEBUG)
43+
# The api-proxy sidecar holds the real Copilot credential and injects it into requests.
4144
# Required domains:
4245
# - api.githubcopilot.com: Copilot API endpoint (proxied via api-proxy)
4346
# - github.com: GitHub API access

src/cli.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1397,6 +1397,10 @@ describe('cli', () => {
13971397
expect(result.enabled).toBe(true);
13981398
expect(result.warnings).toHaveLength(2);
13991399
expect(result.warnings[0]).toContain('no API keys found');
1400+
expect(result.warnings[1]).toContain('OPENAI_API_KEY');
1401+
expect(result.warnings[1]).toContain('ANTHROPIC_API_KEY');
1402+
expect(result.warnings[1]).toContain('COPILOT_GITHUB_TOKEN');
1403+
expect(result.warnings[1]).toContain('COPILOT_API_KEY');
14001404
expect(result.warnings[1]).toContain('GEMINI_API_KEY');
14011405
expect(result.debugMessages).toEqual([]);
14021406
});

src/cli.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,7 @@ export function validateApiProxyConfig(
297297

298298
if (!hasOpenaiKey && !hasAnthropicKey && !hasCopilotKey && !hasGeminiKey) {
299299
warnings.push('⚠️ API proxy enabled but no API keys found in environment');
300-
warnings.push(' Set OPENAI_API_KEY, ANTHROPIC_API_KEY, COPILOT_GITHUB_TOKEN, or GEMINI_API_KEY to use the proxy');
300+
warnings.push(' Set OPENAI_API_KEY, ANTHROPIC_API_KEY, COPILOT_GITHUB_TOKEN, COPILOT_API_KEY, or GEMINI_API_KEY to use the proxy');
301301
}
302302
if (hasOpenaiKey) {
303303
debugMessages.push('OpenAI API key detected - will be held securely in sidecar');
@@ -1903,6 +1903,7 @@ program
19031903
openaiApiKey: process.env.OPENAI_API_KEY,
19041904
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
19051905
copilotGithubToken: process.env.COPILOT_GITHUB_TOKEN,
1906+
copilotApiKey: process.env.COPILOT_API_KEY,
19061907
geminiApiKey: process.env.GEMINI_API_KEY,
19071908
copilotApiTarget: options.copilotApiTarget || process.env.COPILOT_API_TARGET,
19081909
openaiApiTarget: options.openaiApiTarget || process.env.OPENAI_API_TARGET,
@@ -1993,13 +1994,13 @@ program
19931994
config.enableApiProxy || false,
19941995
!!config.openaiApiKey,
19951996
!!config.anthropicApiKey,
1996-
!!config.copilotGithubToken,
1997+
!!(config.copilotGithubToken || config.copilotApiKey),
19971998
!!config.geminiApiKey
19981999
);
19992000

20002001
// Log API proxy status at info level for visibility
20012002
if (config.enableApiProxy) {
2002-
logger.info(`API proxy enabled: OpenAI=${!!config.openaiApiKey}, Anthropic=${!!config.anthropicApiKey}, Copilot=${!!config.copilotGithubToken}, Gemini=${!!config.geminiApiKey}`);
2003+
logger.info(`API proxy enabled: OpenAI=${!!config.openaiApiKey}, Anthropic=${!!config.anthropicApiKey}, Copilot=${!!(config.copilotGithubToken || config.copilotApiKey)}, Gemini=${!!config.geminiApiKey}`);
20032004
}
20042005

20052006
for (const warning of apiProxyValidation.warnings) {
@@ -2052,7 +2053,7 @@ program
20522053
// to prevent sensitive data from flowing to logger (CodeQL sensitive data logging)
20532054
const redactedConfig: Record<string, unknown> = {};
20542055
for (const [key, value] of Object.entries(config)) {
2055-
if (key === 'openaiApiKey' || key === 'anthropicApiKey' || key === 'copilotGithubToken' || key === 'geminiApiKey') continue;
2056+
if (key === 'openaiApiKey' || key === 'anthropicApiKey' || key === 'copilotGithubToken' || key === 'copilotApiKey' || key === 'geminiApiKey') continue;
20562057
redactedConfig[key] = key === 'agentCommand' ? redactSecrets(value as string) : value;
20572058
}
20582059
logger.debug('Configuration:', JSON.stringify(redactedConfig, null, 2));

src/docker-manager.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,6 +722,26 @@ describe('docker-manager', () => {
722722
delete process.env.COPILOT_GITHUB_TOKEN;
723723
});
724724

725+
it('should forward COPILOT_API_KEY when api-proxy is disabled', () => {
726+
process.env.COPILOT_API_KEY = 'cpat_test_byok_key';
727+
const configNoProxy = { ...mockConfig, enableApiProxy: false };
728+
const result = generateDockerCompose(configNoProxy, mockNetworkConfig);
729+
const env = result.services.agent.environment as Record<string, string>;
730+
expect(env.COPILOT_API_KEY).toBe('cpat_test_byok_key');
731+
delete process.env.COPILOT_API_KEY;
732+
});
733+
734+
it('should not forward COPILOT_API_KEY to agent when api-proxy is enabled', () => {
735+
process.env.COPILOT_API_KEY = 'cpat_test_byok_key';
736+
const configWithProxy = { ...mockConfig, enableApiProxy: true, copilotApiKey: 'cpat_test_byok_key' };
737+
const proxyNetworkConfig = { ...mockNetworkConfig, proxyIp: '172.30.0.30' };
738+
const result = generateDockerCompose(configWithProxy, proxyNetworkConfig);
739+
const env = result.services.agent.environment as Record<string, string>;
740+
// Placeholder is set to prevent --env-all from leaking the real key
741+
expect(env.COPILOT_API_KEY).toBe('placeholder-token-for-credential-isolation');
742+
delete process.env.COPILOT_API_KEY;
743+
});
744+
725745
it('should forward AWF_ONE_SHOT_TOKEN_DEBUG when set', () => {
726746
process.env.AWF_ONE_SHOT_TOKEN_DEBUG = '1';
727747
const result = generateDockerCompose(mockConfig, mockNetworkConfig);
@@ -2589,6 +2609,30 @@ describe('docker-manager', () => {
25892609
expect(env.COPILOT_API_TARGET).toBeUndefined();
25902610
});
25912611

2612+
it('should pass COPILOT_API_KEY to api-proxy env when copilotApiKey is provided', () => {
2613+
const configWithProxy = { ...mockConfig, enableApiProxy: true, copilotApiKey: 'cpat_test_byok_key' };
2614+
const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy);
2615+
const proxy = result.services['api-proxy'];
2616+
const env = proxy.environment as Record<string, string>;
2617+
expect(env.COPILOT_API_KEY).toBe('cpat_test_byok_key');
2618+
});
2619+
2620+
it('should set COPILOT_API_URL in agent when only copilotApiKey is provided', () => {
2621+
const configWithProxy = { ...mockConfig, enableApiProxy: true, copilotApiKey: 'cpat_test_byok_key' };
2622+
const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy);
2623+
const agent = result.services.agent;
2624+
const env = agent.environment as Record<string, string>;
2625+
expect(env.COPILOT_API_URL).toBe('http://172.30.0.30:10002');
2626+
});
2627+
2628+
it('should set COPILOT_TOKEN placeholder when copilotApiKey is provided', () => {
2629+
const configWithProxy = { ...mockConfig, enableApiProxy: true, copilotApiKey: 'cpat_test_byok_key' };
2630+
const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy);
2631+
const agent = result.services.agent;
2632+
const env = agent.environment as Record<string, string>;
2633+
expect(env.COPILOT_TOKEN).toBe('placeholder-token-for-credential-isolation');
2634+
});
2635+
25922636
it('should include api-proxy service when enableApiProxy is true with Gemini key', () => {
25932637
const configWithProxy = { ...mockConfig, enableApiProxy: true, geminiApiKey: 'AIza-test-gemini-key' };
25942638
const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy);

0 commit comments

Comments
 (0)