pi-free 2.2.3 → 2.2.4

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,548 @@
1
+ /**
2
+ * Qoder authentication — OAuth device flow + PAT exchange + token refresh.
3
+ *
4
+ * Qoder supports two authentication methods:
5
+ * 1. **Personal Access Token (PAT):** A long-lived `pt-...` token that must
6
+ * be exchanged for a short-lived job token before it can be used for API calls.
7
+ * The PAT is stored and transparently re-exchanged on expiry.
8
+ * 2. **OAuth Device Flow:** PKCE-based browser login, polls for token completion.
9
+ *
10
+ * This module handles login orchestration, token refresh, and credential caching.
11
+ * It conforms to pi's OAuthLoginCallbacks interface so it works with `/login qoder`.
12
+ */
13
+
14
+ import crypto from "node:crypto";
15
+ import { existsSync, readFileSync } from "node:fs";
16
+ import { homedir } from "node:os";
17
+ import { join } from "node:path";
18
+ import type {
19
+ OAuthCredentials,
20
+ OAuthLoginCallbacks,
21
+ } from "@earendil-works/pi-ai";
22
+ import { getMachineId } from "./cosy.ts";
23
+
24
+ // ─── Constants ───────────────────────────────────────────────────────────────
25
+
26
+ const EXCHANGE_URL = "https://openapi.qoder.sh/api/v1/jobToken/exchange";
27
+ const USERINFO_URL = "https://openapi.qoder.sh/api/v1/userinfo";
28
+ const POLL_URL = "https://openapi.qoder.sh/api/v1/deviceToken/poll";
29
+ const REFRESH_URL = "https://center.qoder.sh/algo/api/v3/user/refresh_token";
30
+ const AUTH_FILE = join(homedir(), ".pi", "agent", "auth.json");
31
+ const UA = "pi-free-providers";
32
+
33
+ const PAT_REFRESH_PREFIX = "pat";
34
+
35
+ // ─── Types ───────────────────────────────────────────────────────────────────
36
+
37
+ /** Extended credentials with Qoder-specific identity fields. */
38
+ export interface QoderCredentials extends OAuthCredentials {
39
+ userID: string;
40
+ email: string;
41
+ name: string;
42
+ machineID: string;
43
+ }
44
+
45
+ interface PatExchangeResult {
46
+ jobToken: string;
47
+ jobRefreshToken: string;
48
+ expiresAt: number;
49
+ }
50
+
51
+ // ─── PAT helpers ─────────────────────────────────────────────────────────────
52
+
53
+ function isPatRefresh(refresh: string): boolean {
54
+ return refresh.startsWith(`${PAT_REFRESH_PREFIX}|`);
55
+ }
56
+
57
+ function encodePatRefresh(
58
+ pat: string,
59
+ jobRefreshToken: string,
60
+ userID: string,
61
+ machineID: string,
62
+ ): string {
63
+ return [PAT_REFRESH_PREFIX, pat, jobRefreshToken, userID, machineID].join(
64
+ "|",
65
+ );
66
+ }
67
+
68
+ function decodePatRefresh(refresh: string): {
69
+ pat: string;
70
+ jobRefreshToken: string;
71
+ userID: string;
72
+ machineID: string;
73
+ } {
74
+ const parts = refresh.split("|");
75
+ return {
76
+ pat: parts[1] || "",
77
+ jobRefreshToken: parts[2] || "",
78
+ userID: parts[3] || "",
79
+ machineID: parts[4] || "",
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Exchange a Qoder PAT (pt-...) for a short-lived job token (jt-...).
85
+ * This mirrors the official qodercli flow: PATs cannot authenticate API
86
+ * calls directly — they must first be exchanged.
87
+ */
88
+ async function exchangeJobToken(pat: string): Promise<PatExchangeResult> {
89
+ const res = await fetch(EXCHANGE_URL, {
90
+ method: "POST",
91
+ headers: {
92
+ "Content-Type": "application/json",
93
+ Accept: "application/json",
94
+ "User-Agent": UA,
95
+ "Cosy-Version": "1.0.1",
96
+ "Cosy-ClientType": "5",
97
+ },
98
+ body: JSON.stringify({ personal_token: pat }),
99
+ });
100
+
101
+ if (!res.ok) {
102
+ const text = await res.text().catch(() => "");
103
+ throw new Error(
104
+ `Qoder PAT exchange failed: ${res.status} ${res.statusText}. ${text.slice(0, 200)}`,
105
+ );
106
+ }
107
+
108
+ const data = (await res.json()) as {
109
+ token?: string;
110
+ refresh_token?: string;
111
+ expires_at?: string;
112
+ expires_in?: number;
113
+ };
114
+
115
+ if (!data.token) {
116
+ throw new Error("Qoder PAT exchange returned no job token");
117
+ }
118
+
119
+ let expiresAt = Date.now() + 24 * 60 * 60 * 1000;
120
+ if (data.expires_at) {
121
+ const parsed = Date.parse(data.expires_at);
122
+ if (!Number.isNaN(parsed)) expiresAt = parsed;
123
+ } else if (data.expires_in) {
124
+ // expires_in is in milliseconds per the observed API response
125
+ expiresAt = Date.now() + data.expires_in;
126
+ }
127
+
128
+ return {
129
+ jobToken: data.token,
130
+ jobRefreshToken: data.refresh_token || "",
131
+ expiresAt,
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Fetch user profile using a job token. Best-effort; returns empty strings
137
+ * on failure.
138
+ */
139
+ async function fetchUserInfo(jobToken: string): Promise<{
140
+ userID: string;
141
+ email: string;
142
+ name: string;
143
+ }> {
144
+ let userID = "";
145
+ let email = "";
146
+ let name = "";
147
+ try {
148
+ const res = await fetch(USERINFO_URL, {
149
+ headers: {
150
+ Authorization: `Bearer ${jobToken}`,
151
+ Accept: "application/json",
152
+ "User-Agent": UA,
153
+ "Cosy-Version": "1.0.1",
154
+ "Cosy-ClientType": "5",
155
+ },
156
+ });
157
+ if (res.ok) {
158
+ const info = (await res.json()) as {
159
+ id?: string;
160
+ email?: string;
161
+ name?: string;
162
+ username?: string;
163
+ };
164
+ userID = info.id || "";
165
+ email = info.email || "";
166
+ name = info.name || info.username || "";
167
+ }
168
+ } catch {
169
+ // Best-effort
170
+ }
171
+ return { userID, email, name };
172
+ }
173
+
174
+ /**
175
+ * Build full Qoder credentials from a Personal Access Token.
176
+ * Exchanges the PAT for a job token and resolves user identity.
177
+ */
178
+ async function credentialsFromPat(pat: string): Promise<QoderCredentials> {
179
+ const { jobToken, jobRefreshToken, expiresAt } = await exchangeJobToken(pat);
180
+ const { userID, email, name } = await fetchUserInfo(jobToken);
181
+ const machineID = getMachineId();
182
+
183
+ return {
184
+ refresh: encodePatRefresh(pat, jobRefreshToken, userID, machineID),
185
+ access: jobToken,
186
+ expires: expiresAt - 5 * 60 * 1000, // 5 min buffer
187
+ userID,
188
+ email,
189
+ name,
190
+ machineID,
191
+ } as QoderCredentials;
192
+ }
193
+
194
+ // ─── PKCE helpers ────────────────────────────────────────────────────────────
195
+
196
+ function generatePKCE() {
197
+ const codeVerifier = crypto.randomBytes(32).toString("base64url");
198
+ const codeChallenge = crypto
199
+ .createHash("sha256")
200
+ .update(codeVerifier)
201
+ .digest("base64url");
202
+ return { codeVerifier, codeChallenge };
203
+ }
204
+
205
+ function parseExpiresAt(s?: string, expiresInSeconds?: number): number {
206
+ if (s) {
207
+ const t = Date.parse(s);
208
+ if (!Number.isNaN(t)) return t;
209
+ const ms = Number.parseInt(s, 10);
210
+ if (!Number.isNaN(ms) && ms > 0) return ms;
211
+ }
212
+ if (expiresInSeconds && expiresInSeconds > 0) {
213
+ return Date.now() + expiresInSeconds * 1000;
214
+ }
215
+ return Date.now() + 30 * 24 * 60 * 60 * 1000; // default 30 days
216
+ }
217
+
218
+ function abortableDelay(ms: number, signal?: AbortSignal): Promise<void> {
219
+ if (signal?.aborted)
220
+ return Promise.reject(signal.reason || new Error("Login cancelled"));
221
+ return new Promise((resolve, reject) => {
222
+ const timer = setTimeout(() => {
223
+ signal?.removeEventListener("abort", onAbort);
224
+ resolve();
225
+ }, ms);
226
+ const onAbort = () => {
227
+ clearTimeout(timer);
228
+ reject(signal?.reason || new Error("Login cancelled"));
229
+ };
230
+ signal?.addEventListener("abort", onAbort, { once: true });
231
+ });
232
+ }
233
+
234
+ // ─── OAuth Device Flow ───────────────────────────────────────────────────────
235
+
236
+ async function buildDeviceFlowCredentials(
237
+ callbacks: OAuthLoginCallbacks,
238
+ tokenData: {
239
+ token: string;
240
+ user_id: string;
241
+ refresh_token: string;
242
+ expires_at?: string;
243
+ expires_in?: number;
244
+ },
245
+ machineID: string,
246
+ ): Promise<QoderCredentials> {
247
+ const expireMs = parseExpiresAt(tokenData.expires_at, tokenData.expires_in);
248
+
249
+ (callbacks as unknown as { onProgress?: (msg: string) => void }).onProgress?.(
250
+ "Fetching user profile...",
251
+ );
252
+ let email = "";
253
+ let name = "";
254
+ try {
255
+ const userinfoRes = await fetch(USERINFO_URL, {
256
+ method: "GET",
257
+ headers: {
258
+ Authorization: `Bearer ${tokenData.token}`,
259
+ Accept: "application/json",
260
+ "User-Agent": UA,
261
+ },
262
+ });
263
+ if (userinfoRes.ok) {
264
+ const userinfo = (await userinfoRes.json()) as {
265
+ email?: string;
266
+ name?: string;
267
+ username?: string;
268
+ };
269
+ email = userinfo.email || "";
270
+ name = userinfo.name || userinfo.username || "";
271
+ }
272
+ } catch {
273
+ // Best-effort
274
+ }
275
+
276
+ (callbacks as unknown as { onProgress?: (msg: string) => void }).onProgress?.(
277
+ "Login successful!",
278
+ );
279
+
280
+ return {
281
+ refresh: `${tokenData.refresh_token}|${tokenData.user_id}|${machineID}`,
282
+ access: tokenData.token,
283
+ expires: expireMs - 5 * 60 * 1000,
284
+ userID: tokenData.user_id,
285
+ email,
286
+ name,
287
+ machineID,
288
+ } as QoderCredentials;
289
+ }
290
+
291
+ // ─── OAuth Device Flow ───────────────────────────────────────────────────────
292
+
293
+ /**
294
+ * Run the PKCE-based OAuth device code flow.
295
+ * Opens a browser URL for the user to authenticate, then polls for the token.
296
+ */
297
+ async function runDeviceFlow(
298
+ callbacks: OAuthLoginCallbacks,
299
+ ): Promise<QoderCredentials> {
300
+ const { codeVerifier, codeChallenge } = generatePKCE();
301
+ const nonce = crypto.randomUUID();
302
+ const machineID = getMachineId();
303
+
304
+ const verificationURI = `https://qoder.com/device/selectAccounts?challenge=${codeChallenge}&challenge_method=S256&machine_id=${machineID}&nonce=${nonce}`;
305
+
306
+ // Notify user
307
+ (callbacks as unknown as { onProgress?: (msg: string) => void }).onProgress?.(
308
+ "Please complete login in your browser...",
309
+ );
310
+
311
+ (
312
+ callbacks as unknown as {
313
+ onAuth?: (info: { url: string; instructions: string }) => void;
314
+ }
315
+ ).onAuth?.({
316
+ url: verificationURI,
317
+ instructions: "Click to sign in with your Qoder account in the browser.",
318
+ });
319
+
320
+ const pollURL = `${POLL_URL}?nonce=${encodeURIComponent(nonce)}&verifier=${encodeURIComponent(codeVerifier)}&challenge_method=S256`;
321
+ const pollInterval = 2000;
322
+ const maxAttempts = 90; // 3 minutes
323
+
324
+ // Helper to read signal
325
+ const getSignal = (): AbortSignal | undefined =>
326
+ (callbacks as unknown as { signal?: AbortSignal }).signal;
327
+
328
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
329
+ if (getSignal()?.aborted) throw new Error("Login cancelled");
330
+ await abortableDelay(pollInterval, getSignal());
331
+
332
+ try {
333
+ const response = await fetch(pollURL, {
334
+ method: "GET",
335
+ headers: {
336
+ Accept: "application/json",
337
+ "User-Agent": UA,
338
+ },
339
+ signal: getSignal(),
340
+ });
341
+
342
+ if (response.status === 202 || response.status === 404) {
343
+ continue;
344
+ }
345
+
346
+ if (!response.ok) {
347
+ const errText = await response.text();
348
+ throw new Error(
349
+ `Device token poll failed: ${response.status} ${response.statusText}. Response: ${errText}`,
350
+ );
351
+ }
352
+
353
+ const tokenData = (await response.json()) as {
354
+ token: string;
355
+ user_id: string;
356
+ refresh_token: string;
357
+ expires_at?: string;
358
+ expires_in?: number;
359
+ };
360
+
361
+ if (!tokenData.token) {
362
+ throw new Error("Device token poll returned empty access token");
363
+ }
364
+
365
+ return buildDeviceFlowCredentials(callbacks, tokenData, machineID);
366
+ } catch (e: unknown) {
367
+ const err = e as { name?: string };
368
+ if (err.name === "AbortError" || getSignal()?.aborted) {
369
+ throw new Error("Login cancelled");
370
+ }
371
+ throw e;
372
+ }
373
+ }
374
+
375
+ throw new Error("Authorization timed out");
376
+ }
377
+
378
+ // ─── Public API ──────────────────────────────────────────────────────────────
379
+
380
+ /**
381
+ * Retrieve cached Qoder credentials (userID/email/name/machineID) from
382
+ * pi's auth store. Best-effort — returns null if not found.
383
+ */
384
+ export function getCachedCredentials(): QoderCredentials | null {
385
+ if (existsSync(AUTH_FILE)) {
386
+ try {
387
+ const auth = JSON.parse(readFileSync(AUTH_FILE, "utf-8"));
388
+ const creds = auth?.qoder;
389
+ if (creds?.userID) {
390
+ return creds as QoderCredentials;
391
+ }
392
+ } catch {
393
+ // Best-effort
394
+ }
395
+ }
396
+ return null;
397
+ }
398
+
399
+ /**
400
+ * Main login handler for `/login qoder`.
401
+ *
402
+ * Flow:
403
+ * 1. Check for PAT in env vars (QODER_PERSONAL_ACCESS_TOKEN or QODER_PAT)
404
+ * 2. If no PAT, prompt user for one (or choose browser login)
405
+ * 3. Exchange PAT for job token or run OAuth device flow
406
+ * 4. Cache models after login
407
+ */
408
+ export async function loginQoder(
409
+ callbacks: OAuthLoginCallbacks,
410
+ ): Promise<OAuthCredentials> {
411
+ // 1. Try environment variables first (PAT)
412
+ const pat = process.env.QODER_PERSONAL_ACCESS_TOKEN || process.env.QODER_PAT;
413
+ if (pat) {
414
+ try {
415
+ const creds = await credentialsFromPat(pat);
416
+ return creds as OAuthCredentials;
417
+ } catch {
418
+ (
419
+ callbacks as unknown as { onProgress?: (msg: string) => void }
420
+ ).onProgress?.(
421
+ "Environment PAT invalid, falling back to interactive login...",
422
+ );
423
+ }
424
+ }
425
+
426
+ // 2. Prompt for PAT or browser login
427
+ const prompt = (
428
+ callbacks as unknown as {
429
+ onPrompt: (p: {
430
+ message: string;
431
+ placeholder?: string;
432
+ allowEmpty?: boolean;
433
+ }) => Promise<string>;
434
+ }
435
+ ).onPrompt;
436
+
437
+ if (!prompt) {
438
+ throw new Error("Login cancelled: no prompt handler available");
439
+ }
440
+
441
+ const entered = await prompt({
442
+ message:
443
+ "Paste a Qoder Personal Access Token (pt-...), or leave empty for browser login",
444
+ placeholder: "pt-...",
445
+ allowEmpty: true,
446
+ });
447
+
448
+ if ((callbacks as unknown as { signal?: AbortSignal }).signal?.aborted) {
449
+ throw new Error("Login cancelled");
450
+ }
451
+
452
+ if (entered?.trim()) {
453
+ const creds = await credentialsFromPat(entered.trim());
454
+ return creds as OAuthCredentials;
455
+ }
456
+
457
+ // 3. OAuth device flow
458
+ return runDeviceFlow(callbacks) as Promise<OAuthCredentials>;
459
+ }
460
+
461
+ /**
462
+ * Token refresh handler.
463
+ *
464
+ * For PAT-based credentials: re-exchanges the stored PAT for a fresh job token.
465
+ * For OAuth-based credentials: calls the refresh_token endpoint.
466
+ * Falls back to extending validity by 1 hour if refresh fails.
467
+ */
468
+ export async function refreshQoderToken(
469
+ credentials: OAuthCredentials,
470
+ ): Promise<OAuthCredentials> {
471
+ // PAT-based: re-exchange
472
+ if (isPatRefresh(credentials.refresh)) {
473
+ const { pat } = decodePatRefresh(credentials.refresh);
474
+ if (pat) {
475
+ try {
476
+ const refreshed = await credentialsFromPat(pat);
477
+ return refreshed as OAuthCredentials;
478
+ } catch {
479
+ // Fall through to validity extension
480
+ }
481
+ }
482
+ return {
483
+ ...credentials,
484
+ expires: Date.now() + 60 * 60 * 1000, // extend 1 hour
485
+ };
486
+ }
487
+
488
+ // OAuth-based: use refresh token
489
+ const parts = credentials.refresh.split("|");
490
+ const refreshToken = parts[0] || "";
491
+ const userID = parts[1] || "";
492
+ const machineID = parts[2] || getMachineId();
493
+ const prev = credentials as Partial<QoderCredentials>;
494
+ const prevName = prev.name || "";
495
+ const prevEmail = prev.email || "";
496
+
497
+ try {
498
+ const response = await fetch(REFRESH_URL, {
499
+ method: "POST",
500
+ headers: {
501
+ "Content-Type": "application/json",
502
+ Authorization: `Bearer ${credentials.access}`,
503
+ Accept: "application/json",
504
+ "User-Agent": UA,
505
+ },
506
+ body: JSON.stringify({ refreshToken }),
507
+ });
508
+
509
+ if (response.ok) {
510
+ const data = (await response.json()) as {
511
+ token: string;
512
+ refresh_token?: string;
513
+ expires_at?: string;
514
+ expires_in?: number;
515
+ };
516
+
517
+ const newAccess = data.token;
518
+ const newRefresh = data.refresh_token || refreshToken;
519
+
520
+ let expireMs = Date.now() + 30 * 24 * 60 * 60 * 1000;
521
+ if (data.expires_at) {
522
+ const parsed = Date.parse(data.expires_at);
523
+ if (!Number.isNaN(parsed)) expireMs = parsed;
524
+ } else if (data.expires_in) {
525
+ expireMs = Date.now() + data.expires_in * 1000;
526
+ }
527
+
528
+ return {
529
+ ...credentials,
530
+ refresh: `${newRefresh}|${userID}|${machineID}`,
531
+ access: newAccess,
532
+ expires: expireMs - 5 * 60 * 1000,
533
+ userID,
534
+ email: prevEmail,
535
+ name: prevName,
536
+ machineID,
537
+ } as QoderCredentials;
538
+ }
539
+ } catch {
540
+ // Fall through
541
+ }
542
+
543
+ // Fallback: extend validity by 1 hour
544
+ return {
545
+ ...credentials,
546
+ expires: Date.now() + 60 * 60 * 1000,
547
+ };
548
+ }