@bitkyc08/opencodex 2.7.25 → 2.7.26

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.
@@ -0,0 +1,427 @@
1
+ /**
2
+ * GitHub Copilot OAuth (device authorization grant + copilot_internal token exchange).
3
+ *
4
+ * Uses the public VS Code GitHub OAuth app client id (community-proven for Copilot Pro).
5
+ * This is an unofficial bridge — GitHub may tighten or revoke access. See registry note.
6
+ */
7
+ import type { OAuthController, OAuthCredentials } from "./types";
8
+
9
+ /** VS Code's public GitHub OAuth app — required for copilot_internal/v2/token to succeed. */
10
+ export const GITHUB_COPILOT_OAUTH_CLIENT_ID = "Iv1.b507a08c87ecfe98";
11
+ export const GITHUB_COPILOT_DEFAULT_API_BASE = "https://api.githubcopilot.com";
12
+ export const GITHUB_DEVICE_VERIFY_ORIGIN = "https://github.com";
13
+ export const GITHUB_DEVICE_VERIFY_PATH = "/login/device";
14
+
15
+ const DEVICE_CODE_URL = "https://github.com/login/device/code";
16
+ const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
17
+ const COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token";
18
+ const GITHUB_USER_URL = "https://api.github.com/user";
19
+
20
+ const OAUTH_SCOPE = "read:user";
21
+ const DEFAULT_POLL_INTERVAL_MS = 5000;
22
+ const DEFAULT_DEVICE_FLOW_TTL_MS = 15 * 60 * 1000;
23
+ const OAUTH_EXPIRY_SKEW_MS = 2 * 60 * 1000;
24
+ const MIN_POLL_MS = 1000;
25
+ /** OAuth error codes that make a refresh terminally dead (safe to surface verbatim). */
26
+ const TERMINAL_OAUTH_ERROR_CODES = new Set(["invalid_grant", "access_denied", "expired_token"]);
27
+ const IDENTITY_RETRY_DELAY_MS = 500;
28
+
29
+ /** Honest OpenCodex client fingerprint; VS Code-shaped values only if API requires them later. */
30
+ export const GITHUB_COPILOT_EDITOR_HEADERS: Readonly<Record<string, string>> = {
31
+ "Editor-Version": "opencodex/0.1.0",
32
+ "Editor-Plugin-Version": "opencodex/0.1.0",
33
+ "Copilot-Integration-Id": "vscode-chat",
34
+ "User-Agent": "opencodex",
35
+ Accept: "application/json",
36
+ };
37
+
38
+ interface DeviceAuthorizationResponse {
39
+ user_code?: string;
40
+ device_code?: string;
41
+ verification_uri?: string;
42
+ verification_uri_complete?: string;
43
+ expires_in?: number;
44
+ interval?: number;
45
+ error?: string;
46
+ error_description?: string;
47
+ }
48
+
49
+ interface GithubTokenResponse {
50
+ access_token?: string;
51
+ refresh_token?: string;
52
+ expires_in?: number;
53
+ refresh_token_expires_in?: number;
54
+ error?: string;
55
+ error_description?: string;
56
+ interval?: number;
57
+ }
58
+
59
+ interface CopilotTokenResponse {
60
+ token?: string;
61
+ expires_at?: number;
62
+ refresh_in?: number;
63
+ endpoints?: { api?: string };
64
+ }
65
+
66
+ interface GithubUserResponse {
67
+ login?: string;
68
+ id?: number;
69
+ email?: string;
70
+ }
71
+
72
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
73
+ return new Promise((resolve, reject) => {
74
+ if (signal?.aborted) return reject(new Error("Login cancelled"));
75
+ const t = setTimeout(resolve, ms);
76
+ signal?.addEventListener("abort", () => {
77
+ clearTimeout(t);
78
+ reject(new Error("Login cancelled"));
79
+ }, { once: true });
80
+ });
81
+ }
82
+
83
+ /** Status-only errors — never echo response bodies (may contain tokens). */
84
+ export function githubCopilotHttpError(action: string, status: number): Error {
85
+ return new Error(`GitHub Copilot ${action} failed (${status})`);
86
+ }
87
+
88
+ export function buildGithubDeviceVerifyUrl(userCode: string): string {
89
+ const code = userCode.trim();
90
+ if (!code || !/^[A-Z0-9-]+$/i.test(code)) {
91
+ throw new Error("GitHub Copilot device flow returned an invalid user code");
92
+ }
93
+ return `${GITHUB_DEVICE_VERIFY_ORIGIN}${GITHUB_DEVICE_VERIFY_PATH}?user_code=${encodeURIComponent(code)}`;
94
+ }
95
+
96
+ /**
97
+ * Allowlist for browser-open URLs. Prefer {@link buildGithubDeviceVerifyUrl}; this rejects
98
+ * phishing redirects if a caller still passes a server-supplied verification URI.
99
+ */
100
+ export function isAllowedGithubDeviceVerifyUrl(url: string): boolean {
101
+ try {
102
+ const parsed = new URL(url);
103
+ if (parsed.protocol !== "https:") return false;
104
+ if (parsed.username || parsed.password) return false;
105
+ if (parsed.hostname.toLowerCase() !== "github.com") return false;
106
+ if (parsed.port && parsed.port !== "443") return false;
107
+ const path = parsed.pathname.replace(/\/+$/, "") || "/";
108
+ return path === GITHUB_DEVICE_VERIFY_PATH;
109
+ } catch {
110
+ return false;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Tight allowlist for Copilot API hosts from token `endpoints.api`.
116
+ * Rejects IPs, localhost, non-HTTPS, userinfo, and non-default ports.
117
+ */
118
+ export function validateCopilotApiBaseUrl(raw: string | undefined | null): string | undefined {
119
+ if (raw === undefined || raw === null) return undefined;
120
+ const trimmed = String(raw).trim();
121
+ if (!trimmed) return undefined;
122
+ let parsed: URL;
123
+ try {
124
+ parsed = new URL(trimmed);
125
+ } catch {
126
+ return undefined;
127
+ }
128
+ if (parsed.protocol !== "https:") return undefined;
129
+ if (parsed.username || parsed.password) return undefined;
130
+ if (parsed.port && parsed.port !== "443") return undefined;
131
+ const host = parsed.hostname.toLowerCase();
132
+ if (host === "localhost" || host === "127.0.0.1" || host === "::1" || host.endsWith(".localhost")) {
133
+ return undefined;
134
+ }
135
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(":")) return undefined;
136
+ if (host !== "api.githubcopilot.com" && !host.endsWith(".githubcopilot.com")) return undefined;
137
+ // Normalize to origin only (no path/query from the network).
138
+ return `https://${host}`;
139
+ }
140
+
141
+ export function resolveCopilotApiBaseUrl(raw: string | undefined | null): string {
142
+ return validateCopilotApiBaseUrl(raw) ?? GITHUB_COPILOT_DEFAULT_API_BASE;
143
+ }
144
+
145
+ async function requestDeviceAuthorization(signal?: AbortSignal): Promise<{
146
+ userCode: string;
147
+ deviceCode: string;
148
+ verifyUrl: string;
149
+ expiresInMs: number;
150
+ intervalMs: number;
151
+ }> {
152
+ const response = await fetch(DEVICE_CODE_URL, {
153
+ method: "POST",
154
+ headers: {
155
+ Accept: "application/json",
156
+ "Content-Type": "application/x-www-form-urlencoded",
157
+ "User-Agent": "opencodex",
158
+ },
159
+ body: new URLSearchParams({
160
+ client_id: GITHUB_COPILOT_OAUTH_CLIENT_ID,
161
+ scope: OAUTH_SCOPE,
162
+ }),
163
+ signal,
164
+ });
165
+ if (!response.ok) throw githubCopilotHttpError("device authorization", response.status);
166
+ const payload = (await response.json()) as DeviceAuthorizationResponse;
167
+ if (!payload.user_code || !payload.device_code) {
168
+ throw new Error("GitHub Copilot device authorization response missing required fields");
169
+ }
170
+ // Construct verify URL ourselves — never trust verification_uri_complete for openUrl.
171
+ const verifyUrl = buildGithubDeviceVerifyUrl(payload.user_code);
172
+ return {
173
+ userCode: payload.user_code,
174
+ deviceCode: payload.device_code,
175
+ verifyUrl,
176
+ expiresInMs: typeof payload.expires_in === "number" ? payload.expires_in * 1000 : DEFAULT_DEVICE_FLOW_TTL_MS,
177
+ intervalMs: typeof payload.interval === "number" && payload.interval > 0
178
+ ? payload.interval * 1000
179
+ : DEFAULT_POLL_INTERVAL_MS,
180
+ };
181
+ }
182
+
183
+ async function pollGithubDeviceToken(
184
+ deviceCode: string,
185
+ intervalMs: number,
186
+ expiresInMs: number,
187
+ signal?: AbortSignal,
188
+ ): Promise<{ access: string; refresh?: string }> {
189
+ const deadline = Date.now() + expiresInMs;
190
+ let waitMs = Math.max(MIN_POLL_MS, intervalMs);
191
+ while (Date.now() < deadline) {
192
+ if (signal?.aborted) throw new Error("Login cancelled");
193
+ // RFC 8628: wait the interval BEFORE every poll — an immediate first request is a
194
+ // cadence violation GitHub may answer with slow_down.
195
+ await sleep(waitMs, signal);
196
+ if (Date.now() >= deadline) break;
197
+ const response = await fetch(ACCESS_TOKEN_URL, {
198
+ method: "POST",
199
+ headers: {
200
+ Accept: "application/json",
201
+ "Content-Type": "application/x-www-form-urlencoded",
202
+ "User-Agent": "opencodex",
203
+ },
204
+ body: new URLSearchParams({
205
+ client_id: GITHUB_COPILOT_OAUTH_CLIENT_ID,
206
+ device_code: deviceCode,
207
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
208
+ }),
209
+ // Bound each poll by the remaining lifetime so a hung fetch cannot outlive the flow.
210
+ signal: signal
211
+ ? AbortSignal.any([signal, AbortSignal.timeout(Math.max(MIN_POLL_MS, deadline - Date.now()))])
212
+ : AbortSignal.timeout(Math.max(MIN_POLL_MS, deadline - Date.now())),
213
+ });
214
+ const payload = (await response.json().catch(() => ({}))) as GithubTokenResponse;
215
+ if (response.ok && payload.access_token) {
216
+ // Classic OAuth apps issue a non-expiring `gho_` access token with NO refresh token;
217
+ // expiring-token apps add `ghr_`. Both are valid device-flow successes.
218
+ return {
219
+ access: payload.access_token,
220
+ ...(payload.refresh_token ? { refresh: payload.refresh_token } : {}),
221
+ };
222
+ }
223
+ const error = payload.error;
224
+ if (error === "authorization_pending") {
225
+ continue;
226
+ }
227
+ if (error === "slow_down") {
228
+ // RFC 8628 §3.5: slow_down means increase the interval by 5 seconds; the
229
+ // server-provided `interval` wins when it demands even more.
230
+ const retryAfter = typeof payload.interval === "number" && payload.interval > 0
231
+ ? payload.interval * 1000
232
+ : 0;
233
+ waitMs = Math.max(waitMs + 5000, retryAfter);
234
+ continue;
235
+ }
236
+ if (error === "expired_token") throw new Error("GitHub Copilot device authorization expired");
237
+ if (error === "access_denied") throw new Error("GitHub Copilot device authorization denied");
238
+ if (error === "unsupported_grant_type" || error === "incorrect_device_code") {
239
+ throw new Error(`GitHub Copilot device flow failed (${error})`);
240
+ }
241
+ if (!response.ok) throw githubCopilotHttpError("device token poll", response.status);
242
+ throw new Error(`GitHub Copilot device flow failed (${error ?? "unknown"})`);
243
+ }
244
+ throw new Error("GitHub Copilot device flow timed out");
245
+ }
246
+
247
+ async function refreshGithubAccessToken(refreshToken: string, signal?: AbortSignal): Promise<{ access: string; refresh: string }> {
248
+ const response = await fetch(ACCESS_TOKEN_URL, {
249
+ method: "POST",
250
+ headers: {
251
+ Accept: "application/json",
252
+ "Content-Type": "application/x-www-form-urlencoded",
253
+ "User-Agent": "opencodex",
254
+ },
255
+ body: new URLSearchParams({
256
+ client_id: GITHUB_COPILOT_OAUTH_CLIENT_ID,
257
+ grant_type: "refresh_token",
258
+ refresh_token: refreshToken,
259
+ }),
260
+ signal,
261
+ });
262
+ const payload = (await response.json().catch(() => ({}))) as GithubTokenResponse;
263
+ if (!response.ok) {
264
+ // Extract ONLY the allowlisted OAuth error code — never the body or error_description,
265
+ // which can echo credential material. The code lets the shared terminal-refresh
266
+ // detector mark the account needsReauth instead of retrying a revoked grant forever.
267
+ const code = payload.error && TERMINAL_OAUTH_ERROR_CODES.has(payload.error) ? payload.error : undefined;
268
+ throw new Error(
269
+ code
270
+ ? `GitHub Copilot token refresh failed: ${code} (HTTP ${response.status})`
271
+ : `GitHub Copilot token refresh failed (${response.status})`,
272
+ );
273
+ }
274
+ if (!payload.access_token) throw new Error("GitHub Copilot token refresh missing access token");
275
+ return {
276
+ access: payload.access_token,
277
+ refresh: payload.refresh_token ?? refreshToken,
278
+ };
279
+ }
280
+
281
+ async function exchangeCopilotToken(githubAccessToken: string, signal?: AbortSignal): Promise<{
282
+ access: string;
283
+ expires: number;
284
+ apiBaseUrl: string;
285
+ }> {
286
+ const response = await fetch(COPILOT_TOKEN_URL, {
287
+ method: "GET",
288
+ headers: {
289
+ ...GITHUB_COPILOT_EDITOR_HEADERS,
290
+ Authorization: `token ${githubAccessToken}`,
291
+ },
292
+ signal,
293
+ });
294
+ if (!response.ok) throw githubCopilotHttpError("token exchange", response.status);
295
+ const payload = (await response.json().catch(() => ({}))) as CopilotTokenResponse;
296
+ if (!payload.token || typeof payload.token !== "string") {
297
+ throw new Error("GitHub Copilot token exchange missing token");
298
+ }
299
+ let expires: number;
300
+ if (typeof payload.expires_at === "number" && Number.isFinite(payload.expires_at)) {
301
+ // expires_at is unix seconds from GitHub.
302
+ expires = payload.expires_at * 1000 - OAUTH_EXPIRY_SKEW_MS;
303
+ } else if (typeof payload.refresh_in === "number" && payload.refresh_in > 0) {
304
+ expires = Date.now() + payload.refresh_in * 1000 - OAUTH_EXPIRY_SKEW_MS;
305
+ } else {
306
+ expires = Date.now() + 25 * 60 * 1000 - OAUTH_EXPIRY_SKEW_MS;
307
+ }
308
+ return {
309
+ access: payload.token,
310
+ expires,
311
+ apiBaseUrl: resolveCopilotApiBaseUrl(payload.endpoints?.api),
312
+ };
313
+ }
314
+
315
+ async function fetchGithubIdentityOnce(githubAccessToken: string, signal?: AbortSignal): Promise<{
316
+ email?: string;
317
+ accountId?: string;
318
+ }> {
319
+ const response = await fetch(GITHUB_USER_URL, {
320
+ headers: {
321
+ Accept: "application/vnd.github+json",
322
+ Authorization: `Bearer ${githubAccessToken}`,
323
+ "User-Agent": "opencodex",
324
+ "X-GitHub-Api-Version": "2022-11-28",
325
+ },
326
+ signal,
327
+ });
328
+ if (!response.ok) throw githubCopilotHttpError("identity lookup", response.status);
329
+ const user = (await response.json()) as GithubUserResponse;
330
+ // Prefer numeric id for multiauth stability. Only persist email when GitHub returns one —
331
+ // do not fabricate noreply addresses (privacy-scan + PII hygiene).
332
+ const accountId = typeof user.id === "number"
333
+ ? String(user.id)
334
+ : (typeof user.login === "string" && user.login ? user.login : undefined);
335
+ const email = typeof user.email === "string" && user.email.includes("@") ? user.email : undefined;
336
+ return {
337
+ ...(email ? { email } : {}),
338
+ ...(accountId ? { accountId } : {}),
339
+ };
340
+ }
341
+
342
+ /**
343
+ * Identity is REQUIRED for multi-account safety: an identity-less credential would
344
+ * replace the active slot in the auth store and clobber another GitHub account. One
345
+ * retry covers transient /user failures; a persistent failure fails the login rather
346
+ * than persisting an anonymous credential.
347
+ */
348
+ async function fetchGithubIdentity(githubAccessToken: string, signal?: AbortSignal): Promise<{
349
+ email?: string;
350
+ accountId?: string;
351
+ }> {
352
+ try {
353
+ const identity = await fetchGithubIdentityOnce(githubAccessToken, signal);
354
+ if (identity.accountId) return identity;
355
+ } catch { /* retry once below */ }
356
+ await sleep(IDENTITY_RETRY_DELAY_MS, signal);
357
+ const identity = await fetchGithubIdentityOnce(githubAccessToken, signal);
358
+ if (!identity.accountId) {
359
+ throw new Error("Could not verify GitHub account identity — retry the login");
360
+ }
361
+ return identity;
362
+ }
363
+
364
+ /**
365
+ * The credential `refresh` field carries the DURABLE GitHub grant:
366
+ * - `ghr_…` refresh token (expiring-token apps) → renewed via the refresh grant;
367
+ * - `gho_…` access token (classic apps, no refresh token) → re-exchanged directly.
368
+ * The `access` field always holds the short-lived Copilot API token.
369
+ */
370
+ async function credentialsFromGithubAccess(
371
+ githubAccess: string,
372
+ durableGrant: string,
373
+ signal?: AbortSignal,
374
+ ): Promise<OAuthCredentials> {
375
+ const [copilot, identity] = await Promise.all([
376
+ exchangeCopilotToken(githubAccess, signal),
377
+ fetchGithubIdentity(githubAccess, signal),
378
+ ]);
379
+ return {
380
+ access: copilot.access,
381
+ refresh: durableGrant,
382
+ expires: copilot.expires,
383
+ apiBaseUrl: copilot.apiBaseUrl,
384
+ source: "oauth",
385
+ ...(identity.email ? { email: identity.email } : {}),
386
+ ...(identity.accountId ? { accountId: identity.accountId } : {}),
387
+ };
388
+ }
389
+
390
+ export async function loginGithubCopilot(ctrl: OAuthController): Promise<OAuthCredentials> {
391
+ const device = await requestDeviceAuthorization(ctrl.signal);
392
+ if (!isAllowedGithubDeviceVerifyUrl(device.verifyUrl)) {
393
+ throw new Error("GitHub Copilot refused to open a non-allowlisted verification URL");
394
+ }
395
+ ctrl.onAuth?.({
396
+ url: device.verifyUrl,
397
+ instructions: `Enter code: ${device.userCode}`,
398
+ });
399
+ ctrl.onProgress?.("Waiting for GitHub device authorization…");
400
+ const github = await pollGithubDeviceToken(
401
+ device.deviceCode,
402
+ device.intervalMs,
403
+ device.expiresInMs,
404
+ ctrl.signal,
405
+ );
406
+ ctrl.onProgress?.("Exchanging GitHub token for Copilot access…");
407
+ // Access-only responses (classic gho_ tokens) store the access token itself as the
408
+ // durable grant; expiring-token apps store the ghr_ refresh token.
409
+ return credentialsFromGithubAccess(github.access, github.refresh ?? github.access, ctrl.signal);
410
+ }
411
+
412
+ /**
413
+ * Refresh = renew the durable GitHub grant, then Copilot re-exchange.
414
+ * `refreshToken` is credentials.refresh: a `ghr_` token runs the GitHub refresh grant;
415
+ * anything else (a durable `gho_` access token) re-exchanges directly — both shapes
416
+ * recover expiry AND upstream 401s.
417
+ */
418
+ export async function refreshGithubCopilotToken(
419
+ refreshToken: string,
420
+ signal?: AbortSignal,
421
+ ): Promise<OAuthCredentials> {
422
+ if (refreshToken.startsWith("ghr_")) {
423
+ const github = await refreshGithubAccessToken(refreshToken, signal);
424
+ return credentialsFromGithubAccess(github.access, github.refresh, signal);
425
+ }
426
+ return credentialsFromGithubAccess(refreshToken, refreshToken, signal);
427
+ }
@@ -11,6 +11,7 @@ import { loginKiro, readKiroCliSqlite, refreshKiroToken } from "./kiro";
11
11
  import { loginChatGPT, refreshChatGPTToken } from "./chatgpt";
12
12
  import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity";
13
13
  import { loginCursor, refreshCursorToken } from "./cursor";
14
+ import { loginGithubCopilot, refreshGithubCopilotToken, validateCopilotApiBaseUrl } from "./github-copilot";
14
15
  import { deriveOAuthDefaultModel, deriveOAuthProviderConfig } from "../providers/derive";
15
16
  import { effectiveGoogleMode } from "../providers/registry";
16
17
  import { resolveProviderTransport } from "../providers/xai-transport";
@@ -100,6 +101,14 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderDef> = {
100
101
  providerConfig: oauthConfig("cursor"),
101
102
  defaultModel: oauthDefaultModel("cursor"),
102
103
  },
104
+ "github-copilot": {
105
+ login: (ctrl) => loginGithubCopilot(ctrl),
106
+ refresh: (rt, signal) => refreshGithubCopilotToken(rt, signal),
107
+ providerConfig: oauthConfig("github-copilot"),
108
+ defaultModel: oauthDefaultModel("github-copilot"),
109
+ // Unofficial Copilot bridge — keep proactive traffic lazy-only (no background guardian spam).
110
+ defaultRefreshPolicy: "lazy-only",
111
+ },
103
112
  chatgpt: {
104
113
  login: loginChatGPT,
105
114
  refresh: (rt) => refreshChatGPTToken(rt),
@@ -137,6 +146,11 @@ export function getOAuthCredentialProjectId(provider: string): string | undefine
137
146
  return getCredential(provider)?.projectId;
138
147
  }
139
148
 
149
+ /** Allowlisted Copilot API origin from the active credential, if still valid. */
150
+ export function getOAuthCredentialApiBaseUrl(provider: string): string | undefined {
151
+ return validateCopilotApiBaseUrl(getCredential(provider)?.apiBaseUrl);
152
+ }
153
+
140
154
  /** Provider ids that support real OAuth login (drives the GUI's "Log in with …" buttons). */
141
155
  export function listOAuthProviders(): string[] {
142
156
  return Object.keys(OAUTH_PROVIDERS).filter(isPublicOAuthProvider);
@@ -203,10 +217,13 @@ export async function getValidAccessTokenSnapshot(provider: string): Promise<OAu
203
217
  return resolveAccessSnapshotForAccount(provider, set.activeAccountId);
204
218
  }
205
219
 
220
+ /** Providers whose upstream-401 replay path may force a snapshot refresh. */
221
+ const FORCE_REFRESH_PROVIDERS = new Set(["xai", "github-copilot"]);
222
+
206
223
  export async function forceRefreshOAuthAccessSnapshot(
207
224
  rejected: OAuthAccessSnapshot,
208
225
  ): Promise<OAuthAccessSnapshot> {
209
- if (rejected.provider !== "xai") throw new UnsupportedOAuthProviderError(rejected.provider);
226
+ if (!FORCE_REFRESH_PROVIDERS.has(rejected.provider)) throw new UnsupportedOAuthProviderError(rejected.provider);
210
227
  return resolveAccessSnapshotForAccount(rejected.provider, rejected.accountId, rejected.generation);
211
228
  }
212
229
 
@@ -233,11 +250,25 @@ function readFreshKiroCliCredential(): OAuthCredentials | undefined {
233
250
  /** Terminal refresh failures (revoked/rotated-away grants) — retrying cannot succeed. */
234
251
  function isTerminalRefreshError(err: unknown): boolean {
235
252
  const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
236
- return msg.includes("invalid_grant") || msg.includes("refresh_token_reused") || msg.includes("revoked");
253
+ return msg.includes("invalid_grant")
254
+ || msg.includes("refresh_token_reused")
255
+ || msg.includes("revoked")
256
+ // GitHub Copilot refresh surfaces allowlisted OAuth codes (github-copilot.ts):
257
+ || msg.includes("access_denied")
258
+ || msg.includes("expired_token");
237
259
  }
238
260
  function terminal(error:unknown):boolean{return error instanceof XaiTokenRequestError?["invalid_grant","refresh_token_reused","revoked_token"].includes(error.oauthError??""):isTerminalRefreshError(error);}
239
261
  function authoritative(stored:OAuthCredentials,active:boolean,now:()=>number):OAuthCredentials{if(stored.source!=="local-cli")return stored;const disk=detectGrokCliToken();if(!disk)return stored;const allowed=isSameGrokIdentity(stored,disk)||(active&&!hasComparableGrokIdentity(stored,disk));return allowed&&shouldAdoptGrokGeneration(stored,disk,now(),REFRESH_SKEW_MS)?disk:stored;}
240
- function merged(fresh:OAuthCredentials,previous:OAuthCredentials):OAuthCredentials{return{...fresh,source:previous.source==="local-cli"?"oauth":fresh.source??previous.source??"oauth",...(fresh.projectId===undefined&&previous.projectId?{projectId:previous.projectId}:{}),...(fresh.email===undefined&&previous.email?{email:previous.email}:{}),...(fresh.accountId===undefined&&previous.accountId?{accountId:previous.accountId}:{})};}
262
+ function merged(fresh: OAuthCredentials, previous: OAuthCredentials): OAuthCredentials {
263
+ return {
264
+ ...fresh,
265
+ source: previous.source === "local-cli" ? "oauth" : fresh.source ?? previous.source ?? "oauth",
266
+ ...(fresh.projectId === undefined && previous.projectId ? { projectId: previous.projectId } : {}),
267
+ ...(fresh.apiBaseUrl === undefined && previous.apiBaseUrl ? { apiBaseUrl: previous.apiBaseUrl } : {}),
268
+ ...(fresh.email === undefined && previous.email ? { email: previous.email } : {}),
269
+ ...(fresh.accountId === undefined && previous.accountId ? { accountId: previous.accountId } : {}),
270
+ };
271
+ }
241
272
  export async function refreshXaiAccountWithLock(provider:string,accountId:string,def:OAuthProviderDef,callerCredential:OAuthCredentials,deps:XaiRefreshDeps={}):Promise<string>{const now=deps.now??Date.now;const guard=await(deps.intentLock??createOAuthRefreshIntentLock(provider,accountId)).acquire();try{const stored=getAccountCredential(provider,accountId);if(!stored)throw new OAuthLoginRequiredError(provider);const active=getAccountSet(provider)?.activeAccountId===accountId,candidate=authoritative(stored,active,now);if(credentialGeneration(candidate)!==credentialGeneration(callerCredential)&&candidate.expires>now()+REFRESH_SKEW_MS){if(credentialGeneration(candidate)!==credentialGeneration(stored)){const o=await mergeAccountCredential(provider,accountId,candidate,{expectedGeneration:credentialGeneration(stored),afterPrePersistRead:deps.afterPrePersistRead});if(o.superseded){if(o.stored.expires>now()+REFRESH_SKEW_MS)return o.stored.access;throw new OAuthLoginRequiredError(provider);}}return candidate.access;}if(cached(provider,accountId,candidate,now))throw new OAuthLoginRequiredError(provider);const generation=credentialGeneration(candidate);try{const fresh=merged(await def.refresh(candidate.refresh),candidate);const o=await mergeAccountCredential(provider,accountId,fresh,{expectedGeneration:generation,afterPrePersistRead:deps.afterPrePersistRead});if(o.superseded){if(o.stored.expires>now()+REFRESH_SKEW_MS)return o.stored.access;throw new OAuthLoginRequiredError(provider);}permanentRefreshFailures.delete(verdictKey(provider,accountId,candidate));if(candidate.source==="local-cli")console.warn(XAI_LOCAL_CLI_DETACH_WARNING);return fresh.access;}catch(error){if(!terminal(error))throw error;permanentRefreshFailures.set(verdictKey(provider,accountId,candidate),now()+XAI_PERMANENT_FAILURE_TTL_MS);await markAccountNeedsReauthIfGeneration(provider,accountId,generation);throw new OAuthLoginRequiredError(provider);}}finally{guard.release();}}
242
273
 
243
274
  async function refreshAndPersistAccessToken(
@@ -269,6 +300,7 @@ async function refreshAndPersistAccessToken(
269
300
  // Preserve a previously-discovered project id when a refresh-time re-discovery comes back empty
270
301
  // (e.g. a transient network blip), so Antigravity does not lose its CCA project across refresh.
271
302
  ...(fresh.projectId === undefined && cred.projectId ? { projectId: cred.projectId } : {}),
303
+ ...(fresh.apiBaseUrl === undefined && cred.apiBaseUrl ? { apiBaseUrl: cred.apiBaseUrl } : {}),
272
304
  // Preserve identity fields the refresh response may omit, so identity matching stays stable.
273
305
  ...(fresh.email === undefined && cred.email ? { email: cred.email } : {}),
274
306
  ...(fresh.accountId === undefined && cred.accountId ? { accountId: cred.accountId } : {}),
@@ -318,7 +350,12 @@ export async function resolveModelsAuthToken(name: string, prov: OcxProviderConf
318
350
  * response.
319
351
  */
320
352
  export function buildModelsRequest(prov: OcxProviderConfig, apiKey: string | undefined, providerName = ""): { url: string; headers: Record<string, string> } {
321
- const effectiveProvider = resolveProviderTransport(providerName, prov);
353
+ const effectiveProvider = resolveProviderTransport(
354
+ providerName,
355
+ prov,
356
+ undefined,
357
+ providerName === "github-copilot" ? getOAuthCredentialApiBaseUrl(providerName) : undefined,
358
+ );
322
359
  const headers: Record<string, string> = { ...(effectiveProvider.headers ?? {}) };
323
360
  if (effectiveGoogleMode(providerName, effectiveProvider) === "ai-studio") {
324
361
  // Generative Language API: API key goes in x-goog-api-key (never Authorization: Bearer),
package/src/oauth/kimi.ts CHANGED
@@ -32,6 +32,51 @@ interface TokenResponse {
32
32
  interval?: number;
33
33
  }
34
34
 
35
+ interface KimiJwtPayload {
36
+ user_id?: unknown;
37
+ sub?: unknown;
38
+ email?: unknown;
39
+ }
40
+
41
+ function decodeKimiJwtPayload(token: string): KimiJwtPayload | undefined {
42
+ const parts = token.split(".");
43
+ const payload = parts[1];
44
+ if (parts.length !== 3 || !payload) return undefined;
45
+ try {
46
+ return JSON.parse(Buffer.from(payload, "base64url").toString("utf-8")) as KimiJwtPayload;
47
+ } catch {
48
+ return undefined;
49
+ }
50
+ }
51
+
52
+ function nonEmptyString(value: unknown): string | undefined {
53
+ return typeof value === "string" && value.length > 0 ? value : undefined;
54
+ }
55
+
56
+ /**
57
+ * Stable multiauth identity from Kimi JWTs. `user_id` is preferred ACROSS both tokens
58
+ * before falling back to `sub` — the two claims come from the same issuer namespace but
59
+ * `sub` is the weaker fallback, so a refresh-token `user_id` must beat an access-token
60
+ * `sub`. Email fills from either token and is lowercased.
61
+ */
62
+ export function identityFromKimiTokens(accessToken: string, refreshToken?: string): {
63
+ accountId?: string;
64
+ email?: string;
65
+ } {
66
+ const access = decodeKimiJwtPayload(accessToken);
67
+ const refresh = refreshToken ? decodeKimiJwtPayload(refreshToken) : undefined;
68
+ const accountId =
69
+ nonEmptyString(access?.user_id) ??
70
+ nonEmptyString(refresh?.user_id) ??
71
+ nonEmptyString(access?.sub) ??
72
+ nonEmptyString(refresh?.sub);
73
+ const email = (nonEmptyString(access?.email) ?? nonEmptyString(refresh?.email))?.toLowerCase();
74
+ return {
75
+ ...(accountId ? { accountId } : {}),
76
+ ...(email ? { email } : {}),
77
+ };
78
+ }
79
+
35
80
  function resolveOAuthHost(): string {
36
81
  return process.env.KIMI_CODE_OAUTH_HOST || process.env.KIMI_OAUTH_HOST || DEFAULT_OAUTH_HOST;
37
82
  }
@@ -109,7 +154,13 @@ function parseTokenPayload(payload: TokenResponse, refreshFallback?: string): OA
109
154
  }
110
155
  const refresh = payload.refresh_token ?? refreshFallback;
111
156
  if (!refresh) throw new Error("Kimi token response missing refresh token");
112
- return { access: payload.access_token, refresh, expires: Date.now() + payload.expires_in * 1000 - OAUTH_EXPIRY_SKEW_MS };
157
+ const identity = identityFromKimiTokens(payload.access_token, refresh);
158
+ return {
159
+ access: payload.access_token,
160
+ refresh,
161
+ expires: Date.now() + payload.expires_in * 1000 - OAUTH_EXPIRY_SKEW_MS,
162
+ ...identity,
163
+ };
113
164
  }
114
165
 
115
166
  async function pollForToken(deviceCode: string, intervalMs: number, expiresInMs: number, signal?: AbortSignal): Promise<OAuthCredentials> {
@@ -10,15 +10,16 @@
10
10
  * Exceptions:
11
11
  * - `chatgpt` stays single-slot (always replaced): codex-auth-api uses it as a scratch slot
12
12
  * for Codex pool logins, which have their own ledger (codex-accounts.json).
13
- * - Credentials without identity (no accountId/email — kimi, kiro) replace the active slot
13
+ * - Credentials without identity (no accountId/email — e.g. kiro) replace the active slot
14
14
  * instead of appending: their refresh tokens rotate, so a derived id would duplicate the
15
- * same human on every re-login. Cursor login extracts JWT `sub` as accountId so multiauth
16
- * can append distinct accounts.
15
+ * same human on every re-login. Kimi extracts JWT `user_id`/`sub` as accountId; Cursor
16
+ * extracts JWT `sub` — both append distinct accounts under multiauth.
17
17
  */
18
18
  import { createHash, randomUUID } from "node:crypto";
19
19
  import { chmodSync, closeSync, copyFileSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
20
20
  import { join } from "node:path";
21
21
  import { getConfigDir, atomicWriteFile, backupInvalidConfig, hardenConfigDir, hardenExistingSecret } from "../config";
22
+ import { validateCopilotApiBaseUrl } from "./github-copilot";
22
23
  import type { OAuthCredentialSource, OAuthCredentials, ProviderAccount, ProviderAccountSet } from "./types";
23
24
 
24
25
  type AuthStore = Record<string, ProviderAccountSet>;
@@ -118,6 +119,12 @@ function normalizeCredential(cred: unknown): OAuthCredentials | null {
118
119
  if (typeof candidate.accountId === "string" && candidate.accountId.length > 0) normalized.accountId = candidate.accountId;
119
120
  if (isCredentialSource(candidate.source)) normalized.source = candidate.source;
120
121
  if (typeof candidate.projectId === "string" && candidate.projectId.length > 0) normalized.projectId = candidate.projectId;
122
+ if (typeof candidate.apiBaseUrl === "string" && candidate.apiBaseUrl.length > 0) {
123
+ // Persist only allowlisted Copilot origins; drop anything else so auth.json cannot
124
+ // become an SSRF springboard across reloads.
125
+ const validated = validateCopilotApiBaseUrl(candidate.apiBaseUrl);
126
+ if (validated) normalized.apiBaseUrl = validated;
127
+ }
121
128
  return normalized;
122
129
  }
123
130
 
@@ -223,6 +230,16 @@ export async function saveCredential(provider: string, cred: OAuthCredentials):
223
230
  set.activeAccountId = existing.id;
224
231
  return;
225
232
  }
233
+ // Legacy migration: a pre-identity row (no accountId/email) for this provider is the
234
+ // SAME human re-logging in after the identity extraction shipped — upgrading the
235
+ // active identity-less row in place prevents a stale duplicate that stays selectable
236
+ // and would re-refresh into a second row with the same identity.
237
+ const active = set.accounts.find(a => a.id === set.activeAccountId);
238
+ if (active && active.credential.accountId === undefined && active.credential.email === undefined) {
239
+ active.credential = safe;
240
+ delete active.needsReauth;
241
+ return;
242
+ }
226
243
  const id = newAccountId(safe);
227
244
  set.accounts.push({ id, credential: safe, addedAt: Date.now() });
228
245
  set.activeAccountId = id;
@@ -10,6 +10,11 @@ export type OAuthCredentials = {
10
10
  source?: OAuthCredentialSource;
11
11
  /** Google Antigravity (Cloud Code Assist) discovered project id; injected into the CCA envelope. */
12
12
  projectId?: string;
13
+ /**
14
+ * GitHub Copilot allowlisted API origin from token `endpoints.api` (HTTPS `*.githubcopilot.com` only).
15
+ * Never reuse for Antigravity projectId; validated on write and again at request time.
16
+ */
17
+ apiBaseUrl?: string;
13
18
  };
14
19
 
15
20
  /** One logged-in account inside a provider's account set (multiauth). */