Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
31 changes: 29 additions & 2 deletions containers/api-proxy/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
// Read API keys from environment (set by docker-compose)
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
const COPILOT_API_KEY = process.env.COPILOT_API_KEY;

// Squid proxy configuration (set via HTTP_PROXY/HTTPS_PROXY in docker-compose)
const HTTPS_PROXY = process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
Expand All @@ -57,6 +58,9 @@
if (ANTHROPIC_API_KEY) {
console.log('[API Proxy] Anthropic API key configured');
}
if (COPILOT_API_KEY) {
console.log('[API Proxy] GitHub Copilot API key configured');
}

// Create proxy agent for routing through Squid
const proxyAgent = HTTPS_PROXY ? new HttpsProxyAgent(HTTPS_PROXY) : undefined;
Expand Down Expand Up @@ -169,7 +173,7 @@
status: 'healthy',
service: 'awf-api-proxy',
squid_proxy: HTTPS_PROXY || 'not configured',
providers: { openai: true, anthropic: !!ANTHROPIC_API_KEY },
providers: { openai: true, anthropic: !!ANTHROPIC_API_KEY, copilot: !!COPILOT_API_KEY },
}));
return;
}
Expand All @@ -193,7 +197,7 @@
status: 'healthy',
service: 'awf-api-proxy',
squid_proxy: HTTPS_PROXY || 'not configured',
providers: { openai: false, anthropic: !!ANTHROPIC_API_KEY },
providers: { openai: false, anthropic: !!ANTHROPIC_API_KEY, copilot: !!COPILOT_API_KEY },
}));
return;
}
Expand Down Expand Up @@ -231,6 +235,29 @@
});
}


// GitHub Copilot API proxy (port 10002)
if (COPILOT_API_KEY) {
const copilotServer = http.createServer((req, res) => {
// Health check endpoint
if (req.url === '/health' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'healthy', service: 'copilot-proxy' }));
return;
}

// Log and proxy the request
console.log(`[Copilot Proxy] ${sanitizeForLog(req.method)} ${sanitizeForLog(req.url)}`);

Check warning

Code scanning / CodeQL

Log injection Medium

Log entry depends on a
user-provided value
.

Copilot Autofix

AI 8 months ago

To fix the problem in general, user-controlled values should only be written to logs after being passed through a clear, robust sanitization step that (a) strips or encodes control characters (especially \r and \n), (b) limits length, and (c) makes it obvious in the log line where user input begins and ends. The logger call should never insert raw, unsanitized request data.

In this specific file, most of that is already done via sanitizeForLog. The minimal, non‑functional change that addresses the concern is to: (1) ensure sanitizeForLog behaves safely even for non‑string inputs (e.g., undefined, objects) by converting them to strings before sanitizing, and (2) slightly adjust the log message on line 250 so that user-supplied fields are clearly marked (e.g., method="..." url="...") and continue to go through sanitizeForLog. This keeps all existing behavior (still logs method and URL, same truncation/character stripping) while making the sanitization more robust and explicit.

Concretely:

  • In containers/api-proxy/server.js, update sanitizeForLog so it stringifies non‑string inputs instead of returning an empty string, then strips control characters and truncates as before.
  • In the Copilot proxy server request handler, keep using sanitizeForLog(req.method) and sanitizeForLog(req.url) but change the template string on line 250 to clearly delineate user content, e.g., [Copilot Proxy] method="${...}" url="${...}". This preserves functionality while clarifying intent and improving readability/security posture.

No new imports or external methods are required; we reuse built‑in string manipulation.

Suggested changeset 1
containers/api-proxy/server.js

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/containers/api-proxy/server.js b/containers/api-proxy/server.js
--- a/containers/api-proxy/server.js
+++ b/containers/api-proxy/server.js
@@ -35,11 +35,15 @@
   return STRIPPED_HEADERS.has(lower) || lower.startsWith('x-forwarded-');
 }
 
