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 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.1",
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",
@@ -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
- function readPoolFile(): ClinePoolState {
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
- if (!fs.existsSync(CLINE_POOL_FILE)) return emptyPool();
114
- const raw = fs.readFileSync(CLINE_POOL_FILE, "utf8");
115
- let parsed: unknown;
116
- try {
117
- parsed = JSON.parse(raw);
118
- } catch {
119
- logWarn("cline pool file corrupt — starting with an empty pool", { path: CLINE_POOL_FILE });
120
- return emptyPool();
121
- }
122
- if (typeof parsed !== "object" || parsed === null) {
123
- logWarn("cline pool file unusable — starting with an empty pool", { path: CLINE_POOL_FILE });
124
- return emptyPool();
125
- }
126
- // Boundary-narrowed once: the on-disk blob is external input, so check
127
- // its shape here and read only validated fields below.
128
- const doc: Record<string, unknown> = parsed as Record<string, unknown>;
129
- if (!Array.isArray(doc.accounts)) {
130
- logWarn("cline pool file unusable — starting with an empty pool", { path: CLINE_POOL_FILE });
131
- return emptyPool();
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
- const accounts: ClineAccount[] = [];
134
- for (const entry of doc.accounts as unknown[]) {
135
- if (typeof entry !== "object" || entry === null) continue;
136
- const rec: Record<string, unknown> = entry as Record<string, unknown>;
137
- const slot = typeof rec.slot === "string" ? rec.slot.trim() : "";
138
- const token = typeof rec.token === "string" ? rec.token : "";
139
- if (!slot || (!token.startsWith("workos:") && !isWorkosJwt(token) && !token.startsWith("clp_"))) continue;
140
- const refreshRaw: unknown = rec.refreshToken;
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 activeRaw: unknown = doc.activeSlot;
161
- const activeSlot = typeof activeRaw === "string" && accounts.some((a) => a.slot === activeRaw.trim())
162
- ? activeRaw.trim()
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
- fs.writeFileSync(tmp, JSON.stringify(pool, null, 2), { encoding: "utf8", mode: 0o600 });
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) {