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 +15 -0
- package/config.ts +24 -1
- package/index.ts +53 -26
- package/package.json +1 -1
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
|
-
|
|
226
|
-
|
|
225
|
+
try {
|
|
226
|
+
const providerId = ctx.model?.provider;
|
|
227
|
+
if (!providerId) return;
|
|
227
228
|
|
|
228
|
-
|
|
229
|
+
processQuotaResponse(providerId, event.headers);
|
|
229
230
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
|