pi-freeflow 1.23.1 → 1.23.3
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 +12 -0
- package/package.json +1 -1
- package/src/cline-accounts.ts +137 -52
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.23.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Fix a test that failed intermittently on CI: it compared the whole response body of a chat request, including the timestamp each side stamps when it builds the body. When a request happened to cross a second boundary the two timestamps differed by one and the test went red even though nothing was wrong. The timestamp is no longer compared digit-for-digit, and the test now checks it is a real timestamp instead. No change to how the proxy behaves.
|
|
8
|
+
|
|
9
|
+
## 1.23.2
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- Saved Cline logins are now recoverable. The pool keeps a backup copy beside it, so a pool that is missing or unreadable restores itself from the last good set of logins instead of silently coming back empty — losing every saved login to one bad write is no longer permanent. The backup also holds the largest set of logins ever seen, so it survives a run of smaller bad writes rather than being overwritten by the first one. Removing your last login still sticks: an intentionally empty pool is left alone rather than being repopulated from the backup.
|
|
14
|
+
|
|
3
15
|
## 1.23.1
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-freeflow",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.23.
|
|
4
|
+
"version": "1.23.3",
|
|
5
5
|
"description": "Thin provider for OMP/Pi — model list + dumb relay proxy + log; host pi-ai owns thinking/normalization",
|
|
6
6
|
"main": "extensions/index.ts",
|
|
7
7
|
"types": "src/index.ts",
|
package/src/cline-accounts.ts
CHANGED
|
@@ -27,6 +27,15 @@ export function resolveClinePoolPath(): string {
|
|
|
27
27
|
|
|
28
28
|
export const CLINE_POOL_FILE = resolveClinePoolPath();
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Recovery copy of the pool, read only when the main file is missing or
|
|
32
|
+
* unreadable. Mirrors the relay-state `.bak` convention, with one deliberate
|
|
33
|
+
* difference: an empty pool is a legitimate state here (`/freeflow cline
|
|
34
|
+
* logout` of the last login must stick), so a valid empty main file is never
|
|
35
|
+
* overridden from the backup.
|
|
36
|
+
*/
|
|
37
|
+
export const CLINE_POOL_BACKUP_FILE = `${CLINE_POOL_FILE}.bak`;
|
|
38
|
+
|
|
30
39
|
/** One saved login slot. The token is only ever sent as an explicit bearer header. */
|
|
31
40
|
export interface ClineAccount {
|
|
32
41
|
slot: string;
|
|
@@ -108,60 +117,89 @@ function emptyPool(): ClinePoolState {
|
|
|
108
117
|
return { accounts: [] };
|
|
109
118
|
}
|
|
110
119
|
|
|
111
|
-
|
|
120
|
+
/**
|
|
121
|
+
* Parse a pool document. Returns null when the blob is unusable (bad JSON,
|
|
122
|
+
* wrong shape) so callers can fall back to the backup copy. A valid document
|
|
123
|
+
* with zero accounts is NOT null: that is a real empty pool.
|
|
124
|
+
*/
|
|
125
|
+
function parsePoolDoc(raw: string): ClinePoolState | null {
|
|
126
|
+
let parsed: unknown;
|
|
112
127
|
try {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
128
|
+
parsed = JSON.parse(raw);
|
|
129
|
+
} catch {
|
|
130
|
+
logWarn("cline pool file corrupt", { path: CLINE_POOL_FILE });
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
134
|
+
logWarn("cline pool file unusable", { path: CLINE_POOL_FILE });
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
// Boundary-narrowed once: the on-disk blob is external input, so check
|
|
138
|
+
// its shape here and read only validated fields below.
|
|
139
|
+
const doc: Record<string, unknown> = parsed as Record<string, unknown>;
|
|
140
|
+
if (!Array.isArray(doc.accounts)) {
|
|
141
|
+
logWarn("cline pool file unusable", { path: CLINE_POOL_FILE });
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
const accounts: ClineAccount[] = [];
|
|
145
|
+
for (const entry of doc.accounts as unknown[]) {
|
|
146
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
147
|
+
const rec: Record<string, unknown> = entry as Record<string, unknown>;
|
|
148
|
+
const slot = typeof rec.slot === "string" ? rec.slot.trim() : "";
|
|
149
|
+
const token = typeof rec.token === "string" ? rec.token : "";
|
|
150
|
+
if (!slot || (!token.startsWith("workos:") && !isWorkosJwt(token) && !token.startsWith("clp_"))) continue;
|
|
151
|
+
const refreshRaw: unknown = rec.refreshToken;
|
|
152
|
+
const refreshToken = typeof refreshRaw === "string" && refreshRaw.length > 0 ? refreshRaw : undefined;
|
|
153
|
+
const expiresRaw: unknown = rec.expiresAt;
|
|
154
|
+
const expiresAt = typeof expiresRaw === "number" && Number.isFinite(expiresRaw) && expiresRaw > 0
|
|
155
|
+
? expiresRaw
|
|
156
|
+
: undefined;
|
|
157
|
+
const accountIdRaw: unknown = rec.accountId;
|
|
158
|
+
const accountId = typeof accountIdRaw === "string" && accountIdRaw.trim() ? accountIdRaw.trim() : undefined;
|
|
159
|
+
const emailRaw: unknown = rec.email;
|
|
160
|
+
const email = typeof emailRaw === "string" && emailRaw.trim() ? emailRaw.trim() : undefined;
|
|
161
|
+
accounts.push({
|
|
162
|
+
slot,
|
|
163
|
+
token,
|
|
164
|
+
addedAt: typeof rec.addedAt === "string" ? rec.addedAt : new Date().toISOString(),
|
|
165
|
+
...(refreshToken ? { refreshToken } : {}),
|
|
166
|
+
...(expiresAt !== undefined ? { expiresAt } : {}),
|
|
167
|
+
...(accountId ? { accountId } : {}),
|
|
168
|
+
...(email ? { email } : {}),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
const activeRaw: unknown = doc.activeSlot;
|
|
172
|
+
const activeSlot = typeof activeRaw === "string" && accounts.some((a) => a.slot === activeRaw.trim())
|
|
173
|
+
? activeRaw.trim()
|
|
174
|
+
: undefined;
|
|
175
|
+
return activeSlot ? { accounts, activeSlot } : { accounts };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Recover the pool from the backup copy and heal the main file so the recovery
|
|
180
|
+
* sticks. Returns null when no usable backup exists. Only ever called for a
|
|
181
|
+
* missing or unusable main file — never to override a valid empty pool.
|
|
182
|
+
*/
|
|
183
|
+
function recoverPoolFromBackup(description: string): ClinePoolState | null {
|
|
184
|
+
try {
|
|
185
|
+
const parsed = parsePoolDoc(fs.readFileSync(CLINE_POOL_BACKUP_FILE, "utf8"));
|
|
186
|
+
if (parsed && parsed.accounts.length > 0) {
|
|
187
|
+
logWarn(`cline pool ${description} — recovered from backup`, { slots: parsed.accounts.length, path: CLINE_POOL_BACKUP_FILE });
|
|
188
|
+
savePool(parsed);
|
|
189
|
+
return parsed;
|
|
132
190
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const refreshToken = typeof refreshRaw === "string" && refreshRaw.length > 0 ? refreshRaw : undefined;
|
|
142
|
-
const expiresRaw: unknown = rec.expiresAt;
|
|
143
|
-
const expiresAt = typeof expiresRaw === "number" && Number.isFinite(expiresRaw) && expiresRaw > 0
|
|
144
|
-
? expiresRaw
|
|
145
|
-
: undefined;
|
|
146
|
-
const accountIdRaw: unknown = rec.accountId;
|
|
147
|
-
const accountId = typeof accountIdRaw === "string" && accountIdRaw.trim() ? accountIdRaw.trim() : undefined;
|
|
148
|
-
const emailRaw: unknown = rec.email;
|
|
149
|
-
const email = typeof emailRaw === "string" && emailRaw.trim() ? emailRaw.trim() : undefined;
|
|
150
|
-
accounts.push({
|
|
151
|
-
slot,
|
|
152
|
-
token,
|
|
153
|
-
addedAt: typeof rec.addedAt === "string" ? rec.addedAt : new Date().toISOString(),
|
|
154
|
-
...(refreshToken ? { refreshToken } : {}),
|
|
155
|
-
...(expiresAt !== undefined ? { expiresAt } : {}),
|
|
156
|
-
...(accountId ? { accountId } : {}),
|
|
157
|
-
...(email ? { email } : {}),
|
|
158
|
-
});
|
|
191
|
+
} catch { }
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function readPoolFile(): ClinePoolState {
|
|
196
|
+
try {
|
|
197
|
+
if (!fs.existsSync(CLINE_POOL_FILE)) {
|
|
198
|
+
return recoverPoolFromBackup("main file missing") ?? emptyPool();
|
|
159
199
|
}
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
: undefined;
|
|
164
|
-
return activeSlot ? { accounts, activeSlot } : { accounts };
|
|
200
|
+
const parsed = parsePoolDoc(fs.readFileSync(CLINE_POOL_FILE, "utf8"));
|
|
201
|
+
if (parsed) return parsed;
|
|
202
|
+
return recoverPoolFromBackup("file unusable") ?? emptyPool();
|
|
165
203
|
} catch {
|
|
166
204
|
return emptyPool();
|
|
167
205
|
}
|
|
@@ -190,17 +228,64 @@ export function loadPool(): ClinePoolState {
|
|
|
190
228
|
return cached;
|
|
191
229
|
}
|
|
192
230
|
|
|
231
|
+
/**
|
|
232
|
+
* Keep the largest pool ever seen as the recovery copy.
|
|
233
|
+
*
|
|
234
|
+
* Largest-wins rather than last-write-wins, deliberately. The backup is read
|
|
235
|
+
* only when the main file is missing or unreadable, and two failure modes had
|
|
236
|
+
* to be covered at once:
|
|
237
|
+
* - an existing richer pool being overwritten (compare the file being
|
|
238
|
+
* replaced, before the write), and
|
|
239
|
+
* - growth being lost because the backup always lagged one write (compare
|
|
240
|
+
* the pool just written, after the write).
|
|
241
|
+
* Ties do not replace, so a run of equally-sized destructive writes cannot
|
|
242
|
+
* evict a good copy — which is how a real pool was lost: two good logins were
|
|
243
|
+
* replaced by fixtures, and the second fixture write replaced the backup too.
|
|
244
|
+
*/
|
|
245
|
+
function keepLargestBackup(raw: string): void {
|
|
246
|
+
try {
|
|
247
|
+
const count = accountCountIn(raw);
|
|
248
|
+
if (count <= 0) return;
|
|
249
|
+
let backupCount = -1;
|
|
250
|
+
try {
|
|
251
|
+
backupCount = accountCountIn(fs.readFileSync(CLINE_POOL_BACKUP_FILE, "utf8"));
|
|
252
|
+
} catch {
|
|
253
|
+
backupCount = -1;
|
|
254
|
+
}
|
|
255
|
+
if (count <= backupCount) return;
|
|
256
|
+
fs.writeFileSync(CLINE_POOL_BACKUP_FILE, raw, { encoding: "utf8", mode: 0o600 });
|
|
257
|
+
try { fs.chmodSync(CLINE_POOL_BACKUP_FILE, 0o600); } catch { }
|
|
258
|
+
} catch { }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** How many accounts a raw pool blob holds; -1 when it is unreadable. */
|
|
262
|
+
function accountCountIn(raw: string): number {
|
|
263
|
+
try {
|
|
264
|
+
const doc = JSON.parse(raw) as { accounts?: unknown };
|
|
265
|
+
return Array.isArray(doc.accounts) ? doc.accounts.length : -1;
|
|
266
|
+
} catch {
|
|
267
|
+
return -1;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
193
271
|
/** Atomically persist the pool with owner-only permissions. Never logs secrets. */
|
|
194
272
|
export function savePool(pool: ClinePoolState): void {
|
|
195
273
|
try {
|
|
196
274
|
const dir = path.dirname(CLINE_POOL_FILE);
|
|
197
275
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
276
|
+
// The pool being replaced is the only copy of itself: secure it first.
|
|
277
|
+
try {
|
|
278
|
+
if (fs.existsSync(CLINE_POOL_FILE)) keepLargestBackup(fs.readFileSync(CLINE_POOL_FILE, "utf8"));
|
|
279
|
+
} catch { }
|
|
198
280
|
const tmp = `${CLINE_POOL_FILE}.${Date.now()}.tmp`;
|
|
199
|
-
|
|
281
|
+
const serialized = JSON.stringify(pool, null, 2);
|
|
282
|
+
fs.writeFileSync(tmp, serialized, { encoding: "utf8", mode: 0o600 });
|
|
200
283
|
try {
|
|
201
284
|
fs.chmodSync(tmp, 0o600);
|
|
202
285
|
} catch { }
|
|
203
286
|
fs.renameSync(tmp, CLINE_POOL_FILE);
|
|
287
|
+
// Compare the pool just written too, so growth is never the copy that lags.
|
|
288
|
+
keepLargestBackup(serialized);
|
|
204
289
|
cached = pool;
|
|
205
290
|
cachedMtime = diskMtime();
|
|
206
291
|
} catch (e) {
|