@yeaft/webchat-agent 0.1.839 → 0.1.840

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.839",
3
+ "version": "0.1.840",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -0,0 +1,521 @@
1
+ /**
2
+ * github-copilot.js — GitHub Copilot credential provider.
3
+ *
4
+ * Resolves a GitHub OAuth token usable with the Copilot API and exchanges
5
+ * it for the short-lived Copilot API token. Ported from hermes' Python
6
+ * `copilot_auth.py` (https://github.com/hermes-agent), same client id and
7
+ * the same VS Code / Copilot CLI headers so a working `gh auth login` on
8
+ * the host machine is enough — the user does NOT need to paste an API key.
9
+ *
10
+ * Resolution order (matches hermes / Copilot CLI):
11
+ * 1. env COPILOT_GITHUB_TOKEN
12
+ * 2. env GH_TOKEN
13
+ * 3. env GITHUB_TOKEN
14
+ * 4. disk ~/.yeaft/credentials/github-copilot.json (persisted device flow)
15
+ * 5. shell `gh auth token` (with GITHUB_TOKEN/GH_TOKEN stripped so gh
16
+ * reads its own credential store instead of echoing env back)
17
+ *
18
+ * Token kinds:
19
+ * gho_* OAuth ✓
20
+ * github_pat_* Fine-grained PAT ✓ (needs Copilot Requests perm)
21
+ * ghu_* GitHub App token ✓
22
+ * ghp_* Classic PAT ✗ Copilot API rejects these.
23
+ *
24
+ * The exchanged Copilot API token is a short-lived (~30min) string used
25
+ * as `Authorization: Bearer <token>`. We cache it in-process keyed by a
26
+ * sha256 fingerprint of the raw token (so different raw tokens don't
27
+ * collide) and refresh 120s before expiry.
28
+ *
29
+ * NOTE: This module makes NO network calls at import time. All I/O is
30
+ * lazy. Tests can stub `fetch` and `child_process.execFile` to exercise
31
+ * every branch without touching real GitHub.
32
+ */
33
+
34
+ import { execFile } from 'child_process';
35
+ import { promisify } from 'util';
36
+ import { readFile, writeFile, mkdir, stat, chmod } from 'fs/promises';
37
+ import { createHash } from 'crypto';
38
+ import { homedir } from 'os';
39
+ import { join, dirname } from 'path';
40
+
41
+ const execFileAsync = promisify(execFile);
42
+
43
+ // Same client_id used by Copilot CLI / opencode / hermes. Public — fine to ship.
44
+ export const COPILOT_OAUTH_CLIENT_ID = 'Ov23li8tweQw6odWQebz';
45
+
46
+ const CLASSIC_PAT_PREFIX = 'ghp_';
47
+ const SUPPORTED_PREFIXES = ['gho_', 'github_pat_', 'ghu_'];
48
+ const ENV_VARS = ['COPILOT_GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_TOKEN'];
49
+
50
+ const TOKEN_EXCHANGE_URL = 'https://api.github.com/copilot_internal/v2/token';
51
+ const DEVICE_CODE_URL_TEMPLATE = host => `https://${host}/login/device/code`;
52
+ const ACCESS_TOKEN_URL_TEMPLATE = host => `https://${host}/login/oauth/access_token`;
53
+
54
+ const EDITOR_VERSION = 'vscode/1.104.1';
55
+ const EXCHANGE_USER_AGENT = 'GitHubCopilotChat/0.26.7';
56
+ const REQUEST_USER_AGENT = 'claude-web-chat/1.0';
57
+
58
+ const JWT_REFRESH_MARGIN_SECONDS = 120;
59
+ const DEVICE_POLL_SAFETY_MARGIN_SECONDS = 3;
60
+ const DEFAULT_DEVICE_TIMEOUT_SECONDS = 300;
61
+
62
+ const CREDENTIALS_DIR = join(homedir(), '.yeaft', 'credentials');
63
+ const CREDENTIALS_FILE = join(CREDENTIALS_DIR, 'github-copilot.json');
64
+
65
+ // In-process cache: rawTokenFingerprint → { apiToken, expiresAt }.
66
+ // Keyed by fingerprint (not the token itself) so we never keep the secret
67
+ // in two places. Reset via _resetCacheForTests.
68
+ const _jwtCache = new Map();
69
+
70
+ // In-flight exchange promises keyed by fingerprint. Dedupes concurrent
71
+ // `exchangeToken` calls for the same raw token so we make ONE network call
72
+ // instead of N when several requests race during a cold start or a refresh.
73
+ const _exchangeInFlight = new Map();
74
+
75
+ /**
76
+ * Reset all in-process state. Tests only.
77
+ */
78
+ export function _resetCacheForTests() {
79
+ _jwtCache.clear();
80
+ _exchangeInFlight.clear();
81
+ }
82
+
83
+ /**
84
+ * Anchor for `gh auth token` output validation. gh prints either a bare
85
+ * token followed by a newline or, on misconfiguration, a help banner /
86
+ * error text. We only accept output that looks like a GitHub token, so
87
+ * the help banner doesn't get treated as a credential.
88
+ */
89
+ const GH_TOKEN_SHAPE = /^(gho_|github_pat_|ghu_|ghs_)[A-Za-z0-9_]{20,}$/;
90
+
91
+ /**
92
+ * Validate a raw GitHub token shape. Copilot API rejects classic PATs.
93
+ *
94
+ * @param {string} token
95
+ * @returns {{valid: boolean, message: string}}
96
+ */
97
+ export function validateRawToken(token) {
98
+ const trimmed = (token || '').trim();
99
+ if (!trimmed) return { valid: false, message: 'Empty token' };
100
+ if (trimmed.startsWith(CLASSIC_PAT_PREFIX)) {
101
+ return {
102
+ valid: false,
103
+ message:
104
+ 'Classic Personal Access Tokens (ghp_*) are not supported by the ' +
105
+ 'Copilot API. Use `gh auth login` (produces gho_*) or a fine-grained ' +
106
+ 'PAT with the Copilot Requests permission.',
107
+ };
108
+ }
109
+ // Accept supported prefixes OR anything else that's non-empty — GitHub may
110
+ // introduce new prefixes; we surface server-side rejection later rather
111
+ // than refusing here.
112
+ const supported = SUPPORTED_PREFIXES.some(p => trimmed.startsWith(p));
113
+ if (!supported) {
114
+ return { valid: true, message: 'Unknown prefix, will try anyway' };
115
+ }
116
+ return { valid: true, message: 'OK' };
117
+ }
118
+
119
+ /**
120
+ * Read the persisted device-flow token, if any. Returns null on missing /
121
+ * malformed file. Does NOT throw — callers fall through to the next source.
122
+ *
123
+ * @returns {Promise<{token: string, source: string, obtainedAt: number} | null>}
124
+ */
125
+ export async function readPersistedToken() {
126
+ try {
127
+ const buf = await readFile(CREDENTIALS_FILE, 'utf8');
128
+ const obj = JSON.parse(buf);
129
+ if (obj && typeof obj.token === 'string' && obj.token) return obj;
130
+ return null;
131
+ } catch {
132
+ return null;
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Persist a device-flow token. File mode is 0600 so other users on the
138
+ * machine can't read it. Directory created with 0700.
139
+ *
140
+ * @param {{token: string, source: string}} entry
141
+ */
142
+ export async function writePersistedToken({ token, source }) {
143
+ await mkdir(CREDENTIALS_DIR, { recursive: true, mode: 0o700 });
144
+ const payload = JSON.stringify({ token, source, obtainedAt: Date.now() }, null, 2);
145
+ await writeFile(CREDENTIALS_FILE, payload, { mode: 0o600 });
146
+ // mkdir's `mode` is masked by umask on some systems; ensure dir perms too.
147
+ try {
148
+ const s = await stat(CREDENTIALS_DIR);
149
+ if ((s.mode & 0o077) !== 0) {
150
+ await chmod(CREDENTIALS_DIR, 0o700);
151
+ }
152
+ } catch { /* best effort */ }
153
+ }
154
+
155
+ /**
156
+ * Run `gh auth token`. Strips GITHUB_TOKEN/GH_TOKEN from the subprocess
157
+ * env so gh reads `hosts.yml` instead of echoing the env back at us.
158
+ *
159
+ * @param {object} [opts]
160
+ * @param {string} [opts.hostname] — pass --hostname to gh
161
+ * @returns {Promise<string | null>}
162
+ */
163
+ export async function tryGhCliToken({ hostname } = {}) {
164
+ const args = ['auth', 'token'];
165
+ if (hostname) args.push('--hostname', hostname);
166
+
167
+ const env = { ...process.env };
168
+ delete env.GITHUB_TOKEN;
169
+ delete env.GH_TOKEN;
170
+
171
+ // Don't bother probing every Homebrew path — `gh` on PATH covers 99% of
172
+ // installs. If the user's `gh` isn't on PATH the env vars or device flow
173
+ // are the fallback.
174
+ try {
175
+ const { stdout } = await execFileAsync('gh', args, {
176
+ timeout: 5000,
177
+ env,
178
+ windowsHide: true,
179
+ });
180
+ const trimmed = (stdout || '').trim();
181
+ if (!trimmed) return null;
182
+ // Guard against gh printing a help banner / error text when not logged
183
+ // in. We only trust output that matches the GitHub token shape.
184
+ if (!GH_TOKEN_SHAPE.test(trimmed)) return null;
185
+ return trimmed;
186
+ } catch {
187
+ return null;
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Resolve a raw GitHub token from env / disk / gh CLI. Returns
193
+ * `{token, source}` or `null` if no usable token is available.
194
+ *
195
+ * Sources are tried in priority order; the first valid one wins. Classic
196
+ * PATs (ghp_*) found in env are SKIPPED (they don't work with Copilot)
197
+ * rather than returned as an error — the next source still gets a chance.
198
+ *
199
+ * @param {object} [opts]
200
+ * @param {string} [opts.hostname] — passed through to gh CLI
201
+ * @returns {Promise<{token: string, source: string} | null>}
202
+ */
203
+ export async function resolveRawToken({ hostname } = {}) {
204
+ // 1-3: env vars
205
+ for (const name of ENV_VARS) {
206
+ const val = (process.env[name] || '').trim();
207
+ if (!val) continue;
208
+ if (validateRawToken(val).valid) {
209
+ return { token: val, source: `env:${name}` };
210
+ }
211
+ }
212
+
213
+ // 4: persisted device-flow token
214
+ const persisted = await readPersistedToken();
215
+ if (persisted && validateRawToken(persisted.token).valid) {
216
+ return { token: persisted.token, source: persisted.source || 'persisted' };
217
+ }
218
+
219
+ // 5: gh CLI
220
+ const ghToken = await tryGhCliToken({ hostname });
221
+ if (ghToken && validateRawToken(ghToken).valid) {
222
+ return { token: ghToken, source: 'gh-cli' };
223
+ }
224
+
225
+ return null;
226
+ }
227
+
228
+ /**
229
+ * sha256-prefix fingerprint of a raw token. Never log the token itself.
230
+ */
231
+ function tokenFingerprint(rawToken) {
232
+ return createHash('sha256').update(rawToken, 'utf8').digest('hex').slice(0, 16);
233
+ }
234
+
235
+ /**
236
+ * Exchange a raw GitHub token for a short-lived Copilot API token. Caches
237
+ * in-process; refreshes 120s before expiry. On failure, throws — caller
238
+ * may choose to fall back to the raw token (see `getApiToken`).
239
+ *
240
+ * @param {string} rawToken
241
+ * @param {object} [opts]
242
+ * @param {typeof fetch} [opts.fetchFn] — for tests
243
+ * @returns {Promise<{apiToken: string, expiresAt: number}>}
244
+ */
245
+ export async function exchangeToken(rawToken, { fetchFn = fetch } = {}) {
246
+ if (!rawToken) throw new Error('exchangeToken: empty rawToken');
247
+
248
+ const fp = tokenFingerprint(rawToken);
249
+ const cached = _jwtCache.get(fp);
250
+ const now = Math.floor(Date.now() / 1000);
251
+ if (cached && now < cached.expiresAt - JWT_REFRESH_MARGIN_SECONDS) {
252
+ return cached;
253
+ }
254
+
255
+ // Dedupe: if another caller is already exchanging the same token, await
256
+ // their result instead of firing a parallel request. Cleared in finally
257
+ // so a failure doesn't poison the next attempt.
258
+ const inFlight = _exchangeInFlight.get(fp);
259
+ if (inFlight) return inFlight;
260
+
261
+ const promise = (async () => {
262
+ const res = await fetchFn(TOKEN_EXCHANGE_URL, {
263
+ method: 'GET',
264
+ headers: {
265
+ Authorization: `token ${rawToken}`,
266
+ 'User-Agent': EXCHANGE_USER_AGENT,
267
+ Accept: 'application/json',
268
+ 'Editor-Version': EDITOR_VERSION,
269
+ },
270
+ });
271
+
272
+ if (!res.ok) {
273
+ const body = await res.text().catch(() => '');
274
+ throw new Error(`Copilot token exchange failed: HTTP ${res.status} ${body.slice(0, 200)}`);
275
+ }
276
+ const data = await res.json();
277
+ const apiToken = data && typeof data.token === 'string' ? data.token : '';
278
+ if (!apiToken) throw new Error('Copilot token exchange returned empty token');
279
+
280
+ const expiresAtRaw = data.expires_at;
281
+ const expiresAt =
282
+ typeof expiresAtRaw === 'number' && expiresAtRaw > 0
283
+ ? expiresAtRaw
284
+ : now + 1800; // hermes default: 30 minutes
285
+
286
+ const entry = { apiToken, expiresAt };
287
+ _jwtCache.set(fp, entry);
288
+ return entry;
289
+ })();
290
+
291
+ _exchangeInFlight.set(fp, promise);
292
+ try {
293
+ return await promise;
294
+ } finally {
295
+ _exchangeInFlight.delete(fp);
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Convenience: resolve a raw token AND exchange it. On exchange failure,
301
+ * falls back to the raw token (matches hermes' `get_copilot_api_token`).
302
+ *
303
+ * Returns null if no raw token can be resolved at all.
304
+ *
305
+ * @param {object} [opts]
306
+ * @param {string} [opts.hostname]
307
+ * @param {typeof fetch} [opts.fetchFn]
308
+ * @returns {Promise<{token: string, source: string, exchanged: boolean} | null>}
309
+ */
310
+ export async function getApiToken({ hostname, fetchFn = fetch } = {}) {
311
+ const raw = await resolveRawToken({ hostname });
312
+ if (!raw) return null;
313
+ try {
314
+ const { apiToken } = await exchangeToken(raw.token, { fetchFn });
315
+ return { token: apiToken, source: raw.source, exchanged: true };
316
+ } catch {
317
+ return { token: raw.token, source: raw.source, exchanged: false };
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Headers that Copilot API expects in addition to Authorization. These
323
+ * match the VS Code / Copilot CLI conventions and unlock the internal-only
324
+ * models for accounts that have them.
325
+ *
326
+ * @param {object} [opts]
327
+ * @param {boolean} [opts.isAgentTurn] default true
328
+ * @param {boolean} [opts.isVision] default false
329
+ * @returns {Record<string, string>}
330
+ */
331
+ export function copilotRequestHeaders({ isAgentTurn = true, isVision = false } = {}) {
332
+ const h = {
333
+ 'Editor-Version': EDITOR_VERSION,
334
+ 'User-Agent': REQUEST_USER_AGENT,
335
+ 'Copilot-Integration-Id': 'vscode-chat',
336
+ 'Openai-Intent': 'conversation-edits',
337
+ 'x-initiator': isAgentTurn ? 'agent' : 'user',
338
+ };
339
+ if (isVision) h['Copilot-Vision-Request'] = 'true';
340
+ return h;
341
+ }
342
+
343
+ // ─── Device Flow (used by future UI handler) ─────────────────────
344
+
345
+ /**
346
+ * Step 1 of the GitHub OAuth device flow. Returns the user-visible
347
+ * verification URI + user code plus the device_code needed for polling.
348
+ *
349
+ * @param {object} [opts]
350
+ * @param {string} [opts.host] default github.com
351
+ * @param {typeof fetch} [opts.fetchFn]
352
+ * @returns {Promise<{deviceCode: string, userCode: string, verificationUri: string, interval: number, expiresIn: number}>}
353
+ */
354
+ export async function startDeviceFlow({ host = 'github.com', fetchFn = fetch } = {}) {
355
+ const body = new URLSearchParams({
356
+ client_id: COPILOT_OAUTH_CLIENT_ID,
357
+ scope: 'read:user',
358
+ });
359
+ const res = await fetchFn(DEVICE_CODE_URL_TEMPLATE(host), {
360
+ method: 'POST',
361
+ headers: {
362
+ Accept: 'application/json',
363
+ 'Content-Type': 'application/x-www-form-urlencoded',
364
+ 'User-Agent': REQUEST_USER_AGENT,
365
+ },
366
+ body: body.toString(),
367
+ });
368
+ if (!res.ok) {
369
+ throw new Error(`GitHub device code request failed: HTTP ${res.status}`);
370
+ }
371
+ const data = await res.json();
372
+ if (!data.device_code || !data.user_code) {
373
+ throw new Error('GitHub did not return a device code');
374
+ }
375
+ return {
376
+ deviceCode: data.device_code,
377
+ userCode: data.user_code,
378
+ verificationUri: data.verification_uri || `https://${host}/login/device`,
379
+ interval: Math.max(Number(data.interval) || 5, 1),
380
+ expiresIn: Number(data.expires_in) || DEFAULT_DEVICE_TIMEOUT_SECONDS,
381
+ };
382
+ }
383
+
384
+ /**
385
+ * Poll the access-token endpoint exactly once. Returns one of:
386
+ * { status: 'success', token }
387
+ * { status: 'pending' }
388
+ * { status: 'slow_down', interval } — caller MUST adopt the new interval
389
+ * { status: 'expired' }
390
+ * { status: 'denied' }
391
+ * { status: 'error', error }
392
+ *
393
+ * @param {{deviceCode: string, host?: string, fetchFn?: typeof fetch}} params
394
+ */
395
+ export async function pollDeviceFlow({ deviceCode, host = 'github.com', fetchFn = fetch }) {
396
+ const body = new URLSearchParams({
397
+ client_id: COPILOT_OAUTH_CLIENT_ID,
398
+ device_code: deviceCode,
399
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
400
+ });
401
+ const res = await fetchFn(ACCESS_TOKEN_URL_TEMPLATE(host), {
402
+ method: 'POST',
403
+ headers: {
404
+ Accept: 'application/json',
405
+ 'Content-Type': 'application/x-www-form-urlencoded',
406
+ 'User-Agent': REQUEST_USER_AGENT,
407
+ },
408
+ body: body.toString(),
409
+ });
410
+ // GitHub returns 200 even for the pending/slow_down cases; treat HTTP
411
+ // failures as transient and let the caller's loop retry.
412
+ if (!res.ok) {
413
+ return { status: 'error', error: `HTTP ${res.status}` };
414
+ }
415
+ const data = await res.json();
416
+ if (data.access_token) {
417
+ return { status: 'success', token: data.access_token };
418
+ }
419
+ switch (data.error) {
420
+ case 'authorization_pending':
421
+ return { status: 'pending' };
422
+ case 'slow_down': {
423
+ const next = Number(data.interval);
424
+ return { status: 'slow_down', interval: Number.isFinite(next) && next > 0 ? next : null };
425
+ }
426
+ case 'expired_token':
427
+ return { status: 'expired' };
428
+ case 'access_denied':
429
+ return { status: 'denied' };
430
+ default:
431
+ return { status: 'error', error: data.error || 'unknown' };
432
+ }
433
+ }
434
+
435
+ /**
436
+ * Run the full device flow: kick off, then poll until success/failure/timeout.
437
+ * Calls `onPending({userCode, verificationUri, expiresIn})` immediately so
438
+ * the caller can present the code to the user.
439
+ *
440
+ * @param {object} opts
441
+ * @param {(info: {userCode: string, verificationUri: string, expiresIn: number}) => void} opts.onPending
442
+ * @param {AbortSignal} [opts.signal]
443
+ * @param {number} [opts.timeoutSeconds]
444
+ * @param {string} [opts.host]
445
+ * @param {typeof fetch} [opts.fetchFn]
446
+ * @param {(ms: number) => Promise<void>} [opts.sleepFn] — for tests
447
+ * @returns {Promise<string>} the raw OAuth token
448
+ */
449
+ export async function runDeviceFlow({
450
+ onPending,
451
+ signal,
452
+ timeoutSeconds = DEFAULT_DEVICE_TIMEOUT_SECONDS,
453
+ host = 'github.com',
454
+ fetchFn = fetch,
455
+ sleepFn = ms => new Promise(r => setTimeout(r, ms)),
456
+ } = {}) {
457
+ const start = await startDeviceFlow({ host, fetchFn });
458
+ if (typeof onPending === 'function') {
459
+ onPending({
460
+ userCode: start.userCode,
461
+ verificationUri: start.verificationUri,
462
+ expiresIn: start.expiresIn,
463
+ });
464
+ }
465
+ const deadline = Date.now() + timeoutSeconds * 1000;
466
+ let interval = start.interval;
467
+ while (Date.now() < deadline) {
468
+ if (signal?.aborted) throw new Error('device flow aborted');
469
+ // Race the sleep against the abort signal so cancellation is observed
470
+ // immediately instead of waiting out the current poll interval.
471
+ await abortableSleep(sleepFn, (interval + DEVICE_POLL_SAFETY_MARGIN_SECONDS) * 1000, signal);
472
+ if (signal?.aborted) throw new Error('device flow aborted');
473
+ const r = await pollDeviceFlow({ deviceCode: start.deviceCode, host, fetchFn });
474
+ if (r.status === 'success') {
475
+ // Don't fail the flow if persist fails — the token in memory is still
476
+ // usable for this process. Warn so a recurring failure stays visible.
477
+ try {
478
+ await writePersistedToken({ token: r.token, source: 'device-flow' });
479
+ } catch (err) {
480
+ console.warn(`[copilot-auth] failed to persist device-flow token: ${err.message}`);
481
+ }
482
+ return r.token;
483
+ }
484
+ if (r.status === 'pending') continue;
485
+ if (r.status === 'slow_down') {
486
+ if (r.interval) interval = r.interval;
487
+ else interval += 5;
488
+ continue;
489
+ }
490
+ if (r.status === 'expired') throw new Error('device code expired');
491
+ if (r.status === 'denied') throw new Error('authorization denied');
492
+ // status === 'error' — treat as transient; loop continues.
493
+ }
494
+ throw new Error('device flow timed out');
495
+ }
496
+
497
+ /**
498
+ * Sleep that resolves either when the timeout elapses or when the abort
499
+ * signal fires (whichever comes first). The signal listener is removed in
500
+ * the cleanup paths so we don't leak listeners across many poll cycles.
501
+ */
502
+ function abortableSleep(sleepFn, ms, signal) {
503
+ if (!signal) return sleepFn(ms);
504
+ return new Promise(resolve => {
505
+ let done = false;
506
+ const onAbort = () => {
507
+ if (done) return;
508
+ done = true;
509
+ signal.removeEventListener('abort', onAbort);
510
+ resolve();
511
+ };
512
+ if (signal.aborted) return onAbort();
513
+ signal.addEventListener('abort', onAbort);
514
+ sleepFn(ms).then(() => {
515
+ if (done) return;
516
+ done = true;
517
+ signal.removeEventListener('abort', onAbort);
518
+ resolve();
519
+ });
520
+ });
521
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * credentials/index.js — Registry of credential providers.
3
+ *
4
+ * A "credential provider" knows how to produce an apiKey for a provider
5
+ * dynamically — at request time — instead of the user pasting a static
6
+ * string into config.json. Today we ship one: `github-copilot` (env vars,
7
+ * disk-cached device-flow token, or the `gh` CLI).
8
+ *
9
+ * Contract:
10
+ * getApiKey() → Promise<string>
11
+ * Throws if no credential is available so the router surfaces a
12
+ * clear error rather than sending an empty Authorization header.
13
+ *
14
+ * Routing-time contract: the registry is consulted ONLY when a provider
15
+ * config sets `credentialProvider: "<name>"`. Providers without this
16
+ * field follow the existing static `apiKey` path unchanged — that is the
17
+ * regression guard.
18
+ */
19
+
20
+ import * as githubCopilot from './github-copilot.js';
21
+
22
+ /**
23
+ * @typedef {{ getApiKey: () => Promise<string>, name: string }} CredentialProvider
24
+ */
25
+
26
+ /**
27
+ * @param {string} name
28
+ * @returns {CredentialProvider | null}
29
+ */
30
+ export function getCredentialProvider(name) {
31
+ if (name === 'github-copilot') {
32
+ return {
33
+ name: 'github-copilot',
34
+ async getApiKey() {
35
+ const r = await githubCopilot.getApiToken();
36
+ if (!r || !r.token) {
37
+ throw new Error(
38
+ 'github-copilot credential provider could not resolve a token. ' +
39
+ 'Try: set COPILOT_GITHUB_TOKEN/GH_TOKEN/GITHUB_TOKEN env var, ' +
40
+ 'or run `gh auth login`, or sign in via the device flow.'
41
+ );
42
+ }
43
+ return r.token;
44
+ },
45
+ };
46
+ }
47
+ return null;
48
+ }
49
+
50
+ /**
51
+ * Names of registered credential providers. UI uses this to populate the
52
+ * picker; keep in sync with `getCredentialProvider` above.
53
+ */
54
+ export const CREDENTIAL_PROVIDER_NAMES = ['github-copilot'];
@@ -279,23 +279,47 @@ export class AdapterRouter extends LLMAdapter {
279
279
  // two adapters (e.g. mixed config: openai-responses for gpt-5*, anthropic
280
280
  // for claude-*). Cache key includes the protocol.
281
281
  const protocol = this.#effectiveProtocol(provider, entry);
282
- const cacheKey = `${provider.name}::${protocol}`;
282
+
283
+ // Resolve apiKey. Default path: provider.apiKey (static string from
284
+ // config.json) — completely unchanged from before. Opt-in path: when
285
+ // provider.credentialProvider is set, ask the credential registry for
286
+ // a live token. Throws with a clear hint if no token is available.
287
+ const apiKey = await this.#resolveApiKey(provider);
288
+
289
+ // Cache key includes a short fingerprint of the apiKey so that when a
290
+ // credential provider rotates the token (e.g. Copilot 30-min refresh)
291
+ // we rebuild the adapter rather than reuse one with a stale Authorization
292
+ // header. For static providers the apiKey never changes so this stays
293
+ // a one-time-build cache exactly like before.
294
+ const apiKeyFp = apiKey ? this.#shortFingerprint(apiKey) : 'none';
295
+ const cacheKey = `${provider.name}::${protocol}::${apiKeyFp}`;
283
296
  const cached = this.#adapterCache.get(cacheKey);
284
297
  if (cached) return cached;
285
298
 
299
+ // Token rotation eviction: when a credential provider hands us a NEW
300
+ // fingerprint for the same (provider, protocol) pair, drop the stale
301
+ // entry so the cache doesn't grow unboundedly over a long-lived process
302
+ // (Copilot tokens rotate every ~30 min). Static-apiKey providers never
303
+ // change fingerprint, so this loop never finds anything to evict for
304
+ // them — back-compat preserved.
305
+ const prefix = `${provider.name}::${protocol}::`;
306
+ for (const key of this.#adapterCache.keys()) {
307
+ if (key.startsWith(prefix)) this.#adapterCache.delete(key);
308
+ }
309
+
286
310
  let adapter;
287
311
 
288
312
  if (protocol === 'anthropic') {
289
313
  const { AnthropicAdapter } = await import('./anthropic.js');
290
314
  adapter = new AnthropicAdapter({
291
- apiKey: provider.apiKey,
315
+ apiKey,
292
316
  baseUrl: provider.baseUrl,
293
317
  });
294
318
  } else if (protocol === 'openai-responses') {
295
319
  // OpenAI Responses API (/v1/responses) — canonical OpenAI-compatible path.
296
320
  const { OpenAIResponsesAdapter } = await import('./openai-responses.js');
297
321
  adapter = new OpenAIResponsesAdapter({
298
- apiKey: provider.apiKey,
322
+ apiKey,
299
323
  baseUrl: provider.baseUrl,
300
324
  });
301
325
  } else {
@@ -310,6 +334,51 @@ export class AdapterRouter extends LLMAdapter {
310
334
  return adapter;
311
335
  }
312
336
 
337
+ /**
338
+ * Resolve the apiKey for a provider. If `credentialProvider` is set,
339
+ * delegate to the registry; otherwise return the static `apiKey` from
340
+ * config (unchanged from before this feature).
341
+ *
342
+ * Kept as a separate method so the credential registry is imported
343
+ * lazily — providers that don't use a credential provider never pay
344
+ * the import cost or touch `child_process` / disk.
345
+ *
346
+ * @param {object} provider
347
+ * @returns {Promise<string>}
348
+ */
349
+ async #resolveApiKey(provider) {
350
+ const name = provider && provider.credentialProvider;
351
+ if (!name) return provider?.apiKey || '';
352
+ const { getCredentialProvider, CREDENTIAL_PROVIDER_NAMES } = await import('./credentials/index.js');
353
+ const cp = getCredentialProvider(name);
354
+ if (!cp) {
355
+ throw new Error(
356
+ `Unknown credentialProvider "${name}" on provider "${provider.name}". ` +
357
+ `Known providers: ${CREDENTIAL_PROVIDER_NAMES.join(', ')}. ` +
358
+ `Remove the field to use the static apiKey.`
359
+ );
360
+ }
361
+ return cp.getApiKey();
362
+ }
363
+
364
+ /**
365
+ * Short stable fingerprint of an apiKey for use in the adapter cache key.
366
+ * Never log the apiKey itself. 8 hex chars is plenty for in-process
367
+ * uniqueness — we only need to distinguish "same token" vs "rotated".
368
+ */
369
+ #shortFingerprint(s) {
370
+ // Tiny non-crypto FNV-1a 32-bit hash — avoids loading `crypto` on the
371
+ // hot path. Collisions don't matter for security (the apiKey is still
372
+ // sent verbatim in the request); they only matter for cache freshness
373
+ // and FNV is more than enough.
374
+ let h = 0x811c9dc5;
375
+ for (let i = 0; i < s.length; i += 1) {
376
+ h ^= s.charCodeAt(i);
377
+ h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
378
+ }
379
+ return h.toString(16).padStart(8, '0');
380
+ }
381
+
313
382
  /**
314
383
  * Stream a model response — routes to the correct provider adapter.
315
384
  *