pi-freeflow 1.8.1 → 1.8.2

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-freeflow",
3
3
  "type": "module",
4
- "version": "1.8.1",
4
+ "version": "1.8.2",
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/catalog.ts CHANGED
@@ -191,7 +191,28 @@ export function readCatalogCache(): CatalogCacheData | null {
191
191
  /**
192
192
  * Atomically write catalog cache data to disk using temporary file + rename.
193
193
  * Persists etag alongside models for subsequent If-None-Match conditional requests.
194
+ * Note: fsync not needed — rename is atomic on same filesystem; crash leaves either old or new file intact.
194
195
  */
196
+ function sleepSync(ms: number): void {
197
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
198
+ }
199
+
200
+ function renameWithRetry(from: string, to: string): void {
201
+ const retryable: Record<string, true> = { EPERM: true, EACCES: true, EBUSY: true };
202
+ for (let attempt = 1; ; attempt++) {
203
+ try {
204
+ fs.renameSync(from, to);
205
+ return;
206
+ } catch (e) {
207
+ const code = (e as NodeJS.ErrnoException | null)?.code;
208
+ if (!code || !retryable[code] || attempt >= 3) {
209
+ throw e;
210
+ }
211
+ sleepSync(50);
212
+ }
213
+ }
214
+ }
215
+
195
216
  export function writeCatalogCache(data: CatalogCacheData): void {
196
217
  try {
197
218
  const dir = path.dirname(CATALOG_CACHE_FILE);
@@ -200,7 +221,7 @@ export function writeCatalogCache(data: CatalogCacheData): void {
200
221
  }
201
222
  const tmpPath = `${CATALOG_CACHE_FILE}.${randomUUID()}.tmp`;
202
223
  fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), "utf8");
203
- fs.renameSync(tmpPath, CATALOG_CACHE_FILE);
224
+ renameWithRetry(tmpPath, CATALOG_CACHE_FILE);
204
225
  } catch (err) {
205
226
  logWarn("Could not persist catalog cache to disk", { error: String(err) });
206
227
  }
