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 +1 -1
- package/src/catalog.ts +22 -1
- package/src/commands.ts +29 -18
- package/src/config.ts +1 -1
- package/src/deploy.ts +3 -1
- package/src/health.ts +2 -1
- package/src/index.ts +1 -1
- package/src/logger.ts +4 -0
- package/src/proxy.ts +4 -1
- package/src/relay-state.ts +1 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-freeflow",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.8.
|
|
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
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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
|
|
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
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(),
|
|
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") {
|
package/src/relay-state.ts
CHANGED
|
@@ -512,7 +512,7 @@ lastKnownStateMtimeMs = currentDiskStateMtimeMs();
|
|
|
512
512
|
*/
|
|
513
513
|
export function getActiveRelayState(): RelayState {
|
|
514
514
|
const currentMtime = currentDiskStateMtimeMs();
|
|
515
|
-
if (currentMtime
|
|
515
|
+
if (currentMtime !== lastKnownStateMtimeMs) {
|
|
516
516
|
activeRelayState = resolveRelayState();
|
|
517
517
|
lastKnownStateMtimeMs = currentMtime;
|
|
518
518
|
}
|