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
10 changes: 10 additions & 0 deletions containers/agent/api-proxy-health-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,16 @@ if [ -n "$COPILOT_API_URL" ]; then
echo "[health-check] ✓ COPILOT_GITHUB_TOKEN is placeholder value (correct)"
fi

# Verify COPILOT_API_KEY (BYOK) is placeholder when api-proxy is enabled (if present)
if [ -n "$COPILOT_API_KEY" ]; then
if [ "$COPILOT_API_KEY" != "placeholder-token-for-credential-isolation" ]; then
echo "[health-check][ERROR] COPILOT_API_KEY contains non-placeholder value!"
echo "[health-check][ERROR] Token should be 'placeholder-token-for-credential-isolation'"
exit 1
fi
echo "[health-check] ✓ COPILOT_API_KEY is placeholder value (correct)"
fi

# Verify COPILOT_TOKEN is placeholder (if present)
if [ -n "$COPILOT_TOKEN" ]; then
if [ "$COPILOT_TOKEN" != "placeholder-token-for-credential-isolation" ]; then
Expand Down
31 changes: 25 additions & 6 deletions containers/api-proxy/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,21 @@ function shouldStripHeader(name) {
const OPENAI_API_KEY = (process.env.OPENAI_API_KEY || '').trim() || undefined;
const ANTHROPIC_API_KEY = (process.env.ANTHROPIC_API_KEY || '').trim() || undefined;
const COPILOT_GITHUB_TOKEN = (process.env.COPILOT_GITHUB_TOKEN || '').trim() || undefined;
const COPILOT_API_KEY = (process.env.COPILOT_API_KEY || '').trim() || undefined;

/**
* Resolves the Copilot auth token from environment variables.
* COPILOT_GITHUB_TOKEN (GitHub OAuth) takes precedence over COPILOT_API_KEY (direct key).
* @param {Record<string, string|undefined>} env - Environment variables to inspect
* @returns {string|undefined} The resolved auth token, or undefined if neither is set
*/
function resolveCopilotAuthToken(env = process.env) {
const githubToken = (env.COPILOT_GITHUB_TOKEN || '').trim() || undefined;
const apiKey = (env.COPILOT_API_KEY || '').trim() || undefined;
return githubToken || apiKey;
}

const COPILOT_AUTH_TOKEN = resolveCopilotAuthToken(process.env);
const GEMINI_API_KEY = (process.env.GEMINI_API_KEY || '').trim() || undefined;

/**
Expand Down Expand Up @@ -213,7 +228,9 @@ logRequest('info', 'startup', {
openai: !!OPENAI_API_KEY,
anthropic: !!ANTHROPIC_API_KEY,
gemini: !!GEMINI_API_KEY,
copilot: !!COPILOT_GITHUB_TOKEN,
copilot: !!COPILOT_AUTH_TOKEN,
copilot_github_token: !!COPILOT_GITHUB_TOKEN,
copilot_api_key: !!COPILOT_API_KEY,
},
});

Expand Down Expand Up @@ -752,7 +769,7 @@ function healthResponse() {
openai: !!OPENAI_API_KEY,
anthropic: !!ANTHROPIC_API_KEY,
gemini: !!GEMINI_API_KEY,
copilot: !!COPILOT_GITHUB_TOKEN,
copilot: !!COPILOT_AUTH_TOKEN,
},
metrics_summary: metrics.getSummary(),
rate_limits: limiter.getAllStatus(),
Expand Down Expand Up @@ -857,7 +874,9 @@ if (require.main === module) {


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

proxyRequest(req, res, COPILOT_API_TARGET, {
'Authorization': `Bearer ${COPILOT_GITHUB_TOKEN}`,
'Authorization': `Bearer ${COPILOT_AUTH_TOKEN}`,
}, 'copilot');
});

copilotServer.on('upgrade', (req, socket, head) => {
proxyWebSocket(req, socket, head, COPILOT_API_TARGET, {
'Authorization': `Bearer ${COPILOT_GITHUB_TOKEN}`,
'Authorization': `Bearer ${COPILOT_AUTH_TOKEN}`,
}, 'copilot');
});

Expand Down Expand Up @@ -992,4 +1011,4 @@ if (require.main === module) {
}

// Export for testing
module.exports = { normalizeApiTarget, deriveCopilotApiTarget, normalizeBasePath, buildUpstreamPath, proxyWebSocket };
module.exports = { normalizeApiTarget, deriveCopilotApiTarget, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken };
42 changes: 41 additions & 1 deletion containers/api-proxy/server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
const http = require('http');
const tls = require('tls');
const { EventEmitter } = require('events');
const { normalizeApiTarget, deriveCopilotApiTarget, normalizeBasePath, buildUpstreamPath, proxyWebSocket } = require('./server');
const { normalizeApiTarget, deriveCopilotApiTarget, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken } = require('./server');

describe('normalizeApiTarget', () => {
it('should strip https:// prefix', () => {
Expand Down Expand Up @@ -620,3 +620,43 @@ describe('proxyWebSocket', () => {
});
});

describe('resolveCopilotAuthToken', () => {
it('should return COPILOT_GITHUB_TOKEN when only it is set', () => {
expect(resolveCopilotAuthToken({ COPILOT_GITHUB_TOKEN: 'gho_abc123' })).toBe('gho_abc123');
});

it('should return COPILOT_API_KEY when only it is set', () => {
expect(resolveCopilotAuthToken({ COPILOT_API_KEY: 'sk-byok-key' })).toBe('sk-byok-key');
});

it('should prefer COPILOT_GITHUB_TOKEN over COPILOT_API_KEY when both are set', () => {
expect(resolveCopilotAuthToken({
COPILOT_GITHUB_TOKEN: 'gho_abc123',
COPILOT_API_KEY: 'sk-byok-key',
})).toBe('gho_abc123');
});

it('should return undefined when neither is set', () => {
expect(resolveCopilotAuthToken({})).toBeUndefined();
});

it('should return undefined for empty strings', () => {
expect(resolveCopilotAuthToken({ COPILOT_GITHUB_TOKEN: '', COPILOT_API_KEY: '' })).toBeUndefined();
});

it('should return undefined for whitespace-only values', () => {
expect(resolveCopilotAuthToken({ COPILOT_GITHUB_TOKEN: ' ', COPILOT_API_KEY: ' \n' })).toBeUndefined();
});

it('should trim whitespace from token values', () => {
expect(resolveCopilotAuthToken({ COPILOT_API_KEY: ' sk-byok-key ' })).toBe('sk-byok-key');
});

it('should fall back to COPILOT_API_KEY when COPILOT_GITHUB_TOKEN is whitespace-only', () => {
expect(resolveCopilotAuthToken({
COPILOT_GITHUB_TOKEN: ' ',
COPILOT_API_KEY: 'sk-byok-key',
})).toBe('sk-byok-key');
});
});

1 change: 1 addition & 0 deletions docs-site/src/content/docs/reference/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -675,6 +675,7 @@ sudo -E awf --enable-api-proxy \
| `OPENAI_API_KEY` | OpenAI / Codex |
| `ANTHROPIC_API_KEY` | Anthropic / Claude |
| `COPILOT_GITHUB_TOKEN` | GitHub Copilot |
| `COPILOT_API_KEY` | GitHub Copilot (BYOK) |

**Sidecar ports:**

Expand Down
6 changes: 4 additions & 2 deletions docs/api-proxy-sidecar.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ The API proxy sidecar receives **real credentials** and routing configuration:
| `OPENAI_API_KEY` | Real API key | `--enable-api-proxy` and env set | OpenAI API key (injected into requests) |
| `ANTHROPIC_API_KEY` | Real API key | `--enable-api-proxy` and env set | Anthropic API key (injected into requests) |
| `COPILOT_GITHUB_TOKEN` | Real token | `--enable-api-proxy` and env set | GitHub Copilot token (injected into requests) |
| `COPILOT_API_KEY` | Real API key | `--enable-api-proxy` and env set | GitHub Copilot BYOK key (injected into requests) |
| `HTTP_PROXY` | `http://172.30.0.10:3128` | Always | Routes through Squid for domain filtering |
| `HTTPS_PROXY` | `http://172.30.0.10:3128` | Always | Routes through Squid for domain filtering |

Expand All @@ -140,9 +141,10 @@ The agent container receives **redacted placeholders** and proxy URLs:
| `ANTHROPIC_BASE_URL` | `http://172.30.0.30:10001` | `ANTHROPIC_API_KEY` provided to host | Redirects Anthropic SDK to proxy |
| `ANTHROPIC_AUTH_TOKEN` | `placeholder-token-for-credential-isolation` | `ANTHROPIC_API_KEY` provided to host | Placeholder token (real auth via BASE_URL) |
| `CLAUDE_CODE_API_KEY_HELPER` | `/usr/local/bin/get-claude-key.sh` | `ANTHROPIC_API_KEY` provided to host | Helper script for Claude Code CLI |
| `COPILOT_API_URL` | `http://172.30.0.30:10002` | `COPILOT_GITHUB_TOKEN` provided to host | Redirects Copilot CLI to proxy |
| `COPILOT_TOKEN` | `placeholder-token-for-credential-isolation` | `COPILOT_GITHUB_TOKEN` provided to host | Placeholder token (real auth via API_URL) |
| `COPILOT_API_URL` | `http://172.30.0.30:10002` | `COPILOT_GITHUB_TOKEN` or `COPILOT_API_KEY` provided to host | Redirects Copilot CLI to proxy |
| `COPILOT_TOKEN` | `placeholder-token-for-credential-isolation` | `COPILOT_GITHUB_TOKEN` or `COPILOT_API_KEY` provided to host | Placeholder token (real auth via API_URL) |
| `COPILOT_GITHUB_TOKEN` | `placeholder-token-for-credential-isolation` | `COPILOT_GITHUB_TOKEN` provided to host | Placeholder token protected by one-shot-token |
| `COPILOT_API_KEY` | `placeholder-token-for-credential-isolation` | `COPILOT_API_KEY` provided to host | BYOK placeholder token protected by one-shot-token |
| `OPENAI_API_KEY` | Not set | `--enable-api-proxy` | Excluded from agent (held in api-proxy) |
| `ANTHROPIC_API_KEY` | Not set | `--enable-api-proxy` | Excluded from agent (held in api-proxy) |
| `HTTP_PROXY` | `http://172.30.0.10:3128` | Always | Routes through Squid proxy |
Expand Down
15 changes: 9 additions & 6 deletions examples/github-copilot.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
#
# Prerequisites:
# - GitHub Copilot CLI installed: npm install -g @github/copilot
# - COPILOT_API_KEY environment variable set (for API proxy)
# - COPILOT_GITHUB_TOKEN or COPILOT_API_KEY environment variable set (for API proxy)
# - GITHUB_TOKEN environment variable set (for GitHub API access)
#
# Usage: sudo -E ./examples/github-copilot.sh
Expand All @@ -16,10 +16,12 @@ set -e
echo "=== AWF GitHub Copilot CLI Example (with API Proxy) ==="
echo ""

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

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

# Run Copilot CLI with API proxy enabled
# Use sudo -E to preserve environment variables (COPILOT_GITHUB_TOKEN, GITHUB_TOKEN, AWF_ONE_SHOT_TOKEN_DEBUG)
# Use sudo -E to preserve environment variables (COPILOT_GITHUB_TOKEN, COPILOT_API_KEY, GITHUB_TOKEN, AWF_ONE_SHOT_TOKEN_DEBUG)
# The api-proxy sidecar holds the real Copilot credential and injects it into requests.
# Required domains:
# - api.githubcopilot.com: Copilot API endpoint (proxied via api-proxy)
# - github.com: GitHub API access
Expand Down
4 changes: 4 additions & 0 deletions src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,14 @@

afterEach(() => {
// Clean up the test directory
if (fs.existsSync(testDir)) {

Check warning on line 51 in src/cli.test.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 51 in src/cli.test.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

Check warning on line 51 in src/cli.test.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
fs.rmSync(testDir, { recursive: true, force: true });
}
});

it('should parse domains from file with one domain per line', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, 'github.com\napi.github.com\nnpmjs.org');

Check warning on line 58 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

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

Check warning on line 58 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

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

Check warning on line 58 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

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

const result = parseDomainsFile(filePath);

Expand All @@ -64,7 +64,7 @@

it('should parse comma-separated domains from file', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, 'github.com, api.github.com, npmjs.org');

Check warning on line 67 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

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

Check warning on line 67 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

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

Check warning on line 67 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

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

const result = parseDomainsFile(filePath);

Expand All @@ -73,7 +73,7 @@

it('should handle mixed formats (lines and commas)', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, 'github.com\napi.github.com, npmjs.org\nexample.com');

Check warning on line 76 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

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

Check warning on line 76 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

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

Check warning on line 76 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

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

const result = parseDomainsFile(filePath);

Expand All @@ -82,7 +82,7 @@

it('should skip empty lines', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, 'github.com\n\n\napi.github.com\n\nnpmjs.org');

Check warning on line 85 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

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

Check warning on line 85 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

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

Check warning on line 85 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

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

const result = parseDomainsFile(filePath);

Expand All @@ -91,7 +91,7 @@

it('should skip lines with only whitespace', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, 'github.com\n \n\t\napi.github.com');

Check warning on line 94 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

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

Check warning on line 94 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

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

Check warning on line 94 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

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

const result = parseDomainsFile(filePath);

Expand All @@ -100,7 +100,7 @@

it('should skip comments starting with #', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, '# This is a comment\ngithub.qkg1.top\n# Another comment\napi.github.com');

Check warning on line 103 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

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

Check warning on line 103 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

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

Check warning on line 103 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

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

const result = parseDomainsFile(filePath);

Expand All @@ -109,7 +109,7 @@

it('should handle inline comments (after domain)', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, 'github.com # GitHub main domain\napi.github.com # API endpoint');

Check warning on line 112 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

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

Check warning on line 112 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

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

Check warning on line 112 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

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

const result = parseDomainsFile(filePath);

Expand All @@ -118,7 +118,7 @@

it('should handle domains with inline comments in comma-separated format', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, 'github.com, api.github.com # GitHub domains\nnpmjs.org');

Check warning on line 121 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

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

Check warning on line 121 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

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

Check warning on line 121 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

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

const result = parseDomainsFile(filePath);

Expand All @@ -133,7 +133,7 @@

it('should return empty array for file with only comments and whitespace', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, '# Comment 1\n\n# Comment 2\n \n');

Check warning on line 136 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

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

Check warning on line 136 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

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

Check warning on line 136 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

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

const result = parseDomainsFile(filePath);

Expand Down Expand Up @@ -1397,6 +1397,10 @@
expect(result.enabled).toBe(true);
expect(result.warnings).toHaveLength(2);
expect(result.warnings[0]).toContain('no API keys found');
expect(result.warnings[1]).toContain('OPENAI_API_KEY');
expect(result.warnings[1]).toContain('ANTHROPIC_API_KEY');
expect(result.warnings[1]).toContain('COPILOT_GITHUB_TOKEN');
expect(result.warnings[1]).toContain('COPILOT_API_KEY');
expect(result.warnings[1]).toContain('GEMINI_API_KEY');
expect(result.debugMessages).toEqual([]);
});
Expand Down
9 changes: 5 additions & 4 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ export function validateApiProxyConfig(

if (!hasOpenaiKey && !hasAnthropicKey && !hasCopilotKey && !hasGeminiKey) {
warnings.push('⚠️ API proxy enabled but no API keys found in environment');
warnings.push(' Set OPENAI_API_KEY, ANTHROPIC_API_KEY, COPILOT_GITHUB_TOKEN, or GEMINI_API_KEY to use the proxy');
warnings.push(' Set OPENAI_API_KEY, ANTHROPIC_API_KEY, COPILOT_GITHUB_TOKEN, COPILOT_API_KEY, or GEMINI_API_KEY to use the proxy');
}
if (hasOpenaiKey) {
debugMessages.push('OpenAI API key detected - will be held securely in sidecar');
Expand Down Expand Up @@ -1903,6 +1903,7 @@ 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,
geminiApiKey: process.env.GEMINI_API_KEY,
copilotApiTarget: options.copilotApiTarget || process.env.COPILOT_API_TARGET,
openaiApiTarget: options.openaiApiTarget || process.env.OPENAI_API_TARGET,
Expand Down Expand Up @@ -1993,13 +1994,13 @@ program
config.enableApiProxy || false,
!!config.openaiApiKey,
!!config.anthropicApiKey,
!!config.copilotGithubToken,
!!(config.copilotGithubToken || config.copilotApiKey),
!!config.geminiApiKey
);

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

for (const warning of apiProxyValidation.warnings) {
Expand Down Expand Up @@ -2052,7 +2053,7 @@ program
// to prevent sensitive data from flowing to logger (CodeQL sensitive data logging)
const redactedConfig: Record<string, unknown> = {};
for (const [key, value] of Object.entries(config)) {
if (key === 'openaiApiKey' || key === 'anthropicApiKey' || key === 'copilotGithubToken' || key === 'geminiApiKey') continue;
if (key === 'openaiApiKey' || key === 'anthropicApiKey' || key === 'copilotGithubToken' || key === 'copilotApiKey' || key === 'geminiApiKey') continue;
redactedConfig[key] = key === 'agentCommand' ? redactSecrets(value as string) : value;
}
logger.debug('Configuration:', JSON.stringify(redactedConfig, null, 2));
Expand Down
44 changes: 44 additions & 0 deletions src/docker-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -722,6 +722,26 @@ describe('docker-manager', () => {
delete process.env.COPILOT_GITHUB_TOKEN;
});

it('should forward COPILOT_API_KEY when api-proxy is disabled', () => {
process.env.COPILOT_API_KEY = 'cpat_test_byok_key';
const configNoProxy = { ...mockConfig, enableApiProxy: false };
const result = generateDockerCompose(configNoProxy, mockNetworkConfig);
const env = result.services.agent.environment as Record<string, string>;
expect(env.COPILOT_API_KEY).toBe('cpat_test_byok_key');
delete process.env.COPILOT_API_KEY;
});

it('should not forward COPILOT_API_KEY to agent when api-proxy is enabled', () => {
process.env.COPILOT_API_KEY = 'cpat_test_byok_key';
const configWithProxy = { ...mockConfig, enableApiProxy: true, copilotApiKey: 'cpat_test_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>;
// Placeholder is set to prevent --env-all from leaking the real key
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 Expand Up @@ -2517,6 +2537,30 @@ describe('docker-manager', () => {
expect(env.COPILOT_API_TARGET).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);
const proxy = result.services['api-proxy'];
const env = proxy.environment as Record<string, string>;
expect(env.COPILOT_API_KEY).toBe('cpat_test_byok_key');
});

it('should set COPILOT_API_URL in agent when only copilotApiKey is provided', () => {
const configWithProxy = { ...mockConfig, enableApiProxy: true, copilotApiKey: 'cpat_test_byok_key' };
const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy);
const agent = result.services.agent;
const env = agent.environment as Record<string, string>;
expect(env.COPILOT_API_URL).toBe('http://172.30.0.30:10002');
});

it('should set COPILOT_TOKEN placeholder when copilotApiKey is provided', () => {
const configWithProxy = { ...mockConfig, enableApiProxy: true, copilotApiKey: 'cpat_test_byok_key' };
const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy);
const agent = result.services.agent;
const env = agent.environment as Record<string, string>;
expect(env.COPILOT_TOKEN).toBe('placeholder-token-for-credential-isolation');
});

it('should include api-proxy service when enableApiProxy is true with Gemini key', () => {
const configWithProxy = { ...mockConfig, enableApiProxy: true, geminiApiKey: 'AIza-test-gemini-key' };
const result = generateDockerCompose(configWithProxy, mockNetworkConfigWithProxy);
Expand Down
Loading
Loading