pi-free 2.2.0 → 2.2.1

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
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.2.1] - 2026-06-19
11
+
12
+ ### Security
13
+
14
+ - `~/.pi/free.json` (which contains API keys for paid providers) is now
15
+ written and re-tightened with mode `0600` (owner read/write only) on
16
+ every startup and on every write. Previously the file was world-readable
17
+ on Unix. No-op on Windows. Closes DRYKISS finding.
18
+
19
+ ### Fixed
20
+
21
+ - Wrap quota monitoring and telemetry event handlers in `try/catch` so a
22
+ failed status-bar update or telemetry write can never break the agent
23
+ loop (DRYKISS resilience findings).
24
+
10
25
  ## [2.2.0] - 2026-06-19
11
26
 
12
27
  ### Added
package/config.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  * (e.g. after toggle-{provider}) are visible immediately.
10
10
  */
11
11
 
12
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
13
13
  import { join } from "node:path";
14
14
  export {
15
15
  PROVIDER_BAI,
@@ -130,6 +130,9 @@ function ensureConfigFile(): void {
130
130
  );
131
131
  return;
132
132
  }
133
+ // Always tighten permissions on startup, even if contents are
134
+ // unchanged — older installs may have a world-readable file.
135
+ restrictConfigFilePermissions();
133
136
  // Merge with template to add any missing keys, preserving existing values
134
137
  const merged = { ...CONFIG_TEMPLATE, ...existing };
135
138
  if (JSON.stringify(merged) !== JSON.stringify(existing)) {
@@ -138,6 +141,7 @@ function ensureConfigFile(): void {
138
141
  `${JSON.stringify(merged, null, 2)}\n`,
139
142
  "utf8",
140
143
  );
144
+ restrictConfigFilePermissions();
141
145
  }
142
146
  } else {
143
147
  writeFileSync(
@@ -145,6 +149,7 @@ function ensureConfigFile(): void {
145
149
  `${JSON.stringify(CONFIG_TEMPLATE, null, 2)}\n`,
146
150
  "utf8",
147
151
  );
152
+ restrictConfigFilePermissions();
148
153
  }
149
154
  } catch (err) {
150
155
  _logger.warn("Could not create config file", {
@@ -154,6 +159,24 @@ function ensureConfigFile(): void {
154
159
  }
155
160
  }
156
161
 
162
+ /**
163
+ * Restrict `~/.pi/free.json` to owner read/write (0600). The file may
164
+ * contain API keys for paid providers, so it must never be world-readable.
165
+ * Best-effort: if chmod is not supported on the platform/filesystem,
166
+ * log a warning and continue (the keys are still safe inside the user's
167
+ * home directory).
168
+ */
169
+ function restrictConfigFilePermissions(): void {
170
+ try {
171
+ chmodSync(CONFIG_PATH, 0o600);
172
+ } catch (err) {
173
+ _logger.warn("Could not restrict config file permissions to 0600", {
174
+ path: CONFIG_PATH,
175
+ error: err instanceof Error ? err.message : String(err),
176
+ });
177
+ }
178
+ }
179
+
157
180
  export function loadConfigFile(): PiFreeConfig {
158
181
  try {
159
182
  return JSON.parse(
package/index.ts CHANGED
@@ -222,29 +222,42 @@ function setupQuotaMonitoring(pi: ExtensionAPI) {
222
222
  (pi as any).on(
223
223
  "after_provider_response",
224
224
  (event: { status: number; headers: Record<string, string> }, ctx: any) => {
225
- const providerId = ctx.model?.provider;
226
- if (!providerId) return;
225
+ try {
226
+ const providerId = ctx.model?.provider;
227
+ if (!providerId) return;
227
228
 
228
- processQuotaResponse(providerId, event.headers);
229
+ processQuotaResponse(providerId, event.headers);
229
230
 
230
- // Update status bar with quota for the active provider
231
- const status = formatQuotaStatus(providerId);
232
- if (status) {
233
- ctx.ui.setStatus("quota", status);
231
+ // Update status bar with quota for the active provider
232
+ const status = formatQuotaStatus(providerId);
233
+ if (status) {
234
+ ctx.ui.setStatus("quota", status);
235
+ }
236
+ } catch (err) {
237
+ // Quota monitoring is best-effort — never break the agent flow
238
+ _logger.warn("quota monitoring failed", {
239
+ error: err instanceof Error ? err.message : String(err),
240
+ });
234
241
  }
235
242
  },
236
243
  );
237
244
 
238
245
  // Clear quota status when switching away from a provider
239
246
  pi.on("model_select", (_event, ctx) => {
240
- const providerId = ctx.model?.provider;
241
- if (!providerId) {
242
- ctx.ui.setStatus("quota", undefined);
243
- return;
247
+ try {
248
+ const providerId = ctx.model?.provider;
249
+ if (!providerId) {
250
+ ctx.ui.setStatus("quota", undefined);
251
+ return;
252
+ }
253
+ // Show cached quota on provider switch (if still fresh)
254
+ const status = formatQuotaStatus(providerId);
255
+ ctx.ui.setStatus("quota", status);
256
+ } catch (err) {
257
+ _logger.warn("quota status update failed", {
258
+ error: err instanceof Error ? err.message : String(err),
259
+ });
244
260
  }
245
- // Show cached quota on provider switch (if still fresh)
246
- const status = formatQuotaStatus(providerId);
247
- ctx.ui.setStatus("quota", status);
248
261
  });
249
262
  }
250
263
 
@@ -261,7 +274,14 @@ function setupTelemetry(pi: ExtensionAPI) {
261
274
  const provider = ctx.model?.provider;
262
275
  const model = ctx.model?.id;
263
276
  if (provider && model) {
264
- startModelCall(provider, model);
277
+ try {
278
+ startModelCall(provider, model);
279
+ } catch (err) {
280
+ // Telemetry is best-effort — never break the agent flow
281
+ _logger.warn("telemetry startModelCall failed", {
282
+ error: err instanceof Error ? err.message : String(err),
283
+ });
284
+ }
265
285
  }
266
286
  });
267
287
 
@@ -300,17 +320,24 @@ function setupTelemetry(pi: ExtensionAPI) {
300
320
  const cost = usage?.cost?.total ?? 0;
301
321
  const isError = msg.stopReason === "error" || !!msg.errorMessage;
302
322
 
303
- await recordModelCall(
304
- provider,
305
- model,
306
- { input: inputTokens, output: outputTokens, totalTokens },
307
- cost,
308
- {
309
- success: !isError,
310
- stopReason: msg.stopReason,
311
- errorMessage: msg.errorMessage,
312
- },
313
- );
323
+ try {
324
+ await recordModelCall(
325
+ provider,
326
+ model,
327
+ { input: inputTokens, output: outputTokens, totalTokens },
328
+ cost,
329
+ {
330
+ success: !isError,
331
+ stopReason: msg.stopReason,
332
+ errorMessage: msg.errorMessage,
333
+ },
334
+ );
335
+ } catch (err) {
336
+ // Telemetry is best-effort — never break the agent flow
337
+ _logger.warn("telemetry recordModelCall failed", {
338
+ error: err instanceof Error ? err.message : String(err),
339
+ });
340
+ }
314
341
  });
315
342
  }
316
343
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-free",
3
- "version": "2.2.0",
3
+ "version": "2.2.1",
4
4
  "type": "module",
5
5
  "description": "AI model providers for Pi with free model filtering and dynamic model fetching",
6
6
  "keywords": [