Skip to content

Commit bb7bc60

Browse files
lpcoxCopilot
andcommitted
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>
1 parent c532baa commit bb7bc60

2 files changed

Lines changed: 324 additions & 2 deletions

File tree

containers/api-proxy/server.js

Lines changed: 239 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -884,6 +884,216 @@ 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+
/**
904+
* Perform a lightweight probe against the provider's API to check if the
905+
* configured key is still accepted. Results are logged and stored in
906+
* `keyValidationResults` — the health endpoint exposes them.
907+
*
908+
* Validation is **non-blocking by default**: the proxy still serves traffic
909+
* even if a key is rejected. Set AWF_VALIDATE_KEYS=strict to exit(1) on
910+
* any auth rejection.
911+
*
912+
* Only validates against known default targets. Custom/enterprise targets
913+
* are skipped because we don't know what probe endpoints they expose.
914+
*/
915+
async function validateApiKeys() {
916+
const mode = (process.env.AWF_VALIDATE_KEYS || 'warn').toLowerCase(); // off | warn | strict
917+
if (mode === 'off') {
918+
logRequest('info', 'key_validation', { message: 'Key validation disabled (AWF_VALIDATE_KEYS=off)' });
919+
keyValidationComplete = true;
920+
return;
921+
}
922+
923+
const TIMEOUT_MS = 10_000;
924+
const probes = [];
925+
926+
// --- Copilot (COPILOT_GITHUB_TOKEN only — COPILOT_API_KEY has no probe endpoint) ---
927+
if (COPILOT_GITHUB_TOKEN) {
928+
if (COPILOT_API_TARGET !== 'api.githubcopilot.com') {
929+
keyValidationResults.copilot = { status: 'skipped', message: `Custom target ${COPILOT_API_TARGET}; validation skipped` };
930+
logRequest('info', 'key_validation', { provider: 'copilot', ...keyValidationResults.copilot });
931+
} else {
932+
probes.push(probeProvider('copilot', `https://${COPILOT_API_TARGET}/models`, {
933+
method: 'GET',
934+
headers: {
935+
'Authorization': `Bearer ${COPILOT_GITHUB_TOKEN}`,
936+
'Copilot-Integration-Id': COPILOT_INTEGRATION_ID,
937+
},
938+
}, TIMEOUT_MS));
939+
}
940+
} else if (COPILOT_API_KEY && !COPILOT_GITHUB_TOKEN) {
941+
keyValidationResults.copilot = { status: 'skipped', message: 'COPILOT_API_KEY configured but startup validation is not supported for this auth mode' };
942+
logRequest('info', 'key_validation', { provider: 'copilot', ...keyValidationResults.copilot });
943+
}
944+
945+
// --- OpenAI ---
946+
if (OPENAI_API_KEY) {
947+
if (OPENAI_API_TARGET !== 'api.openai.com') {
948+
keyValidationResults.openai = { status: 'skipped', message: `Custom target ${OPENAI_API_TARGET}; validation skipped` };
949+
logRequest('info', 'key_validation', { provider: 'openai', ...keyValidationResults.openai });
950+
} else {
951+
probes.push(probeProvider('openai', `https://${OPENAI_API_TARGET}/v1/models`, {
952+
method: 'GET',
953+
headers: { 'Authorization': `Bearer ${OPENAI_API_KEY}` },
954+
}, TIMEOUT_MS));
955+
}
956+
}
957+
958+
// --- Anthropic ---
959+
if (ANTHROPIC_API_KEY) {
960+
if (ANTHROPIC_API_TARGET !== 'api.anthropic.com') {
961+
keyValidationResults.anthropic = { status: 'skipped', message: `Custom target ${ANTHROPIC_API_TARGET}; validation skipped` };
962+
logRequest('info', 'key_validation', { provider: 'anthropic', ...keyValidationResults.anthropic });
963+
} else {
964+
// POST /v1/messages with an empty body — 400 = key valid (bad body), 401 = key invalid
965+
probes.push(probeProvider('anthropic', `https://${ANTHROPIC_API_TARGET}/v1/messages`, {
966+
method: 'POST',
967+
headers: {
968+
'x-api-key': ANTHROPIC_API_KEY,
969+
'anthropic-version': '2023-06-01',
970+
'content-type': 'application/json',
971+
},
972+
body: '{}',
973+
}, TIMEOUT_MS));
974+
}
975+
}
976+
977+
// --- Gemini ---
978+
if (GEMINI_API_KEY) {
979+
if (GEMINI_API_TARGET !== 'generativelanguage.googleapis.com') {
980+
keyValidationResults.gemini = { status: 'skipped', message: `Custom target ${GEMINI_API_TARGET}; validation skipped` };
981+
logRequest('info', 'key_validation', { provider: 'gemini', ...keyValidationResults.gemini });
982+
} else {
983+
probes.push(probeProvider('gemini', `https://${GEMINI_API_TARGET}/v1beta/models`, {
984+
method: 'GET',
985+
headers: { 'x-goog-api-key': GEMINI_API_KEY },
986+
}, TIMEOUT_MS));
987+
}
988+
}
989+
990+
if (probes.length === 0) {
991+
logRequest('info', 'key_validation', { message: 'No providers to validate' });
992+
keyValidationComplete = true;
993+
return;
994+
}
995+
996+
await Promise.allSettled(probes);
997+
keyValidationComplete = true;
998+
999+
// Summarize
1000+
const failures = Object.entries(keyValidationResults)
1001+
.filter(([, r]) => r.status === 'auth_rejected');
1002+
1003+
if (failures.length > 0) {
1004+
for (const [provider, result] of failures) {
1005+
logRequest('error', 'key_validation_failed', {
1006+
provider,
1007+
message: `${provider.toUpperCase()} API key validation failed — ${result.message}. Rotate the secret and re-run.`,
1008+
});
1009+
}
1010+
if (mode === 'strict') {
1011+
logRequest('error', 'key_validation_strict_exit', {
1012+
message: `AWF_VALIDATE_KEYS=strict: exiting due to ${failures.length} auth failure(s)`,
1013+
providers: failures.map(([p]) => p),
1014+
});
1015+
process.exit(1);
1016+
}
1017+
} else {
1018+
logRequest('info', 'key_validation', { message: 'All configured API keys validated successfully' });
1019+
}
1020+
}
1021+
1022+
/**
1023+
* Probe a single provider to check if the API key is accepted.
1024+
*
1025+
* @param {string} provider - Provider name (copilot, openai, etc.)
1026+
* @param {string} url - Probe URL
1027+
* @param {{ method: string, headers: Record<string,string>, body?: string }} opts
1028+
* @param {number} timeoutMs
1029+
*/
1030+
async function probeProvider(provider, url, opts, timeoutMs) {
1031+
keyValidationResults[provider] = { status: 'pending', message: 'Validating...' };
1032+
try {
1033+
const status = await httpProbe(url, opts, timeoutMs);
1034+
1035+
if (status >= 200 && status < 300) {
1036+
keyValidationResults[provider] = { status: 'valid', message: `HTTP ${status}` };
1037+
logRequest('info', 'key_validation', { provider, status: 'valid', httpStatus: status });
1038+
} else if (status === 401 || status === 403) {
1039+
keyValidationResults[provider] = { status: 'auth_rejected', message: `HTTP ${status} — token expired or invalid` };
1040+
logRequest('warn', 'key_validation', { provider, status: 'auth_rejected', httpStatus: status });
1041+
} else if (status === 400) {
1042+
// 400 for Anthropic means key is valid but request body was bad — expected
1043+
keyValidationResults[provider] = { status: 'valid', message: `HTTP ${status} (auth accepted, probe body rejected)` };
1044+
logRequest('info', 'key_validation', { provider, status: 'valid', httpStatus: status, note: 'probe body rejected but auth accepted' });
1045+
} else {
1046+
keyValidationResults[provider] = { status: 'inconclusive', message: `HTTP ${status}` };
1047+
logRequest('warn', 'key_validation', { provider, status: 'inconclusive', httpStatus: status });
1048+
}
1049+
} catch (err) {
1050+
const message = err && err.message ? err.message : String(err);
1051+
keyValidationResults[provider] = { status: 'network_error', message };
1052+
logRequest('warn', 'key_validation', { provider, status: 'network_error', error: message });
1053+
}
1054+
}
1055+
1056+
/**
1057+
* Make an HTTPS request through the proxy and return the HTTP status code.
1058+
*
1059+
* @param {string} url
1060+
* @param {{ method: string, headers: Record<string,string>, body?: string }} opts
1061+
* @param {number} timeoutMs
1062+
* @returns {Promise<number>} HTTP status code
1063+
*/
1064+
function httpProbe(url, opts, timeoutMs) {
1065+
return new Promise((resolve, reject) => {
1066+
const parsed = new URL(url);
1067+
const isHttps = parsed.protocol === 'https:';
1068+
const mod = isHttps ? https : http;
1069+
const reqOpts = {
1070+
hostname: parsed.hostname,
1071+
port: parsed.port || (isHttps ? 443 : 80),
1072+
path: parsed.pathname + parsed.search,
1073+
method: opts.method,
1074+
headers: { ...opts.headers },
1075+
...(isHttps && proxyAgent ? { agent: proxyAgent } : {}),
1076+
timeout: timeoutMs,
1077+
};
1078+
1079+
const req = mod.request(reqOpts, (res) => {
1080+
// Consume body to free the socket
1081+
res.resume();
1082+
res.on('end', () => resolve(res.statusCode));
1083+
});
1084+
1085+
req.on('timeout', () => {
1086+
req.destroy(new Error(`Probe timed out after ${timeoutMs}ms`));
1087+
});
1088+
req.on('error', reject);
1089+
1090+
if (opts.body) {
1091+
req.write(opts.body);
1092+
}
1093+
req.end();
1094+
});
1095+
}
1096+
8871097
function healthResponse() {
8881098
return {
8891099
status: 'healthy',
@@ -895,6 +1105,10 @@ function healthResponse() {
8951105
gemini: !!GEMINI_API_KEY,
8961106
copilot: !!COPILOT_AUTH_TOKEN,
8971107
},
1108+
key_validation: {
1109+
complete: keyValidationComplete,
1110+
results: keyValidationResults,
1111+
},
8981112
metrics_summary: metrics.getSummary(),
8991113
rate_limits: limiter.getAllStatus(),
9001114
};
@@ -923,6 +1137,24 @@ if (require.main === module) {
9231137
// Health port is always 10000 — this is what Docker healthcheck hits
9241138
const HEALTH_PORT = 10000;
9251139

1140+
// Startup latch: count expected listeners, run validation when all are ready
1141+
let expectedListeners = 1; // port 10000 (always)
1142+
if (ANTHROPIC_API_KEY) expectedListeners++;
1143+
if (COPILOT_AUTH_TOKEN) expectedListeners++;
1144+
if (GEMINI_API_KEY) expectedListeners++;
1145+
if (OPENAI_API_KEY || ANTHROPIC_API_KEY || COPILOT_AUTH_TOKEN) expectedListeners++; // OpenCode (10004)
1146+
let readyListeners = 0;
1147+
function onListenerReady() {
1148+
readyListeners++;
1149+
if (readyListeners === expectedListeners) {
1150+
logRequest('info', 'startup_complete', { message: `All ${expectedListeners} listeners ready, starting key validation` });
1151+
validateApiKeys().catch((err) => {
1152+
logRequest('error', 'key_validation_error', { message: 'Unexpected error during key validation', error: String(err) });
1153+
keyValidationComplete = true;
1154+
});
1155+
}
1156+
}
1157+
9261158
// OpenAI API proxy (port 10000)
9271159
if (OPENAI_API_KEY) {
9281160
const server = http.createServer((req, res) => {
@@ -943,6 +1175,7 @@ if (require.main === module) {
9431175

9441176
server.listen(HEALTH_PORT, '0.0.0.0', () => {
9451177
logRequest('info', 'server_start', { message: `OpenAI proxy listening on port ${HEALTH_PORT}`, target: OPENAI_API_TARGET });
1178+
onListenerReady();
9461179
});
9471180
} else {
9481181
// No OpenAI key — still need a health endpoint on port 10000 for Docker healthcheck
@@ -960,6 +1193,7 @@ if (require.main === module) {
9601193

9611194
server.listen(HEALTH_PORT, '0.0.0.0', () => {
9621195
logRequest('info', 'server_start', { message: `Health endpoint listening on port ${HEALTH_PORT} (OpenAI not configured)` });
1196+
onListenerReady();
9631197
});
9641198
}
9651199

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

9941228
server.listen(10001, '0.0.0.0', () => {
9951229
logRequest('info', 'server_start', { message: 'Anthropic proxy listening on port 10001', target: ANTHROPIC_API_TARGET });
1230+
onListenerReady();
9961231
});
9971232
}
9981233

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

10541289
copilotServer.listen(10002, '0.0.0.0', () => {
10551290
logRequest('info', 'server_start', { message: 'GitHub Copilot proxy listening on port 10002' });
1291+
onListenerReady();
10561292
});
10571293
}
10581294

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

10881324
geminiServer.listen(10003, '0.0.0.0', () => {
10891325
logRequest('info', 'server_start', { message: 'Google Gemini proxy listening on port 10003', target: GEMINI_API_TARGET });
1326+
onListenerReady();
10901327
});
10911328
} else {
10921329
// No Gemini key — listen on port 10003 and return 503 so the Gemini CLI
@@ -1195,6 +1432,7 @@ if (require.main === module) {
11951432

11961433
opencodeServer.listen(10004, '0.0.0.0', () => {
11971434
logRequest('info', 'server_start', { message: `OpenCode proxy listening on port 10004 (-> ${opencodeStartupRoute.target})` });
1435+
onListenerReady();
11981436
});
11991437
}
12001438

@@ -1213,4 +1451,4 @@ if (require.main === module) {
12131451
}
12141452

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

0 commit comments

Comments
 (0)