-/** Sanitize a string for safe logging (strip control chars, limit length). */
+/** Sanitize a value for safe logging (stringify, strip control chars, limit length). */
 function sanitizeForLog(str) {
-  if (typeof str !== 'string') return '';
+  if (str === null || str === undefined) {
+    return '';
+  }
+  // Ensure we always operate on a string representation
+  const s = String(str);
   // eslint-disable-next-line no-control-regex
-  return str.replace(/[\x00-\x1f\x7f]/g, '').slice(0, 200);
+  return s.replace(/[\x00-\x1f\x7f]/g, '').slice(0, 200);
 }
 
 // Read API keys from environment (set by docker-compose)
@@ -247,7 +249,7 @@
     }
 
     // Log and proxy the request
-    console.log(`[Copilot Proxy] ${sanitizeForLog(req.method)} ${sanitizeForLog(req.url)}`);
+    console.log(`[Copilot Proxy] method="${sanitizeForLog(req.method)}" url="${sanitizeForLog(req.url)}"`);
     console.log(`[Copilot Proxy] Injecting Authorization header with COPILOT_GITHUB_TOKEN`);
     proxyRequest(req, res, 'api.githubcopilot.com', {
       'Authorization': `Bearer ${COPILOT_GITHUB_TOKEN}`,
EOF
@@ -35,11 +35,15 @@
return STRIPPED_HEADERS.has(lower) || lower.startsWith('x-forwarded-');
}

/** Sanitize a string for safe logging (strip control chars, limit length). */
/** Sanitize a value for safe logging (stringify, strip control chars, limit length). */
function sanitizeForLog(str) {
if (typeof str !== 'string') return '';
if (str === null || str === undefined) {
return '';
}
// Ensure we always operate on a string representation
const s = String(str);
// eslint-disable-next-line no-control-regex
return str.replace(/[\x00-\x1f\x7f]/g, '').slice(0, 200);
return s.replace(/[\x00-\x1f\x7f]/g, '').slice(0, 200);
}

// Read API keys from environment (set by docker-compose)
@@ -247,7 +249,7 @@
}

// Log and proxy the request
console.log(`[Copilot Proxy] ${sanitizeForLog(req.method)} ${sanitizeForLog(req.url)}`);
console.log(`[Copilot Proxy] method="${sanitizeForLog(req.method)}" url="${sanitizeForLog(req.url)}"`);
console.log(`[Copilot Proxy] Injecting Authorization header with COPILOT_GITHUB_TOKEN`);
proxyRequest(req, res, 'api.githubcopilot.com', {
'Authorization': `Bearer ${COPILOT_GITHUB_TOKEN}`,
Copilot is powered by AI and may make mistakes. Always verify output.
console.log(`[Copilot Proxy] Injecting Authorization header with COPILOT_API_KEY`);
proxyRequest(req, res, 'api.githubcopilot.com', {
'Authorization': `Bearer ${COPILOT_API_KEY}`,
});
});

