@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.
- package/gui/dist/assets/{index-Dq3eZ1cU.css → index-BnrJO9Wz.css} +1 -1
- package/gui/dist/assets/{index-QyUBi3W_.js → index-BvQ5spEX.js} +3 -3
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/cli/index.ts +7 -2
- package/src/config.ts +60 -2
- package/src/lib/errors.ts +51 -12
- package/src/lib/process-control.ts +1 -1
- package/src/lib/redact.ts +8 -0
- package/src/lib/windows-secret-acl.ts +217 -74
- package/src/oauth/github-copilot.ts +427 -0
- package/src/oauth/index.ts +41 -4
- package/src/oauth/kimi.ts +52 -1
- package/src/oauth/store.ts +20 -3
- package/src/oauth/types.ts +5 -0
- package/src/providers/github-copilot-transport.ts +56 -0
- package/src/providers/quota.ts +111 -59
- package/src/providers/registry.ts +56 -2
- package/src/providers/xai-transport.ts +5 -0
- package/src/server/ports.ts +44 -2
- package/src/server/proxy-liveness.ts +26 -4
- package/src/server/request-log.ts +20 -6
- package/src/server/responses.ts +14 -5
- package/src/update/job.ts +59 -11
|
@@ -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
|
+
}
|
package/src/oauth/index.ts
CHANGED
|
@@ -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
|
|
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")
|
|
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{
|
|
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(
|
|
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
|
-
|
|
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> {
|
package/src/oauth/store.ts
CHANGED
|
@@ -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 —
|
|
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.
|
|
16
|
-
*
|
|
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;
|
package/src/oauth/types.ts
CHANGED
|
@@ -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). */
|