pi-freeflow 1.22.1 → 1.22.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.
@@ -4,13 +4,16 @@
4
4
  * Cline serves its free models direct-only (never through the relay pool),
5
5
  * one bearer key per login slot. Keys live in a 0600 file beside the relay
6
6
  * state; a corrupt file reads back as an empty pool (never throws, never
7
- * logs secrets). Request failover walks the pool with per-slot cooldowns.
7
+ * logs secrets). Request failover walks the pool in order, trying every slot
8
+ * fresh on every request — no slot is ever locked out.
8
9
  */
9
10
 
10
11
  import fs from "node:fs";
11
12
  import path from "node:path";
12
13
  import { RELAY_STATE_FILE } from "./config.ts";
13
14
  import { logWarn } from "./logger.ts";
15
+ import type { FetchImpl } from "./cline-device-auth.ts";
16
+ import { isWorkosJwt } from "./cline-device-auth.ts";
14
17
 
15
18
  /** Env override for the pool file location (tests/CI sandbox). */
16
19
  export const CLINE_POOL_FILE_ENV = "PI_FREEFLOW_CLINE_POOL_FILE";
@@ -29,14 +32,13 @@ export interface ClineAccount {
29
32
  slot: string;
30
33
  token: string;
31
34
  addedAt: string;
32
- lastOkAt?: number;
33
35
  /** WorkOS refresh token for device-login slots; absent on legacy key slots. */
34
36
  refreshToken?: string;
35
37
  /** Epoch-ms when `token` expires; absent means "no known expiry" (legacy slots). */
36
38
  expiresAt?: number;
37
- /** WorkOS account id, kept for display/diagnostics only. */
39
+ /** WorkOS account id, shown when choosing which login to remove. */
38
40
  accountId?: string;
39
- /** Login email, kept for display/diagnostics only. */
41
+ /** Login email, shown when choosing which login to remove. */
40
42
  email?: string;
41
43
  }
42
44
 
