@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
|
@@ -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'];
|
package/unify/llm/router.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
*
|