Skip to content

Commit c7d506a

Browse files
lpcoxCopilotCopilot
authored
feat(api-proxy): add startup API key validation (#2200)
* feat(api-proxy): add startup API key validation Add a validateApiKeys() function that probes each configured provider's API at startup to detect expired or invalid credentials before the agent starts making requests. This directly addresses issue #2185 where an expired COPILOT_GITHUB_TOKEN caused cryptic 401 errors deep in agent logs with no clear guidance on the fix. Key design: - Validates Copilot (GET /models), OpenAI (GET /v1/models), Anthropic (POST /v1/messages with anthropic-version header), and Gemini (GET /v1beta/models) tokens - Runs after all listeners are ready via a startup latch - Results exposed in /health endpoint (key_validation field) - Non-blocking by default (AWF_VALIDATE_KEYS=warn) — logs clear error messages but doesn't prevent startup - AWF_VALIDATE_KEYS=strict exits with code 1 on auth rejection - AWF_VALIDATE_KEYS=off disables validation entirely - Skips validation for custom API targets (non-default endpoints) - Skips COPILOT_API_KEY-only setups (no probe endpoint available) - Classifies errors as auth_rejected vs network_error vs inconclusive - Routes probe requests through Squid proxy (respects domain allowlist) - 10s timeout per probe, all probes run in parallel Closes #2185 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(api-proxy): add validateApiKeys orchestration tests Port and adapt 18 tests from PR #2199 (copilot-swe-agent) covering the validateApiKeys orchestrator for all four providers: - OpenAI: valid (200), auth_rejected (401), skipped (custom target), no-op (no key) - Anthropic: valid (400 = key accepted), auth_rejected (401, 403), skipped (custom target) - Copilot: valid (200 with ghu_ token), auth_rejected (401), skipped (custom target, BYOK mode) - Gemini: valid (200), auth_rejected (403), skipped (custom target) - Cross-cutting: network_error (timeout), no-op (no keys at all) To make validateApiKeys testable without module-level state: - Added overrides parameter for injecting keys/targets in tests - Exported keyValidationResults and resetKeyValidationState() - Used 'in' operator for override resolution (supports explicit undefined) Co-authored-by: copilot-swe-agent[bot] <198982749+copilot-swe-agent[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(api-proxy): address review feedback on key validation - httpProbe: add settle-once guard with resolveOnce/rejectOnce to prevent hanging if response stream errors or socket closes early; also handle res 'error' and 'close' events - Startup latch: clarify comment that only validation-participating listeners are counted (no-key Gemini 503 handler excluded) - Test: replace hard-coded port 19999 with dynamic port allocation to prevent flakiness when something listens on that port Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+copilot-swe-agent[bot]@users.noreply.github.com>
1 parent c532baa commit c7d506a

2 files changed

Lines changed: 619 additions & 2 deletions

File tree

containers/api-proxy/server.js

Lines changed: 290 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -884,6 +884,265 @@ function proxyWebSocket(req, socket, head, targetHost, injectHeaders, provider,
884884
/**
885885
* Build the enhanced health response (superset of original format).
886886
*/
887+
// ---------------------------------------------------------------------------
888+
// Startup key validation
889+
// ---------------------------------------------------------------------------
890+
891+
/**
892+
* Validation result for a single provider's API key.
893+
* @typedef {'pending'|'valid'|'auth_rejected'|'network_error'|'inconclusive'|'skipped'} ValidationStatus
894+
* @typedef {{ status: ValidationStatus, message: string }} ValidationResult
895+
*/
896+
897+
/** @type {Record<string, ValidationResult>} */
898+
const keyValidationResults = {};
899+
900+
/** Set to true once validateApiKeys() has finished (regardless of outcome). */
901+
let keyValidationComplete = false;
902+
903+
/** Reset validation state (used in tests). */
904+
function resetKeyValidationState() {
905+
for (const key of Object.keys(keyValidationResults)) {
906+
delete keyValidationResults[key];
907+
}
908+
keyValidationComplete = false;
909+
}
910+
911+
/**
912+
* Perform a lightweight probe against the provider's API to check if the
913+
* configured key is still accepted. Results are logged and stored in
914+
* `keyValidationResults` — the health endpoint exposes them.
915+
*
916+
* Validation is **non-blocking by default**: the proxy still serves traffic
917+
* even if a key is rejected. Set AWF_VALIDATE_KEYS=strict to exit(1) on
918+
* any auth rejection.
919+
*
920+
* Only validates against known default targets. Custom/enterprise targets
921+
* are skipped because we don't know what probe endpoints they expose.
922+
*
923+
* @param {object} [overrides={}] - Optional key/target overrides (used in tests)
924+
* @param {string} [overrides.openaiKey] - Override OPENAI_API_KEY
925+
* @param {string} [overrides.openaiTarget] - Override OPENAI_API_TARGET
926+
* @param {string} [overrides.anthropicKey] - Override ANTHROPIC_API_KEY
927+
* @param {string} [overrides.anthropicTarget] - Override ANTHROPIC_API_TARGET
928+
* @param {string} [overrides.copilotGithubToken] - Override COPILOT_GITHUB_TOKEN
929+
* @param {string} [overrides.copilotApiKey] - Override COPILOT_API_KEY
930+
* @param {string} [overrides.copilotAuthToken] - Override COPILOT_AUTH_TOKEN
931+
* @param {string} [overrides.copilotTarget] - Override COPILOT_API_TARGET
932+
* @param {string} [overrides.copilotIntegrationId] - Override COPILOT_INTEGRATION_ID
933+
* @param {string} [overrides.geminiKey] - Override GEMINI_API_KEY
934+
* @param {string} [overrides.geminiTarget] - Override GEMINI_API_TARGET
935+
* @param {number} [overrides.timeoutMs] - Override probe timeout
936+
*/
937+
async function validateApiKeys(overrides = {}) {
938+
const mode = (process.env.AWF_VALIDATE_KEYS || 'warn').toLowerCase(); // off | warn | strict
939+
if (mode === 'off') {
940+
logRequest('info', 'key_validation', { message: 'Key validation disabled (AWF_VALIDATE_KEYS=off)' });
941+
keyValidationComplete = true;
942+
return;
943+
}
944+
945+
const ov = (key, fallback) => key in overrides ? overrides[key] : fallback;
946+
const openaiKey = ov('openaiKey', OPENAI_API_KEY);
947+
const openaiTarget = ov('openaiTarget', OPENAI_API_TARGET);
948+
const anthropicKey = ov('anthropicKey', ANTHROPIC_API_KEY);
949+
const anthropicTarget = ov('anthropicTarget', ANTHROPIC_API_TARGET);
950+
const copilotGithubToken = ov('copilotGithubToken', COPILOT_GITHUB_TOKEN);
951+
const copilotApiKey = ov('copilotApiKey', COPILOT_API_KEY);
952+
const copilotAuthToken = ov('copilotAuthToken', COPILOT_AUTH_TOKEN);
953+
const copilotTarget = ov('copilotTarget', COPILOT_API_TARGET);
954+
const copilotIntegrationId = ov('copilotIntegrationId', COPILOT_INTEGRATION_ID);
955+
const geminiKey = ov('geminiKey', GEMINI_API_KEY);
956+
const geminiTarget = ov('geminiTarget', GEMINI_API_TARGET);
957+
const TIMEOUT_MS = ov('timeoutMs', 10_000);
958+
959+
const probes = [];
960+
961+
// --- Copilot (COPILOT_GITHUB_TOKEN only — COPILOT_API_KEY has no probe endpoint) ---
962+
if (copilotGithubToken) {
963+
if (copilotTarget !== 'api.githubcopilot.com') {
964+
keyValidationResults.copilot = { status: 'skipped', message: `Custom target ${copilotTarget}; validation skipped` };
965+
logRequest('info', 'key_validation', { provider: 'copilot', ...keyValidationResults.copilot });
966+
} else {
967+
probes.push(probeProvider('copilot', `https://${copilotTarget}/models`, {
968+
method: 'GET',
969+
headers: {
970+
'Authorization': `Bearer ${copilotGithubToken}`,
971+
'Copilot-Integration-Id': copilotIntegrationId,
972+
},
973+
}, TIMEOUT_MS));
974+
}
975+
} else if (copilotApiKey && !copilotGithubToken) {
976+
keyValidationResults.copilot = { status: 'skipped', message: 'COPILOT_API_KEY configured but startup validation is not supported for this auth mode' };
977+
logRequest('info', 'key_validation', { provider: 'copilot', ...keyValidationResults.copilot });
978+
}
979+
980+
// --- OpenAI ---
981+
if (openaiKey) {
982+
if (openaiTarget !== 'api.openai.com') {
983+
keyValidationResults.openai = { status: 'skipped', message: `Custom target ${openaiTarget}; validation skipped` };
984+
logRequest('info', 'key_validation', { provider: 'openai', ...keyValidationResults.openai });
985+
} else {
986+
probes.push(probeProvider('openai', `https://${openaiTarget}/v1/models`, {
987+
method: 'GET',
988+
headers: { 'Authorization': `Bearer ${openaiKey}` },
989+
}, TIMEOUT_MS));
990+
}
991+
}
992+
993+
// --- Anthropic ---
994+
if (anthropicKey) {
995+
if (anthropicTarget !== 'api.anthropic.com') {
996+
keyValidationResults.anthropic = { status: 'skipped', message: `Custom target ${anthropicTarget}; validation skipped` };
997+
logRequest('info', 'key_validation', { provider: 'anthropic', ...keyValidationResults.anthropic });
998+
} else {
999+
// POST /v1/messages with an empty body — 400 = key valid (bad body), 401 = key invalid
1000+
probes.push(probeProvider('anthropic', `https://${anthropicTarget}/v1/messages`, {
1001+
method: 'POST',
1002+
headers: {
1003+
'x-api-key': anthropicKey,
1004+
'anthropic-version': '2023-06-01',
1005+
'content-type': 'application/json',
1006+
},
1007+
body: '{}',
1008+
}, TIMEOUT_MS));
1009+
}
1010+
}
1011+
1012+
// --- Gemini ---
1013+
if (geminiKey) {
1014+
if (geminiTarget !== 'generativelanguage.googleapis.com') {
1015+
keyValidationResults.gemini = { status: 'skipped', message: `Custom target ${geminiTarget}; validation skipped` };
1016+
logRequest('info', 'key_validation', { provider: 'gemini', ...keyValidationResults.gemini });
1017+
} else {
1018+
probes.push(probeProvider('gemini', `https://${geminiTarget}/v1beta/models`, {
1019+
method: 'GET',
1020+
headers: { 'x-goog-api-key': geminiKey },
1021+
}, TIMEOUT_MS));
1022+
}
1023+
}
1024+
1025+
if (probes.length === 0) {
1026+
logRequest('info', 'key_validation', { message: 'No providers to validate' });
1027+
keyValidationComplete = true;
1028+
return;
1029+
}
1030+
1031+
await Promise.allSettled(probes);
1032+
keyValidationComplete = true;
1033+
1034+
// Summarize
1035+
const failures = Object.entries(keyValidationResults)
1036+
.filter(([, r]) => r.status === 'auth_rejected');
1037+
1038+
if (failures.length > 0) {
1039+
for (const [provider, result] of failures) {
1040+
logRequest('error', 'key_validation_failed', {
1041+
provider,
1042+
message: `${provider.toUpperCase()} API key validation failed — ${result.message}. Rotate the secret and re-run.`,
1043+
});
1044+
}
1045+
if (mode === 'strict') {
1046+
logRequest('error', 'key_validation_strict_exit', {
1047+
message: `AWF_VALIDATE_KEYS=strict: exiting due to ${failures.length} auth failure(s)`,
1048+
providers: failures.map(([p]) => p),
1049+
});
1050+
process.exit(1);
1051+
}
1052+
} else {
1053+
logRequest('info', 'key_validation', { message: 'All configured API keys validated successfully' });
1054+
}
1055+
}
1056+
1057+
/**
1058+
* Probe a single provider to check if the API key is accepted.
1059+
*
1060+
* @param {string} provider - Provider name (copilot, openai, etc.)
1061+
* @param {string} url - Probe URL
1062+
* @param {{ method: string, headers: Record<string,string>, body?: string }} opts
1063+
* @param {number} timeoutMs
1064+
*/
1065+
async function probeProvider(provider, url, opts, timeoutMs) {
1066+
keyValidationResults[provider] = { status: 'pending', message: 'Validating...' };
1067+
try {
1068+
const status = await httpProbe(url, opts, timeoutMs);
1069+
1070+
if (status >= 200 && status < 300) {
1071+
keyValidationResults[provider] = { status: 'valid', message: `HTTP ${status}` };
1072+
logRequest('info', 'key_validation', { provider, status: 'valid', httpStatus: status });
1073+
} else if (status === 401 || status === 403) {
1074+
keyValidationResults[provider] = { status: 'auth_rejected', message: `HTTP ${status} — token expired or invalid` };
1075+
logRequest('warn', 'key_validation', { provider, status: 'auth_rejected', httpStatus: status });
1076+
} else if (status === 400) {
1077+
// 400 for Anthropic means key is valid but request body was bad — expected
1078+
keyValidationResults[provider] = { status: 'valid', message: `HTTP ${status} (auth accepted, probe body rejected)` };
1079+
logRequest('info', 'key_validation', { provider, status: 'valid', httpStatus: status, note: 'probe body rejected but auth accepted' });
1080+
} else {
1081+
keyValidationResults[provider] = { status: 'inconclusive', message: `HTTP ${status}` };
1082+
logRequest('warn', 'key_validation', { provider, status: 'inconclusive', httpStatus: status });
1083+
}
1084+
} catch (err) {
1085+
const message = err && err.message ? err.message : String(err);
1086+
keyValidationResults[provider] = { status: 'network_error', message };
1087+
logRequest('warn', 'key_validation', { provider, status: 'network_error', error: message });
1088+
}
1089+
}
1090+
1091+
/**
1092+
* Make an HTTPS request through the proxy and return the HTTP status code.
1093+
*
1094+
* @param {string} url
1095+
* @param {{ method: string, headers: Record<string,string>, body?: string }} opts
1096+
* @param {number} timeoutMs
1097+
* @returns {Promise<number>} HTTP status code
1098+
*/
1099+
function httpProbe(url, opts, timeoutMs) {
1100+
return new Promise((resolve, reject) => {
1101+
const parsed = new URL(url);
1102+
const isHttps = parsed.protocol === 'https:';
1103+
const mod = isHttps ? https : http;
1104+
const reqOpts = {
1105+
hostname: parsed.hostname,
1106+
port: parsed.port || (isHttps ? 443 : 80),
1107+
path: parsed.pathname + parsed.search,
1108+
method: opts.method,
1109+
headers: { ...opts.headers },
1110+
...(isHttps && proxyAgent ? { agent: proxyAgent } : {}),
1111+
timeout: timeoutMs,
1112+
};
1113+
1114+
let settled = false;
1115+
const resolveOnce = (statusCode) => {
1116+
if (settled) return;
1117+
settled = true;
1118+
resolve(statusCode);
1119+
};
1120+
const rejectOnce = (err) => {
1121+
if (settled) return;
1122+
settled = true;
1123+
reject(err);
1124+
};
1125+
1126+
const req = mod.request(reqOpts, (res) => {
1127+
// Consume body to free the socket
1128+
res.resume();
1129+
res.on('end', () => resolveOnce(res.statusCode));
1130+
res.on('error', rejectOnce);
1131+
res.on('close', () => resolveOnce(res.statusCode));
1132+
});
1133+
1134+
req.on('timeout', () => {
1135+
req.destroy(new Error(`Probe timed out after ${timeoutMs}ms`));
1136+
});
1137+
req.on('error', rejectOnce);
1138+
1139+
if (opts.body) {
1140+
req.write(opts.body);
1141+
}
1142+
req.end();
1143+
});
1144+
}
1145+
8871146
function healthResponse() {
8881147
return {
8891148
status: 'healthy',
@@ -895,6 +1154,10 @@ function healthResponse() {
8951154
gemini: !!GEMINI_API_KEY,
8961155
copilot: !!COPILOT_AUTH_TOKEN,
8971156
},
1157+
key_validation: {
1158+
complete: keyValidationComplete,
1159+
results: keyValidationResults,
1160+
},
8981161
metrics_summary: metrics.getSummary(),
8991162
rate_limits: limiter.getAllStatus(),
9001163
};
@@ -923,6 +1186,26 @@ if (require.main === module) {
9231186
// Health port is always 10000 — this is what Docker healthcheck hits
9241187
const HEALTH_PORT = 10000;
9251188

1189+
// Startup latch: count listeners that participate in key validation.
1190+
// The no-key Gemini 503 handler binds port 10003 but doesn't participate
1191+
// in validation, so it's intentionally excluded from the count.
1192+
let expectedListeners = 1; // port 10000 (always)
1193+
if (ANTHROPIC_API_KEY) expectedListeners++;
1194+
if (COPILOT_AUTH_TOKEN) expectedListeners++;
1195+
if (GEMINI_API_KEY) expectedListeners++;
1196+
if (OPENAI_API_KEY || ANTHROPIC_API_KEY || COPILOT_AUTH_TOKEN) expectedListeners++; // OpenCode (10004)
1197+
let readyListeners = 0;
1198+
function onListenerReady() {
1199+
readyListeners++;
1200+
if (readyListeners === expectedListeners) {
1201+
logRequest('info', 'startup_complete', { message: `All ${expectedListeners} validation-participating listeners ready, starting key validation` });
1202+
validateApiKeys().catch((err) => {
1203+
logRequest('error', 'key_validation_error', { message: 'Unexpected error during key validation', error: String(err) });
1204+
keyValidationComplete = true;
1205+
});
1206+
}
1207+
}
1208+
9261209
// OpenAI API proxy (port 10000)
9271210
if (OPENAI_API_KEY) {
9281211
const server = http.createServer((req, res) => {
@@ -943,6 +1226,7 @@ if (require.main === module) {
9431226

9441227
server.listen(HEALTH_PORT, '0.0.0.0', () => {
9451228
logRequest('info', 'server_start', { message: `OpenAI proxy listening on port ${HEALTH_PORT}`, target: OPENAI_API_TARGET });
1229+
onListenerReady();
9461230
});
9471231
} else {
9481232
// No OpenAI key — still need a health endpoint on port 10000 for Docker healthcheck
@@ -960,6 +1244,7 @@ if (require.main === module) {
9601244

9611245
server.listen(HEALTH_PORT, '0.0.0.0', () => {
9621246
logRequest('info', 'server_start', { message: `Health endpoint listening on port ${HEALTH_PORT} (OpenAI not configured)` });
1247+
onListenerReady();
9631248
});
9641249
}
9651250

@@ -993,6 +1278,7 @@ if (require.main === module) {
9931278

9941279
server.listen(10001, '0.0.0.0', () => {
9951280
logRequest('info', 'server_start', { message: 'Anthropic proxy listening on port 10001', target: ANTHROPIC_API_TARGET });
1281+
onListenerReady();
9961282
});
9971283
}
9981284

@@ -1053,6 +1339,7 @@ if (require.main === module) {
10531339

10541340
copilotServer.listen(10002, '0.0.0.0', () => {
10551341
logRequest('info', 'server_start', { message: 'GitHub Copilot proxy listening on port 10002' });
1342+
onListenerReady();
10561343
});
10571344
}
10581345

@@ -1087,6 +1374,7 @@ if (require.main === module) {
10871374

10881375
geminiServer.listen(10003, '0.0.0.0', () => {
10891376
logRequest('info', 'server_start', { message: 'Google Gemini proxy listening on port 10003', target: GEMINI_API_TARGET });
1377+
onListenerReady();
10901378
});
10911379
} else {
10921380
// No Gemini key — listen on port 10003 and return 503 so the Gemini CLI
@@ -1195,6 +1483,7 @@ if (require.main === module) {
11951483

11961484
opencodeServer.listen(10004, '0.0.0.0', () => {
11971485
logRequest('info', 'server_start', { message: `OpenCode proxy listening on port 10004 (-> ${opencodeStartupRoute.target})` });
1486+
onListenerReady();
11981487
});
11991488
}
12001489

@@ -1213,4 +1502,4 @@ if (require.main === module) {
12131502
}
12141503

12151504
// Export for testing
1216-
module.exports = { normalizeApiTarget, deriveCopilotApiTarget, deriveGitHubApiTarget, deriveGitHubApiBasePath, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken, resolveOpenCodeRoute, shouldStripHeader, stripGeminiKeyParam };
1505+
module.exports = { normalizeApiTarget, deriveCopilotApiTarget, deriveGitHubApiTarget, deriveGitHubApiBasePath, normalizeBasePath, buildUpstreamPath, proxyWebSocket, resolveCopilotAuthToken, resolveOpenCodeRoute, shouldStripHeader, stripGeminiKeyParam, validateApiKeys, probeProvider, httpProbe, keyValidationResults, resetKeyValidationState };

0 commit comments

Comments
 (0)