@@ -64,41 +66,31 @@ export interface ClineRefreshResult {
64
66
  */
65
67
  export type ClineRefresher = (refreshToken: string) => Promise<ClineRefreshResult | null>;
66
68
 
67
- /** Usable bearer for one slot, resolved via refresh when stale. */
68
- export interface ValidSlotCredentials {
69
- slot: string;
70
- token: string;
71
- }
72
-
73
- /** Cooldowns after failures so a bad key stops absorbing traffic. */
74
- const AUTH_COOLDOWN_MS = 10 * 60_000;
75
- const RATE_COOLDOWN_MS = 90_000;
76
- const SERVER_COOLDOWN_MS = 45_000;
77
- const DEFAULT_COOLDOWN_MS = 30_000;
78
-
79
- const slotCooldowns = new Map<string, number>();
80
-
81
- /** Test-only: clear in-memory slot cooldowns. */
82
- export function _resetClineCooldownsForTest(): void {
83
- slotCooldowns.clear();
84
- }
85
-
86
69
  /** Test-only: drop the loadPool mtime cache. */
87
70
  export function _resetClinePoolCacheForTest(): void {
88
71
  cached = null;
89
72
  cachedMtime = -1;
90
73
  }
91
74
 
92
- function markSlotCooldown(slot: string, ms: number): void {
93
- slotCooldowns.set(slot, Date.now() + ms);
94
- }
75
+ /** Version this proxy reports as its Cline client build. */
76
+ export const CLINE_CLIENT_VERSION = "3.5.54";
95
77
 
96
- /** True when the slot is not cooling down. */
97
- export function isClineSlotHealthy(slot: string): boolean {
98
- const until = slotCooldowns.get(slot);
99
- if (!until) return true;
100
- return Date.now() >= until;
101
- }
78
+ /**
79
+ * Client headers the Cline API expects. Measured live (2026-09-21): the
80
+ * `cline-free/*` models answer 403 "only available via Cline product surfaces"
81
+ * with a bare bearer, and 200 with this set; every non-prefixed catalog model
82
+ * (e.g. z-ai/glm-5.3-flash) answers 200 either way. Header names and shape
83
+ * mirror reference/cline providers/request-headers.ts
84
+ * (DEFAULT_CLINE_REQUEST_HEADERS) — no credential is derived from them.
85
+ */
86
+ export const CLINE_CLIENT_HEADERS: Record<string, string> = {
87
+ "HTTP-Referer": "https://cline.bot",
88
+ "X-Title": "Cline",
89
+ "X-IS-MULTIROOT": "false",
90
+ "X-CLIENT-TYPE": "cline-sdk",
91
+ "X-CLIENT-VERSION": CLINE_CLIENT_VERSION,
92
+ "User-Agent": `Cline/${CLINE_CLIENT_VERSION}`,
93
+ };
102
94
 
103
95
  /** Machine-classified outcome of one Cline chat attempt. */
104
96
  export type ClineErrorKind = "ok" | "auth" | "rate-limit" | "exhausted" | "server" | "client";
@@ -144,7 +136,7 @@ function readPoolFile(): ClinePoolState {
144
136
  const rec: Record<string, unknown> = entry as Record<string, unknown>;
145
137
  const slot = typeof rec.slot === "string" ? rec.slot.trim() : "";
146
138
  const token = typeof rec.token === "string" ? rec.token : "";
147
- if (!slot || !token.startsWith("workos:")) continue;
139
+ if (!slot || (!token.startsWith("workos:") && !isWorkosJwt(token) && !token.startsWith("clp_"))) continue;
148
140
  const refreshRaw: unknown = rec.refreshToken;
149
141
  const refreshToken = typeof refreshRaw === "string" && refreshRaw.length > 0 ? refreshRaw : undefined;
150
142
  const expiresRaw: unknown = rec.expiresAt;
@@ -159,7 +151,6 @@ function readPoolFile(): ClinePoolState {
159
151
  slot,
160
152
  token,
161
153
  addedAt: typeof rec.addedAt === "string" ? rec.addedAt : new Date().toISOString(),
162
- ...(typeof rec.lastOkAt === "number" ? { lastOkAt: rec.lastOkAt } : {}),
163
154
  ...(refreshToken ? { refreshToken } : {}),
164
155
  ...(expiresAt !== undefined ? { expiresAt } : {}),
165
156
  ...(accountId ? { accountId } : {}),
@@ -224,6 +215,29 @@ export function redactedToken(token: string): string {
224
215
  return `…${tail}`;
225
216
  }
226
217
 
218
+ /**
219
+ * Persist a patch to one slot against the file as it is on disk right now.
220
+ * A request holds its pool snapshot across network round trips, and the host
221
+ * process writes the same file for login/logout, so writing the snapshot back
222
+ * would revert a login added or removed while the request was in flight. A
223
+ * slot that no longer exists is left alone rather than resurrected.
224
+ */
225
+ function patchAccountOnDisk(slot: string, patch: Partial<ClineAccount>): void {
226
+ const live = readPoolFile();
227
+ const target = live.accounts.find((a) => a.slot === slot);
228
+ if (!target) return;
229
+ Object.assign(target, patch);
230
+ savePool(live);
231
+ }
232
+
233
+ /** Record which slot served a turn, without clobbering concurrent edits. */
234
+ function markActiveSlotOnDisk(slot: string): void {
235
+ const live = readPoolFile();
236
+ if (!live.accounts.some((a) => a.slot === slot)) return;
237
+ live.activeSlot = slot;
238
+ savePool(live);
239
+ }
240
+
227
241
  /**
228
242
  * Save (or replace) one login slot. The token must carry the `workos:` prefix.
229
243
  * Throws on bad input — the message never echoes the token.
@@ -238,7 +252,7 @@ export function addAccount(
238
252
  const cleanSlot = (slot || "").trim();
239
253
  if (!cleanSlot) throw new Error("Cline slot name cannot be empty");
240
254
  if (cleanSlot.length > 64) throw new Error("Cline slot name is too long (max 64 characters)");
241
- if (!token.startsWith("workos:")) throw new Error("Cline token must start with workos:");
255
+ if (!token.startsWith("workos:") && !isWorkosJwt(token) && !token.startsWith("clp_")) throw new Error("Cline token must be a workos: login grant or a clp_ API key");
242
256
  const pool = loadPool();
243
257
  const existing = pool.accounts.find((a) => a.slot === cleanSlot);
244
258
  if (existing) {
@@ -270,7 +284,6 @@ export function addAccount(
270
284
  });
271
285
  }
272
286
  if (!pool.activeSlot) pool.activeSlot = cleanSlot;
273
- slotCooldowns.delete(cleanSlot);
274
287
  savePool(pool);
275
288
  return pool;
276
289
  }
@@ -294,7 +307,6 @@ export function isClineTokenStale(account: ClineAccount, skewMs = CLINE_REFRESH_
294
307
  * caller should fall back to the stale bearer (`null`).
295
308
  */
296
309
  async function refreshAccountInPlace(
297
- pool: ClinePoolState,
298
310
  account: ClineAccount,
299
311
  refreshImpl: ClineRefresher,
300
312
  ): Promise<boolean | null> {
@@ -308,7 +320,7 @@ async function refreshAccountInPlace(
308
320
  void e;
309
321
  return null;
310
322
  }
311
- if (!fresh || typeof fresh.token !== "string" || !fresh.token.startsWith("workos:")) {
323
+ if (!fresh || typeof fresh.token !== "string" || (!fresh.token.startsWith("workos:") && !isWorkosJwt(fresh.token) && !fresh.token.startsWith("clp_"))) {
312
324
  logWarn("cline slot refresh rejected — grant is dead", { slot: account.slot });
313
325
  return false;
314
326
  }
@@ -321,33 +333,18 @@ async function refreshAccountInPlace(
321
333
  }
322
334
  if (fresh.accountId?.trim()) account.accountId = fresh.accountId.trim();
323
335
  if (fresh.email?.trim()) account.email = fresh.email.trim();
324
- savePool(pool);
336
+ // Persist only the refreshed fields: the snapshot this request holds may
337
+ // predate a login added or removed in the host process.
338
+ patchAccountOnDisk(account.slot, {
339
+ token: account.token,
340
+ ...(account.refreshToken ? { refreshToken: account.refreshToken } : {}),
341
+ expiresAt: account.expiresAt,
342
+ ...(account.accountId ? { accountId: account.accountId } : {}),
343
+ ...(account.email ? { email: account.email } : {}),
344
+ });
325
345
  return true;
326
346
  }
327
347
 
328
- /**
329
- * Resolve a usable bearer for one slot. Fresh slots and legacy slots without
330
- * a known expiry return immediately; stale slots with a refresh grant try one
331
- * refresh and persist it. Resolves null when the slot is unknown or its grant
332
- * is dead (invalid_grant) — the caller should prompt for a fresh login.
333
- */
334
- export async function getValidSlotCredentials(
335
- slot: string,
336
- opts?: { refreshImpl?: ClineRefresher; skewMs?: number },
337
- ): Promise<ValidSlotCredentials | null> {
338
- const cleanSlot = (slot || "").trim();
339
- if (!cleanSlot) return null;
340
- const pool = loadPool();
341
- const account = pool.accounts.find((a) => a.slot === cleanSlot);
342
- if (!account) return null;
343
- if (!isClineTokenStale(account, opts?.skewMs)) return { slot: account.slot, token: account.token };
344
- if (!account.refreshToken || !opts?.refreshImpl) return { slot: account.slot, token: account.token };
345
- const outcome = await refreshAccountInPlace(pool, account, opts.refreshImpl);
346
- if (outcome === true) return { slot: account.slot, token: account.token };
347
- if (outcome === false) return null;
348
- return { slot: account.slot, token: account.token };
349
- }
350
-
351
348
  /** Drop one login slot. Returns false when the slot was not saved. */
352
349
  export function removeAccount(slot: string): boolean {
353
350
  const cleanSlot = (slot || "").trim();
@@ -356,7 +353,6 @@ export function removeAccount(slot: string): boolean {
356
353
  if (idx < 0) return false;
357
354
  pool.accounts.splice(idx, 1);
358
355
  if (pool.activeSlot === cleanSlot) pool.activeSlot = pool.accounts[0]?.slot;
359
- slotCooldowns.delete(cleanSlot);
360
356
  savePool(pool);
361
357
  return true;
362
358
  }
@@ -366,13 +362,10 @@ export interface ClineRollOpts {
366
362
  body: string;
367
363
  /** Chat completions endpoint (proxy passes its configured URL explicitly). */
368
364
  chatUrl: string;
369
- /** Restrict the roll to these slots; default is the whole pool. */
370
- slots?: string[];
371
- fetchImpl?: typeof fetch;
372
- reqId?: string;
365
+ fetchImpl?: FetchImpl;
373
366
  /**
374
367
  * Device-login refresher. When present, a stale slot refreshes once before
375
- * its attempt, and a 401/403 refreshes once before the slot cools out.
368
+ * its attempt, and a 401/403 refreshes once before the slot is skipped.
376
369
  * Absent: every slot serves its stored bearer exactly as before.
377
370
  */
378
371
  refreshImpl?: ClineRefresher;
@@ -382,52 +375,54 @@ export interface ClineRollResult {
382
375
  res: Response;
383
376
  /** Slot that served the response; null when no account was tried. */
384
377
  slot: string | null;
385
- /** True when every account cooled out (or the pool is empty). */
378
+ /** True when every account failed (or the pool is empty). */
386
379
  exhausted: boolean;
387
380
  kind: ClineErrorKind;
388
381
  }
389
382
 
390
- function exhaustedResponse(reason: string): Response {
383
+ /**
384
+ * Synthetic body for the two states that never reached Cline: nothing is
385
+ * saved to sign in with, or no saved login could reach Cline. Neither is a
386
+ * rate limit, so it must not answer 429 — that status makes hosts back off
387
+ * and hide the actionable message behind a "rate limited" notice.
388
+ */
389
+ function exhaustedResponse(reason: string, status: number): Response {
391
390
  return new Response(
392
391
  JSON.stringify({ error: { message: reason, code: "cline_pool_exhausted" } }),
393
- { status: 429, headers: { "content-type": "application/json" } },
392
+ { status, headers: { "content-type": "application/json" } },
394
393
  );
395
394
  }
396
395
 
397
396
  /**
398
- * POST one chat body to the Cline endpoint, rolling across saved slots with
399
- * per-slot cooldowns. Each attempt sends its slot bearer explicitly; raw
400
- * tokens never leave this module. Response bodies are never consumed here —
401
- * the caller owns the returned Response.
397
+ * POST one chat body to the Cline endpoint, rolling across every saved slot.
398
+ * Each attempt sends its slot bearer explicitly; raw tokens never leave this
399
+ * module. Superseded failure bodies are cancelled here; the response handed
400
+ * back to the caller is left untouched so the caller can read it.
402
401
  */
403
402
  export async function rollChat(opts: ClineRollOpts): Promise<ClineRollResult> {
404
403
  const fetchImpl = opts.fetchImpl ?? fetch;
405
404
  const pool = loadPool();
406
- const wanted = opts.slots?.map((s) => s.trim()).filter(Boolean);
407
- let candidates = wanted?.length
408
- ? pool.accounts.filter((a) => wanted.includes(a.slot))
409
- : [...pool.accounts];
405
+ let candidates = [...pool.accounts];
410
406
  if (pool.activeSlot) {
411
407
  const at = candidates.findIndex((a) => a.slot === pool.activeSlot);
412
408
  if (at > 0) candidates = [candidates[at], ...candidates.slice(0, at), ...candidates.slice(at + 1)];
413
409
  }
414
- candidates = candidates.filter((a) => isClineSlotHealthy(a.slot));
415
410
  if (candidates.length === 0) {
416
- const reason = pool.accounts.length === 0
417
- ? "No Cline accounts saved — add one with /freeflow cline login"
418
- : "All Cline accounts are cooling down — try again shortly";
419
- logWarn("cline pool exhausted", { slots: pool.accounts.length });
420
- return { res: exhaustedResponse(reason), slot: null, exhausted: true, kind: "exhausted" };
411
+ const reason = "No Cline logins saved — add one with /freeflow cline login";
412
+ logWarn("cline pool empty", { slots: pool.accounts.length });
413
+ return { res: exhaustedResponse(reason, 401), slot: null, exhausted: true, kind: "exhausted" };
421
414
  }
415
+ let lastRes: Response | null = null;
416
+ let lastSlot: string | null = null;
417
+ let lastKind: ClineErrorKind = "exhausted";
422
418
  for (const account of candidates) {
423
419
  // Stale device-login bearer: one refresh before the attempt. A dead grant
424
- // cools the slot out immediately; a transient fault keeps the stale token.
420
+ // skips the slot for this turn; a transient fault keeps the stale token.
425
421
  let refreshedThisSlot = false;
426
422
  if (opts.refreshImpl && account.refreshToken && isClineTokenStale(account)) {
427
- const outcome = await refreshAccountInPlace(pool, account, opts.refreshImpl);
423
+ const outcome = await refreshAccountInPlace(account, opts.refreshImpl);
428
424
  if (outcome === false) {
429
- markSlotCooldown(account.slot, AUTH_COOLDOWN_MS);
430
- logWarn("cline slot refresh rejected — cooling slot", { slot: account.slot });
425
+ logWarn("cline slot refresh rejected — skipping slot this turn", { slot: account.slot });
431
426
  continue;
432
427
  }
433
428
  refreshedThisSlot = outcome === true;
@@ -438,67 +433,73 @@ export async function rollChat(opts: ClineRollOpts): Promise<ClineRollResult> {
438
433
  method: "POST",
439
434
  headers: {
440
435
  "content-type": "application/json",
436
+ ...CLINE_CLIENT_HEADERS,
441
437
  authorization: `Bearer ${token}`,
442
438
  },
443
439
  body: opts.body,
444
440
  });
445
441
  } catch (e) {
446
- markSlotCooldown(account.slot, DEFAULT_COOLDOWN_MS);
447
- logWarn("cline slot fetch error — cooling slot", { slot: account.slot });
442
+ logWarn("cline slot fetch error — trying next slot", { slot: account.slot });
448
443
  void e;
449
444
  return null;
450
445
  }
451
446
  };
452
- const succeed = (res: Response, kind: ClineErrorKind): ClineRollResult => {
453
- if (kind === "ok") {
454
- account.lastOkAt = Date.now();
455
- pool.activeSlot = account.slot;
456
- savePool(pool);
447
+ const succeed = async (res: Response, kind: ClineErrorKind): Promise<ClineRollResult> => {
448
+ if (kind === "ok") markActiveSlotOnDisk(account.slot);
449
+ if (lastRes && lastRes !== res) {
450
+ try { await lastRes.body?.cancel(); } catch { }
457
451
  }
452
+ lastRes = null;
458
453
  return { res, slot: account.slot, exhausted: false, kind };
459
454
  };
460
- const cool = (kind: ClineErrorKind, status: number): void => {
461
- markSlotCooldown(
462
- account.slot,
463
- kind === "auth" ? AUTH_COOLDOWN_MS : kind === "rate-limit" ? RATE_COOLDOWN_MS : SERVER_COOLDOWN_MS,
464
- );
465
- logWarn("cline slot failed — rolling to next slot", { slot: account.slot, status });
455
+ const stashFailure = async (res: Response, kind: ClineErrorKind): Promise<void> => {
456
+ if (lastRes && lastRes !== res) {
457
+ try { await lastRes.body?.cancel(); } catch { }
458
+ }
459
+ lastRes = res;
460
+ lastSlot = account.slot;
461
+ lastKind = kind;
466
462
  };
467
463
  let res = await attempt(account.token);
468
464
  if (!res) continue;
469
465
  let kind = mapClineError(res.status);
470
- if (kind === "ok" || kind === "client") return succeed(res, kind);
466
+ if (kind === "ok" || kind === "client") return await succeed(res, kind);
471
467
  // Auth failure on a refreshable slot that has not refreshed yet: one
472
468
  // refresh, then exactly one retry with the fresh bearer.
473
469
  if (kind === "auth" && !refreshedThisSlot && opts.refreshImpl && account.refreshToken) {
474
- try {
475
- await res.body?.cancel();
476
- } catch { }
477
- const outcome = await refreshAccountInPlace(pool, account, opts.refreshImpl);
470
+ const outcome = await refreshAccountInPlace(account, opts.refreshImpl);
478
471
  if (outcome === true) {
472
+ try {
473
+ await res.body?.cancel();
474
+ } catch { }
479
475
  refreshedThisSlot = true;
480
476
  res = await attempt(account.token);
481
477
  if (!res) continue;
482
478
  kind = mapClineError(res.status);
483
- if (kind === "ok" || kind === "client") return succeed(res, kind);
479
+ if (kind === "ok" || kind === "client") return await succeed(res, kind);
484
480
  } else if (outcome === false) {
485
481
  try {
486
482
  await res.body?.cancel();
487
483
  } catch { }
488
- cool(kind, res.status);
484
+ logWarn("cline slot refresh rejected — trying next slot", { slot: account.slot });
489
485
  continue;
490
486
  }
491
- // Transient refresh fault: fall through and cool the slot on the
492
- // original auth result below.
487
+ // Transient refresh fault: the original response is still intact, so it is
488
+ // stashed below and can be surfaced to the caller.
493
489
  }
494
- cool(kind, res.status);
495
- try {
496
- await res.body?.cancel();
497
- } catch { }
490
+ logWarn("cline slot failed — trying next slot", { slot: account.slot, status: res.status });
491
+ await stashFailure(res, kind);
492
+ }
493
+ if (lastRes) {
494
+ logWarn("cline pool exhausted after roll — returning last upstream failure", { slots: candidates.length });
495
+ return { res: lastRes, slot: lastSlot, exhausted: true, kind: lastKind };
498
496
  }
499
- logWarn("cline pool exhausted after roll", { slots: candidates.length });
497
+ // Every slot was skipped before producing a response: its saved login was
498
+ // rejected, or the network call threw. Nothing is rate-limited here, so say
499
+ // what actually happened instead of implying the user should wait.
500
+ logWarn("cline pool exhausted with no upstream response", { slots: candidates.length });
500
501
  return {
501
- res: exhaustedResponse("All Cline accounts failed or are cooling down — try again shortly"),
502
+ res: exhaustedResponse("Every saved Cline login failed to reach Cline — sign in again with /freeflow cline login", 502),
502
503
  slot: null,
503
504
  exhausted: true,
504
505
  kind: "exhausted",
@@ -7,6 +7,8 @@
7
7
  * mocked tests only, no live calls here.
8
8
  */
9
9
 
10
+ import { fetchWithSystemCA } from "./system-ca-fetch.ts";
11
+
10
12
  export interface OAuthCredentials {
11
13
  access: string;
12
14
  refresh: string;
@@ -58,12 +60,20 @@ export function resolveClineWorkosClientId(): string {
58
60
  return BUNDLED_CLINE_WORKOS_CLIENT_ID;
59
61
  }
60
62
 
61
- /** Resolved client id used by start/poll. */
62
- export const CLINE_WORKOS_CLIENT_ID = resolveClineWorkosClientId();
63
+ /**
64
+ * Wire form of a Cline credential. Cline OAuth access tokens are WorkOS JWTs
65
+ * (base64url eyJ header) and must ride as workos:<jwt>; dashboard API keys
66
+ * (clp_ apikey category) go verbatim — prefixing them 401s. Mirrors the
67
+ * 9router getClineAccessToken guard (open-sse/shared/clineAuth.js).
68
+ */
69
+ export function isWorkosJwt(token: string): boolean {
70
+ return /^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/.test(token.trim());
71
+ }
63
72
 
64
73
  export function toApiKey(accessToken: string): string {
65
74
  const token = accessToken.trim();
66
- return token.toLowerCase().startsWith(WORKOS_TOKEN_PREFIX) ? token : `${WORKOS_TOKEN_PREFIX}${token}`;
75
+ if (token.toLowerCase().startsWith(WORKOS_TOKEN_PREFIX)) return token;
76
+ return isWorkosJwt(token) ? `${WORKOS_TOKEN_PREFIX}${token}` : token;
67
77
  }
68
78
 
69
79
  const DEVICE_AUTHORIZATION_PATH = "/user_management/authorize/device";
@@ -73,7 +83,7 @@ const REFRESH_PATH = "/api/v1/auth/refresh";
73
83
 
74
84
  const HTTP_TIMEOUT_MS = 30 * 1000;
75
85
 
76
- export type FetchImpl = typeof fetch;
86
+ export type FetchImpl = (input: string, init?: RequestInit) => Promise<Response>;
77
87
 
78
88
  function joinUrl(base: string, path: string): string {
79
89
  return base.replace(/\/+$/, "") + path;
@@ -126,7 +136,7 @@ export function deriveExpiry(explicitMs: number | undefined, accessToken: string
126
136
 
127
137
  export async function startDeviceAuth(
128
138
  workosBase: string = DEFAULT_WORKOS_BASE,
129
- fetchImpl: FetchImpl = fetch,
139
+ fetchImpl: FetchImpl = fetchWithSystemCA,
130
140
  ): Promise<DeviceAuth> {
131
141
  const response = await fetchImpl(joinUrl(workosBase, DEVICE_AUTHORIZATION_PATH), {
132
142
  method: "POST",
@@ -173,7 +183,7 @@ export async function pollDeviceToken(
173
183
  options?: PollOptions,
174
184
  ): Promise<DevicePollTokens> {
175
185
  if (!deviceCode) throw new Error("deviceCode is required");
176
- const fetchFn = options?.fetchImpl ?? fetch;
186
+ const fetchFn = options?.fetchImpl ?? fetchWithSystemCA;
177
187
  const outerSignal = options?.signal;
178
188
  const deadline = Date.now() + (options?.maxWaitMs ?? intervalSeconds * 1000 * 60 * 10);
179
189
  let interval = Math.max(1, intervalSeconds);
@@ -199,7 +209,7 @@ export async function pollDeviceToken(
199
209
  }
200
210
  if (error instanceof ClineAuthError) throw error;
201
211
  if (Date.now() > deadline) throw new Error("WorkOS device authorization timed out");
202
- await sleepMs(interval * 1000, outerSignal);
212
+ await sleepMs(remainingMs(deadline, interval), outerSignal);
203
213
  continue;
204
214
  }
205
215
  const payload = (await response.json().catch(() => ({}))) as Record<string, unknown>;
@@ -219,6 +229,9 @@ export async function pollDeviceToken(
219
229
  payload.error === "invalid_grant"
220
230
  ) {
221
231
  throwDenied(payload, response.status, `Device login refused (${String(payload.error)})`);
232
+ } else if (response.status >= 500 || response.status === 429) {
233
+ // Transient WorkOS blip: keep polling until the code's own deadline rather
234
+ // than failing a login that would have succeeded seconds later.
222
235
  } else {
223
236
  throw new ClineAuthError(
224
237
  typeof payload.error_description === "string"
@@ -228,10 +241,19 @@ export async function pollDeviceToken(
228
241
  );
229
242
  }
230
243
  if (Date.now() > deadline) throw new Error("WorkOS device authorization timed out");
231
- await sleepMs(interval * 1000, outerSignal);
244
+ await sleepMs(remainingMs(deadline, interval), outerSignal);
232
245
  }
233
246
  }
234
247
 
248
+ /**
249
+ * Wait before the next poll, never past the device code's own expiry: a
250
+ * `slow_down` reply grows the interval, and sleeping the full interval after
251
+ * the deadline check would keep a dead login alive for minutes.
252
+ */
253
+ function remainingMs(deadline: number, intervalSeconds: number): number {
254
+ return Math.max(0, Math.min(intervalSeconds * 1000, deadline - Date.now()));
255
+ }
256
+
235
257
  async function sleepMs(ms: number, signal?: AbortSignal): Promise<void> {
236
258
  if (signal?.aborted) throw new ClineAuthError("Device login cancelled", { errorCode: "cancelled" });
237
259
  if (!signal) {
@@ -258,7 +280,7 @@ interface ClineTokenData {
258
280
  refreshToken?: string;
259
281
  tokenType: string;
260
282
  expiresAt: string;
261
- userInfo: { email: string; clineUserId: string | null };
283
+ userInfo?: { email?: string; clineUserId?: string | null };
262
284
  }
263
285
 
264
286
  function toCredentials(data: ClineTokenData, fallbackRefresh?: string): OAuthCredentials {
@@ -274,8 +296,8 @@ function toCredentials(data: ClineTokenData, fallbackRefresh?: string): OAuthCre
274
296
  access: data.accessToken,
275
297
  refresh,
276
298
  expires: deriveExpiry(explicit, data.accessToken),
277
- accountId: data.userInfo.clineUserId ?? undefined,
278
- email: data.userInfo.email || undefined,
299
+ accountId: data.userInfo?.clineUserId ?? undefined,
300
+ email: data.userInfo?.email || undefined,
279
301
  metadata: { tokenType: data.tokenType },
280
302
  };
281
303
  }
@@ -295,7 +317,7 @@ export async function registerClineToken(
295
317
  apiBase: string = DEFAULT_API_BASE,
296
318
  accessToken = "",
297
319
  refreshToken = "",
298
- fetchImpl: FetchImpl = fetch,
320
+ fetchImpl: FetchImpl = fetchWithSystemCA,
299
321
  ): Promise<OAuthCredentials> {
300
322
  if (!accessToken || !refreshToken) throw new Error("accessToken and refreshToken are required");
301
323
  const response = await fetchImpl(joinUrl(apiBase, REGISTER_PATH), {
@@ -316,7 +338,7 @@ export async function registerClineToken(
316
338
  export async function refreshClineToken(
317
339
  apiBase: string = DEFAULT_API_BASE,
318
340
  refreshToken = "",
319
- fetchImpl: FetchImpl = fetch,
341
+ fetchImpl: FetchImpl = fetchWithSystemCA,
320
342
  ): Promise<OAuthCredentials> {
321
343
  if (!refreshToken) throw new Error("refreshToken is required");
322
344
  const response = await fetchImpl(joinUrl(apiBase, REFRESH_PATH), {