pi-freeflow 1.22.2 → 1.23.0
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/README.md +3 -0
- package/package.json +1 -1
- package/src/catalog.ts +294 -286
- package/src/cline-accounts.ts +112 -113
- package/src/cline-device-auth.ts +27 -8
- package/src/commands.ts +56 -21
- package/src/daemon.ts +86 -91
- package/src/index.ts +2 -7
- package/src/proxy.ts +55 -10
package/src/cline-accounts.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
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";
|
|
@@ -31,14 +32,13 @@ export interface ClineAccount {
|
|
|
31
32
|
slot: string;
|
|
32
33
|
token: string;
|
|
33
34
|
addedAt: string;
|
|
34
|
-
lastOkAt?: number;
|
|
35
35
|
/** WorkOS refresh token for device-login slots; absent on legacy key slots. */
|
|
36
36
|
refreshToken?: string;
|
|
37
37
|
/** Epoch-ms when `token` expires; absent means "no known expiry" (legacy slots). */
|
|
38
38
|
expiresAt?: number;
|
|
39
|
-
/** WorkOS account id,
|
|
39
|
+
/** WorkOS account id, shown when choosing which login to remove. */
|
|
40
40
|
accountId?: string;
|
|
41
|
-
/** Login email,
|
|
41
|
+
/** Login email, shown when choosing which login to remove. */
|
|
42
42
|
email?: string;
|
|
43
43
|
}
|
|
44
44
|
|
|
@@ -66,41 +66,31 @@ export interface ClineRefreshResult {
|
|
|
66
66
|
*/
|
|
67
67
|
export type ClineRefresher = (refreshToken: string) => Promise<ClineRefreshResult | null>;
|
|
68
68
|
|
|
69
|
-
/** Usable bearer for one slot, resolved via refresh when stale. */
|
|
70
|
-
export interface ValidSlotCredentials {
|
|
71
|
-
slot: string;
|
|
72
|
-
token: string;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** Cooldowns after failures so a bad key stops absorbing traffic. */
|
|
76
|
-
const AUTH_COOLDOWN_MS = 10 * 60_000;
|
|
77
|
-
const RATE_COOLDOWN_MS = 90_000;
|
|
78
|
-
const SERVER_COOLDOWN_MS = 45_000;
|
|
79
|
-
const DEFAULT_COOLDOWN_MS = 30_000;
|
|
80
|
-
|
|
81
|
-
const slotCooldowns = new Map<string, number>();
|
|
82
|
-
|
|
83
|
-
/** Test-only: clear in-memory slot cooldowns. */
|
|
84
|
-
export function _resetClineCooldownsForTest(): void {
|
|
85
|
-
slotCooldowns.clear();
|
|
86
|
-
}
|
|
87
|
-
|
|
88
69
|
/** Test-only: drop the loadPool mtime cache. */
|
|
89
70
|
export function _resetClinePoolCacheForTest(): void {
|
|
90
71
|
cached = null;
|
|
91
72
|
cachedMtime = -1;
|
|
92
73
|
}
|
|
93
74
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
75
|
+
/** Version this proxy reports as its Cline client build. */
|
|
76
|
+
export const CLINE_CLIENT_VERSION = "3.5.54";
|
|
97
77
|
|
|
98
|
-
/**
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
+
};
|
|
104
94
|
|
|
105
95
|
/** Machine-classified outcome of one Cline chat attempt. */
|
|
106
96
|
export type ClineErrorKind = "ok" | "auth" | "rate-limit" | "exhausted" | "server" | "client";
|
|
@@ -161,7 +151,6 @@ function readPoolFile(): ClinePoolState {
|
|
|
161
151
|
slot,
|
|
162
152
|
token,
|
|
163
153
|
addedAt: typeof rec.addedAt === "string" ? rec.addedAt : new Date().toISOString(),
|
|
164
|
-
...(typeof rec.lastOkAt === "number" ? { lastOkAt: rec.lastOkAt } : {}),
|
|
165
154
|
...(refreshToken ? { refreshToken } : {}),
|
|
166
155
|
...(expiresAt !== undefined ? { expiresAt } : {}),
|
|
167
156
|
...(accountId ? { accountId } : {}),
|
|
@@ -226,6 +215,29 @@ export function redactedToken(token: string): string {
|
|
|
226
215
|
return `…${tail}`;
|
|
227
216
|
}
|
|
228
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
|
+
|
|
229
241
|
/**
|
|
230
242
|
* Save (or replace) one login slot. The token must carry the `workos:` prefix.
|
|
231
243
|
* Throws on bad input — the message never echoes the token.
|
|
@@ -272,7 +284,6 @@ export function addAccount(
|
|
|
272
284
|
});
|
|
273
285
|
}
|
|
274
286
|
if (!pool.activeSlot) pool.activeSlot = cleanSlot;
|
|
275
|
-
slotCooldowns.delete(cleanSlot);
|
|
276
287
|
savePool(pool);
|
|
277
288
|
return pool;
|
|
278
289
|
}
|
|
@@ -296,7 +307,6 @@ export function isClineTokenStale(account: ClineAccount, skewMs = CLINE_REFRESH_
|
|
|
296
307
|
* caller should fall back to the stale bearer (`null`).
|
|
297
308
|
*/
|
|
298
309
|
async function refreshAccountInPlace(
|
|
299
|
-
pool: ClinePoolState,
|
|
300
310
|
account: ClineAccount,
|
|
301
311
|
refreshImpl: ClineRefresher,
|
|
302
312
|
): Promise<boolean | null> {
|
|
@@ -323,33 +333,18 @@ async function refreshAccountInPlace(
|
|
|
323
333
|
}
|
|
324
334
|
if (fresh.accountId?.trim()) account.accountId = fresh.accountId.trim();
|
|
325
335
|
if (fresh.email?.trim()) account.email = fresh.email.trim();
|
|
326
|
-
|
|
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
|
+
});
|
|
327
345
|
return true;
|
|
328
346
|
}
|
|
329
347
|
|
|
330
|
-
/**
|
|
331
|
-
* Resolve a usable bearer for one slot. Fresh slots and legacy slots without
|
|
332
|
-
* a known expiry return immediately; stale slots with a refresh grant try one
|
|
333
|
-
* refresh and persist it. Resolves null when the slot is unknown or its grant
|
|
334
|
-
* is dead (invalid_grant) — the caller should prompt for a fresh login.
|
|
335
|
-
*/
|
|
336
|
-
export async function getValidSlotCredentials(
|
|
337
|
-
slot: string,
|
|
338
|
-
opts?: { refreshImpl?: ClineRefresher; skewMs?: number },
|
|
339
|
-
): Promise<ValidSlotCredentials | null> {
|
|
340
|
-
const cleanSlot = (slot || "").trim();
|
|
341
|
-
if (!cleanSlot) return null;
|
|
342
|
-
const pool = loadPool();
|
|
343
|
-
const account = pool.accounts.find((a) => a.slot === cleanSlot);
|
|
344
|
-
if (!account) return null;
|
|
345
|
-
if (!isClineTokenStale(account, opts?.skewMs)) return { slot: account.slot, token: account.token };
|
|
346
|
-
if (!account.refreshToken || !opts?.refreshImpl) return { slot: account.slot, token: account.token };
|
|
347
|
-
const outcome = await refreshAccountInPlace(pool, account, opts.refreshImpl);
|
|
348
|
-
if (outcome === true) return { slot: account.slot, token: account.token };
|
|
349
|
-
if (outcome === false) return null;
|
|
350
|
-
return { slot: account.slot, token: account.token };
|
|
351
|
-
}
|
|
352
|
-
|
|
353
348
|
/** Drop one login slot. Returns false when the slot was not saved. */
|
|
354
349
|
export function removeAccount(slot: string): boolean {
|
|
355
350
|
const cleanSlot = (slot || "").trim();
|
|
@@ -358,7 +353,6 @@ export function removeAccount(slot: string): boolean {
|
|
|
358
353
|
if (idx < 0) return false;
|
|
359
354
|
pool.accounts.splice(idx, 1);
|
|
360
355
|
if (pool.activeSlot === cleanSlot) pool.activeSlot = pool.accounts[0]?.slot;
|
|
361
|
-
slotCooldowns.delete(cleanSlot);
|
|
362
356
|
savePool(pool);
|
|
363
357
|
return true;
|
|
364
358
|
}
|
|
@@ -368,13 +362,10 @@ export interface ClineRollOpts {
|
|
|
368
362
|
body: string;
|
|
369
363
|
/** Chat completions endpoint (proxy passes its configured URL explicitly). */
|
|
370
364
|
chatUrl: string;
|
|
371
|
-
/** Restrict the roll to these slots; default is the whole pool. */
|
|
372
|
-
slots?: string[];
|
|
373
365
|
fetchImpl?: FetchImpl;
|
|
374
|
-
reqId?: string;
|
|
375
366
|
/**
|
|
376
367
|
* Device-login refresher. When present, a stale slot refreshes once before
|
|
377
|
-
* 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.
|
|
378
369
|
* Absent: every slot serves its stored bearer exactly as before.
|
|
379
370
|
*/
|
|
380
371
|
refreshImpl?: ClineRefresher;
|
|
@@ -384,52 +375,54 @@ export interface ClineRollResult {
|
|
|
384
375
|
res: Response;
|
|
385
376
|
/** Slot that served the response; null when no account was tried. */
|
|
386
377
|
slot: string | null;
|
|
387
|
-
/** True when every account
|
|
378
|
+
/** True when every account failed (or the pool is empty). */
|
|
388
379
|
exhausted: boolean;
|
|
389
380
|
kind: ClineErrorKind;
|
|
390
381
|
}
|
|
391
382
|
|
|
392
|
-
|
|
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 {
|
|
393
390
|
return new Response(
|
|
394
391
|
JSON.stringify({ error: { message: reason, code: "cline_pool_exhausted" } }),
|
|
395
|
-
{ status
|
|
392
|
+
{ status, headers: { "content-type": "application/json" } },
|
|
396
393
|
);
|
|
397
394
|
}
|
|
398
395
|
|
|
399
396
|
/**
|
|
400
|
-
* POST one chat body to the Cline endpoint, rolling across saved
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
* 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.
|
|
404
401
|
*/
|
|
405
402
|
export async function rollChat(opts: ClineRollOpts): Promise<ClineRollResult> {
|
|
406
403
|
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
407
404
|
const pool = loadPool();
|
|
408
|
-
|
|
409
|
-
let candidates = wanted?.length
|
|
410
|
-
? pool.accounts.filter((a) => wanted.includes(a.slot))
|
|
411
|
-
: [...pool.accounts];
|
|
405
|
+
let candidates = [...pool.accounts];
|
|
412
406
|
if (pool.activeSlot) {
|
|
413
407
|
const at = candidates.findIndex((a) => a.slot === pool.activeSlot);
|
|
414
408
|
if (at > 0) candidates = [candidates[at], ...candidates.slice(0, at), ...candidates.slice(at + 1)];
|
|
415
409
|
}
|
|
416
|
-
candidates = candidates.filter((a) => isClineSlotHealthy(a.slot));
|
|
417
410
|
if (candidates.length === 0) {
|
|
418
|
-
const reason =
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
logWarn("cline pool exhausted", { slots: pool.accounts.length });
|
|
422
|
-
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" };
|
|
423
414
|
}
|
|
415
|
+
let lastRes: Response | null = null;
|
|
416
|
+
let lastSlot: string | null = null;
|
|
417
|
+
let lastKind: ClineErrorKind = "exhausted";
|
|
424
418
|
for (const account of candidates) {
|
|
425
419
|
// Stale device-login bearer: one refresh before the attempt. A dead grant
|
|
426
|
-
//
|
|
420
|
+
// skips the slot for this turn; a transient fault keeps the stale token.
|
|
427
421
|
let refreshedThisSlot = false;
|
|
428
422
|
if (opts.refreshImpl && account.refreshToken && isClineTokenStale(account)) {
|
|
429
|
-
const outcome = await refreshAccountInPlace(
|
|
423
|
+
const outcome = await refreshAccountInPlace(account, opts.refreshImpl);
|
|
430
424
|
if (outcome === false) {
|
|
431
|
-
|
|
432
|
-
logWarn("cline slot refresh rejected — cooling slot", { slot: account.slot });
|
|
425
|
+
logWarn("cline slot refresh rejected — skipping slot this turn", { slot: account.slot });
|
|
433
426
|
continue;
|
|
434
427
|
}
|
|
435
428
|
refreshedThisSlot = outcome === true;
|
|
@@ -440,67 +433,73 @@ export async function rollChat(opts: ClineRollOpts): Promise<ClineRollResult> {
|
|
|
440
433
|
method: "POST",
|
|
441
434
|
headers: {
|
|
442
435
|
"content-type": "application/json",
|
|
436
|
+
...CLINE_CLIENT_HEADERS,
|
|
443
437
|
authorization: `Bearer ${token}`,
|
|
444
438
|
},
|
|
445
439
|
body: opts.body,
|
|
446
440
|
});
|
|
447
441
|
} catch (e) {
|
|
448
|
-
|
|
449
|
-
logWarn("cline slot fetch error — cooling slot", { slot: account.slot });
|
|
442
|
+
logWarn("cline slot fetch error — trying next slot", { slot: account.slot });
|
|
450
443
|
void e;
|
|
451
444
|
return null;
|
|
452
445
|
}
|
|
453
446
|
};
|
|
454
|
-
const succeed = (res: Response, kind: ClineErrorKind): ClineRollResult => {
|
|
455
|
-
if (kind === "ok")
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
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 { }
|
|
459
451
|
}
|
|
452
|
+
lastRes = null;
|
|
460
453
|
return { res, slot: account.slot, exhausted: false, kind };
|
|
461
454
|
};
|
|
462
|
-
const
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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;
|
|
468
462
|
};
|
|
469
463
|
let res = await attempt(account.token);
|
|
470
464
|
if (!res) continue;
|
|
471
465
|
let kind = mapClineError(res.status);
|
|
472
|
-
if (kind === "ok" || kind === "client") return succeed(res, kind);
|
|
466
|
+
if (kind === "ok" || kind === "client") return await succeed(res, kind);
|
|
473
467
|
// Auth failure on a refreshable slot that has not refreshed yet: one
|
|
474
468
|
// refresh, then exactly one retry with the fresh bearer.
|
|
475
469
|
if (kind === "auth" && !refreshedThisSlot && opts.refreshImpl && account.refreshToken) {
|
|
476
|
-
|
|
477
|
-
await res.body?.cancel();
|
|
478
|
-
} catch { }
|
|
479
|
-
const outcome = await refreshAccountInPlace(pool, account, opts.refreshImpl);
|
|
470
|
+
const outcome = await refreshAccountInPlace(account, opts.refreshImpl);
|
|
480
471
|
if (outcome === true) {
|
|
472
|
+
try {
|
|
473
|
+
await res.body?.cancel();
|
|
474
|
+
} catch { }
|
|
481
475
|
refreshedThisSlot = true;
|
|
482
476
|
res = await attempt(account.token);
|
|
483
477
|
if (!res) continue;
|
|
484
478
|
kind = mapClineError(res.status);
|
|
485
|
-
if (kind === "ok" || kind === "client") return succeed(res, kind);
|
|
479
|
+
if (kind === "ok" || kind === "client") return await succeed(res, kind);
|
|
486
480
|
} else if (outcome === false) {
|
|
487
481
|
try {
|
|
488
482
|
await res.body?.cancel();
|
|
489
483
|
} catch { }
|
|
490
|
-
|
|
484
|
+
logWarn("cline slot refresh rejected — trying next slot", { slot: account.slot });
|
|
491
485
|
continue;
|
|
492
486
|
}
|
|
493
|
-
// Transient refresh fault:
|
|
494
|
-
//
|
|
487
|
+
// Transient refresh fault: the original response is still intact, so it is
|
|
488
|
+
// stashed below and can be surfaced to the caller.
|
|
495
489
|
}
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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 };
|
|
500
496
|
}
|
|
501
|
-
|
|
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 });
|
|
502
501
|
return {
|
|
503
|
-
res: exhaustedResponse("
|
|
502
|
+
res: exhaustedResponse("Every saved Cline login failed to reach Cline — sign in again with /freeflow cline login", 502),
|
|
504
503
|
slot: null,
|
|
505
504
|
exhausted: true,
|
|
506
505
|
kind: "exhausted",
|
package/src/cline-device-auth.ts
CHANGED
|
@@ -45,6 +45,16 @@ export const DEFAULT_WORKOS_BASE = "https://api.workos.com";
|
|
|
45
45
|
export const DEFAULT_API_BASE = "https://api.cline.bot";
|
|
46
46
|
export const WORKOS_TOKEN_PREFIX = "workos:";
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Cline's own sign-out route, for switching accounts in the browser. Verified
|
|
50
|
+
* live: it answers 302 -> https://app.cline.bot/dashboard. Cline ships no
|
|
51
|
+
* hosted sign-out page on its auth host (every /logout, /sign-out, /signout,
|
|
52
|
+
* /account path there is 404), so this app route is the one to point at.
|
|
53
|
+
* The device flow reuses whatever account the browser is signed in with, and
|
|
54
|
+
* a private window clears that without ending the current session.
|
|
55
|
+
*/
|
|
56
|
+
export const CLINE_BROWSER_SIGNOUT_URL = "https://api.cline.bot/api/v1/auth/logout";
|
|
57
|
+
|
|
48
58
|
/** Env override for the WorkOS client id (wins over the bundled default). */
|
|
49
59
|
export const CLINE_WORKOS_CLIENT_ID_ENV = "PI_FREEFLOW_CLINE_WORKOS_CLIENT_ID";
|
|
50
60
|
|
|
@@ -60,9 +70,6 @@ export function resolveClineWorkosClientId(): string {
|
|
|
60
70
|
return BUNDLED_CLINE_WORKOS_CLIENT_ID;
|
|
61
71
|
}
|
|
62
72
|
|
|
63
|
-
/** Resolved client id used by start/poll. */
|
|
64
|
-
export const CLINE_WORKOS_CLIENT_ID = resolveClineWorkosClientId();
|
|
65
|
-
|
|
66
73
|
/**
|
|
67
74
|
* Wire form of a Cline credential. Cline OAuth access tokens are WorkOS JWTs
|
|
68
75
|
* (base64url eyJ header) and must ride as workos:<jwt>; dashboard API keys
|
|
@@ -212,7 +219,7 @@ export async function pollDeviceToken(
|
|
|
212
219
|
}
|
|
213
220
|
if (error instanceof ClineAuthError) throw error;
|
|
214
221
|
if (Date.now() > deadline) throw new Error("WorkOS device authorization timed out");
|
|
215
|
-
await sleepMs(interval
|
|
222
|
+
await sleepMs(remainingMs(deadline, interval), outerSignal);
|
|
216
223
|
continue;
|
|
217
224
|
}
|
|
218
225
|
const payload = (await response.json().catch(() => ({}))) as Record<string, unknown>;
|
|
@@ -232,6 +239,9 @@ export async function pollDeviceToken(
|
|
|
232
239
|
payload.error === "invalid_grant"
|
|
233
240
|
) {
|
|
234
241
|
throwDenied(payload, response.status, `Device login refused (${String(payload.error)})`);
|
|
242
|
+
} else if (response.status >= 500 || response.status === 429) {
|
|
243
|
+
// Transient WorkOS blip: keep polling until the code's own deadline rather
|
|
244
|
+
// than failing a login that would have succeeded seconds later.
|
|
235
245
|
} else {
|
|
236
246
|
throw new ClineAuthError(
|
|
237
247
|
typeof payload.error_description === "string"
|
|
@@ -241,10 +251,19 @@ export async function pollDeviceToken(
|
|
|
241
251
|
);
|
|
242
252
|
}
|
|
243
253
|
if (Date.now() > deadline) throw new Error("WorkOS device authorization timed out");
|
|
244
|
-
await sleepMs(interval
|
|
254
|
+
await sleepMs(remainingMs(deadline, interval), outerSignal);
|
|
245
255
|
}
|
|
246
256
|
}
|
|
247
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Wait before the next poll, never past the device code's own expiry: a
|
|
260
|
+
* `slow_down` reply grows the interval, and sleeping the full interval after
|
|
261
|
+
* the deadline check would keep a dead login alive for minutes.
|
|
262
|
+
*/
|
|
263
|
+
function remainingMs(deadline: number, intervalSeconds: number): number {
|
|
264
|
+
return Math.max(0, Math.min(intervalSeconds * 1000, deadline - Date.now()));
|
|
265
|
+
}
|
|
266
|
+
|
|
248
267
|
async function sleepMs(ms: number, signal?: AbortSignal): Promise<void> {
|
|
249
268
|
if (signal?.aborted) throw new ClineAuthError("Device login cancelled", { errorCode: "cancelled" });
|
|
250
269
|
if (!signal) {
|
|
@@ -271,7 +290,7 @@ interface ClineTokenData {
|
|
|
271
290
|
refreshToken?: string;
|
|
272
291
|
tokenType: string;
|
|
273
292
|
expiresAt: string;
|
|
274
|
-
userInfo
|
|
293
|
+
userInfo?: { email?: string; clineUserId?: string | null };
|
|
275
294
|
}
|
|
276
295
|
|
|
277
296
|
function toCredentials(data: ClineTokenData, fallbackRefresh?: string): OAuthCredentials {
|
|
@@ -287,8 +306,8 @@ function toCredentials(data: ClineTokenData, fallbackRefresh?: string): OAuthCre
|
|
|
287
306
|
access: data.accessToken,
|
|
288
307
|
refresh,
|
|
289
308
|
expires: deriveExpiry(explicit, data.accessToken),
|
|
290
|
-
accountId: data.userInfo
|
|
291
|
-
email: data.userInfo
|
|
309
|
+
accountId: data.userInfo?.clineUserId ?? undefined,
|
|
310
|
+
email: data.userInfo?.email || undefined,
|
|
292
311
|
metadata: { tokenType: data.tokenType },
|
|
293
312
|
};
|
|
294
313
|
}
|
package/src/commands.ts
CHANGED
|
@@ -58,9 +58,9 @@ import {
|
|
|
58
58
|
formatRelayPickerItem,
|
|
59
59
|
formatRelayStatusLabel,
|
|
60
60
|
} from "./relay-state.ts";
|
|
61
|
-
import { addAccount,
|
|
61
|
+
import { addAccount, loadPool, redactedToken, removeAccount } from "./cline-accounts.ts";
|
|
62
62
|
import type { ClinePoolState } from "./cline-accounts.ts";
|
|
63
|
-
import { pollDeviceToken, registerClineToken, startDeviceAuth, toApiKey } from "./cline-device-auth.ts";
|
|
63
|
+
import { CLINE_BROWSER_SIGNOUT_URL, pollDeviceToken, registerClineToken, startDeviceAuth, toApiKey } from "./cline-device-auth.ts";
|
|
64
64
|
import { isCertError } from "./system-ca-fetch.ts";
|
|
65
65
|
import type {
|
|
66
66
|
ExtensionAPI,
|
|
@@ -367,8 +367,9 @@ function nextClineSlot(pool: ClinePoolState): string {
|
|
|
367
367
|
function formatClineAccountLines(pool: ClinePoolState): string[] {
|
|
368
368
|
return pool.accounts.map((a, idx) => {
|
|
369
369
|
const star = a.slot === pool.activeSlot ? "*" : " ";
|
|
370
|
-
const
|
|
371
|
-
|
|
370
|
+
const who = a.email || a.accountId;
|
|
371
|
+
const whoPart = who ? ` ${who}` : "";
|
|
372
|
+
return `${star} [${idx + 1}] [${a.slot}]${whoPart} key ending ${redactedToken(a.token)}`;
|
|
372
373
|
});
|
|
373
374
|
}
|
|
374
375
|
/** User-safe one-line message for caught values (never echoes secrets). */
|
|
@@ -1462,7 +1463,7 @@ export function createCommandSpec(
|
|
|
1462
1463
|
}
|
|
1463
1464
|
const link = started.verificationUriComplete ?? started.verificationUri;
|
|
1464
1465
|
const minutes = Math.max(1, Math.round(started.expiresIn / 60));
|
|
1465
|
-
const loginLine = `Cline login [${slot}]\nOpen this link in your browser:\n${link}\nEnter code: ${started.userCode} (expires in ~${minutes} min)\nWaiting for approval — approve or cancel in the browser; this finishes on its own
|
|
1466
|
+
const loginLine = `Cline login [${slot}]\nOpen this link in your browser:\n${link}\nEnter code: ${started.userCode} (expires in ~${minutes} min)\nWaiting for approval — approve or cancel in the browser; this finishes on its own.\nDifferent account? Sign out first: ${CLINE_BROWSER_SIGNOUT_URL} (or use a private window)`;
|
|
1466
1467
|
ctx.ui.notify(loginLine, "info");
|
|
1467
1468
|
try { ctx.ui.setStatus("cline-login", `Cline login [${slot}] code ${started.userCode}`); } catch { }
|
|
1468
1469
|
const progress = setInterval(() => {
|
|
@@ -1488,9 +1489,7 @@ export function createCommandSpec(
|
|
|
1488
1489
|
if (e && typeof e === "object" && "errorCode" in e && typeof e.errorCode === "string") {
|
|
1489
1490
|
code = e.errorCode;
|
|
1490
1491
|
}
|
|
1491
|
-
if (code === "
|
|
1492
|
-
ctx.ui.notify(loginLine, "info");
|
|
1493
|
-
} else if (code === "access_denied" || code === "cancelled") {
|
|
1492
|
+
if (code === "access_denied") {
|
|
1494
1493
|
ctx.ui.notify("Cline login cancelled.", "warning");
|
|
1495
1494
|
} else if (code === "expired_token") {
|
|
1496
1495
|
ctx.ui.notify("Cline login code expired — run /freeflow cline login again for a fresh code.", "warning");
|
|
@@ -1506,24 +1505,60 @@ export function createCommandSpec(
|
|
|
1506
1505
|
if (!pool.accounts.length) {
|
|
1507
1506
|
ctx.ui.notify("No Cline logins saved — add one with /freeflow cline login", "info");
|
|
1508
1507
|
} else {
|
|
1509
|
-
|
|
1510
|
-
const star = a.slot === pool.activeSlot ? "*" : " ";
|
|
1511
|
-
const health = isClineSlotHealthy(a.slot) ? "ready" : "cooling";
|
|
1512
|
-
return `${star} [${idx + 1}] [${a.slot}] key ending ${redactedToken(a.token)} — ${health}`;
|
|
1513
|
-
});
|
|
1514
|
-
ctx.ui.notify(`Cline logins (${pool.accounts.length}):\n${lines.join("\n")}`, "info");
|
|
1508
|
+
ctx.ui.notify(`Cline logins (${pool.accounts.length}):\n${formatClineAccountLines(pool).join("\n")}`, "info");
|
|
1515
1509
|
}
|
|
1516
1510
|
} else if (action === "logout" || action === "remove") {
|
|
1517
|
-
const
|
|
1518
|
-
if (!
|
|
1519
|
-
ctx.ui.notify("
|
|
1520
|
-
} else if (!removeAccount(slot)) {
|
|
1521
|
-
ctx.ui.notify(`Cline slot '${slot}' not found`, "warning");
|
|
1511
|
+
const pool = loadPool();
|
|
1512
|
+
if (!pool.accounts.length) {
|
|
1513
|
+
ctx.ui.notify("No Cline logins saved — nothing to remove", "info");
|
|
1522
1514
|
} else {
|
|
1523
|
-
|
|
1515
|
+
const resolveSlot = (raw: string): string | null => {
|
|
1516
|
+
const clean = (raw || "").trim();
|
|
1517
|
+
if (!clean) return null;
|
|
1518
|
+
const byIndex = Number.parseInt(clean, 10);
|
|
1519
|
+
if (String(byIndex) === clean && byIndex >= 1 && byIndex <= pool.accounts.length) return pool.accounts[byIndex - 1].slot;
|
|
1520
|
+
const exact = pool.accounts.find((a) => a.slot === clean);
|
|
1521
|
+
if (exact) return exact.slot;
|
|
1522
|
+
const folded = pool.accounts.find((a) => a.slot.toLowerCase() === clean.toLowerCase());
|
|
1523
|
+
return folded ? folded.slot : null;
|
|
1524
|
+
};
|
|
1525
|
+
let slot = resolveSlot(arg);
|
|
1526
|
+
if (arg && !slot) {
|
|
1527
|
+
const saved = pool.accounts.map((a, idx) => `${idx + 1}=${a.slot}`).join(", ");
|
|
1528
|
+
ctx.ui.notify(`Cline slot '${arg}' not found. Saved: ${saved}`, "warning");
|
|
1529
|
+
} else {
|
|
1530
|
+
if (!slot) {
|
|
1531
|
+
if (pool.accounts.length === 1) {
|
|
1532
|
+
slot = pool.accounts[0].slot;
|
|
1533
|
+
} else {
|
|
1534
|
+
const opts = formatClineAccountLines(pool);
|
|
1535
|
+
const choice = await ctx.ui.select("Remove Cline login", [...opts, "Cancel"]);
|
|
1536
|
+
if (!choice || choice === "Cancel") {
|
|
1537
|
+
ctx.ui.notify("Cancelled — no slot removed", "warning");
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
const hit = pool.accounts.find((a, idx) => opts[idx] === choice);
|
|
1541
|
+
slot = hit ? hit.slot : null;
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
if (!slot) {
|
|
1545
|
+
ctx.ui.notify("Cancelled — no slot removed", "warning");
|
|
1546
|
+
} else if (!removeAccount(slot)) {
|
|
1547
|
+
ctx.ui.notify(`Cline slot '${slot}' not found`, "warning");
|
|
1548
|
+
} else {
|
|
1549
|
+
ctx.ui.notify(`Removed Cline login [${slot}]`, "info");
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1524
1552
|
}
|
|
1553
|
+
} else if (action === "signout" || action === "sign-out" || action === "switch") {
|
|
1554
|
+
// Signing out is a browser action; it never touches saved logins here.
|
|
1555
|
+
const saved = loadPool().accounts.length;
|
|
1556
|
+
ctx.ui.notify(
|
|
1557
|
+
`Sign out of Cline in your browser to use a different account:\n${CLINE_BROWSER_SIGNOUT_URL}\nA private window works too, and keeps your current session.\nThen run /freeflow cline login again — each login gets its own slot (${saved ? `you have ${saved}; next is ` : "next is "}${nextClineSlot(loadPool())}).`,
|
|
1558
|
+
"info",
|
|
1559
|
+
);
|
|
1525
1560
|
} else {
|
|
1526
|
-
ctx.ui.notify("Usage: /freeflow cline login [--key] [slot] | /freeflow cline accounts | /freeflow cline logout [slot]", "warning");
|
|
1561
|
+
ctx.ui.notify("Usage: /freeflow cline login [--key] [slot] | /freeflow cline accounts | /freeflow cline logout [slot] | /freeflow cline signout", "warning");
|
|
1527
1562
|
}
|
|
1528
1563
|
} else if (sub === "export") {
|
|
1529
1564
|
const tokens = rest.trim() ? rest.trim().split(/\s+/) : [];
|