copilotServer.listen(10002, '0.0.0.0', () => {
console.log('[API Proxy] GitHub Copilot proxy listening on port 10002');
});
}
// Graceful shutdown
process.on('SIGTERM', () => {
console.log('[API Proxy] Received SIGTERM, shutting down gracefully...');
Expand Down
17 changes: 12 additions & 5 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,12 +262,14 @@ export interface ApiProxyValidationResult {
* @param enableApiProxy - Whether --enable-api-proxy flag was provided
* @param hasOpenaiKey - Whether an OpenAI API key is present
* @param hasAnthropicKey - Whether an Anthropic API key is present
* @param hasCopilotKey - Whether a GitHub Copilot API key is present
* @returns ApiProxyValidationResult with warnings and debug messages
*/
export function validateApiProxyConfig(
enableApiProxy: boolean,
hasOpenaiKey?: boolean,
hasAnthropicKey?: boolean
hasAnthropicKey?: boolean,
hasCopilotKey?: boolean
): ApiProxyValidationResult {
if (!enableApiProxy) {
return { enabled: false, warnings: [], debugMessages: [] };
Expand All @@ -276,16 +278,19 @@ export function validateApiProxyConfig(
const warnings: string[] = [];
const debugMessages: string[] = [];

if (!hasOpenaiKey && !hasAnthropicKey) {
if (!hasOpenaiKey && !hasAnthropicKey && !hasCopilotKey) {
warnings.push('⚠️ API proxy enabled but no API keys found in environment');
warnings.push(' Set OPENAI_API_KEY or ANTHROPIC_API_KEY to use the proxy');
warnings.push(' Set OPENAI_API_KEY, ANTHROPIC_API_KEY, or COPILOT_API_KEY to use the proxy');
}
if (hasOpenaiKey) {
debugMessages.push('OpenAI API key detected - will be held securely in sidecar');
}
if (hasAnthropicKey) {
debugMessages.push('Anthropic API key detected - will be held securely in sidecar');
}
if (hasCopilotKey) {
debugMessages.push('GitHub Copilot API key detected - will be held securely in sidecar');
}

return { enabled: true, warnings, debugMessages };
}
Expand Down Expand Up @@ -987,6 +992,7 @@ program
enableApiProxy: options.enableApiProxy,
openaiApiKey: process.env.OPENAI_API_KEY,
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
copilotApiKey: process.env.COPILOT_API_KEY,
};

// Warn if --env-all is used
Expand Down Expand Up @@ -1025,7 +1031,8 @@ program
const apiProxyValidation = validateApiProxyConfig(
config.enableApiProxy || false,
!!config.openaiApiKey,
!!config.anthropicApiKey
!!config.anthropicApiKey,
!!config.copilotApiKey
);
for (const warning of apiProxyValidation.warnings) {
logger.warn(warning);
Expand All @@ -1038,7 +1045,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') continue;
if (key === 'openaiApiKey' || key === 'anthropicApiKey' || key === 'copilotApiKey') continue;
redactedConfig[key] = key === 'agentCommand' ? redactSecrets(value as string) : value;
}
logger.debug('Configuration:', JSON.stringify(redactedConfig, null, 2));
Expand Down
12 changes: 12 additions & 0 deletions src/docker-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,7 @@ export function generateDockerCompose(
EXCLUDED_ENV_VARS.add('CODEX_API_KEY');
EXCLUDED_ENV_VARS.add('ANTHROPIC_API_KEY');
EXCLUDED_ENV_VARS.add('CLAUDE_API_KEY');
EXCLUDED_ENV_VARS.add('COPILOT_API_KEY');
}

// Start with required/overridden environment variables
Expand Down Expand Up @@ -421,6 +422,7 @@ export function generateDockerCompose(
if (process.env.OPENAI_API_KEY && !config.enableApiProxy) environment.OPENAI_API_KEY = process.env.OPENAI_API_KEY;
if (process.env.CODEX_API_KEY && !config.enableApiProxy) environment.CODEX_API_KEY = process.env.CODEX_API_KEY;
if (process.env.ANTHROPIC_API_KEY && !config.enableApiProxy) environment.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
if (process.env.COPILOT_API_KEY && !config.enableApiProxy) environment.COPILOT_API_KEY = process.env.COPILOT_API_KEY;
if (process.env.USER) environment.USER = process.env.USER;
if (process.env.TERM) environment.TERM = process.env.TERM;
if (process.env.XDG_CONFIG_HOME) environment.XDG_CONFIG_HOME = process.env.XDG_CONFIG_HOME;
Expand Down Expand Up @@ -950,6 +952,7 @@ export function generateDockerCompose(
// Pass API keys securely to sidecar (not visible to agent)
...(config.openaiApiKey && { OPENAI_API_KEY: config.openaiApiKey }),
...(config.anthropicApiKey && { ANTHROPIC_API_KEY: config.anthropicApiKey }),
...(config.copilotApiKey && { COPILOT_API_KEY: config.copilotApiKey }),
// Route through Squid to respect domain whitelisting
HTTP_PROXY: `http://${networkConfig.squidIp}:${SQUID_PORT}`,
HTTPS_PROXY: `http://${networkConfig.squidIp}:${SQUID_PORT}`,
Expand Down Expand Up @@ -1013,6 +1016,15 @@ export function generateDockerCompose(
environment.CLAUDE_CODE_API_KEY_HELPER = '/usr/local/bin/get-claude-key.sh';
logger.debug('Claude Code API key helper configured: /usr/local/bin/get-claude-key.sh');
}
if (config.copilotApiKey) {
environment.COPILOT_API_URL = `http://${networkConfig.proxyIp}:10002`;
logger.debug(`GitHub Copilot API will be proxied through sidecar at http://${networkConfig.proxyIp}:10002`);

// Set placeholder token for GitHub Copilot CLI compatibility
// Real authentication happens via COPILOT_API_URL pointing to api-proxy
environment.COPILOT_TOKEN = 'placeholder-token-for-credential-isolation';
logger.debug('COPILOT_TOKEN set to placeholder value for credential isolation');
}

logger.info('API proxy sidecar enabled - API keys will be held securely in sidecar container');
logger.info('API proxy will route through Squid to respect domain whitelisting');
Expand Down
22 changes: 19 additions & 3 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,32 +385,36 @@ export interface WrapperConfig {
* Enable API proxy sidecar for holding authentication credentials
*
* When true, deploys a Node.js proxy sidecar container that:
* - Holds OpenAI and Anthropic API keys securely
* - Holds OpenAI, Anthropic, and GitHub Copilot API keys securely
* - Automatically injects authentication headers
* - Routes all traffic through Squid to respect domain whitelisting
* - Proxies requests to LLM providers
*
* The sidecar exposes two endpoints accessible from the agent container:
* The sidecar exposes three endpoints accessible from the agent container:
* - http://api-proxy:10000 - OpenAI API proxy (for Codex)
* - http://api-proxy:10001 - Anthropic API proxy (for Claude)
* - http://api-proxy:10002 - GitHub Copilot API proxy
*
* When the corresponding API key is provided, the following environment
* variables are set in the agent container:
* - OPENAI_BASE_URL=http://api-proxy:10000/v1 (set when OPENAI_API_KEY is provided)
* - ANTHROPIC_BASE_URL=http://api-proxy:10001 (set when ANTHROPIC_API_KEY is provided)
* - COPILOT_API_URL=http://api-proxy:10002 (set when COPILOT_API_KEY is provided)
* - CLAUDE_CODE_API_KEY_HELPER=/usr/local/bin/get-claude-key.sh (set when ANTHROPIC_API_KEY is provided)
*
* API keys are passed via environment variables:
* - OPENAI_API_KEY - Optional OpenAI API key for Codex
* - ANTHROPIC_API_KEY - Optional Anthropic API key for Claude
* - COPILOT_API_KEY - Optional GitHub Copilot API key
*
* @default false
* @example
* ```bash
* # Enable API proxy with keys from environment
* export OPENAI_API_KEY="sk-..."
* export ANTHROPIC_API_KEY="sk-ant-..."
* awf --enable-api-proxy --allow-domains api.openai.com,api.anthropic.com -- command
* export COPILOT_API_KEY="ghu_..."
* awf --enable-api-proxy --allow-domains api.openai.com,api.anthropic.com,api.githubcopilot.com -- command
* ```
*/
enableApiProxy?: boolean;
Expand Down Expand Up @@ -438,6 +442,18 @@ export interface WrapperConfig {
* @default undefined
*/
anthropicApiKey?: string;

/**
* GitHub Copilot API key (used by API proxy sidecar)
*
* When enableApiProxy is true, this key is injected into the Node.js sidecar
* container and used to authenticate requests to api.githubcopilot.com.
*
* The key is NOT exposed to the agent container - only the proxy URL is provided.
*
* @default undefined
*/
copilotApiKey?: string;
}

/**
Expand Down
78 changes: 78 additions & 0 deletions tests/integration/api-proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,4 +172,82 @@ describe('API Proxy Sidecar', () => {
// Port 10001 should also be healthy
expect(result.stdout).toContain('anthropic-proxy');
}, 180000);

test('should start api-proxy sidecar with Copilot key and pass healthcheck', async () => {
const result = await runner.runWithSudo(
`curl -s http://${API_PROXY_IP}:10002/health`,
{
allowDomains: ['api.githubcopilot.com'],
enableApiProxy: true,
buildLocal: true,
logLevel: 'debug',
timeout: 120000,
env: {
COPILOT_API_KEY: 'ghu_fake-test-key-12345',
},
}
);

expect(result).toSucceed();
expect(result.stdout).toContain('"status":"healthy"');
expect(result.stdout).toContain('copilot-proxy');
}, 180000);

test('should set COPILOT_API_URL in agent when Copilot key is provided', async () => {
const result = await runner.runWithSudo(
'bash -c "echo COPILOT_API_URL=$COPILOT_API_URL"',
{
allowDomains: ['api.githubcopilot.com'],
enableApiProxy: true,
buildLocal: true,
logLevel: 'debug',
timeout: 120000,
env: {
COPILOT_API_KEY: 'ghu_fake-test-key-12345',
},
}
);

expect(result).toSucceed();
expect(result.stdout).toContain(`COPILOT_API_URL=http://${API_PROXY_IP}:10002`);
}, 180000);

test('should set COPILOT_TOKEN to placeholder in agent when Copilot key is provided', async () => {
const result = await runner.runWithSudo(
'bash -c "echo COPILOT_TOKEN=$COPILOT_TOKEN"',
{
allowDomains: ['api.githubcopilot.com'],
enableApiProxy: true,
buildLocal: true,
logLevel: 'debug',
timeout: 120000,
env: {
COPILOT_API_KEY: 'ghu_fake-test-key-12345',
},
}
);

expect(result).toSucceed();
expect(result.stdout).toContain('COPILOT_TOKEN=placeholder-token-for-credential-isolation');
}, 180000);

test('should report copilot in health providers when Copilot key is provided', async () => {
// When Copilot key is provided, the main health endpoint should report copilot: true
const result = await runner.runWithSudo(
`curl -s http://${API_PROXY_IP}:10000/health`,
{
allowDomains: ['api.githubcopilot.com'],
enableApiProxy: true,
buildLocal: true,
logLevel: 'debug',
timeout: 120000,
env: {
COPILOT_API_KEY: 'ghu_fake-test-key-12345',
},
}
);

expect(result).toSucceed();
expect(result.stdout).toContain('"copilot":true');
}, 180000);

Copilot AI Feb 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing unit tests for Copilot API proxy functionality. Consider adding tests similar to those for OpenAI and Anthropic: 1) Test that api-proxy service is created with Copilot token, 2) Test that COPILOT_GITHUB_TOKEN is passed to api-proxy sidecar environment, 3) Test that COPILOT_API_URL is set in agent when Copilot token is provided, 4) Test that COPILOT_TOKEN placeholder is set in agent, 5) Test that COPILOT_GITHUB_TOKEN placeholder is set in agent early (before --env-all). See lines 1444-1487 for similar tests for OpenAI and Anthropic.

Suggested change
}, 180000);
}, 180000);
test('should set COPILOT_GITHUB_TOKEN to placeholder in agent when Copilot token is provided', async () => {
const result = await runner.runWithSudo(
'bash -c "echo COPILOT_GITHUB_TOKEN=$COPILOT_GITHUB_TOKEN"',
{
allowDomains: ['api.githubcopilot.com'],
enableApiProxy: true,
buildLocal: true,
logLevel: 'debug',
timeout: 120000,
env: {
// Provide a fake real token; the agent should only see a placeholder value.
COPILOT_GITHUB_TOKEN: 'ghp_fake-test-token-12345',
},
}
);
expect(result).toSucceed();
expect(result.stdout).toContain(
'COPILOT_GITHUB_TOKEN=placeholder-token-for-credential-isolation'
);
}, 180000);
test('should set COPILOT_GITHUB_TOKEN placeholder in agent early (before --env-all)', async () => {
// This test ensures that the placeholder for COPILOT_GITHUB_TOKEN is established
// as part of the agent startup sequence, not injected later via a bulk env export.
const result = await runner.runWithSudo(
'bash -c "echo COPILOT_GITHUB_TOKEN=$COPILOT_GITHUB_TOKEN"',
{
allowDomains: ['api.githubcopilot.com'],
enableApiProxy: true,
buildLocal: true,
logLevel: 'debug',
timeout: 120000,
env: {
COPILOT_GITHUB_TOKEN: 'ghp_fake-test-token-12345',
},
}
);
expect(result).toSucceed();
expect(result.stdout).toContain(
'COPILOT_GITHUB_TOKEN=placeholder-token-for-credential-isolation'
);
}, 180000);

Copilot uses AI. Check for mistakes.
});
Loading