pi-freeflow 1.44.0 → 1.45.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 +29 -0
- package/README.md +11 -13
- package/package.json +1 -1
- package/src/catalog.ts +38 -14
- package/src/client-recovery.ts +238 -0
- package/src/client-staging.ts +316 -0
- package/src/client.ts +117 -472
- package/src/cline-accounts.ts +26 -1223
- package/src/cline-device-auth.ts +5 -3
- package/src/cline-pool.ts +691 -0
- package/src/cline-roll.ts +642 -0
- package/src/commands-cline.ts +283 -0
- package/src/commands-relay.ts +808 -0
- package/src/commands-startup.ts +264 -0
- package/src/commands.ts +119 -1202
- package/src/config-paths.ts +254 -0
- package/src/config.ts +6 -687
- package/src/daemon.ts +16 -1
- package/src/deploy.ts +3 -1
- package/src/fingerprint-env.ts +334 -0
- package/src/fingerprint-tools.ts +348 -0
- package/src/index.ts +10 -6
- package/src/lane-translation.ts +263 -0
- package/src/models.ts +70 -112
- package/src/opencode-fingerprint.ts +29 -1310
- package/src/opencode-identity.ts +464 -0
- package/src/proxy-lanes.ts +1125 -0
- package/src/proxy-ports.ts +317 -0
- package/src/proxy.ts +74 -1385
- package/src/relay-codec.ts +365 -0
- package/src/relay-state.ts +10 -367
- package/src/relay.ts +78 -31
- package/src/sse-aggregator.ts +659 -0
- package/src/stream-cloak.ts +882 -0
- package/src/stream-pipe.ts +22 -871
- package/src/system-ca-fetch.ts +15 -4
- package/src/token.ts +7 -2
- package/src/tool-args.ts +10 -3
- package/src/tool-translation.ts +1 -236
- package/src/toolcall-audit.ts +20 -7
- package/src/update-checker.ts +17 -1
- package/src/update-core.ts +5 -2
- package/src/updater.ts +8 -3
- package/src/upstream-health.ts +22 -2
- package/src/zen-sidecar.ts +1 -0
- package/src/zen-transport.ts +48 -18
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-user Cline API key pool for pi-freeflow.
|
|
3
|
+
*
|
|
4
|
+
* Cline serves its free models direct-only (never through the relay pool),
|
|
5
|
+
* one bearer key per login slot. Keys live in a 0600 file beside the relay
|
|
6
|
+
* state; a corrupt file reads back as an empty pool (never throws, never
|
|
7
|
+
* logs secrets). Request failover walks the pool in order, trying every slot
|
|
8
|
+
* fresh on every request. A slot whose grant Cline rejected outright is parked
|
|
9
|
+
* for a cooldown, never dropped: the credential stays in the pool and a fresh
|
|
10
|
+
* login into that slot clears the pause at once.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { writeFileAtomic } from "./atomic-write.ts";
|
|
16
|
+
import { RELAY_STATE_FILE } from "./config.ts";
|
|
17
|
+
import { logWarn } from "./logger.ts";
|
|
18
|
+
import { isWorkosJwt } from "./cline-device-auth.ts";
|
|
19
|
+
|
|
20
|
+
/** Env override for the pool file location (tests/CI sandbox). */
|
|
21
|
+
const CLINE_POOL_FILE_ENV = "PI_FREEFLOW_CLINE_POOL_FILE";
|
|
22
|
+
|
|
23
|
+
/** Resolve the per-user pool file: beside the relay state file. */
|
|
24
|
+
function resolveClinePoolPath(): string {
|
|
25
|
+
const override = (process.env[CLINE_POOL_FILE_ENV] || "").trim();
|
|
26
|
+
if (override) return override;
|
|
27
|
+
return path.join(path.dirname(RELAY_STATE_FILE), "pi-freeflow-cline-pool.json");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const CLINE_POOL_FILE = resolveClinePoolPath();
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Recovery copy of the pool, read only when the main file is missing or
|
|
34
|
+
* unreadable. Mirrors the relay-state `.bak` convention, with one deliberate
|
|
35
|
+
* difference: an empty pool is a legitimate state here (`/freeflow cline
|
|
36
|
+
* logout` of the last login must stick), so a valid empty main file is never
|
|
37
|
+
* overridden from the backup.
|
|
38
|
+
*/
|
|
39
|
+
export const CLINE_POOL_BACKUP_FILE = `${CLINE_POOL_FILE}.bak`;
|
|
40
|
+
export const CLINE_POOL_LOCK_FILE = `${CLINE_POOL_FILE}.lock`;
|
|
41
|
+
const CLINE_LOCK_STALE_MS = 30_000;
|
|
42
|
+
|
|
43
|
+
export function tryAcquireClineLock(depth = 0): boolean {
|
|
44
|
+
if (depth >= 3) return false;
|
|
45
|
+
try {
|
|
46
|
+
fs.writeFileSync(CLINE_POOL_LOCK_FILE, String(process.pid), { flag: "wx" });
|
|
47
|
+
return true;
|
|
48
|
+
} catch (e) {
|
|
49
|
+
const code = (e as NodeJS.ErrnoException | null)?.code;
|
|
50
|
+
if (code !== "EEXIST") {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
let stale = false;
|
|
54
|
+
try {
|
|
55
|
+
stale = Date.now() - fs.statSync(CLINE_POOL_LOCK_FILE).mtimeMs > CLINE_LOCK_STALE_MS;
|
|
56
|
+
} catch {
|
|
57
|
+
return tryAcquireClineLock(depth + 1);
|
|
58
|
+
}
|
|
59
|
+
if (!stale) {
|
|
60
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
|
|
61
|
+
return tryAcquireClineLock(depth + 1);
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
fs.rmSync(CLINE_POOL_LOCK_FILE, { force: true });
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
return tryAcquireClineLock(depth + 1);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function releaseClineLock(): void {
|
|
73
|
+
try { fs.rmSync(CLINE_POOL_LOCK_FILE, { force: true }); } catch { }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function withClineLock<T>(fn: () => T): T {
|
|
77
|
+
const locked = tryAcquireClineLock();
|
|
78
|
+
if (!locked) {
|
|
79
|
+
logWarn("cline pool lock held — proceeding without lock", {
|
|
80
|
+
lock: CLINE_POOL_LOCK_FILE,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
return fn();
|
|
85
|
+
} finally {
|
|
86
|
+
if (locked) {
|
|
87
|
+
releaseClineLock();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** One saved login slot. The token is only ever sent as an explicit bearer header. */
|
|
92
|
+
export interface ClineAccount {
|
|
93
|
+
slot: string;
|
|
94
|
+
token: string;
|
|
95
|
+
addedAt: string;
|
|
96
|
+
/** WorkOS refresh token for device-login slots; absent on legacy key slots. */
|
|
97
|
+
refreshToken?: string;
|
|
98
|
+
/** Epoch-ms when `token` expires; absent means "no known expiry" (legacy slots). */
|
|
99
|
+
expiresAt?: number;
|
|
100
|
+
/** WorkOS account id, shown when choosing which login to remove. */
|
|
101
|
+
accountId?: string;
|
|
102
|
+
/** Login email, shown when choosing which login to remove. */
|
|
103
|
+
email?: string;
|
|
104
|
+
/**
|
|
105
|
+
* Epoch ms until which Cline has rejected this slot's grant outright: the
|
|
106
|
+
* roll skips the slot until then rather than paying a doomed refresh every
|
|
107
|
+
* turn. The credential itself is never dropped — past the stamp the slot is
|
|
108
|
+
* tried again, and saving a fresh login into the slot clears it at once.
|
|
109
|
+
*/
|
|
110
|
+
deadUntil?: number;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** One slot's serve counters. See `ClinePoolState.usage`. */
|
|
114
|
+
export interface ClineSlotUsage {
|
|
115
|
+
served: number;
|
|
116
|
+
lastAt: number;
|
|
117
|
+
lastModel: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface ClinePoolState {
|
|
121
|
+
accounts: ClineAccount[];
|
|
122
|
+
activeSlot?: string;
|
|
123
|
+
/**
|
|
124
|
+
* Daily free-limit resets per slot per *requested* model id (epoch ms):
|
|
125
|
+
* `limits[slot][modelId] = resetAt`. Cline reports its own model name in the
|
|
126
|
+
* 429 body, which never matches the id we asked for, so the requested id is
|
|
127
|
+
* the only key a later request can look up. Expired entries are dropped on
|
|
128
|
+
* read. Optional: pools written before this existed load unchanged.
|
|
129
|
+
*/
|
|
130
|
+
limits?: Record<string, Record<string, number>>;
|
|
131
|
+
/**
|
|
132
|
+
* Per-slot serve counters: `usage[slot] = { served, lastAt, lastModel }`.
|
|
133
|
+
* Bumped every time the slot serves a turn (`served` counts up, `lastAt`
|
|
134
|
+
* is epoch ms, `lastModel` the requested model id). Counters only — no
|
|
135
|
+
* token, email, or other identity ever lives here; callers map
|
|
136
|
+
* slot→identity at display time. Optional: pools written before this
|
|
137
|
+
* existed load unchanged.
|
|
138
|
+
*/
|
|
139
|
+
usage?: Record<string, ClineSlotUsage>;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let cached: ClinePoolState | null = null;
|
|
143
|
+
let cachedMtime = -1;
|
|
144
|
+
|
|
145
|
+
/** Test-only: drop the loadPool mtime cache. */
|
|
146
|
+
export function _resetClinePoolCacheForTest(): void {
|
|
147
|
+
cached = null;
|
|
148
|
+
cachedMtime = -1;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function emptyPool(): ClinePoolState {
|
|
152
|
+
return { accounts: [] };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Deep clone a pool snapshot so callers can never alias the cache: mutating a
|
|
157
|
+
* loaded pool must not corrupt the next load, and mutating the argument after
|
|
158
|
+
* savePool must not rewrite what was cached.
|
|
159
|
+
*/
|
|
160
|
+
export function clonePool(pool: ClinePoolState): ClinePoolState {
|
|
161
|
+
return {
|
|
162
|
+
accounts: pool.accounts.map((a) => ({ ...a })),
|
|
163
|
+
...(pool.activeSlot !== undefined ? { activeSlot: pool.activeSlot } : {}),
|
|
164
|
+
...(pool.limits
|
|
165
|
+
? { limits: Object.fromEntries(Object.entries(pool.limits).map(([slot, models]) => [slot, { ...models }])) }
|
|
166
|
+
: {}),
|
|
167
|
+
...(pool.usage
|
|
168
|
+
? { usage: Object.fromEntries(Object.entries(pool.usage).map(([slot, row]) => [slot, { ...row }])) }
|
|
169
|
+
: {}),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Read the optional `limits` map out of a pool document, dropping anything
|
|
175
|
+
* malformed and every entry whose reset has already passed. Returns undefined
|
|
176
|
+
* when nothing usable is left, so a pool that never saw a cap keeps its old
|
|
177
|
+
* shape on disk.
|
|
178
|
+
*/
|
|
179
|
+
export function parseLimits(raw: unknown, now: number): Record<string, Record<string, number>> | undefined {
|
|
180
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined;
|
|
181
|
+
const limits: Record<string, Record<string, number>> = {};
|
|
182
|
+
for (const [slot, modelsRaw] of Object.entries(raw as Record<string, unknown>)) {
|
|
183
|
+
if (!slot.trim()) continue;
|
|
184
|
+
if (typeof modelsRaw !== "object" || modelsRaw === null || Array.isArray(modelsRaw)) continue;
|
|
185
|
+
const models: Record<string, number> = {};
|
|
186
|
+
for (const [model, resetRaw] of Object.entries(modelsRaw as Record<string, unknown>)) {
|
|
187
|
+
if (!model.trim()) continue;
|
|
188
|
+
if (typeof resetRaw !== "number" || !Number.isFinite(resetRaw) || resetRaw <= now) continue;
|
|
189
|
+
models[model] = resetRaw;
|
|
190
|
+
}
|
|
191
|
+
if (Object.keys(models).length > 0) limits[slot.trim()] = models;
|
|
192
|
+
}
|
|
193
|
+
return Object.keys(limits).length > 0 ? limits : undefined;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Read the optional `usage` map out of a pool document, dropping anything
|
|
198
|
+
* malformed. Returns undefined when nothing usable is left, so a pool that
|
|
199
|
+
* never served a turn keeps its old shape on disk.
|
|
200
|
+
*/
|
|
201
|
+
export function parseUsage(raw: unknown): Record<string, ClineSlotUsage> | undefined {
|
|
202
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined;
|
|
203
|
+
const usage: Record<string, ClineSlotUsage> = {};
|
|
204
|
+
for (const [slot, entryRaw] of Object.entries(raw as Record<string, unknown>)) {
|
|
205
|
+
if (!slot.trim()) continue;
|
|
206
|
+
if (typeof entryRaw !== "object" || entryRaw === null || Array.isArray(entryRaw)) continue;
|
|
207
|
+
const entry: Record<string, unknown> = entryRaw as Record<string, unknown>;
|
|
208
|
+
const served = entry.served;
|
|
209
|
+
const lastAt = entry.lastAt;
|
|
210
|
+
const lastModel = entry.lastModel;
|
|
211
|
+
if (typeof served !== "number" || !Number.isFinite(served) || served < 0) continue;
|
|
212
|
+
if (typeof lastAt !== "number" || !Number.isFinite(lastAt) || lastAt <= 0) continue;
|
|
213
|
+
if (typeof lastModel !== "string") continue;
|
|
214
|
+
usage[slot.trim()] = { served: Math.floor(served), lastAt, lastModel };
|
|
215
|
+
}
|
|
216
|
+
return Object.keys(usage).length > 0 ? usage : undefined;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Parse a pool document. Returns null when the blob is unusable (bad JSON,
|
|
221
|
+
* wrong shape) so callers can fall back to the backup copy. A valid document
|
|
222
|
+
* with zero accounts is NOT null: that is a real empty pool.
|
|
223
|
+
*/
|
|
224
|
+
export function parsePoolDoc(raw: string): ClinePoolState | null {
|
|
225
|
+
let parsed: unknown;
|
|
226
|
+
try {
|
|
227
|
+
parsed = JSON.parse(raw);
|
|
228
|
+
} catch {
|
|
229
|
+
logWarn("cline pool file corrupt", { path: CLINE_POOL_FILE });
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
233
|
+
logWarn("cline pool file unusable", { path: CLINE_POOL_FILE });
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
// Boundary-narrowed once: the on-disk blob is external input, so check
|
|
237
|
+
// its shape here and read only validated fields below.
|
|
238
|
+
const doc: Record<string, unknown> = parsed as Record<string, unknown>;
|
|
239
|
+
if (!Array.isArray(doc.accounts)) {
|
|
240
|
+
logWarn("cline pool file unusable", { path: CLINE_POOL_FILE });
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
const accounts: ClineAccount[] = [];
|
|
244
|
+
// One clock for the whole document: a quarantine stamp and a cap reset are
|
|
245
|
+
// both compared against the same instant.
|
|
246
|
+
const now = Date.now();
|
|
247
|
+
for (const entry of doc.accounts as unknown[]) {
|
|
248
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
249
|
+
const rec: Record<string, unknown> = entry as Record<string, unknown>;
|
|
250
|
+
const slot = typeof rec.slot === "string" ? rec.slot.trim() : "";
|
|
251
|
+
const token = typeof rec.token === "string" ? rec.token.trim() : "";
|
|
252
|
+
if (!slot || (!token.startsWith("workos:") && !isWorkosJwt(token) && !token.startsWith("clp_"))) {
|
|
253
|
+
logWarn("cline pool entry rejected", { slot: slot || "(unnamed)" });
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
const refreshRaw: unknown = rec.refreshToken;
|
|
257
|
+
const refreshToken = typeof refreshRaw === "string" && refreshRaw.length > 0 ? refreshRaw : undefined;
|
|
258
|
+
const expiresRaw: unknown = rec.expiresAt;
|
|
259
|
+
const expiresAt = typeof expiresRaw === "number" && Number.isFinite(expiresRaw) && expiresRaw > 0
|
|
260
|
+
? expiresRaw
|
|
261
|
+
: undefined;
|
|
262
|
+
const accountIdRaw: unknown = rec.accountId;
|
|
263
|
+
const accountId = typeof accountIdRaw === "string" && accountIdRaw.trim() ? accountIdRaw.trim() : undefined;
|
|
264
|
+
const emailRaw: unknown = rec.email;
|
|
265
|
+
const email = typeof emailRaw === "string" && emailRaw.trim() ? emailRaw.trim() : undefined;
|
|
266
|
+
// An elapsed quarantine is dropped on read, so a slot Cline rejected last
|
|
267
|
+
// week is a normal candidate again and the field never lingers in the file.
|
|
268
|
+
const deadRaw: unknown = rec.deadUntil;
|
|
269
|
+
const deadUntil = typeof deadRaw === "number" && Number.isFinite(deadRaw) && deadRaw > now ? deadRaw : undefined;
|
|
270
|
+
accounts.push({
|
|
271
|
+
slot,
|
|
272
|
+
token,
|
|
273
|
+
addedAt: typeof rec.addedAt === "string" ? rec.addedAt : new Date().toISOString(),
|
|
274
|
+
...(refreshToken ? { refreshToken } : {}),
|
|
275
|
+
...(expiresAt !== undefined ? { expiresAt } : {}),
|
|
276
|
+
...(accountId ? { accountId } : {}),
|
|
277
|
+
...(email ? { email } : {}),
|
|
278
|
+
...(deadUntil !== undefined ? { deadUntil } : {}),
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
const activeRaw: unknown = doc.activeSlot;
|
|
282
|
+
const activeSlot = typeof activeRaw === "string" && accounts.some((a) => a.slot === activeRaw.trim())
|
|
283
|
+
? activeRaw.trim()
|
|
284
|
+
: undefined;
|
|
285
|
+
const limits = parseLimits(doc.limits, now);
|
|
286
|
+
const usage = parseUsage(doc.usage);
|
|
287
|
+
const state: ClinePoolState = { accounts };
|
|
288
|
+
if (activeSlot) state.activeSlot = activeSlot;
|
|
289
|
+
if (limits) state.limits = limits;
|
|
290
|
+
if (usage) state.usage = usage;
|
|
291
|
+
return state;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Recover the pool from the backup copy and heal the main file so the recovery
|
|
296
|
+
* sticks. Returns null when no usable backup exists. Only ever called for a
|
|
297
|
+
* missing or unusable main file — never to override a valid empty pool.
|
|
298
|
+
*/
|
|
299
|
+
export function recoverPoolFromBackup(description: string): ClinePoolState | null {
|
|
300
|
+
try {
|
|
301
|
+
const parsed = parsePoolDoc(fs.readFileSync(CLINE_POOL_BACKUP_FILE, "utf8"));
|
|
302
|
+
if (parsed && parsed.accounts.length > 0) {
|
|
303
|
+
logWarn(`cline pool ${description} — recovered from backup`, { slots: parsed.accounts.length, path: CLINE_POOL_BACKUP_FILE });
|
|
304
|
+
savePool(parsed);
|
|
305
|
+
return parsed;
|
|
306
|
+
}
|
|
307
|
+
} catch { }
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function readPoolFile(): ClinePoolState {
|
|
312
|
+
try {
|
|
313
|
+
if (!fs.existsSync(CLINE_POOL_FILE)) {
|
|
314
|
+
return recoverPoolFromBackup("main file missing") ?? emptyPool();
|
|
315
|
+
}
|
|
316
|
+
const parsed = parsePoolDoc(fs.readFileSync(CLINE_POOL_FILE, "utf8"));
|
|
317
|
+
if (parsed) return parsed;
|
|
318
|
+
return recoverPoolFromBackup("file unusable") ?? emptyPool();
|
|
319
|
+
} catch {
|
|
320
|
+
return emptyPool();
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function diskMtime(): number {
|
|
325
|
+
try {
|
|
326
|
+
return fs.statSync(CLINE_POOL_FILE).mtimeMs;
|
|
327
|
+
} catch {
|
|
328
|
+
return -1;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Load the pool, re-reading from disk only when another process changed the
|
|
334
|
+
* file (mtime moved). Never throws; corrupt reads come back empty.
|
|
335
|
+
*/
|
|
336
|
+
export function loadPool(): ClinePoolState {
|
|
337
|
+
const before = diskMtime();
|
|
338
|
+
if (cached && before === cachedMtime) return clonePool(cached);
|
|
339
|
+
let loaded = readPoolFile();
|
|
340
|
+
let stamp = diskMtime();
|
|
341
|
+
// The read is only trustworthy when the file did not move across it. Reading
|
|
342
|
+
// first and stamping afterwards latches pre-read content under the post-write
|
|
343
|
+
// mtime, which hides the change until the next write. Re-read once; the mtime
|
|
344
|
+
// observed after that read is the one the returned pool is cached under.
|
|
345
|
+
if (stamp !== before) {
|
|
346
|
+
loaded = readPoolFile();
|
|
347
|
+
stamp = diskMtime();
|
|
348
|
+
}
|
|
349
|
+
cached = clonePool(loaded);
|
|
350
|
+
cachedMtime = stamp;
|
|
351
|
+
return clonePool(cached);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** How many accounts a raw pool blob holds; -1 when it is unreadable. */
|
|
355
|
+
function accountCountIn(raw: string): number {
|
|
356
|
+
try {
|
|
357
|
+
const doc = JSON.parse(raw) as { accounts?: unknown };
|
|
358
|
+
return Array.isArray(doc.accounts) ? doc.accounts.length : -1;
|
|
359
|
+
} catch {
|
|
360
|
+
return -1;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Keep the largest pool ever seen as the recovery copy.
|
|
366
|
+
*
|
|
367
|
+
* Largest-wins rather than last-write-wins, deliberately. The backup is read
|
|
368
|
+
* only when the main file is missing or unreadable, and two failure modes had
|
|
369
|
+
* to be covered at once:
|
|
370
|
+
* - an existing richer pool being overwritten (compare the file being
|
|
371
|
+
* replaced, before the write), and
|
|
372
|
+
* - growth being lost because the backup always lagged one write (compare
|
|
373
|
+
* the pool just written, after the write).
|
|
374
|
+
* Ties do not replace, so a run of equally-sized destructive writes cannot
|
|
375
|
+
* evict a good copy — which is how a real pool was lost: two good logins were
|
|
376
|
+
* replaced by fixtures, and the second fixture write replaced the backup too.
|
|
377
|
+
*/
|
|
378
|
+
export function keepLargestBackup(raw: string): void {
|
|
379
|
+
try {
|
|
380
|
+
const count = accountCountIn(raw);
|
|
381
|
+
if (count <= 0) return;
|
|
382
|
+
let backupCount = -1;
|
|
383
|
+
try {
|
|
384
|
+
backupCount = accountCountIn(fs.readFileSync(CLINE_POOL_BACKUP_FILE, "utf8"));
|
|
385
|
+
} catch {
|
|
386
|
+
backupCount = -1;
|
|
387
|
+
}
|
|
388
|
+
if (count <= backupCount) return;
|
|
389
|
+
writeFileAtomic(CLINE_POOL_BACKUP_FILE, raw, { mode: 0o600 });
|
|
390
|
+
} catch { }
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Scrub one explicitly removed login out of the recovery copy.
|
|
395
|
+
*
|
|
396
|
+
* Largest-wins keeps that copy rich, which is what saves a real pool from an
|
|
397
|
+
* accidental smaller write — but it also means a logout's token (and its
|
|
398
|
+
* refresh grant) can sit in `<pool>.bak` and sign the user back in if the main
|
|
399
|
+
* file is ever lost. A removal is an explicit instruction, so the removed
|
|
400
|
+
* identity is dropped from the copy too — matched by slot or by bearer, since
|
|
401
|
+
* a legacy pool can hold one account under two slots. Every other account
|
|
402
|
+
* stays available for recovery.
|
|
403
|
+
*/
|
|
404
|
+
export function forgetAccountInBackup(removed: ClineAccount): void {
|
|
405
|
+
try {
|
|
406
|
+
if (!fs.existsSync(CLINE_POOL_BACKUP_FILE)) return;
|
|
407
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(CLINE_POOL_BACKUP_FILE, "utf8"));
|
|
408
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return;
|
|
409
|
+
const doc: Record<string, unknown> = parsed as Record<string, unknown>;
|
|
410
|
+
if (!Array.isArray(doc.accounts)) return;
|
|
411
|
+
const kept = (doc.accounts as unknown[]).filter((entry) => {
|
|
412
|
+
if (typeof entry !== "object" || entry === null) return true;
|
|
413
|
+
const rec: Record<string, unknown> = entry as Record<string, unknown>;
|
|
414
|
+
if (typeof rec.slot === "string" && rec.slot.trim() === removed.slot) return false;
|
|
415
|
+
return !(typeof rec.token === "string" && rec.token.length > 0 && rec.token === removed.token);
|
|
416
|
+
});
|
|
417
|
+
if (kept.length === doc.accounts.length) return;
|
|
418
|
+
doc.accounts = kept;
|
|
419
|
+
writeFileAtomic(CLINE_POOL_BACKUP_FILE, JSON.stringify(doc, null, 2), { mode: 0o600 });
|
|
420
|
+
} catch { }
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Atomically persist the pool with owner-only permissions (temp file + rename,
|
|
425
|
+
* see src/atomic-write.ts). Never logs secrets.
|
|
426
|
+
*
|
|
427
|
+
* Returns false when the pool did not reach the disk, so a caller that tells
|
|
428
|
+
* the user "saved" can tell the truth instead. The bytes are read back after
|
|
429
|
+
* the rename: a rename that reported success is not proof that the next read
|
|
430
|
+
* sees the new pool — a read-only target is the live failure mode.
|
|
431
|
+
*/
|
|
432
|
+
export function savePool(pool: ClinePoolState): boolean {
|
|
433
|
+
try {
|
|
434
|
+
const dir = path.dirname(CLINE_POOL_FILE);
|
|
435
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
436
|
+
// The pool being replaced is the only copy of itself: secure it first.
|
|
437
|
+
try {
|
|
438
|
+
if (fs.existsSync(CLINE_POOL_FILE)) keepLargestBackup(fs.readFileSync(CLINE_POOL_FILE, "utf8"));
|
|
439
|
+
} catch { }
|
|
440
|
+
const serialized = JSON.stringify(pool, null, 2);
|
|
441
|
+
writeFileAtomic(CLINE_POOL_FILE, serialized, { mode: 0o600 });
|
|
442
|
+
if (fs.readFileSync(CLINE_POOL_FILE, "utf8") !== serialized) {
|
|
443
|
+
throw new Error(`pool write did not land at ${CLINE_POOL_FILE}`);
|
|
444
|
+
}
|
|
445
|
+
// Compare the pool just written too, so growth is never the copy that lags.
|
|
446
|
+
keepLargestBackup(serialized);
|
|
447
|
+
cached = clonePool(pool);
|
|
448
|
+
cachedMtime = diskMtime();
|
|
449
|
+
return true;
|
|
450
|
+
} catch (e) {
|
|
451
|
+
logWarn("could not persist cline pool", { slots: pool.accounts.length, reason: e instanceof Error ? e.message : String(e) });
|
|
452
|
+
// The in-memory snapshot must not outlive the write that failed: with the
|
|
453
|
+
// cache dropped, the next load re-reads the disk and sees what actually
|
|
454
|
+
// landed instead of the pool this process hoped to write.
|
|
455
|
+
cached = null;
|
|
456
|
+
cachedMtime = -1;
|
|
457
|
+
return false;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Last-4 display for a token; the full value is never shown or logged. */
|
|
462
|
+
export function redactedToken(token: string): string {
|
|
463
|
+
const tail = (token || "").slice(-4) || "????";
|
|
464
|
+
return `…${tail}`;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Persist a patch to one slot against the file as it is on disk right now.
|
|
469
|
+
* A request holds its pool snapshot across network round trips, and the host
|
|
470
|
+
* process writes the same file for login/logout, so writing the snapshot back
|
|
471
|
+
* would revert a login added or removed while the request was in flight. A
|
|
472
|
+
* slot that no longer exists is left alone rather than resurrected.
|
|
473
|
+
*/
|
|
474
|
+
export function patchAccountOnDisk(slot: string, patch: Partial<ClineAccount>): void {
|
|
475
|
+
withClineLock(() => {
|
|
476
|
+
const live = readPoolFile();
|
|
477
|
+
const target = live.accounts.find((a) => a.slot === slot);
|
|
478
|
+
if (!target) return;
|
|
479
|
+
Object.assign(target, patch);
|
|
480
|
+
savePool(live);
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Record which slot served a turn, without clobbering concurrent edits. */
|
|
485
|
+
export function markActiveSlotOnDisk(slot: string): void {
|
|
486
|
+
withClineLock(() => {
|
|
487
|
+
const live = readPoolFile();
|
|
488
|
+
if (!live.accounts.some((a) => a.slot === slot)) return;
|
|
489
|
+
live.activeSlot = slot;
|
|
490
|
+
savePool(live);
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Bump one slot's serve counters against the file as it is on disk right
|
|
496
|
+
* now: a request holds its pool snapshot across round trips, and the host
|
|
497
|
+
* process writes the same file for login/logout, so writing the snapshot
|
|
498
|
+
* back would revert a login added or removed while the request was in
|
|
499
|
+
* flight. A slot that no longer exists is left alone rather than
|
|
500
|
+
* resurrected. The row carries counters only — never token or identity.
|
|
501
|
+
*/
|
|
502
|
+
export function recordClineUsageOnDisk(slot: string, modelId: string): void {
|
|
503
|
+
withClineLock(() => {
|
|
504
|
+
const cleanSlot = (slot || "").trim();
|
|
505
|
+
if (!cleanSlot) return;
|
|
506
|
+
const live = readPoolFile();
|
|
507
|
+
if (!live.accounts.some((a) => a.slot === cleanSlot)) return;
|
|
508
|
+
const prev = live.usage?.[cleanSlot];
|
|
509
|
+
const served = typeof prev?.served === "number" && Number.isFinite(prev.served) && prev.served >= 0
|
|
510
|
+
? Math.floor(prev.served) + 1
|
|
511
|
+
: 1;
|
|
512
|
+
live.usage = {
|
|
513
|
+
...(live.usage ?? {}),
|
|
514
|
+
[cleanSlot]: { served, lastAt: Date.now(), lastModel: (modelId || "").trim() },
|
|
515
|
+
};
|
|
516
|
+
savePool(live);
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** The reset still in force for one slot+requested model, or null when free. */
|
|
521
|
+
export function activeLimitFor(pool: ClinePoolState, slot: string, model: string, now: number): number | null {
|
|
522
|
+
if (!model) return null;
|
|
523
|
+
const at = pool.limits?.[slot]?.[model];
|
|
524
|
+
return typeof at === "number" && Number.isFinite(at) && at > now ? at : null;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Persist one free-limit observation against the file as it is on disk right
|
|
529
|
+
* now: a request holds its pool snapshot across round trips, and the host
|
|
530
|
+
* process writes the same file for login/logout, so writing the snapshot back
|
|
531
|
+
* would revert a login added or removed while the request was in flight.
|
|
532
|
+
*/
|
|
533
|
+
export function recordLimitOnDisk(slot: string, model: string, resetAt: number): void {
|
|
534
|
+
withClineLock(() => {
|
|
535
|
+
if (!model) return;
|
|
536
|
+
const live = readPoolFile();
|
|
537
|
+
if (!live.accounts.some((a) => a.slot === slot)) return;
|
|
538
|
+
const known = live.limits?.[slot]?.[model];
|
|
539
|
+
if (typeof known === "number" && known >= resetAt) return;
|
|
540
|
+
const limits = live.limits ?? {};
|
|
541
|
+
limits[slot] = { ...(limits[slot] ?? {}), [model]: resetAt };
|
|
542
|
+
live.limits = limits;
|
|
543
|
+
savePool(live);
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** Drop one slot+model limit once that slot serves the model again. */
|
|
548
|
+
export function clearLimitOnDisk(slot: string, model: string): void {
|
|
549
|
+
withClineLock(() => {
|
|
550
|
+
if (!model) return;
|
|
551
|
+
const live = readPoolFile();
|
|
552
|
+
const limits = live.limits;
|
|
553
|
+
const forSlot = limits?.[slot];
|
|
554
|
+
if (!limits || !forSlot || typeof forSlot[model] !== "number") return;
|
|
555
|
+
delete forSlot[model];
|
|
556
|
+
if (Object.keys(forSlot).length === 0) delete limits[slot];
|
|
557
|
+
if (Object.keys(limits).length === 0) delete live.limits;
|
|
558
|
+
savePool(live);
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Identity of a Cline login. Two slots holding the same account share one free
|
|
564
|
+
* quota, so rotating between them buys nothing — callers use this to detect
|
|
565
|
+
* that instead of saving a duplicate.
|
|
566
|
+
*
|
|
567
|
+
* accountId (WorkOS user id) is authoritative; email is the readable fallback.
|
|
568
|
+
* When neither side carries an identity — a legacy `clp_` key slot has none —
|
|
569
|
+
* only an identical bearer proves the same account.
|
|
570
|
+
*/
|
|
571
|
+
export function sameClineAccount(account: ClineAccount, identity: { token?: string; accountId?: string; email?: string }): boolean {
|
|
572
|
+
const idA = account.accountId?.trim();
|
|
573
|
+
const idB = identity.accountId?.trim();
|
|
574
|
+
if (idA && idB) return idA === idB;
|
|
575
|
+
const mailA = account.email?.trim().toLowerCase();
|
|
576
|
+
const mailB = identity.email?.trim().toLowerCase();
|
|
577
|
+
if (mailA && mailB) return mailA === mailB;
|
|
578
|
+
const token = identity.token;
|
|
579
|
+
return typeof token === "string" && token.length > 0 && account.token === token;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* The other slot already holding this account, or null when it is new.
|
|
584
|
+
* `exceptSlot` skips the slot being written, so re-logging into an existing
|
|
585
|
+
* slot is never treated as a duplicate.
|
|
586
|
+
*/
|
|
587
|
+
export function findClineAccountSlot(
|
|
588
|
+
pool: ClinePoolState,
|
|
589
|
+
identity: { token?: string; accountId?: string; email?: string },
|
|
590
|
+
exceptSlot?: string,
|
|
591
|
+
): string | null {
|
|
592
|
+
const hit = pool.accounts.find(
|
|
593
|
+
(a) => a.slot !== exceptSlot && sameClineAccount(a, identity),
|
|
594
|
+
);
|
|
595
|
+
return hit ? hit.slot : null;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Save (or replace) one login slot. The token must carry the `workos:` prefix.
|
|
600
|
+
* Throws on bad input — the message never echoes the token.
|
|
601
|
+
* Extras carry the device-login grant (refresh token, expiry, identity);
|
|
602
|
+
* legacy key slots keep calling with two args and load untouched.
|
|
603
|
+
* Refuses an account that another slot already holds.
|
|
604
|
+
*/
|
|
605
|
+
export function addAccount(
|
|
606
|
+
slot: string,
|
|
607
|
+
token: string,
|
|
608
|
+
extras?: { refreshToken?: string; expiresAt?: number; accountId?: string; email?: string },
|
|
609
|
+
): ClinePoolState {
|
|
610
|
+
return withClineLock(() => {
|
|
611
|
+
const cleanSlot = (slot || "").trim();
|
|
612
|
+
if (!cleanSlot) throw new Error("Cline slot name cannot be empty");
|
|
613
|
+
if (cleanSlot.length > 64) throw new Error("Cline slot name is too long (max 64 characters)");
|
|
614
|
+
const cleanToken = (token || "").trim();
|
|
615
|
+
if (!cleanToken.startsWith("workos:") && !isWorkosJwt(cleanToken) && !cleanToken.startsWith("clp_")) throw new Error("Cline token must be a workos: login grant or a clp_ API key");
|
|
616
|
+
const pool = loadPool();
|
|
617
|
+
const existing = pool.accounts.find((a) => a.slot === cleanSlot);
|
|
618
|
+
const duplicate = findClineAccountSlot(pool, { token: cleanToken, accountId: extras?.accountId, email: extras?.email }, cleanSlot);
|
|
619
|
+
if (duplicate) {
|
|
620
|
+
const who = extras?.email?.trim() || existing?.email?.trim() || "this account";
|
|
621
|
+
throw new Error(`That Cline account (${who}) is already saved as [${duplicate}] — log in with a different account, or use /freeflow cline logout ${duplicate} first`);
|
|
622
|
+
}
|
|
623
|
+
if (existing) {
|
|
624
|
+
existing.token = cleanToken;
|
|
625
|
+
delete existing.deadUntil;
|
|
626
|
+
if (extras?.refreshToken) existing.refreshToken = extras.refreshToken;
|
|
627
|
+
if (extras?.expiresAt !== undefined) {
|
|
628
|
+
if (Number.isFinite(extras.expiresAt) && extras.expiresAt > 0) existing.expiresAt = extras.expiresAt;
|
|
629
|
+
else delete existing.expiresAt;
|
|
630
|
+
}
|
|
631
|
+
if (extras?.accountId !== undefined) {
|
|
632
|
+
if (extras.accountId.trim()) existing.accountId = extras.accountId.trim();
|
|
633
|
+
else delete existing.accountId;
|
|
634
|
+
}
|
|
635
|
+
if (extras?.email !== undefined) {
|
|
636
|
+
if (extras.email.trim()) existing.email = extras.email.trim();
|
|
637
|
+
else delete existing.email;
|
|
638
|
+
}
|
|
639
|
+
} else {
|
|
640
|
+
pool.accounts.push({
|
|
641
|
+
slot: cleanSlot,
|
|
642
|
+
token: cleanToken,
|
|
643
|
+
addedAt: new Date().toISOString(),
|
|
644
|
+
...(extras?.refreshToken ? { refreshToken: extras.refreshToken } : {}),
|
|
645
|
+
...(extras?.expiresAt !== undefined && Number.isFinite(extras.expiresAt) && extras.expiresAt > 0
|
|
646
|
+
? { expiresAt: extras.expiresAt }
|
|
647
|
+
: {}),
|
|
648
|
+
...(extras?.accountId?.trim() ? { accountId: extras.accountId.trim() } : {}),
|
|
649
|
+
...(extras?.email?.trim() ? { email: extras.email.trim() } : {}),
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
if (!pool.activeSlot) pool.activeSlot = cleanSlot;
|
|
653
|
+
if (!savePool(pool)) {
|
|
654
|
+
let landed = false;
|
|
655
|
+
try {
|
|
656
|
+
landed = loadPool().accounts.some((a) => a.slot === cleanSlot && a.token === cleanToken);
|
|
657
|
+
} catch {
|
|
658
|
+
landed = false;
|
|
659
|
+
}
|
|
660
|
+
if (!landed) {
|
|
661
|
+
throw new Error(
|
|
662
|
+
`Cline login [${cleanSlot}] could not be saved to ${CLINE_POOL_FILE} — check that the directory is writable and log in again`,
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
return clonePool(pool);
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** Drop one login slot. Returns false when the slot was not saved. */
|
|
671
|
+
export function removeAccount(slot: string): boolean {
|
|
672
|
+
return withClineLock(() => {
|
|
673
|
+
const cleanSlot = (slot || "").trim();
|
|
674
|
+
const pool = loadPool();
|
|
675
|
+
const idx = pool.accounts.findIndex((a) => a.slot === cleanSlot);
|
|
676
|
+
if (idx < 0) return false;
|
|
677
|
+
const [removed] = pool.accounts.splice(idx, 1);
|
|
678
|
+
if (pool.usage?.[cleanSlot] !== undefined) {
|
|
679
|
+
delete pool.usage[cleanSlot];
|
|
680
|
+
if (Object.keys(pool.usage).length === 0) delete pool.usage;
|
|
681
|
+
}
|
|
682
|
+
if (pool.limits?.[cleanSlot] !== undefined) {
|
|
683
|
+
delete pool.limits[cleanSlot];
|
|
684
|
+
if (Object.keys(pool.limits).length === 0) delete pool.limits;
|
|
685
|
+
}
|
|
686
|
+
if (pool.activeSlot === cleanSlot) pool.activeSlot = pool.accounts[0]?.slot;
|
|
687
|
+
savePool(pool);
|
|
688
|
+
forgetAccountInBackup(removed);
|
|
689
|
+
return true;
|
|
690
|
+
});
|
|
691
|
+
}
|