From cd11923eb97f670da8b3cfb04ce17f14ed9fb547 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 17:33:40 +0000 Subject: [PATCH 1/4] Initial plan From 1ee57919bb3b9424162db8158f277ff003c60bc5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 17:46:57 +0000 Subject: [PATCH 2/4] feat: add startup key validation to api-proxy sidecar Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/7ec635c1-158c-4da1-b031-1cb92243b56d --- containers/api-proxy/server.js | 250 ++++++++++++++++- containers/api-proxy/server.test.js | 417 +++++++++++++++++++++++++++- 2 files changed, 665 insertions(+), 2 deletions(-) diff --git a/containers/api-proxy/server.js b/containers/api-proxy/server.js index 829b632d..5d576e2d 100644 --- a/containers/api-proxy/server.js +++ b/containers/api-proxy/server.js @@ -336,6 +336,240 @@ if (!proxyAgent) { logRequest('warn', 'startup', { message: 'No HTTPS_PROXY configured, requests will go direct' }); } +/** + * Send a lightweight probe request to validate an API key. + * Routes through proxyAgent (Squid) for the same path as real requests. + * Never throws — all errors are captured in the result. + * + * @param {string} provider - Provider name for logging + * @param {string} target - Upstream hostname (e.g. 'api.openai.com') + * @param {string} path - URL path for the probe (e.g. '/v1/models') + * @param {string} method - HTTP method ('GET' or 'POST') + * @param {Buffer|null} body - Optional request body (null for GET requests) + * @param {Record} headers - Request headers to inject + * @param {number[]} successStatuses - Status codes that indicate the key is valid + * @param {number[]} failStatuses - Status codes that indicate the key is invalid/rejected + * @param {object} [opts={}] - Options + * @param {number} [opts.timeoutMs=10000] - Per-request timeout in milliseconds + * @returns {Promise<{result: 'success'|'failed'|'timeout'|'error', status?: number, duration_ms: number, error?: string}>} + */ +function validateKey(provider, target, path, method, body, headers, successStatuses, failStatuses, opts = {}) { + const timeoutMs = opts.timeoutMs || 10000; + const startTime = Date.now(); + + return new Promise((resolve) => { + let settled = false; + const finish = (result) => { + if (settled) return; + settled = true; + resolve({ ...result, duration_ms: Date.now() - startTime }); + }; + + const reqHeaders = { ...headers }; + if (body && body.length > 0) { + reqHeaders['content-length'] = String(body.length); + } + + const options = { + hostname: target, + port: 443, + path, + method, + headers: reqHeaders, + agent: proxyAgent, + }; + + let timer; + const req = https.request(options, (res) => { + res.resume(); + res.on('end', () => { + clearTimeout(timer); + const status = res.statusCode; + if (successStatuses.includes(status)) { + finish({ result: 'success', status }); + } else if (failStatuses.includes(status)) { + finish({ result: 'failed', status }); + } else { + finish({ result: 'error', status }); + } + }); + res.on('error', () => { + clearTimeout(timer); + finish({ result: 'error' }); + }); + }); + + timer = setTimeout(() => { + req.destroy(); + finish({ result: 'timeout' }); + }, timeoutMs); + + req.on('error', (err) => { + clearTimeout(timer); + finish({ result: 'error', error: err.message }); + }); + + if (body && body.length > 0) { + req.write(body); + } + req.end(); + }); +} + +/** + * Validate API keys at startup by sending lightweight probe requests to each configured provider. + * Called once after all proxy servers have started listening. + * Never throws or crashes the process — all errors are logged and the proxy continues running. + * + * Validation is skipped for: + * - Custom API targets (non-default hostnames or non-empty base paths) + * - Copilot classic PATs (ghp_*) and COPILOT_API_KEY BYOK mode + * + * @param {object} [overrides={}] - Optional key/target overrides (used in tests) + * @param {number} [overrides.timeoutMs=10000] - Per-probe timeout in milliseconds + */ +async function validateApiKeys(overrides = {}) { + const openaiKey = overrides.openaiKey !== undefined ? overrides.openaiKey : OPENAI_API_KEY; + const openaiTarget = overrides.openaiTarget !== undefined ? overrides.openaiTarget : OPENAI_API_TARGET; + const openaiBasePath = overrides.openaiBasePath !== undefined ? overrides.openaiBasePath : OPENAI_API_BASE_PATH; + const anthropicKey = overrides.anthropicKey !== undefined ? overrides.anthropicKey : ANTHROPIC_API_KEY; + const anthropicTarget = overrides.anthropicTarget !== undefined ? overrides.anthropicTarget : ANTHROPIC_API_TARGET; + const anthropicBasePath = overrides.anthropicBasePath !== undefined ? overrides.anthropicBasePath : ANTHROPIC_API_BASE_PATH; + const copilotGithubToken = overrides.copilotGithubToken !== undefined ? overrides.copilotGithubToken : COPILOT_GITHUB_TOKEN; + const copilotAuthToken = overrides.copilotAuthToken !== undefined ? overrides.copilotAuthToken : COPILOT_AUTH_TOKEN; + const copilotTarget = overrides.copilotTarget !== undefined ? overrides.copilotTarget : COPILOT_API_TARGET; + const copilotTargetOverridden = overrides.copilotTargetOverridden !== undefined ? overrides.copilotTargetOverridden : !!process.env.COPILOT_API_TARGET; + const copilotIntegrationId = overrides.copilotIntegrationId !== undefined ? overrides.copilotIntegrationId : COPILOT_INTEGRATION_ID; + const geminiKey = overrides.geminiKey !== undefined ? overrides.geminiKey : GEMINI_API_KEY; + const geminiTarget = overrides.geminiTarget !== undefined ? overrides.geminiTarget : GEMINI_API_TARGET; + const geminiBasePath = overrides.geminiBasePath !== undefined ? overrides.geminiBasePath : GEMINI_API_BASE_PATH; + const probeOpts = overrides.timeoutMs !== undefined ? { timeoutMs: overrides.timeoutMs } : {}; + + const tasks = []; + + // ── OpenAI (GET /v1/models → 200 valid, 401 invalid) ──────────────────────── + if (openaiKey) { + if (openaiTarget !== 'api.openai.com' || openaiBasePath) { + logRequest('warn', 'key_validation_skipped', { + provider: 'openai', + message: `Validation skipped — custom API target (${openaiTarget})`, + }); + } else { + tasks.push( + validateKey('openai', 'api.openai.com', '/v1/models', 'GET', null, + { 'Authorization': `Bearer ${openaiKey}` }, [200], [401], probeOpts, + ).then(r => { + if (r.result === 'success') { + logRequest('info', 'key_validation_success', { provider: 'openai', message: 'OpenAI API key validated successfully', duration_ms: r.duration_ms }); + } else if (r.result === 'failed') { + logRequest('error', 'key_validation_failed', { provider: 'openai', status: r.status, message: 'OpenAI API key is invalid or expired. Requests to this provider will fail.' }); + } else if (r.result === 'timeout') { + logRequest('warn', 'key_validation_timeout', { provider: 'openai', message: 'Key validation timed out after 10s — network may not be ready' }); + } else { + logRequest('warn', 'key_validation_error', { provider: 'openai', message: `Key validation probe failed unexpectedly (status: ${r.status}, error: ${r.error || 'unknown'})` }); + } + }) + ); + } + } + + // ── Anthropic (POST /v1/messages — 400 = key valid, 401/403 = key rejected) ─ + if (anthropicKey) { + if (anthropicTarget !== 'api.anthropic.com' || anthropicBasePath) { + logRequest('warn', 'key_validation_skipped', { + provider: 'anthropic', + message: `Validation skipped — custom API target (${anthropicTarget})`, + }); + } else { + const probeBody = Buffer.from(JSON.stringify({})); + tasks.push( + validateKey('anthropic', 'api.anthropic.com', '/v1/messages', 'POST', probeBody, + { 'x-api-key': anthropicKey, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' }, + [400], [401, 403], probeOpts, + ).then(r => { + if (r.result === 'success') { + // 400 = key accepted, request body is incomplete (expected for this minimal probe) + logRequest('info', 'key_validation_success', { + provider: 'anthropic', + message: 'Anthropic API key accepted (probe returned 400 — key valid, request body incomplete as expected)', + duration_ms: r.duration_ms, + }); + } else if (r.result === 'failed') { + logRequest('error', 'key_validation_failed', { provider: 'anthropic', status: r.status, message: 'Anthropic API key is invalid or expired. Requests to this provider will fail.' }); + } else if (r.result === 'timeout') { + logRequest('warn', 'key_validation_timeout', { provider: 'anthropic', message: 'Key validation timed out after 10s — network may not be ready' }); + } else { + logRequest('warn', 'key_validation_error', { provider: 'anthropic', message: `Key validation probe failed unexpectedly (status: ${r.status}, error: ${r.error || 'unknown'})` }); + } + }) + ); + } + } + + // ── Copilot (GET /models — only for non-classic COPILOT_GITHUB_TOKEN) ──────── + if (copilotAuthToken) { + if (copilotTargetOverridden) { + logRequest('warn', 'key_validation_skipped', { + provider: 'copilot', + message: `Validation skipped — custom API target (${copilotTarget})`, + }); + } else if (copilotGithubToken && !copilotGithubToken.startsWith('ghp_')) { + // Non-classic GitHub token (ghu_, gho_, github_pat_, etc.) — can probe /models + tasks.push( + validateKey('copilot', copilotTarget, '/models', 'GET', null, + { 'Authorization': `Bearer ${copilotGithubToken}`, 'Copilot-Integration-Id': copilotIntegrationId }, + [200], [401], probeOpts, + ).then(r => { + if (r.result === 'success') { + logRequest('info', 'key_validation_success', { provider: 'copilot', message: 'Copilot GitHub token validated successfully', duration_ms: r.duration_ms }); + } else if (r.result === 'failed') { + logRequest('error', 'key_validation_failed', { provider: 'copilot', status: r.status, message: 'Copilot GitHub token is invalid or expired. Requests to this provider will fail.' }); + } else if (r.result === 'timeout') { + logRequest('warn', 'key_validation_timeout', { provider: 'copilot', message: 'Key validation timed out after 10s — network may not be ready' }); + } else { + logRequest('warn', 'key_validation_error', { provider: 'copilot', message: `Key validation probe failed unexpectedly (status: ${r.status}, error: ${r.error || 'unknown'})` }); + } + }) + ); + } else { + // Classic ghp_* PAT or COPILOT_API_KEY BYOK — validation not supported for this auth mode + logRequest('warn', 'key_validation_skipped', { + provider: 'copilot', + message: 'Validation skipped — COPILOT_API_KEY auth mode does not support probe endpoint', + }); + } + } + + // ── Gemini (GET /v1beta/models → 200 valid, 400/403 invalid) ──────────────── + if (geminiKey) { + if (geminiTarget !== 'generativelanguage.googleapis.com' || geminiBasePath) { + logRequest('warn', 'key_validation_skipped', { + provider: 'gemini', + message: `Validation skipped — custom API target (${geminiTarget})`, + }); + } else { + tasks.push( + validateKey('gemini', 'generativelanguage.googleapis.com', '/v1beta/models', 'GET', null, + { 'x-goog-api-key': geminiKey }, + [200], [400, 403], probeOpts, + ).then(r => { + if (r.result === 'success') { + logRequest('info', 'key_validation_success', { provider: 'gemini', message: 'Gemini API key validated successfully', duration_ms: r.duration_ms }); + } else if (r.result === 'failed') { + logRequest('error', 'key_validation_failed', { provider: 'gemini', status: r.status, message: 'Gemini API key is invalid or expired. Requests to this provider will fail.' }); + } else if (r.result === 'timeout') { + logRequest('warn', 'key_validation_timeout', { provider: 'gemini', message: 'Key validation timed out after 10s — network may not be ready' }); + } else { + logRequest('warn', 'key_validation_error', { provider: 'gemini', message: `Key validation probe failed unexpectedly (status: ${r.status}, error: ${r.error || 'unknown'})` }); + } + }) + ); + } + } + + await Promise.all(tasks); +} + /** * Resolves the OpenCode routing configuration based on available credentials. * Priority: OPENAI_API_KEY > ANTHROPIC_API_KEY > copilotToken (COPILOT_GITHUB_TOKEN / COPILOT_API_KEY) @@ -923,6 +1157,9 @@ if (require.main === module) { // Health port is always 10000 — this is what Docker healthcheck hits const HEALTH_PORT = 10000; + // Collect listen promises so we can fire key validation after all servers are ready + const listenPromises = []; + // OpenAI API proxy (port 10000) if (OPENAI_API_KEY) { const server = http.createServer((req, res) => { @@ -941,6 +1178,7 @@ if (require.main === module) { }, 'openai', OPENAI_API_BASE_PATH); }); + listenPromises.push(new Promise(r => server.once('listening', r))); server.listen(HEALTH_PORT, '0.0.0.0', () => { logRequest('info', 'server_start', { message: `OpenAI proxy listening on port ${HEALTH_PORT}`, target: OPENAI_API_TARGET }); }); @@ -958,6 +1196,7 @@ if (require.main === module) { socket.destroy(); }); + listenPromises.push(new Promise(r => server.once('listening', r))); server.listen(HEALTH_PORT, '0.0.0.0', () => { logRequest('info', 'server_start', { message: `Health endpoint listening on port ${HEALTH_PORT} (OpenAI not configured)` }); }); @@ -991,6 +1230,7 @@ if (require.main === module) { proxyWebSocket(req, socket, head, ANTHROPIC_API_TARGET, anthropicHeaders, 'anthropic', ANTHROPIC_API_BASE_PATH); }); + listenPromises.push(new Promise(r => server.once('listening', r))); server.listen(10001, '0.0.0.0', () => { logRequest('info', 'server_start', { message: 'Anthropic proxy listening on port 10001', target: ANTHROPIC_API_TARGET }); }); @@ -1051,6 +1291,7 @@ if (require.main === module) { }, 'copilot'); }); + listenPromises.push(new Promise(r => copilotServer.once('listening', r))); copilotServer.listen(10002, '0.0.0.0', () => { logRequest('info', 'server_start', { message: 'GitHub Copilot proxy listening on port 10002' }); }); @@ -1085,6 +1326,7 @@ if (require.main === module) { }, 'gemini', GEMINI_API_BASE_PATH); }); + listenPromises.push(new Promise(r => geminiServer.once('listening', r))); geminiServer.listen(10003, '0.0.0.0', () => { logRequest('info', 'server_start', { message: 'Google Gemini proxy listening on port 10003', target: GEMINI_API_TARGET }); }); @@ -1107,6 +1349,7 @@ if (require.main === module) { socket.destroy(); }); + listenPromises.push(new Promise(r => geminiServer.once('listening', r))); geminiServer.listen(10003, '0.0.0.0', () => { logRequest('info', 'server_start', { message: 'Gemini endpoint listening on port 10003 (Gemini not configured — returning 503)' }); }); @@ -1193,11 +1436,16 @@ if (require.main === module) { proxyWebSocket(req, socket, head, route.target, headers, 'opencode', route.basePath); }); + listenPromises.push(new Promise(r => opencodeServer.once('listening', r))); opencodeServer.listen(10004, '0.0.0.0', () => { logRequest('info', 'server_start', { message: `OpenCode proxy listening on port 10004 (-> ${opencodeStartupRoute.target})` }); }); } + // After all servers are listening, fire key validation as a background task. + // This ensures the Docker healthcheck port (10000) passes before validation begins. + Promise.all(listenPromises).then(() => validateApiKeys()); + // Graceful shutdown process.on('SIGTERM', async () => { logRequest('info', 'shutdown', { message: 'Received SIGTERM, shutting down gracefully' }); @@ -1213,4 +1461,4 @@ if (require.main === module) { } // Export for testing -module.exports = { normalizeApiTarget, deriveCopilotApiTarget, deriveGitHubApiTarget, deriveGitHubApiBasePath, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken, resolveOpenCodeRoute, shouldStripHeader, stripGeminiKeyParam }; +module.exports = { normalizeApiTarget, deriveCopilotApiTarget, deriveGitHubApiTarget, deriveGitHubApiBasePath, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken, resolveOpenCodeRoute, shouldStripHeader, stripGeminiKeyParam, validateKey, validateApiKeys }; diff --git a/containers/api-proxy/server.test.js b/containers/api-proxy/server.test.js index 60b08865..52146424 100644 --- a/containers/api-proxy/server.test.js +++ b/containers/api-proxy/server.test.js @@ -3,9 +3,10 @@ */ const http = require('http'); +const https = require('https'); const tls = require('tls'); const { EventEmitter } = require('events'); -const { normalizeApiTarget, deriveCopilotApiTarget, deriveGitHubApiTarget, deriveGitHubApiBasePath, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken, resolveOpenCodeRoute, shouldStripHeader, stripGeminiKeyParam } = require('./server'); +const { normalizeApiTarget, deriveCopilotApiTarget, deriveGitHubApiTarget, deriveGitHubApiBasePath, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken, resolveOpenCodeRoute, shouldStripHeader, stripGeminiKeyParam, validateKey, validateApiKeys } = require('./server'); describe('normalizeApiTarget', () => { it('should strip https:// prefix', () => { @@ -979,3 +980,417 @@ describe('resolveOpenCodeRoute', () => { expect(route.headers['x-api-key']).toBeUndefined(); }); }); + +// ── Helpers for validateKey / validateApiKeys tests ──────────────────────────── + +/** + * Create a mock https.request implementation that responds with the given status code. + * @param {number} statusCode - HTTP status code to respond with + */ +function mockHttpsRequestWithStatus(statusCode) { + return jest.spyOn(https, 'request').mockImplementation((options, callback) => { + const req = new EventEmitter(); + req.write = jest.fn(); + req.end = jest.fn(() => { + setImmediate(() => { + const res = new EventEmitter(); + res.statusCode = statusCode; + res.resume = jest.fn(); + callback(res); + setImmediate(() => res.emit('end')); + }); + }); + req.destroy = jest.fn(); + return req; + }); +} + +/** + * Collect structured log lines emitted by logRequest() (written to process.stdout). + * Returns an object with the captured lines array and a Jest spy to restore later. + */ +function collectLogOutput() { + const lines = []; + const spy = jest.spyOn(process.stdout, 'write').mockImplementation((data) => { + try { + lines.push(JSON.parse(data.toString())); + } catch { + // ignore non-JSON writes + } + return true; + }); + return { lines, spy }; +} + +describe('validateKey', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns success when status code is in successStatuses', async () => { + mockHttpsRequestWithStatus(200); + const result = await validateKey( + 'openai', 'api.openai.com', '/v1/models', 'GET', null, + { 'Authorization': 'Bearer sk-test' }, [200], [401], + ); + expect(result.result).toBe('success'); + expect(result.status).toBe(200); + }); + + it('returns failed when status code is in failStatuses', async () => { + mockHttpsRequestWithStatus(401); + const result = await validateKey( + 'openai', 'api.openai.com', '/v1/models', 'GET', null, + { 'Authorization': 'Bearer sk-invalid' }, [200], [401], + ); + expect(result.result).toBe('failed'); + expect(result.status).toBe(401); + }); + + it('returns error for unexpected status codes not in either list', async () => { + mockHttpsRequestWithStatus(500); + const result = await validateKey( + 'openai', 'api.openai.com', '/v1/models', 'GET', null, + {}, [200], [401], + ); + expect(result.result).toBe('error'); + expect(result.status).toBe(500); + }); + + it('includes duration_ms in the result', async () => { + mockHttpsRequestWithStatus(200); + const result = await validateKey( + 'openai', 'api.openai.com', '/v1/models', 'GET', null, + {}, [200], [401], + ); + expect(typeof result.duration_ms).toBe('number'); + expect(result.duration_ms).toBeGreaterThanOrEqual(0); + }); + + it('returns timeout when the request takes longer than timeoutMs', async () => { + jest.spyOn(https, 'request').mockImplementation(() => { + const req = new EventEmitter(); + req.write = jest.fn(); + req.end = jest.fn(); // never calls back + req.destroy = jest.fn(() => req.emit('close')); + return req; + }); + const result = await validateKey( + 'openai', 'api.openai.com', '/v1/models', 'GET', null, + {}, [200], [401], { timeoutMs: 20 }, + ); + expect(result.result).toBe('timeout'); + expect(result.duration_ms).toBeGreaterThanOrEqual(0); + }); + + it('returns error with message on network error', async () => { + jest.spyOn(https, 'request').mockImplementation(() => { + const req = new EventEmitter(); + req.write = jest.fn(); + req.end = jest.fn(() => { + setImmediate(() => req.emit('error', new Error('connection refused'))); + }); + req.destroy = jest.fn(); + return req; + }); + const result = await validateKey( + 'openai', 'api.openai.com', '/v1/models', 'GET', null, + {}, [200], [401], + ); + expect(result.result).toBe('error'); + expect(result.error).toBe('connection refused'); + }); + + it('sends POST body when provided', async () => { + const captured = []; + jest.spyOn(https, 'request').mockImplementation((options, callback) => { + const req = new EventEmitter(); + req.write = jest.fn((data) => captured.push(data)); + req.end = jest.fn(() => { + setImmediate(() => { + const res = new EventEmitter(); + res.statusCode = 400; + res.resume = jest.fn(); + callback(res); + setImmediate(() => res.emit('end')); + }); + }); + req.destroy = jest.fn(); + return req; + }); + const body = Buffer.from(JSON.stringify({})); + await validateKey( + 'anthropic', 'api.anthropic.com', '/v1/messages', 'POST', body, + {}, [400], [401, 403], + ); + expect(captured.length).toBe(1); + expect(captured[0]).toEqual(body); + }); + + it('passes the correct hostname and path in request options', async () => { + let capturedOptions; + jest.spyOn(https, 'request').mockImplementation((options, callback) => { + capturedOptions = options; + const req = new EventEmitter(); + req.write = jest.fn(); + req.end = jest.fn(() => { + setImmediate(() => { + const res = new EventEmitter(); + res.statusCode = 200; + res.resume = jest.fn(); + callback(res); + setImmediate(() => res.emit('end')); + }); + }); + req.destroy = jest.fn(); + return req; + }); + await validateKey( + 'gemini', 'generativelanguage.googleapis.com', '/v1beta/models', 'GET', null, + { 'x-goog-api-key': 'test-key' }, [200], [400, 403], + ); + expect(capturedOptions.hostname).toBe('generativelanguage.googleapis.com'); + expect(capturedOptions.path).toBe('/v1beta/models'); + expect(capturedOptions.method).toBe('GET'); + expect(capturedOptions.headers['x-goog-api-key']).toBe('test-key'); + }); + + it('handles multiple failStatuses (e.g. Anthropic 401 and 403)', async () => { + mockHttpsRequestWithStatus(403); + const result = await validateKey( + 'anthropic', 'api.anthropic.com', '/v1/messages', 'POST', null, + {}, [400], [401, 403], + ); + expect(result.result).toBe('failed'); + expect(result.status).toBe(403); + }); +}); + +describe('validateApiKeys', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('logs key_validation_success when OpenAI probe returns 200', async () => { + const { lines } = collectLogOutput(); + mockHttpsRequestWithStatus(200); + await validateApiKeys({ openaiKey: 'sk-test', openaiTarget: 'api.openai.com', openaiBasePath: '' }); + const successLog = lines.find(l => l.event === 'key_validation_success' && l.provider === 'openai'); + expect(successLog).toBeDefined(); + expect(successLog.level).toBe('info'); + }); + + it('logs key_validation_failed when OpenAI probe returns 401', async () => { + const { lines } = collectLogOutput(); + mockHttpsRequestWithStatus(401); + await validateApiKeys({ openaiKey: 'sk-bad', openaiTarget: 'api.openai.com', openaiBasePath: '' }); + const failLog = lines.find(l => l.event === 'key_validation_failed' && l.provider === 'openai'); + expect(failLog).toBeDefined(); + expect(failLog.level).toBe('error'); + expect(failLog.status).toBe(401); + }); + + it('logs key_validation_skipped for custom OpenAI API target', async () => { + const { lines } = collectLogOutput(); + await validateApiKeys({ openaiKey: 'sk-test', openaiTarget: 'my-llm-router.internal', openaiBasePath: '' }); + const skippedLog = lines.find(l => l.event === 'key_validation_skipped' && l.provider === 'openai'); + expect(skippedLog).toBeDefined(); + expect(skippedLog.level).toBe('warn'); + expect(skippedLog.message).toContain('custom API target'); + }); + + it('logs key_validation_skipped for non-empty OpenAI base path', async () => { + const { lines } = collectLogOutput(); + await validateApiKeys({ openaiKey: 'sk-test', openaiTarget: 'api.openai.com', openaiBasePath: '/serving-endpoints' }); + const skippedLog = lines.find(l => l.event === 'key_validation_skipped' && l.provider === 'openai'); + expect(skippedLog).toBeDefined(); + }); + + it('does not validate OpenAI when openaiKey is not provided', async () => { + const { lines } = collectLogOutput(); + const spy = jest.spyOn(https, 'request'); + await validateApiKeys({ openaiKey: undefined }); + const openaiLogs = lines.filter(l => l.provider === 'openai'); + expect(openaiLogs).toHaveLength(0); + expect(spy).not.toHaveBeenCalled(); + }); + + it('logs key_validation_success when Anthropic probe returns 400 (key valid, body incomplete)', async () => { + const { lines } = collectLogOutput(); + mockHttpsRequestWithStatus(400); + await validateApiKeys({ anthropicKey: 'sk-ant-test', anthropicTarget: 'api.anthropic.com', anthropicBasePath: '' }); + const successLog = lines.find(l => l.event === 'key_validation_success' && l.provider === 'anthropic'); + expect(successLog).toBeDefined(); + expect(successLog.level).toBe('info'); + expect(successLog.message).toContain('400'); + }); + + it('logs key_validation_failed when Anthropic probe returns 401', async () => { + const { lines } = collectLogOutput(); + mockHttpsRequestWithStatus(401); + await validateApiKeys({ anthropicKey: 'sk-ant-bad', anthropicTarget: 'api.anthropic.com', anthropicBasePath: '' }); + const failLog = lines.find(l => l.event === 'key_validation_failed' && l.provider === 'anthropic'); + expect(failLog).toBeDefined(); + expect(failLog.level).toBe('error'); + }); + + it('logs key_validation_failed when Anthropic probe returns 403', async () => { + const { lines } = collectLogOutput(); + mockHttpsRequestWithStatus(403); + await validateApiKeys({ anthropicKey: 'sk-ant-bad', anthropicTarget: 'api.anthropic.com', anthropicBasePath: '' }); + const failLog = lines.find(l => l.event === 'key_validation_failed' && l.provider === 'anthropic'); + expect(failLog).toBeDefined(); + expect(failLog.status).toBe(403); + }); + + it('logs key_validation_skipped for custom Anthropic API target', async () => { + const { lines } = collectLogOutput(); + await validateApiKeys({ anthropicKey: 'sk-ant-test', anthropicTarget: 'proxy.corp.internal', anthropicBasePath: '' }); + const skippedLog = lines.find(l => l.event === 'key_validation_skipped' && l.provider === 'anthropic'); + expect(skippedLog).toBeDefined(); + expect(skippedLog.message).toContain('custom API target'); + }); + + it('validates Copilot when COPILOT_GITHUB_TOKEN is a non-classic token (ghu_)', async () => { + const { lines } = collectLogOutput(); + mockHttpsRequestWithStatus(200); + await validateApiKeys({ + copilotAuthToken: 'ghu_valid_token', + copilotGithubToken: 'ghu_valid_token', + copilotTarget: 'api.githubcopilot.com', + copilotTargetOverridden: false, + copilotIntegrationId: 'copilot-developer-cli', + }); + const successLog = lines.find(l => l.event === 'key_validation_success' && l.provider === 'copilot'); + expect(successLog).toBeDefined(); + expect(successLog.level).toBe('info'); + }); + + it('logs key_validation_skipped for classic ghp_ PAT in COPILOT_GITHUB_TOKEN', async () => { + const { lines } = collectLogOutput(); + const spy = jest.spyOn(https, 'request'); + await validateApiKeys({ + copilotAuthToken: 'ghp_classic_token', + copilotGithubToken: 'ghp_classic_token', + copilotTarget: 'api.githubcopilot.com', + copilotTargetOverridden: false, + }); + const skippedLog = lines.find(l => l.event === 'key_validation_skipped' && l.provider === 'copilot'); + expect(skippedLog).toBeDefined(); + expect(skippedLog.message).toContain('COPILOT_API_KEY auth mode'); + expect(spy).not.toHaveBeenCalled(); + }); + + it('logs key_validation_skipped when only COPILOT_API_KEY is set (no COPILOT_GITHUB_TOKEN)', async () => { + const { lines } = collectLogOutput(); + const spy = jest.spyOn(https, 'request'); + await validateApiKeys({ + copilotAuthToken: 'sk-byok-key', + copilotGithubToken: undefined, + copilotTarget: 'api.githubcopilot.com', + copilotTargetOverridden: false, + }); + const skippedLog = lines.find(l => l.event === 'key_validation_skipped' && l.provider === 'copilot'); + expect(skippedLog).toBeDefined(); + expect(skippedLog.message).toContain('COPILOT_API_KEY auth mode'); + expect(spy).not.toHaveBeenCalled(); + }); + + it('logs key_validation_skipped for custom Copilot API target', async () => { + const { lines } = collectLogOutput(); + await validateApiKeys({ + copilotAuthToken: 'ghu_valid', + copilotGithubToken: 'ghu_valid', + copilotTarget: 'copilot-api.mycompany.ghe.com', + copilotTargetOverridden: true, + copilotIntegrationId: 'copilot-developer-cli', + }); + const skippedLog = lines.find(l => l.event === 'key_validation_skipped' && l.provider === 'copilot'); + expect(skippedLog).toBeDefined(); + expect(skippedLog.message).toContain('custom API target'); + }); + + it('logs key_validation_failed when Copilot probe returns 401', async () => { + const { lines } = collectLogOutput(); + mockHttpsRequestWithStatus(401); + await validateApiKeys({ + copilotAuthToken: 'ghu_invalid', + copilotGithubToken: 'ghu_invalid', + copilotTarget: 'api.githubcopilot.com', + copilotTargetOverridden: false, + copilotIntegrationId: 'copilot-developer-cli', + }); + const failLog = lines.find(l => l.event === 'key_validation_failed' && l.provider === 'copilot'); + expect(failLog).toBeDefined(); + expect(failLog.level).toBe('error'); + }); + + it('logs key_validation_success when Gemini probe returns 200', async () => { + const { lines } = collectLogOutput(); + mockHttpsRequestWithStatus(200); + await validateApiKeys({ geminiKey: 'ai-test-key', geminiTarget: 'generativelanguage.googleapis.com', geminiBasePath: '' }); + const successLog = lines.find(l => l.event === 'key_validation_success' && l.provider === 'gemini'); + expect(successLog).toBeDefined(); + expect(successLog.level).toBe('info'); + }); + + it('logs key_validation_failed when Gemini probe returns 400', async () => { + const { lines } = collectLogOutput(); + mockHttpsRequestWithStatus(400); + await validateApiKeys({ geminiKey: 'ai-bad-key', geminiTarget: 'generativelanguage.googleapis.com', geminiBasePath: '' }); + const failLog = lines.find(l => l.event === 'key_validation_failed' && l.provider === 'gemini'); + expect(failLog).toBeDefined(); + expect(failLog.level).toBe('error'); + }); + + it('logs key_validation_failed when Gemini probe returns 403', async () => { + const { lines } = collectLogOutput(); + mockHttpsRequestWithStatus(403); + await validateApiKeys({ geminiKey: 'ai-bad-key', geminiTarget: 'generativelanguage.googleapis.com', geminiBasePath: '' }); + const failLog = lines.find(l => l.event === 'key_validation_failed' && l.provider === 'gemini'); + expect(failLog).toBeDefined(); + expect(failLog.status).toBe(403); + }); + + it('logs key_validation_skipped for custom Gemini API target', async () => { + const { lines } = collectLogOutput(); + await validateApiKeys({ geminiKey: 'ai-test', geminiTarget: 'my-vertex-endpoint.internal', geminiBasePath: '' }); + const skippedLog = lines.find(l => l.event === 'key_validation_skipped' && l.provider === 'gemini'); + expect(skippedLog).toBeDefined(); + expect(skippedLog.message).toContain('custom API target'); + }); + + it('logs key_validation_timeout when a probe times out', async () => { + const { lines } = collectLogOutput(); + jest.spyOn(https, 'request').mockImplementation(() => { + const req = new EventEmitter(); + req.write = jest.fn(); + req.end = jest.fn(); // never responds + req.destroy = jest.fn(() => req.emit('close')); + return req; + }); + await validateApiKeys({ + openaiKey: 'sk-test', + openaiTarget: 'api.openai.com', + openaiBasePath: '', + timeoutMs: 20, + }); + const timeoutLog = lines.find(l => l.event === 'key_validation_timeout' && l.provider === 'openai'); + expect(timeoutLog).toBeDefined(); + expect(timeoutLog.level).toBe('warn'); + }, 5000); + + it('does not validate any provider when no keys are provided', async () => { + const { lines } = collectLogOutput(); + const spy = jest.spyOn(https, 'request'); + await validateApiKeys({ + openaiKey: undefined, + anthropicKey: undefined, + copilotAuthToken: undefined, + geminiKey: undefined, + }); + const validationLogs = lines.filter(l => l.event && l.event.startsWith('key_validation')); + expect(validationLogs).toHaveLength(0); + expect(spy).not.toHaveBeenCalled(); + }); +}); From abbf4677dfbd79d8f8868c5cdb7f5fa6a4adc232 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 17:49:27 +0000 Subject: [PATCH 3/4] fix: use dynamic timeout value in key_validation_timeout log messages Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/7ec635c1-158c-4da1-b031-1cb92243b56d --- containers/api-proxy/server.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/containers/api-proxy/server.js b/containers/api-proxy/server.js index 5d576e2d..3b2f99e6 100644 --- a/containers/api-proxy/server.js +++ b/containers/api-proxy/server.js @@ -444,6 +444,7 @@ async function validateApiKeys(overrides = {}) { const geminiTarget = overrides.geminiTarget !== undefined ? overrides.geminiTarget : GEMINI_API_TARGET; const geminiBasePath = overrides.geminiBasePath !== undefined ? overrides.geminiBasePath : GEMINI_API_BASE_PATH; const probeOpts = overrides.timeoutMs !== undefined ? { timeoutMs: overrides.timeoutMs } : {}; + const timeoutSecs = Math.round((probeOpts.timeoutMs || 10000) / 1000); const tasks = []; @@ -464,7 +465,7 @@ async function validateApiKeys(overrides = {}) { } else if (r.result === 'failed') { logRequest('error', 'key_validation_failed', { provider: 'openai', status: r.status, message: 'OpenAI API key is invalid or expired. Requests to this provider will fail.' }); } else if (r.result === 'timeout') { - logRequest('warn', 'key_validation_timeout', { provider: 'openai', message: 'Key validation timed out after 10s — network may not be ready' }); + logRequest('warn', 'key_validation_timeout', { provider: 'openai', message: `Key validation timed out after ${timeoutSecs}s — network may not be ready` }); } else { logRequest('warn', 'key_validation_error', { provider: 'openai', message: `Key validation probe failed unexpectedly (status: ${r.status}, error: ${r.error || 'unknown'})` }); } @@ -497,7 +498,7 @@ async function validateApiKeys(overrides = {}) { } else if (r.result === 'failed') { logRequest('error', 'key_validation_failed', { provider: 'anthropic', status: r.status, message: 'Anthropic API key is invalid or expired. Requests to this provider will fail.' }); } else if (r.result === 'timeout') { - logRequest('warn', 'key_validation_timeout', { provider: 'anthropic', message: 'Key validation timed out after 10s — network may not be ready' }); + logRequest('warn', 'key_validation_timeout', { provider: 'anthropic', message: `Key validation timed out after ${timeoutSecs}s — network may not be ready` }); } else { logRequest('warn', 'key_validation_error', { provider: 'anthropic', message: `Key validation probe failed unexpectedly (status: ${r.status}, error: ${r.error || 'unknown'})` }); } @@ -525,7 +526,7 @@ async function validateApiKeys(overrides = {}) { } else if (r.result === 'failed') { logRequest('error', 'key_validation_failed', { provider: 'copilot', status: r.status, message: 'Copilot GitHub token is invalid or expired. Requests to this provider will fail.' }); } else if (r.result === 'timeout') { - logRequest('warn', 'key_validation_timeout', { provider: 'copilot', message: 'Key validation timed out after 10s — network may not be ready' }); + logRequest('warn', 'key_validation_timeout', { provider: 'copilot', message: `Key validation timed out after ${timeoutSecs}s — network may not be ready` }); } else { logRequest('warn', 'key_validation_error', { provider: 'copilot', message: `Key validation probe failed unexpectedly (status: ${r.status}, error: ${r.error || 'unknown'})` }); } @@ -558,7 +559,7 @@ async function validateApiKeys(overrides = {}) { } else if (r.result === 'failed') { logRequest('error', 'key_validation_failed', { provider: 'gemini', status: r.status, message: 'Gemini API key is invalid or expired. Requests to this provider will fail.' }); } else if (r.result === 'timeout') { - logRequest('warn', 'key_validation_timeout', { provider: 'gemini', message: 'Key validation timed out after 10s — network may not be ready' }); + logRequest('warn', 'key_validation_timeout', { provider: 'gemini', message: `Key validation timed out after ${timeoutSecs}s — network may not be ready` }); } else { logRequest('warn', 'key_validation_error', { provider: 'gemini', message: `Key validation probe failed unexpectedly (status: ${r.status}, error: ${r.error || 'unknown'})` }); } From e74042fba3253dae818c183d7fac4c9a78f9cb96 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 17:51:50 +0000 Subject: [PATCH 4/4] refactor: extract DEFAULT_VALIDATION_TIMEOUT_MS and fix JSDoc Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/7ec635c1-158c-4da1-b031-1cb92243b56d --- containers/api-proxy/server.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/containers/api-proxy/server.js b/containers/api-proxy/server.js index 3b2f99e6..86991ef4 100644 --- a/containers/api-proxy/server.js +++ b/containers/api-proxy/server.js @@ -336,12 +336,18 @@ if (!proxyAgent) { logRequest('warn', 'startup', { message: 'No HTTPS_PROXY configured, requests will go direct' }); } +// Default per-probe timeout for key validation (10 seconds). +// Validation runs after startup and before the agent processes any requests, +// so a short timeout is acceptable. If the network isn't ready yet, the probe +// will time out and log a warning rather than blocking startup indefinitely. +const DEFAULT_VALIDATION_TIMEOUT_MS = 10000; + /** * Send a lightweight probe request to validate an API key. * Routes through proxyAgent (Squid) for the same path as real requests. * Never throws — all errors are captured in the result. * - * @param {string} provider - Provider name for logging + * @param {string} provider - Provider name used for log context only; does not affect validation behavior * @param {string} target - Upstream hostname (e.g. 'api.openai.com') * @param {string} path - URL path for the probe (e.g. '/v1/models') * @param {string} method - HTTP method ('GET' or 'POST') @@ -350,11 +356,11 @@ if (!proxyAgent) { * @param {number[]} successStatuses - Status codes that indicate the key is valid * @param {number[]} failStatuses - Status codes that indicate the key is invalid/rejected * @param {object} [opts={}] - Options - * @param {number} [opts.timeoutMs=10000] - Per-request timeout in milliseconds + * @param {number} [opts.timeoutMs] - Per-request timeout in ms (default: DEFAULT_VALIDATION_TIMEOUT_MS) * @returns {Promise<{result: 'success'|'failed'|'timeout'|'error', status?: number, duration_ms: number, error?: string}>} */ function validateKey(provider, target, path, method, body, headers, successStatuses, failStatuses, opts = {}) { - const timeoutMs = opts.timeoutMs || 10000; + const timeoutMs = opts.timeoutMs || DEFAULT_VALIDATION_TIMEOUT_MS; const startTime = Date.now(); return new Promise((resolve) => { @@ -444,7 +450,7 @@ async function validateApiKeys(overrides = {}) { const geminiTarget = overrides.geminiTarget !== undefined ? overrides.geminiTarget : GEMINI_API_TARGET; const geminiBasePath = overrides.geminiBasePath !== undefined ? overrides.geminiBasePath : GEMINI_API_BASE_PATH; const probeOpts = overrides.timeoutMs !== undefined ? { timeoutMs: overrides.timeoutMs } : {}; - const timeoutSecs = Math.round((probeOpts.timeoutMs || 10000) / 1000); + const timeoutSecs = Math.round((probeOpts.timeoutMs || DEFAULT_VALIDATION_TIMEOUT_MS) / 1000); const tasks = [];