package/src/commands.ts CHANGED
@@ -350,12 +350,13 @@ export function createCommandSpec(
350
350
  const { url, auth } = await deployer(token, name, (m) =>
351
351
  ctx.ui.notify(m, "info"),
352
352
  );
353
- setRelay(true, url, `deployed ${name}`);
354
- // Persist the per-deployment shared secret with the relay entry
355
- // (setRelay wrote without it; this save must include it).
356
- const deployed = relayState.relays.find((r) => r.url === url);
357
- if (deployed) deployed.auth = auth;
358
- setActiveRelayState(relayState);
353
+ relayState = withRelayState((s) => {
354
+ const r = ensureRelay(s, url, `deployed ${name}`);
355
+ r.auth = auth;
356
+ s.enabled = true;
357
+ s.url = url;
358
+ return s;
359
+ });
359
360
  persist();
360
361
  let probeNote = "";
361
362
  try {
@@ -589,12 +590,17 @@ export function createCommandSpec(
589
590
  const tokens = rest.trim().split(/\s+/);
590
591
  const targetUrl = tokens[0];
591
592
  const customLabel = tokens.slice(1).join(" ").trim() || undefined;
592
- applyRelayState((s) => {
593
- const added = ensureRelay(s, targetUrl, customLabel);
594
- s.enabled = true;
595
- s.url = added.url;
596
- return s;
597
- });
593
+ try {
594
+ applyRelayState((s) => {
595
+ const added = ensureRelay(s, targetUrl, customLabel);
596
+ s.enabled = true;
597
+ s.url = added.url;
598
+ return s;
599
+ });
600
+ } catch (e) {
601
+ ctx.ui.notify((e as Error).message, "warning");
602
+ return;
603
+ }
598
604
  persist();
599
605
  flash();
600
606
  ctx.ui.notify(
@@ -632,12 +638,17 @@ export function createCommandSpec(
632
638
  } catch {}
633
639
  const looksLikeUrl = parsedUrl && (parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:");
634
640
  if (looksLikeUrl) {
635
- applyRelayState((s) => {
636
- const added = ensureRelay(s, targetToken, customLabel);
637
- s.enabled = true;
638
- s.url = added.url;
639
- return s;
640
- });
641
+ try {
642
+ applyRelayState((s) => {
643
+ const added = ensureRelay(s, targetToken, customLabel);
644
+ s.enabled = true;
645
+ s.url = added.url;
646
+ return s;
647
+ });
648
+ } catch (e) {
649
+ ctx.ui.notify((e as Error).message, "warning");
650
+ return;
651
+ }
641
652
  persist();
642
653
  flash();
643
654
  } else {
package/src/config.ts CHANGED
@@ -75,7 +75,7 @@ export const RATE_LIMIT_MAX: Record<Upstream, number> = {
75
75
  };
76
76
 
77
77
  // ── Whitelists & Security ───────────────────────────────────────────
78
- export const ALLOWED_PATH_PATTERN = /^\/v1\/[a-zA-Z0-9/_.,\-?&=]*$/;
78
+ export const ALLOWED_PATH_PATTERN = /^\/v1\/[a-zA-Z0-9/_.,\-?&= %]*$/;
79
79
  export const PATH_TRAVERSAL_PATTERN = /\.\./;
80
80
  export const ALLOWED_METHODS = new Set(["GET", "POST", "OPTIONS", "HEAD"]);
81
81
 
package/src/deploy.ts CHANGED
@@ -75,6 +75,8 @@ const timingSafeEqualStr = function(a, b) {
75
75
  // (never forwarded upstream) instead of living in the denylist.
76
76
  const DENY_HEADERS = ["host", "connection", "content-length", "keep-alive", "proxy-connection", "proxy-authenticate", "proxy-authorization", "transfer-encoding", "te", "trailer", "upgrade", "x-relay-target", "x-relay-path"];
77
77
  async function relayHandler(req) {
78
+ if (!["GET", "POST", "HEAD", "OPTIONS"].includes(req.method)) return new Response("Method Not Allowed", { status: 405 });
79
+ if (Number(req.headers.get("content-length") || 0) > 32 * 1024 * 1024) return new Response("Payload Too Large", { status: 413 });
78
80
  const target = req.headers.get("x-relay-target");
79
81
  if (!target) return new Response(JSON.stringify({ error: "Missing x-relay-target header" }), { status: 400, headers: { "content-type": "application/json" } });
80
82
  if (RELAY_AUTH && !timingSafeEqualStr(req.headers.get("x-relay-auth") || "", RELAY_AUTH)) {
@@ -159,11 +161,11 @@ export async function deployVercelRelay(
159
161
  name: string,
160
162
  onProgress?: (msg: string) => void,
161
163
  ): Promise<{ url: string; auth: string }> {
164
+ name = baseRelayName(name) || "relay-worker";
162
165
  const auth = {
163
166
  Authorization: `Bearer ${token}`,
164
167
  "Content-Type": "application/json",
165
168
  };
166
-
167
169
  // 1. Create deployment (3 inline files, no git repository required)
168
170
  onProgress?.("Uploading relay files to Vercel…");
169
171
  log("info", `Starting Vercel deployment: ${name}`);
package/src/health.ts CHANGED
@@ -55,7 +55,8 @@ export function getHealthData(portOverride?: number): HealthData {
55
55
 
56
56
  function isLoopbackIP(ip: string): boolean {
57
57
  if (!ip) return false;
58
- const clean = ip.startsWith("::ffff:") ? ip.slice(7) : ip;
58
+ const withoutZone = ip.split("%")[0];
59
+ const clean = withoutZone.startsWith("::ffff:") ? withoutZone.slice(7) : withoutZone;
59
60
  return clean === "127.0.0.1" || clean === "::1" || clean === "localhost";
60
61
  }
61
62
 
package/src/index.ts CHANGED
@@ -150,7 +150,7 @@ export function buildProviderConfig(
150
150
  : m.source === "kilo"
151
151
  ? {
152
152
  supportsDeveloperRole: false,
153
- supportsReasoningEffort: false,
153
+ supportsReasoningEffort: !!m.thinkingLevelMap,
154
154
  }
155
155
  : {
156
156
  supportsDeveloperRole: false,
package/src/logger.ts CHANGED
@@ -197,6 +197,10 @@ export function formatLogMeta(
197
197
  if (meta && Object.keys(meta).length > 0) {
198
198
  const safe: Record<string, unknown> = {};
199
199
  for (const [k, v] of Object.entries(meta)) {
200
+ if (/(auth|token|secret|password|bearer|api.?key|cookie|x-relay-auth)/i.test(k)) {
201
+ safe[k] = "[REDACTED]";
202
+ continue;
203
+ }
200
204
  if (typeof v === "string" && v.length > 800) {
201
205
  safe[k] = `${v.slice(0, 800)}…(${v.length})`;
202
206
  } else if (v instanceof Error) {
package/src/proxy.ts CHANGED
@@ -66,6 +66,7 @@ export function validatePath(rawUrl: string): URL | null {
66
66
  if (PATH_TRAVERSAL_PATTERN.test(cleaned)) return null;
67
67
  try {
68
68
  const decoded = decodeURIComponent(cleaned);
69
+ if (PATH_TRAVERSAL_PATTERN.test(decoded)) return null;
69
70
  if (decoded !== cleaned && !ALLOWED_PATH_PATTERN.test(`/${decoded}`)) {
70
71
  return null;
71
72
  }
@@ -171,6 +172,7 @@ export async function killPortHolder(port: number): Promise<boolean> {
171
172
  }) as string;
172
173
  const pid = out.trim().split(/\s+/)[0];
173
174
  if (!pid || !/^\d+$/.test(pid)) return false;
175
+ if (Number(pid) === process.pid) return false; // never self-kill (Unix) — matches Windows guard at 161
174
176
  execSync(`kill -9 ${pid}`, { timeout: 3000, stdio: "ignore" });
175
177
  return true;
176
178
  } catch {
@@ -522,7 +524,7 @@ export function startProxy(
522
524
  fwd["connection"] = "keep-alive";
523
525
 
524
526
  const controller = new AbortController();
525
- const timeoutId = setTimeout(() => controller.abort(), 300_000);
527
+ const timeoutId = setTimeout(() => controller.abort(upstreamTimeoutError()), UPSTREAM_HEADER_TIMEOUT_MS);
526
528
  const onClientClose = () => {
527
529
  if (!res.writableEnded) controller.abort();
528
530
  };
@@ -600,6 +602,7 @@ export function startProxy(
600
602
  let settled = false;
601
603
 
602
604
  const tryListen = async (port: number) => {
605
+ server.removeAllListeners("error");
603
606
  server.once("error", async (err: NodeJS.ErrnoException) => {
604
607
  if (settled) return;
605
608
  if (err.code === "EADDRINUSE") {
@@ -512,7 +512,7 @@ lastKnownStateMtimeMs = currentDiskStateMtimeMs();
512
512
  */
513
513
  export function getActiveRelayState(): RelayState {
514
514
  const currentMtime = currentDiskStateMtimeMs();
515
- if (currentMtime > 0 && currentMtime > lastKnownStateMtimeMs) {
515
+ if (currentMtime !== lastKnownStateMtimeMs) {
516
516
  activeRelayState = resolveRelayState();
517
517
  lastKnownStateMtimeMs = currentMtime;
518
518
  }