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.
- package/CHANGELOG.md +18 -0
- package/package.json +1 -1
- package/src/catalog.ts +294 -286
- package/src/cline-accounts.ts +118 -117
- package/src/cline-device-auth.ts +35 -13
- package/src/commands.ts +57 -22
- package/src/daemon.ts +86 -91
- package/src/index.ts +2 -7
- package/src/proxy.ts +64 -5
- package/src/system-ca-fetch.ts +235 -0
package/src/cline-accounts.ts
CHANGED
|
@@ -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
|
|
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,
|
|
39
|
+
/** WorkOS account id, shown when choosing which login to remove. */
|
|
38
40
|
accountId?: string;
|
|
39
|
-
/** Login email,
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
}
|
|
75
|
+
/** Version this proxy reports as its Cline client build. */
|
|
76
|
+
export const CLINE_CLIENT_VERSION = "3.5.54";
|
|
95
77
|
|
|
96
|
-
/**
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
378
|
+
/** True when every account failed (or the pool is empty). */
|
|
386
379
|
exhausted: boolean;
|
|
387
380
|
kind: ClineErrorKind;
|
|
388
381
|
}
|
|
389
382
|
|
|
390
|
-
|
|
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
|
|
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
|
|
399
|
-
*
|
|
400
|
-
*
|
|
401
|
-
* the caller
|
|
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
|
-
|
|
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 =
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
//
|
|
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(
|
|
423
|
+
const outcome = await refreshAccountInPlace(account, opts.refreshImpl);
|
|
428
424
|
if (outcome === false) {
|
|
429
|
-
|
|
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
|
-
|
|
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
|
-
|
|
455
|
-
|
|
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
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
-
|
|
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
|
-
|
|
484
|
+
logWarn("cline slot refresh rejected — trying next slot", { slot: account.slot });
|
|
489
485
|
continue;
|
|
490
486
|
}
|
|
491
|
-
// Transient refresh fault:
|
|
492
|
-
//
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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
|
-
|
|
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("
|
|
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",
|
package/src/cline-device-auth.ts
CHANGED
|
@@ -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
|
-
/**
|
|
62
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 ??
|
|
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
|
|
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
|
|
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
|
|
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
|
|
278
|
-
email: data.userInfo
|
|
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 =
|
|
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 =
|
|
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), {
|