pi-freeflow 1.4.7 → 1.4.9
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/extensions/index.ts +16 -1
- package/package.json +55 -55
- package/src/commands.ts +126 -5
- package/src/config.ts +14 -0
- package/src/deploy.ts +153 -19
- package/src/stream-pipe.ts +8 -3
- package/src/update-checker.ts +156 -0
package/extensions/index.ts
CHANGED
|
@@ -4,5 +4,20 @@
|
|
|
4
4
|
* Lightweight bridge re-exporting the modular codebase rooted in src/
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
import { checkForUpdateInBackground } from "../src/update-checker.ts";
|
|
8
|
+
import originalDefault from "../src/index.ts";
|
|
9
|
+
import type { ExtensionAPI } from "../src/types.ts";
|
|
10
|
+
|
|
11
|
+
export default async function (pi: ExtensionAPI): Promise<void> {
|
|
12
|
+
try {
|
|
13
|
+
const r = checkForUpdateInBackground() as unknown as Promise<void> | void;
|
|
14
|
+
if (r && typeof (r as Promise<void>).catch === "function") {
|
|
15
|
+
(r as Promise<void>).catch(() => {});
|
|
16
|
+
}
|
|
17
|
+
} catch {
|
|
18
|
+
// swallow — never block activation
|
|
19
|
+
}
|
|
20
|
+
return originalDefault(pi);
|
|
21
|
+
}
|
|
22
|
+
|
|
8
23
|
export * from "../src/index.ts";
|
package/package.json
CHANGED
|
@@ -1,55 +1,55 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "pi-freeflow",
|
|
3
|
-
"type": "module",
|
|
4
|
-
"version": "1.4.
|
|
5
|
-
"description": "Thin provider for OMP/Pi — model list + dumb relay proxy + log; host pi-ai owns thinking/normalization",
|
|
6
|
-
"main": "extensions/index.ts",
|
|
7
|
-
"types": "src/index.ts",
|
|
8
|
-
"keywords": [
|
|
9
|
-
"pi-package",
|
|
10
|
-
"pi-extension",
|
|
11
|
-
"oh-my-pi",
|
|
12
|
-
"omp",
|
|
13
|
-
"free-models",
|
|
14
|
-
"opencode",
|
|
15
|
-
"kilocode",
|
|
16
|
-
"ai-models",
|
|
17
|
-
"relay"
|
|
18
|
-
],
|
|
19
|
-
"author": "trefeon",
|
|
20
|
-
"license": "MIT",
|
|
21
|
-
"repository": {
|
|
22
|
-
"type": "git",
|
|
23
|
-
"url": "git+https://github.com/trefeon/pi-freeflow.git"
|
|
24
|
-
},
|
|
25
|
-
"homepage": "https://github.com/trefeon/pi-freeflow#readme",
|
|
26
|
-
"engines": {
|
|
27
|
-
"node": ">=22.6.0"
|
|
28
|
-
},
|
|
29
|
-
"omp": {
|
|
30
|
-
"extensions": [
|
|
31
|
-
"./extensions"
|
|
32
|
-
]
|
|
33
|
-
},
|
|
34
|
-
"pi": {
|
|
35
|
-
"extensions": [
|
|
36
|
-
"./extensions"
|
|
37
|
-
]
|
|
38
|
-
},
|
|
39
|
-
"files": [
|
|
40
|
-
"extensions",
|
|
41
|
-
"src",
|
|
42
|
-
"README.md",
|
|
43
|
-
"LICENSE"
|
|
44
|
-
],
|
|
45
|
-
"scripts": {
|
|
46
|
-
"test": "node --experimental-strip-types --test test/**/*.test.ts",
|
|
47
|
-
"typecheck": "tsc --noEmit",
|
|
48
|
-
"smoke": "node --experimental-strip-types -e \"import('./extensions/index.ts').then(() => console.log('✓ Smoke test passed: extensions/index.ts loaded successfully')).catch(err => { console.error(err); process.exit(1); })\""
|
|
49
|
-
},
|
|
50
|
-
"devDependencies": {
|
|
51
|
-
"@earendil-works/pi-coding-agent": "^0.84.3",
|
|
52
|
-
"@types/node": "^22.13.9",
|
|
53
|
-
"typescript": "^5.8.2"
|
|
54
|
-
}
|
|
55
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-freeflow",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "1.4.9",
|
|
5
|
+
"description": "Thin provider for OMP/Pi — model list + dumb relay proxy + log; host pi-ai owns thinking/normalization",
|
|
6
|
+
"main": "extensions/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"pi-package",
|
|
10
|
+
"pi-extension",
|
|
11
|
+
"oh-my-pi",
|
|
12
|
+
"omp",
|
|
13
|
+
"free-models",
|
|
14
|
+
"opencode",
|
|
15
|
+
"kilocode",
|
|
16
|
+
"ai-models",
|
|
17
|
+
"relay"
|
|
18
|
+
],
|
|
19
|
+
"author": "trefeon",
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/trefeon/pi-freeflow.git"
|
|
24
|
+
},
|
|
25
|
+
"homepage": "https://github.com/trefeon/pi-freeflow#readme",
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=22.6.0"
|
|
28
|
+
},
|
|
29
|
+
"omp": {
|
|
30
|
+
"extensions": [
|
|
31
|
+
"./extensions"
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
"pi": {
|
|
35
|
+
"extensions": [
|
|
36
|
+
"./extensions"
|
|
37
|
+
]
|
|
38
|
+
},
|
|
39
|
+
"files": [
|
|
40
|
+
"extensions",
|
|
41
|
+
"src",
|
|
42
|
+
"README.md",
|
|
43
|
+
"LICENSE"
|
|
44
|
+
],
|
|
45
|
+
"scripts": {
|
|
46
|
+
"test": "node --experimental-strip-types --test --test-concurrency=1 test/**/*.test.ts",
|
|
47
|
+
"typecheck": "tsc --noEmit",
|
|
48
|
+
"smoke": "node --experimental-strip-types -e \"import('./extensions/index.ts').then(() => console.log('✓ Smoke test passed: extensions/index.ts loaded successfully')).catch(err => { console.error(err); process.exit(1); })\""
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@earendil-works/pi-coding-agent": "^0.84.3",
|
|
52
|
+
"@types/node": "^22.13.9",
|
|
53
|
+
"typescript": "^5.8.2"
|
|
54
|
+
}
|
|
55
|
+
}
|
package/src/commands.ts
CHANGED
|
@@ -4,8 +4,18 @@
|
|
|
4
4
|
* log viewing, debug level configuration, and live catalog refreshing.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { refreshCatalog, setAliveCatalog } from "./catalog.ts";
|
|
12
|
+
import { DEBUG_STATE_FILE, DEFAULT_RELAY_URL, LOG_FILE } from "./config.ts";
|
|
13
|
+
import {
|
|
14
|
+
compareVersions,
|
|
15
|
+
fetchLatestVersion,
|
|
16
|
+
getCachedUpdate,
|
|
17
|
+
isLinkedInstall,
|
|
18
|
+
} from "./update-checker.ts";
|
|
9
19
|
import {
|
|
10
20
|
deployCloudflareWorker,
|
|
11
21
|
deployDenoRelay,
|
|
@@ -66,13 +76,59 @@ export function updateStatusBar(ui?: ExtensionUIContext): void {
|
|
|
66
76
|
}
|
|
67
77
|
}
|
|
68
78
|
|
|
79
|
+
function getLocalVersion(): string {
|
|
80
|
+
try {
|
|
81
|
+
const thisDir = path.dirname(fileURLToPath(import.meta.url));
|
|
82
|
+
const pkgPath = path.join(thisDir, "..", "package.json");
|
|
83
|
+
const raw = readFileSync(pkgPath, "utf8");
|
|
84
|
+
const pkg = JSON.parse(raw) as { version?: string };
|
|
85
|
+
if (typeof pkg.version === "string" && pkg.version.trim().length > 0) {
|
|
86
|
+
return pkg.version.trim();
|
|
87
|
+
}
|
|
88
|
+
return "0.0.0";
|
|
89
|
+
} catch {
|
|
90
|
+
return "0.0.0";
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function spawnWithProgress(
|
|
95
|
+
cmd: string,
|
|
96
|
+
args: string[],
|
|
97
|
+
ctx: ExtensionContext,
|
|
98
|
+
): Promise<number> {
|
|
99
|
+
return new Promise((resolve) => {
|
|
100
|
+
try {
|
|
101
|
+
const child = spawn(cmd, args, {
|
|
102
|
+
shell: process.platform === "win32",
|
|
103
|
+
stdio: "pipe",
|
|
104
|
+
});
|
|
105
|
+
child.stdout?.on("data", (d: Buffer) => {
|
|
106
|
+
const s = String(d).trim();
|
|
107
|
+
if (s) ctx.ui.notify(s, "info");
|
|
108
|
+
});
|
|
109
|
+
child.stderr?.on("data", (d: Buffer) => {
|
|
110
|
+
const s = String(d).trim();
|
|
111
|
+
if (s) ctx.ui.notify(s, "info");
|
|
112
|
+
});
|
|
113
|
+
child.on("error", (err: Error) => {
|
|
114
|
+
ctx.ui.notify(`spawn ${cmd} failed: ${err.message}`, "warning");
|
|
115
|
+
resolve(1);
|
|
116
|
+
});
|
|
117
|
+
child.on("close", (code: number | null) => resolve(code ?? 0));
|
|
118
|
+
} catch (e) {
|
|
119
|
+
ctx.ui.notify(`spawn ${cmd} failed: ${(e as Error).message}`, "warning");
|
|
120
|
+
resolve(1);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
69
125
|
export function createCommandSpec(
|
|
70
126
|
_pi: ExtensionAPI,
|
|
71
127
|
onCatalogRefreshed?: (models: RegisteredModel[]) => void,
|
|
72
128
|
): Omit<RegisteredCommand, "name"> {
|
|
73
129
|
return {
|
|
74
130
|
description:
|
|
75
|
-
"Relay egress: auto | on | off | status | add <URL> [name] | list | use <URL|name|index> [name] | label <target> <name> | remove <target> | logs [level] [n] | debug on|off | refresh | deploy vercel | deploy cloudflare | deploy deno",
|
|
131
|
+
"Relay egress: auto | on | off | status | add <URL> [name] | list | use <URL|name|index> [name] | label <target> <name> | remove <target> | logs [level] [n] | debug on|off | refresh | update | deploy vercel | deploy cloudflare | deploy deno",
|
|
76
132
|
getArgumentCompletions: (prefix: string) =>
|
|
77
133
|
[
|
|
78
134
|
"auto",
|
|
@@ -346,8 +402,73 @@ export function createCommandSpec(
|
|
|
346
402
|
setActiveRelayState(relayState);
|
|
347
403
|
persist();
|
|
348
404
|
flash();
|
|
349
|
-
} else if (sub === "status") {
|
|
350
|
-
flash();
|
|
405
|
+
} else if (sub === "status") {
|
|
406
|
+
flash();
|
|
407
|
+
try {
|
|
408
|
+
const local = getLocalVersion();
|
|
409
|
+
let latest: string | null = null;
|
|
410
|
+
const cached = getCachedUpdate();
|
|
411
|
+
if (cached && typeof cached.latest === "string") {
|
|
412
|
+
latest = cached.latest;
|
|
413
|
+
}
|
|
414
|
+
if (latest && compareVersions(latest, local) > 0) {
|
|
415
|
+
ctx.ui.notify(
|
|
416
|
+
`Update available: ${local} -> ${latest} - run /freeflow update`,
|
|
417
|
+
"info",
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
} catch {
|
|
421
|
+
// swallow — status banner is best-effort
|
|
422
|
+
}
|
|
423
|
+
} else if (sub === "update") {
|
|
424
|
+
if (isLinkedInstall()) {
|
|
425
|
+
ctx.ui.notify(
|
|
426
|
+
"LINK install (D:/github_repo/pi-freeflow) is live - just omp restart, no npm update needed.",
|
|
427
|
+
"info",
|
|
428
|
+
);
|
|
429
|
+
} else {
|
|
430
|
+
try {
|
|
431
|
+
ctx.ui.notify("Checking for updates…", "info");
|
|
432
|
+
let latest: string | null = null;
|
|
433
|
+
try {
|
|
434
|
+
latest = await fetchLatestVersion();
|
|
435
|
+
} catch {
|
|
436
|
+
latest = null;
|
|
437
|
+
}
|
|
438
|
+
if (!latest) {
|
|
439
|
+
const cached = getCachedUpdate();
|
|
440
|
+
if (cached && typeof cached.latest === "string") {
|
|
441
|
+
latest = cached.latest;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
if (!latest) {
|
|
445
|
+
ctx.ui.notify("Could not check latest version (offline?)", "warning");
|
|
446
|
+
} else {
|
|
447
|
+
const local = getLocalVersion();
|
|
448
|
+
const cmp = compareVersions(latest, local);
|
|
449
|
+
if (cmp <= 0) {
|
|
450
|
+
ctx.ui.notify(`Already on latest (v${local})`, "info");
|
|
451
|
+
} else {
|
|
452
|
+
ctx.ui.notify(`Update available: v${local} → v${latest} — updating…`, "info");
|
|
453
|
+
let code = await spawnWithProgress("omp", ["plugin", "update", "pi-freeflow"], ctx);
|
|
454
|
+
if (code !== 0) {
|
|
455
|
+
ctx.ui.notify(`omp update exited ${code}, trying npm…`, "info");
|
|
456
|
+
code = await spawnWithProgress("npm", ["i", "-g", "pi-freeflow@latest"], ctx);
|
|
457
|
+
}
|
|
458
|
+
if (code === 0) {
|
|
459
|
+
ctx.ui.notify(`Updated to ${latest}, restart OMP`, "info");
|
|
460
|
+
} else {
|
|
461
|
+
ctx.ui.notify(
|
|
462
|
+
`Update failed (exit ${code}) — try manually: npm i -g pi-freeflow@latest`,
|
|
463
|
+
"warning",
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
} catch (e) {
|
|
469
|
+
ctx.ui.notify(`Update failed: ${(e as Error).message} — try manually: npm i -g pi-freeflow@latest`, "warning");
|
|
470
|
+
}
|
|
471
|
+
}
|
|
351
472
|
} else if (sub === "list") {
|
|
352
473
|
showList();
|
|
353
474
|
} else if (sub === "add") {
|
package/src/config.ts
CHANGED
|
@@ -138,7 +138,21 @@ export function resolveDebugStatePath(): string {
|
|
|
138
138
|
}
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
export function resolveUpdateCachePath(): string {
|
|
142
|
+
try {
|
|
143
|
+
return path.join(homedir(), ".pi", "agent", "pi-freeflow-update.json");
|
|
144
|
+
} catch {
|
|
145
|
+
return path.join(
|
|
146
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
147
|
+
"..",
|
|
148
|
+
".update-cache.json",
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
141
153
|
export const RELAY_STATE_FILE = resolveRelayStatePath();
|
|
142
154
|
export const LOG_FILE = resolveLogFilePath();
|
|
143
155
|
export const CATALOG_CACHE_FILE = resolveCatalogCachePath();
|
|
144
156
|
export const DEBUG_STATE_FILE = resolveDebugStatePath();
|
|
157
|
+
export const UPDATE_CACHE_FILE = resolveUpdateCachePath();
|
|
158
|
+
export const UPDATE_CHECK_TTL_MS = 86_400_000;
|
package/src/deploy.ts
CHANGED
|
@@ -14,18 +14,61 @@ import { log, logError } from "./logger.ts";
|
|
|
14
14
|
*/
|
|
15
15
|
export const VERCEL_RELAY_WORKER = `// Only the 2 upstreams pi-freeflow talks to. Anything else = open proxy abuse.
|
|
16
16
|
const ALLOWED_TARGETS = ["https://opencode.ai", "https://api.kilo.ai"];
|
|
17
|
+
const resolveRelayTarget = function(target, relayPath) {
|
|
18
|
+
let targetUrl;
|
|
19
|
+
try { targetUrl = new URL(target); } catch { return { ok: false, status: 400, reason: "invalid x-relay-target" }; }
|
|
20
|
+
if (typeof relayPath !== "string" || relayPath.indexOf("@") !== -1 || relayPath.indexOf("\\") !== -1 || relayPath.charAt(0) !== "/") {
|
|
21
|
+
return { ok: false, status: 403, reason: "forbidden x-relay-path" };
|
|
22
|
+
}
|
|
23
|
+
let finalUrl;
|
|
24
|
+
try { finalUrl = new URL(relayPath, targetUrl); } catch { return { ok: false, status: 403, reason: "forbidden x-relay-path" }; }
|
|
25
|
+
if (finalUrl.hostname !== targetUrl.hostname || finalUrl.protocol !== targetUrl.protocol || finalUrl.port !== targetUrl.port || finalUrl.username || finalUrl.password) {
|
|
26
|
+
return { ok: false, status: 403, reason: "forbidden x-relay-path (host mismatch)" };
|
|
27
|
+
}
|
|
28
|
+
return { ok: true, url: finalUrl.toString() };
|
|
29
|
+
};
|
|
30
|
+
const isPrivateHostname = function(h) {
|
|
31
|
+
if (!h) return true
|
|
32
|
+
let host = String(h).trim().toLowerCase().replace(/^\[|\]$/g, "")
|
|
33
|
+
if (host.length > 1 && host.endsWith(".")) host = host.slice(0, -1)
|
|
34
|
+
if (!host) return true
|
|
35
|
+
if (host === "localhost" || host === "0.0.0.0" || host === "127.0.0.1" || host.endsWith(".localhost") || host.endsWith(".local") || host.endsWith(".internal")) return true
|
|
36
|
+
if (host.startsWith("::")) return true
|
|
37
|
+
const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
|
|
38
|
+
if (v4) {
|
|
39
|
+
const a = Number(v4[1])
|
|
40
|
+
const b = Number(v4[2])
|
|
41
|
+
if (a === 0 || a === 10 || a === 127) return true
|
|
42
|
+
if (a === 169 && b === 254) return true
|
|
43
|
+
if (a === 192 && b === 168) return true
|
|
44
|
+
if (a === 172 && b >= 16 && b <= 31) return true
|
|
45
|
+
if (a === 100 && b >= 64 && b <= 127) return true
|
|
46
|
+
return false
|
|
47
|
+
}
|
|
48
|
+
if (host.includes(":")) {
|
|
49
|
+
if (host.startsWith("fc") || host.startsWith("fd")) return true
|
|
50
|
+
if (/^fe[89ab]/.test(host)) return true
|
|
51
|
+
return false
|
|
52
|
+
}
|
|
53
|
+
return false
|
|
54
|
+
};
|
|
17
55
|
export const config = { runtime: "edge" };
|
|
18
56
|
export default async function handler(req) {
|
|
19
57
|
const target = req.headers.get("x-relay-target");
|
|
20
|
-
const relayPath = req.headers.get("x-relay-path") || "/";
|
|
21
58
|
if (!target) return new Response(JSON.stringify({ error: "Missing x-relay-target header" }), { status: 400, headers: { "content-type": "application/json" } });
|
|
22
|
-
|
|
59
|
+
let targetUrl;
|
|
60
|
+
try { targetUrl = new URL(target); } catch { return new Response(JSON.stringify({ error: "invalid x-relay-target" }), { status: 400, headers: { "content-type": "application/json" } }); }
|
|
61
|
+
if (targetUrl.protocol !== "http:" && targetUrl.protocol !== "https:") return new Response(JSON.stringify({ error: "forbidden x-relay-target protocol" }), { status: 403, headers: { "content-type": "application/json" } });
|
|
62
|
+
if (targetUrl.username || targetUrl.password) return new Response(JSON.stringify({ error: "forbidden x-relay-target (embedded credentials)" }), { status: 403, headers: { "content-type": "application/json" } });
|
|
63
|
+
if (isPrivateHostname(targetUrl.hostname)) return new Response(JSON.stringify({ error: "forbidden x-relay-target (private/loopback host)" }), { status: 403, headers: { "content-type": "application/json" } });
|
|
64
|
+
const cleanTarget = target.replace(/\/$/, "");
|
|
23
65
|
if (!ALLOWED_TARGETS.includes(cleanTarget)) return new Response(JSON.stringify({ error: "Forbidden target" }), { status: 403, headers: { "content-type": "application/json" } });
|
|
24
|
-
|
|
25
|
-
const
|
|
66
|
+
const relayPath = req.headers.get("x-relay-path") || "/";
|
|
67
|
+
const resolved = resolveRelayTarget(target, relayPath);
|
|
68
|
+
if (!resolved.ok) return new Response(JSON.stringify({ error: resolved.reason }), { status: resolved.status, headers: { "content-type": "application/json" } });
|
|
26
69
|
const headers = new Headers(req.headers);
|
|
27
|
-
|
|
28
|
-
const response = await fetch(
|
|
70
|
+
["host", "connection", "content-length", "keep-alive", "proxy-connection", "proxy-authenticate", "proxy-authorization", "transfer-encoding", "te", "trailer", "upgrade", "x-relay-target", "x-relay-path", "x-relay-auth"].forEach((h) => headers.delete(h));
|
|
71
|
+
const response = await fetch(resolved.url, { method: req.method, headers, body: req.method !== "GET" && req.method !== "HEAD" ? req.body : undefined, duplex: "half" });
|
|
29
72
|
return new Response(response.body, { status: response.status, headers: response.headers });
|
|
30
73
|
}`;
|
|
31
74
|
|
|
@@ -142,23 +185,68 @@ export type DeployPlatform = "vercel" | "cloudflare" | "deno";
|
|
|
142
185
|
|
|
143
186
|
/**
|
|
144
187
|
* Module Worker relay deployed to Cloudflare Workers.
|
|
145
|
-
* Same whitelist contract as the Vercel Edge relay,
|
|
146
|
-
* `config` export or undici-only `duplex` flag (plain body passthrough).
|
|
188
|
+
* Same whitelist contract as the Vercel Edge relay, with SSRF guard, relay-path resolver, and streaming duplex.
|
|
147
189
|
*/
|
|
148
190
|
export const CLOUDFLARE_RELAY_WORKER = `// Only the 2 upstreams this relay talks to. Anything else = open proxy abuse.
|
|
149
191
|
const ALLOWED_TARGETS = ["https://opencode.ai", "https://api.kilo.ai"];
|
|
192
|
+
const resolveRelayTarget = function(target, relayPath) {
|
|
193
|
+
let targetUrl;
|
|
194
|
+
try { targetUrl = new URL(target); } catch { return { ok: false, status: 400, reason: "invalid x-relay-target" }; }
|
|
195
|
+
if (typeof relayPath !== "string" || relayPath.indexOf("@") !== -1 || relayPath.indexOf("\\") !== -1 || relayPath.charAt(0) !== "/") {
|
|
196
|
+
return { ok: false, status: 403, reason: "forbidden x-relay-path" };
|
|
197
|
+
}
|
|
198
|
+
let finalUrl;
|
|
199
|
+
try { finalUrl = new URL(relayPath, targetUrl); } catch { return { ok: false, status: 403, reason: "forbidden x-relay-path" }; }
|
|
200
|
+
if (finalUrl.hostname !== targetUrl.hostname || finalUrl.protocol !== targetUrl.protocol || finalUrl.port !== targetUrl.port || finalUrl.username || finalUrl.password) {
|
|
201
|
+
return { ok: false, status: 403, reason: "forbidden x-relay-path (host mismatch)" };
|
|
202
|
+
}
|
|
203
|
+
return { ok: true, url: finalUrl.toString() };
|
|
204
|
+
};
|
|
205
|
+
const isPrivateHostname = function(h) {
|
|
206
|
+
if (!h) return true
|
|
207
|
+
let host = String(h).trim().toLowerCase().replace(/^\[|\]$/g, "")
|
|
208
|
+
if (host.length > 1 && host.endsWith(".")) host = host.slice(0, -1)
|
|
209
|
+
if (!host) return true
|
|
210
|
+
if (host === "localhost" || host === "0.0.0.0" || host === "127.0.0.1" || host.endsWith(".localhost") || host.endsWith(".local") || host.endsWith(".internal")) return true
|
|
211
|
+
if (host.startsWith("::")) return true
|
|
212
|
+
const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
|
|
213
|
+
if (v4) {
|
|
214
|
+
const a = Number(v4[1])
|
|
215
|
+
const b = Number(v4[2])
|
|
216
|
+
if (a === 0 || a === 10 || a === 127) return true
|
|
217
|
+
if (a === 169 && b === 254) return true
|
|
218
|
+
if (a === 192 && b === 168) return true
|
|
219
|
+
if (a === 172 && b >= 16 && b <= 31) return true
|
|
220
|
+
if (a === 100 && b >= 64 && b <= 127) return true
|
|
221
|
+
return false
|
|
222
|
+
}
|
|
223
|
+
if (host.includes(":")) {
|
|
224
|
+
if (host.startsWith("fc") || host.startsWith("fd")) return true
|
|
225
|
+
if (/^fe[89ab]/.test(host)) return true
|
|
226
|
+
return false
|
|
227
|
+
}
|
|
228
|
+
return false
|
|
229
|
+
};
|
|
150
230
|
export default {
|
|
151
231
|
async fetch(request) {
|
|
152
232
|
const target = request.headers.get("x-relay-target");
|
|
153
|
-
const relayPath = request.headers.get("x-relay-path") || "/";
|
|
154
233
|
if (!target) return new Response(JSON.stringify({ error: "Missing x-relay-target header" }), { status: 400, headers: { "content-type": "application/json" } });
|
|
155
|
-
|
|
234
|
+
let targetUrl;
|
|
235
|
+
try { targetUrl = new URL(target); } catch { return new Response(JSON.stringify({ error: "invalid x-relay-target" }), { status: 400, headers: { "content-type": "application/json" } }); }
|
|
236
|
+
if (targetUrl.protocol !== "http:" && targetUrl.protocol !== "https:") return new Response(JSON.stringify({ error: "forbidden x-relay-target protocol" }), { status: 403, headers: { "content-type": "application/json" } });
|
|
237
|
+
if (targetUrl.username || targetUrl.password) return new Response(JSON.stringify({ error: "forbidden x-relay-target (embedded credentials)" }), { status: 403, headers: { "content-type": "application/json" } });
|
|
238
|
+
if (isPrivateHostname(targetUrl.hostname)) return new Response(JSON.stringify({ error: "forbidden x-relay-target (private/loopback host)" }), { status: 403, headers: { "content-type": "application/json" } });
|
|
239
|
+
const cleanTarget = target.replace(/\/$/, "");
|
|
156
240
|
if (!ALLOWED_TARGETS.includes(cleanTarget)) return new Response(JSON.stringify({ error: "Forbidden target" }), { status: 403, headers: { "content-type": "application/json" } });
|
|
157
|
-
|
|
241
|
+
const relayPath = request.headers.get("x-relay-path") || "/";
|
|
242
|
+
const resolved = resolveRelayTarget(target, relayPath);
|
|
243
|
+
if (!resolved.ok) return new Response(JSON.stringify({ error: resolved.reason }), { status: resolved.status, headers: { "content-type": "application/json" } });
|
|
158
244
|
const headers = new Headers(request.headers);
|
|
159
|
-
|
|
245
|
+
["host", "connection", "content-length", "keep-alive", "proxy-connection", "proxy-authenticate", "proxy-authorization", "transfer-encoding", "te", "trailer", "upgrade", "x-relay-target", "x-relay-path", "x-relay-auth"].forEach((h) => headers.delete(h));
|
|
160
246
|
try {
|
|
161
|
-
const
|
|
247
|
+
const init = { method: request.method, headers };
|
|
248
|
+
if (request.method !== "GET" && request.method !== "HEAD") { init.body = request.body; init.duplex = "half"; }
|
|
249
|
+
const response = await fetch(resolved.url, init);
|
|
162
250
|
return new Response(response.body, { status: response.status, headers: response.headers });
|
|
163
251
|
} catch (error) {
|
|
164
252
|
return new Response(JSON.stringify({ error: String(error) }), { status: 502, headers: { "content-type": "application/json" } });
|
|
@@ -168,21 +256,67 @@ export default {
|
|
|
168
256
|
|
|
169
257
|
/**
|
|
170
258
|
* Relay script deployed to Deno Deploy (Deno.serve variant).
|
|
171
|
-
* Same whitelist contract
|
|
259
|
+
* Same whitelist contract as the Vercel Edge relay, with SSRF guard and relay-path resolver.
|
|
172
260
|
*/
|
|
173
261
|
export const DENO_RELAY_SCRIPT = `// Only the 2 upstreams this relay talks to. Anything else = open proxy abuse.
|
|
174
262
|
const ALLOWED_TARGETS = ["https://opencode.ai", "https://api.kilo.ai"];
|
|
263
|
+
const resolveRelayTarget = function(target, relayPath) {
|
|
264
|
+
let targetUrl;
|
|
265
|
+
try { targetUrl = new URL(target); } catch { return { ok: false, status: 400, reason: "invalid x-relay-target" }; }
|
|
266
|
+
if (typeof relayPath !== "string" || relayPath.indexOf("@") !== -1 || relayPath.indexOf("\\") !== -1 || relayPath.charAt(0) !== "/") {
|
|
267
|
+
return { ok: false, status: 403, reason: "forbidden x-relay-path" };
|
|
268
|
+
}
|
|
269
|
+
let finalUrl;
|
|
270
|
+
try { finalUrl = new URL(relayPath, targetUrl); } catch { return { ok: false, status: 403, reason: "forbidden x-relay-path" }; }
|
|
271
|
+
if (finalUrl.hostname !== targetUrl.hostname || finalUrl.protocol !== targetUrl.protocol || finalUrl.port !== targetUrl.port || finalUrl.username || finalUrl.password) {
|
|
272
|
+
return { ok: false, status: 403, reason: "forbidden x-relay-path (host mismatch)" };
|
|
273
|
+
}
|
|
274
|
+
return { ok: true, url: finalUrl.toString() };
|
|
275
|
+
};
|
|
276
|
+
const isPrivateHostname = function(h) {
|
|
277
|
+
if (!h) return true
|
|
278
|
+
let host = String(h).trim().toLowerCase().replace(/^\[|\]$/g, "")
|
|
279
|
+
if (host.length > 1 && host.endsWith(".")) host = host.slice(0, -1)
|
|
280
|
+
if (!host) return true
|
|
281
|
+
if (host === "localhost" || host === "0.0.0.0" || host === "127.0.0.1" || host.endsWith(".localhost") || host.endsWith(".local") || host.endsWith(".internal")) return true
|
|
282
|
+
if (host.startsWith("::")) return true
|
|
283
|
+
const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
|
|
284
|
+
if (v4) {
|
|
285
|
+
const a = Number(v4[1])
|
|
286
|
+
const b = Number(v4[2])
|
|
287
|
+
if (a === 0 || a === 10 || a === 127) return true
|
|
288
|
+
if (a === 169 && b === 254) return true
|
|
289
|
+
if (a === 192 && b === 168) return true
|
|
290
|
+
if (a === 172 && b >= 16 && b <= 31) return true
|
|
291
|
+
if (a === 100 && b >= 64 && b <= 127) return true
|
|
292
|
+
return false
|
|
293
|
+
}
|
|
294
|
+
if (host.includes(":")) {
|
|
295
|
+
if (host.startsWith("fc") || host.startsWith("fd")) return true
|
|
296
|
+
if (/^fe[89ab]/.test(host)) return true
|
|
297
|
+
return false
|
|
298
|
+
}
|
|
299
|
+
return false
|
|
300
|
+
};
|
|
175
301
|
Deno.serve(async (request) => {
|
|
176
302
|
const target = request.headers.get("x-relay-target");
|
|
177
|
-
const relayPath = request.headers.get("x-relay-path") || "/";
|
|
178
303
|
if (!target) return new Response(JSON.stringify({ error: "Missing x-relay-target header" }), { status: 400, headers: { "content-type": "application/json" } });
|
|
179
|
-
|
|
304
|
+
let targetUrl;
|
|
305
|
+
try { targetUrl = new URL(target); } catch { return new Response(JSON.stringify({ error: "invalid x-relay-target" }), { status: 400, headers: { "content-type": "application/json" } }); }
|
|
306
|
+
if (targetUrl.protocol !== "http:" && targetUrl.protocol !== "https:") return new Response(JSON.stringify({ error: "forbidden x-relay-target protocol" }), { status: 403, headers: { "content-type": "application/json" } });
|
|
307
|
+
if (targetUrl.username || targetUrl.password) return new Response(JSON.stringify({ error: "forbidden x-relay-target (embedded credentials)" }), { status: 403, headers: { "content-type": "application/json" } });
|
|
308
|
+
if (isPrivateHostname(targetUrl.hostname)) return new Response(JSON.stringify({ error: "forbidden x-relay-target (private/loopback host)" }), { status: 403, headers: { "content-type": "application/json" } });
|
|
309
|
+
const cleanTarget = target.replace(/\/$/, "");
|
|
180
310
|
if (!ALLOWED_TARGETS.includes(cleanTarget)) return new Response(JSON.stringify({ error: "Forbidden target" }), { status: 403, headers: { "content-type": "application/json" } });
|
|
181
|
-
|
|
311
|
+
const relayPath = request.headers.get("x-relay-path") || "/";
|
|
312
|
+
const resolved = resolveRelayTarget(target, relayPath);
|
|
313
|
+
if (!resolved.ok) return new Response(JSON.stringify({ error: resolved.reason }), { status: resolved.status, headers: { "content-type": "application/json" } });
|
|
182
314
|
const headers = new Headers(request.headers);
|
|
183
|
-
|
|
315
|
+
["host", "connection", "content-length", "keep-alive", "proxy-connection", "proxy-authenticate", "proxy-authorization", "transfer-encoding", "te", "trailer", "upgrade", "x-relay-target", "x-relay-path", "x-relay-auth"].forEach((h) => headers.delete(h));
|
|
184
316
|
try {
|
|
185
|
-
const
|
|
317
|
+
const init = { method: request.method, headers };
|
|
318
|
+
if (request.method !== "GET" && request.method !== "HEAD") { init.body = request.body; init.duplex = "half"; }
|
|
319
|
+
const response = await fetch(resolved.url, init);
|
|
186
320
|
return new Response(response.body, { status: response.status, headers: response.headers });
|
|
187
321
|
} catch (error) {
|
|
188
322
|
return new Response(JSON.stringify({ error: String(error) }), { status: 502, headers: { "content-type": "application/json" } });
|
package/src/stream-pipe.ts
CHANGED
|
@@ -223,9 +223,14 @@ export function pipeUpstreamStream(
|
|
|
223
223
|
// is not at fault; still give the host a terminal event.
|
|
224
224
|
ensureTerminalEvent(false, "stream interrupted by client", false);
|
|
225
225
|
} else {
|
|
226
|
-
// Upstream socket died mid-stream with no error event
|
|
227
|
-
//
|
|
228
|
-
|
|
226
|
+
// Upstream socket died mid-stream with no error event.
|
|
227
|
+
// For muse-spark large payloads: raxtant 514KB failed but feoni 802KB
|
|
228
|
+
// succeeded with same 2.6MB in — so this is edge-specific, not pure
|
|
229
|
+
// provider token limit. Keep penalize=true to rotate failing relay,
|
|
230
|
+
// but inject incomplete (not failed) for substantial to avoid alarming
|
|
231
|
+
// stream_error. Small premature (<50 chunks) stays failed+penalize.
|
|
232
|
+
const isSubstantial = totalChunks > 50 && totalBytes > 100 * 1024;
|
|
233
|
+
ensureTerminalEvent(!isSubstantial, "stream closed prematurely", true);
|
|
229
234
|
}
|
|
230
235
|
}
|
|
231
236
|
if (!res.writableEnded) res.end();
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Update checker for pi-freeflow — background npm registry poll with 24h cache.
|
|
3
|
+
*
|
|
4
|
+
* LINK-skip: when the extension entry is a symlink (developer `omp plugin link`),
|
|
5
|
+
* the checker is disabled entirely.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
|
|
12
|
+
import { UPDATE_CACHE_FILE, UPDATE_CHECK_TTL_MS } from "./config.ts";
|
|
13
|
+
import { logInfo } from "./logger.ts";
|
|
14
|
+
|
|
15
|
+
const REGISTRY_URL = "https://registry.npmjs.org/pi-freeflow/latest";
|
|
16
|
+
|
|
17
|
+
export interface UpdateCacheData {
|
|
18
|
+
latest: string;
|
|
19
|
+
checkedAt: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Detect a LINK install by checking whether the published extension entry is a
|
|
24
|
+
* symlink. `omp plugin link <path>` creates a symlink at extensions/index.ts
|
|
25
|
+
* pointing at the dev checkout — when that link exists we skip the update check.
|
|
26
|
+
*/
|
|
27
|
+
export function isLinkedInstall(): boolean {
|
|
28
|
+
try {
|
|
29
|
+
const thisDir = path.dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
const entry = path.join(thisDir, "..", "extensions", "index.ts");
|
|
31
|
+
return lstatSync(entry).isSymbolicLink();
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function getCachedUpdate(): UpdateCacheData | null {
|
|
38
|
+
try {
|
|
39
|
+
if (!existsSync(UPDATE_CACHE_FILE)) return null;
|
|
40
|
+
const raw = readFileSync(UPDATE_CACHE_FILE, "utf8");
|
|
41
|
+
const data = JSON.parse(raw) as UpdateCacheData;
|
|
42
|
+
if (!data || typeof data.latest !== "string" || typeof data.checkedAt !== "number") {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
return data;
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function setCachedUpdate(latest: string): void {
|
|
52
|
+
try {
|
|
53
|
+
const dir = path.dirname(UPDATE_CACHE_FILE);
|
|
54
|
+
mkdirSync(dir, { recursive: true });
|
|
55
|
+
const data: UpdateCacheData = { latest, checkedAt: Date.now() };
|
|
56
|
+
writeFileSync(UPDATE_CACHE_FILE, JSON.stringify(data, null, 2), "utf8");
|
|
57
|
+
} catch {
|
|
58
|
+
// swallow — cache is best-effort
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function fetchLatestVersion(): Promise<string | null> {
|
|
63
|
+
try {
|
|
64
|
+
const res = await fetch(REGISTRY_URL, {
|
|
65
|
+
signal: AbortSignal.timeout(3000),
|
|
66
|
+
headers: { Accept: "application/json" },
|
|
67
|
+
});
|
|
68
|
+
if (!res.ok) return null;
|
|
69
|
+
const json = (await res.json()) as { version?: string };
|
|
70
|
+
const v = json?.version;
|
|
71
|
+
if (typeof v === "string" && v.trim().length > 0) return v.trim();
|
|
72
|
+
return null;
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Simple semver compare: split on '.' and compare numeric segments.
|
|
80
|
+
* Returns >0 if a > b, <0 if a < b, 0 if equal.
|
|
81
|
+
*/
|
|
82
|
+
export function compareVersions(a: string, b: string): number {
|
|
83
|
+
try {
|
|
84
|
+
const pa = a.split(".").map((s) => Number(s) || 0);
|
|
85
|
+
const pb = b.split(".").map((s) => Number(s) || 0);
|
|
86
|
+
const len = Math.max(pa.length, pb.length);
|
|
87
|
+
for (let i = 0; i < len; i++) {
|
|
88
|
+
const da = pa[i] ?? 0;
|
|
89
|
+
const db = pb[i] ?? 0;
|
|
90
|
+
if (da !== db) return da - db;
|
|
91
|
+
}
|
|
92
|
+
return 0;
|
|
93
|
+
} catch {
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function getLocalVersion(): string | null {
|
|
99
|
+
try {
|
|
100
|
+
const thisDir = path.dirname(fileURLToPath(import.meta.url));
|
|
101
|
+
const pkgPath = path.join(thisDir, "..", "package.json");
|
|
102
|
+
const raw = readFileSync(pkgPath, "utf8");
|
|
103
|
+
const pkg = JSON.parse(raw) as { version?: string };
|
|
104
|
+
if (typeof pkg.version === "string" && pkg.version.trim().length > 0) {
|
|
105
|
+
return pkg.version.trim();
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Non-blocking background update check. Safe to call during extension
|
|
115
|
+
* activation without awaiting — it never throws and never blocks the caller.
|
|
116
|
+
*
|
|
117
|
+
* Flow: LINK-skip → cache TTL (24h) skip → fetchLatestVersion in background
|
|
118
|
+
* → cache result → log if latest > local.
|
|
119
|
+
*/
|
|
120
|
+
export function checkForUpdateInBackground(): void {
|
|
121
|
+
try {
|
|
122
|
+
if (isLinkedInstall()) return;
|
|
123
|
+
|
|
124
|
+
const cached = getCachedUpdate();
|
|
125
|
+
if (
|
|
126
|
+
cached &&
|
|
127
|
+
typeof cached.checkedAt === "number" &&
|
|
128
|
+
Date.now() - cached.checkedAt < UPDATE_CHECK_TTL_MS
|
|
129
|
+
) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
void fetchLatestVersion()
|
|
134
|
+
.then((latest) => {
|
|
135
|
+
if (!latest) return;
|
|
136
|
+
try {
|
|
137
|
+
setCachedUpdate(latest);
|
|
138
|
+
} catch {
|
|
139
|
+
// swallow
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
const local = getLocalVersion();
|
|
143
|
+
if (local && compareVersions(latest, local) > 0) {
|
|
144
|
+
logInfo(`pi-freeflow update available: ${local} -> ${latest} (run /freeflow update)`);
|
|
145
|
+
}
|
|
146
|
+
} catch {
|
|
147
|
+
// swallow
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
.catch(() => {
|
|
151
|
+
// swallow — offline / transient network error
|
|
152
|
+
});
|
|
153
|
+
} catch {
|
|
154
|
+
// swallow — never throw from background check
|
|
155
|
+
}
|
|
156
|
+
}
|