pi-freeflow 1.5.0 → 1.7.0
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/README.md +62 -86
- package/package.json +2 -3
- package/src/catalog.ts +21 -12
- package/src/commands.ts +308 -146
- package/src/config.ts +24 -2
- package/src/deploy.ts +128 -185
- package/src/health.ts +2 -7
- package/src/index.ts +22 -4
- package/src/logger.ts +52 -24
- package/src/models.ts +139 -67
- package/src/probe.ts +49 -0
- package/src/proxy.ts +150 -57
- package/src/relay-state.ts +380 -69
- package/src/relay.ts +72 -26
- package/src/stream-pipe.ts +307 -271
- package/src/types.ts +4 -2
- package/src/update-checker.ts +21 -5
package/src/config.ts
CHANGED
|
@@ -17,7 +17,6 @@ export const OPENCODE_API_URL = `${UPSTREAM_OPENCODE}/v1`;
|
|
|
17
17
|
export const DEFAULT_PORT = 28180;
|
|
18
18
|
export const LEGACY_PORT = 18080;
|
|
19
19
|
export const HOST = "127.0.0.1";
|
|
20
|
-
export const DEFAULT_HOST = "127.0.0.1";
|
|
21
20
|
|
|
22
21
|
export function resolvePort(): number {
|
|
23
22
|
const envPort = process.env.FREEFLOW_PORT;
|
|
@@ -49,7 +48,6 @@ export function opencodeHeaders(): Record<string, string> {
|
|
|
49
48
|
}
|
|
50
49
|
|
|
51
50
|
// ── Relay and Deployment constants ──────────────────────────────────
|
|
52
|
-
export const DEFAULT_RELAY_URL = "";
|
|
53
51
|
export const VERCEL_API = "https://api.vercel.com";
|
|
54
52
|
|
|
55
53
|
// ── Catalog & Logging constants ─────────────────────────────────────
|
|
@@ -151,9 +149,33 @@ export function resolveUpdateCachePath(): string {
|
|
|
151
149
|
}
|
|
152
150
|
}
|
|
153
151
|
|
|
152
|
+
export function resolveOnboardedFlagPath(): string {
|
|
153
|
+
try {
|
|
154
|
+
return path.join(homedir(), ".pi", "agent", "pi-freeflow-onboarded");
|
|
155
|
+
} catch {
|
|
156
|
+
return path.join(
|
|
157
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
158
|
+
"..",
|
|
159
|
+
".onboarded",
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
154
164
|
export const RELAY_STATE_FILE = resolveRelayStatePath();
|
|
155
165
|
export const LOG_FILE = resolveLogFilePath();
|
|
156
166
|
export const CATALOG_CACHE_FILE = resolveCatalogCachePath();
|
|
157
167
|
export const DEBUG_STATE_FILE = resolveDebugStatePath();
|
|
158
168
|
export const UPDATE_CACHE_FILE = resolveUpdateCachePath();
|
|
169
|
+
export const ONBOARDED_FLAG_FILE = resolveOnboardedFlagPath();
|
|
159
170
|
export const UPDATE_CHECK_TTL_MS = 86_400_000;
|
|
171
|
+
// ── Security & Relay Validation ─────────────────────────────────────
|
|
172
|
+
/** Opt-out for tests/dev: when set to "1", relay URLs on http:// and private hosts are allowed. */
|
|
173
|
+
export const ALLOW_UNSAFE_RELAY_ENV = "PI_FREEFLOW_ALLOW_UNSAFE_RELAY";
|
|
174
|
+
/** Max request body the proxy buffers before responding 413. */
|
|
175
|
+
export const MAX_BODY_BYTES = 32 * 1024 * 1024;
|
|
176
|
+
/** Default timeout for upstream headers while proxying a request. */
|
|
177
|
+
export const UPSTREAM_HEADER_TIMEOUT_MS = 300_000;
|
|
178
|
+
/** Timeout for refreshing the catalog (host fetchDynamicModels). */
|
|
179
|
+
export const CATALOG_REFRESH_TIMEOUT_MS = 10_000;
|
|
180
|
+
/** Timeout for probing a relay with a light /v1/models request. */
|
|
181
|
+
export const PROBE_TIMEOUT_MS = 5_000;
|
package/src/deploy.ts
CHANGED
|
@@ -7,13 +7,22 @@
|
|
|
7
7
|
|
|
8
8
|
import { VERCEL_API } from "./config.ts";
|
|
9
9
|
import { log, logError } from "./logger.ts";
|
|
10
|
+
import { randomBytes } from "node:crypto";
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
13
|
+
* Canonical guarded request pipeline shared by every platform relay template.
|
|
14
|
+
* The platform wrappers below only adapt the entry point (Vercel fetch
|
|
15
|
+
* handler / Cloudflare module worker / Deno.serve) around this core, so the
|
|
16
|
+
* whitelist, SSRF guard, path resolver, header denylist, auth gate and
|
|
17
|
+
* streaming forward live exactly once.
|
|
18
|
+
*
|
|
19
|
+
* @param relayAuth Per-deployment shared secret. Empty string disables the
|
|
20
|
+
* auth gate (legacy/manual deploys); built-in deploys always embed one.
|
|
14
21
|
*/
|
|
15
|
-
|
|
22
|
+
function buildRelayWorkerCore(relayAuth: string): string {
|
|
23
|
+
return `// Only the 2 upstreams pi-freeflow talks to. Anything else = open proxy abuse.
|
|
16
24
|
const ALLOWED_TARGETS = ["https://opencode.ai", "https://api.kilo.ai"];
|
|
25
|
+
const RELAY_AUTH = ${JSON.stringify(relayAuth)};
|
|
17
26
|
const resolveRelayTarget = function(target, relayPath) {
|
|
18
27
|
let targetUrl;
|
|
19
28
|
try { targetUrl = new URL(target); } catch { return { ok: false, status: 400, reason: "invalid x-relay-target" }; }
|
|
@@ -27,35 +36,50 @@ const resolveRelayTarget = function(target, relayPath) {
|
|
|
27
36
|
}
|
|
28
37
|
return { ok: true, url: finalUrl.toString() };
|
|
29
38
|
};
|
|
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
|
|
39
|
+
const isPrivateHostname = function(h) {
|
|
40
|
+
if (!h) return true
|
|
41
|
+
let host = String(h).trim().toLowerCase().replace(/^\[|\]$/g, "")
|
|
42
|
+
if (host.length > 1 && host.endsWith(".")) host = host.slice(0, -1)
|
|
43
|
+
if (!host) return true
|
|
44
|
+
if (host === "localhost" || host === "0.0.0.0" || host === "127.0.0.1" || host.endsWith(".localhost") || host.endsWith(".local") || host.endsWith(".internal")) return true
|
|
45
|
+
if (host.startsWith("::")) return true
|
|
46
|
+
const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
|
|
47
|
+
if (v4) {
|
|
48
|
+
const a = Number(v4[1])
|
|
49
|
+
const b = Number(v4[2])
|
|
50
|
+
if (a === 0 || a === 10 || a === 127) return true
|
|
51
|
+
if (a === 169 && b === 254) return true
|
|
52
|
+
if (a === 192 && b === 168) return true
|
|
53
|
+
if (a === 172 && b >= 16 && b <= 31) return true
|
|
54
|
+
if (a === 100 && b >= 64 && b <= 127) return true
|
|
55
|
+
return false
|
|
56
|
+
}
|
|
57
|
+
if (host.includes(":")) {
|
|
58
|
+
if (host.startsWith("fc") || host.startsWith("fd")) return true
|
|
59
|
+
if (/^fe[89ab]/.test(host)) return true
|
|
60
|
+
return false
|
|
61
|
+
}
|
|
62
|
+
return false
|
|
54
63
|
};
|
|
55
|
-
|
|
56
|
-
|
|
64
|
+
// Constant-time string comparison (no timingSafeEqual in every edge runtime).
|
|
65
|
+
const timingSafeEqualStr = function(a, b) {
|
|
66
|
+
if (typeof a !== "string" || typeof b !== "string") return false;
|
|
67
|
+
let diff = a.length ^ b.length;
|
|
68
|
+
const len = Math.max(a.length, b.length);
|
|
69
|
+
for (let i = 0; i < len; i++) {
|
|
70
|
+
diff |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0);
|
|
71
|
+
}
|
|
72
|
+
return diff === 0;
|
|
73
|
+
};
|
|
74
|
+
// x-relay-auth is read by the handler below, so it is stripped explicitly
|
|
75
|
+
// (never forwarded upstream) instead of living in the denylist.
|
|
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
|
+
async function relayHandler(req) {
|
|
57
78
|
const target = req.headers.get("x-relay-target");
|
|
58
79
|
if (!target) return new Response(JSON.stringify({ error: "Missing x-relay-target header" }), { status: 400, headers: { "content-type": "application/json" } });
|
|
80
|
+
if (RELAY_AUTH && !timingSafeEqualStr(req.headers.get("x-relay-auth") || "", RELAY_AUTH)) {
|
|
81
|
+
return new Response(JSON.stringify({ error: "unauthorized" }), { status: 401, headers: { "content-type": "application/json" } });
|
|
82
|
+
}
|
|
59
83
|
let targetUrl;
|
|
60
84
|
try { targetUrl = new URL(target); } catch { return new Response(JSON.stringify({ error: "invalid x-relay-target" }), { status: 400, headers: { "content-type": "application/json" } }); }
|
|
61
85
|
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" } });
|
|
@@ -67,10 +91,60 @@ export default async function handler(req) {
|
|
|
67
91
|
const resolved = resolveRelayTarget(target, relayPath);
|
|
68
92
|
if (!resolved.ok) return new Response(JSON.stringify({ error: resolved.reason }), { status: resolved.status, headers: { "content-type": "application/json" } });
|
|
69
93
|
const headers = new Headers(req.headers);
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
94
|
+
DENY_HEADERS.forEach((h) => headers.delete(h));
|
|
95
|
+
headers.delete("x-relay-auth");
|
|
96
|
+
try {
|
|
97
|
+
const init = { method: req.method, headers };
|
|
98
|
+
if (req.method !== "GET" && req.method !== "HEAD") { init.body = req.body; init.duplex = "half"; }
|
|
99
|
+
const response = await fetch(resolved.url, init);
|
|
100
|
+
return new Response(response.body, { status: response.status, headers: response.headers });
|
|
101
|
+
} catch (error) {
|
|
102
|
+
return new Response(JSON.stringify({ error: String(error) }), { status: 502, headers: { "content-type": "application/json" } });
|
|
103
|
+
}
|
|
104
|
+
}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Vercel Edge Function relay template: canonical core wrapped in the edge-runtime module handler.
|
|
109
|
+
*/
|
|
110
|
+
export function buildVercelRelayWorker(relayAuth: string): string {
|
|
111
|
+
return `${buildRelayWorkerCore(relayAuth)}
|
|
112
|
+
export const config = { runtime: "edge" };
|
|
113
|
+
export default async function handler(req) {
|
|
114
|
+
return relayHandler(req);
|
|
73
115
|
}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Cloudflare Workers module relay template: canonical core wrapped in the module fetch handler.
|
|
120
|
+
*/
|
|
121
|
+
export function buildCloudflareRelayWorker(relayAuth: string): string {
|
|
122
|
+
return `${buildRelayWorkerCore(relayAuth)}
|
|
123
|
+
export default {
|
|
124
|
+
async fetch(request) {
|
|
125
|
+
return relayHandler(request);
|
|
126
|
+
},
|
|
127
|
+
};`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Deno Deploy relay script template: canonical core wrapped in Deno.serve.
|
|
132
|
+
*/
|
|
133
|
+
export function buildDenoRelayScript(relayAuth: string): string {
|
|
134
|
+
return `${buildRelayWorkerCore(relayAuth)}
|
|
135
|
+
Deno.serve(async (request) => {
|
|
136
|
+
return relayHandler(request);
|
|
137
|
+
});`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Public template constants (tests/README): the canonical core without a
|
|
142
|
+
* secret, so the auth gate is disabled. Deployed relays always embed their
|
|
143
|
+
* own per-deployment secret.
|
|
144
|
+
*/
|
|
145
|
+
export const VERCEL_RELAY_WORKER = buildVercelRelayWorker("");
|
|
146
|
+
export const CLOUDFLARE_RELAY_WORKER = buildCloudflareRelayWorker("");
|
|
147
|
+
export const DENO_RELAY_SCRIPT = buildDenoRelayScript("");
|
|
74
148
|
|
|
75
149
|
/**
|
|
76
150
|
* Deploy a fresh Vercel Edge Relay project in-memory.
|
|
@@ -78,13 +152,13 @@ export default async function handler(req) {
|
|
|
78
152
|
* @param token Vercel personal access token (used in-memory only)
|
|
79
153
|
* @param name Unique project/deployment name (e.g. pi-freeflow-relay-abc123)
|
|
80
154
|
* @param onProgress Optional callback for user-facing progress updates
|
|
81
|
-
* @returns Deployed
|
|
155
|
+
* @returns Deployed { url, auth } — url is the public relay URL, auth the embedded shared secret
|
|
82
156
|
*/
|
|
83
157
|
export async function deployVercelRelay(
|
|
84
158
|
token: string,
|
|
85
159
|
name: string,
|
|
86
160
|
onProgress?: (msg: string) => void,
|
|
87
|
-
): Promise<string> {
|
|
161
|
+
): Promise<{ url: string; auth: string }> {
|
|
88
162
|
const auth = {
|
|
89
163
|
Authorization: `Bearer ${token}`,
|
|
90
164
|
"Content-Type": "application/json",
|
|
@@ -93,6 +167,9 @@ export async function deployVercelRelay(
|
|
|
93
167
|
// 1. Create deployment (3 inline files, no git repository required)
|
|
94
168
|
onProgress?.("Uploading relay files to Vercel…");
|
|
95
169
|
log("info", `Starting Vercel deployment: ${name}`);
|
|
170
|
+
// Per-deployment shared secret: embedded in the worker, persisted on the
|
|
171
|
+
// relay-state entry by the caller, and required via x-relay-auth.
|
|
172
|
+
const relayAuth = randomBytes(24).toString("base64url");
|
|
96
173
|
|
|
97
174
|
const dep = await fetch(`${VERCEL_API}/v13/deployments`, {
|
|
98
175
|
method: "POST",
|
|
@@ -100,7 +177,7 @@ export async function deployVercelRelay(
|
|
|
100
177
|
body: JSON.stringify({
|
|
101
178
|
name,
|
|
102
179
|
files: [
|
|
103
|
-
{ file: "api/relay.js", data:
|
|
180
|
+
{ file: "api/relay.js", data: buildVercelRelayWorker(relayAuth) },
|
|
104
181
|
{
|
|
105
182
|
file: "package.json",
|
|
106
183
|
data: JSON.stringify({ name, version: "1.0.0" }),
|
|
@@ -160,7 +237,7 @@ export async function deployVercelRelay(
|
|
|
160
237
|
if (j.readyState === "READY" && j.url) {
|
|
161
238
|
const deployedUrl = `https://${j.url}`;
|
|
162
239
|
log("info", `Vercel relay successfully deployed: ${deployedUrl}`);
|
|
163
|
-
return deployedUrl;
|
|
240
|
+
return { url: deployedUrl, auth: relayAuth };
|
|
164
241
|
}
|
|
165
242
|
if (j.readyState === "ERROR" || j.readyState === "CANCELED") {
|
|
166
243
|
const err = `Deployment failed with state: ${j.readyState}`;
|
|
@@ -183,146 +260,6 @@ const DENO_API = "https://api.deno.com/v2";
|
|
|
183
260
|
|
|
184
261
|
export type DeployPlatform = "vercel" | "cloudflare" | "deno";
|
|
185
262
|
|
|
186
|
-
/**
|
|
187
|
-
* Module Worker relay deployed to Cloudflare Workers.
|
|
188
|
-
* Same whitelist contract as the Vercel Edge relay, with SSRF guard, relay-path resolver, and streaming duplex.
|
|
189
|
-
*/
|
|
190
|
-
export const CLOUDFLARE_RELAY_WORKER = `// Only the 2 upstreams this relay talks to. Anything else = open proxy abuse.
|
|
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
|
-
};
|
|
230
|
-
export default {
|
|
231
|
-
async fetch(request) {
|
|
232
|
-
const target = request.headers.get("x-relay-target");
|
|
233
|
-
if (!target) return new Response(JSON.stringify({ error: "Missing x-relay-target header" }), { status: 400, headers: { "content-type": "application/json" } });
|
|
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(/\/$/, "");
|
|
240
|
-
if (!ALLOWED_TARGETS.includes(cleanTarget)) return new Response(JSON.stringify({ error: "Forbidden target" }), { status: 403, headers: { "content-type": "application/json" } });
|
|
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" } });
|
|
244
|
-
const headers = new Headers(request.headers);
|
|
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));
|
|
246
|
-
try {
|
|
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);
|
|
250
|
-
return new Response(response.body, { status: response.status, headers: response.headers });
|
|
251
|
-
} catch (error) {
|
|
252
|
-
return new Response(JSON.stringify({ error: String(error) }), { status: 502, headers: { "content-type": "application/json" } });
|
|
253
|
-
}
|
|
254
|
-
},
|
|
255
|
-
};`;
|
|
256
|
-
|
|
257
|
-
/**
|
|
258
|
-
* Relay script deployed to Deno Deploy (Deno.serve variant).
|
|
259
|
-
* Same whitelist contract as the Vercel Edge relay, with SSRF guard and relay-path resolver.
|
|
260
|
-
*/
|
|
261
|
-
export const DENO_RELAY_SCRIPT = `// Only the 2 upstreams this relay talks to. Anything else = open proxy abuse.
|
|
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
|
-
};
|
|
301
|
-
Deno.serve(async (request) => {
|
|
302
|
-
const target = request.headers.get("x-relay-target");
|
|
303
|
-
if (!target) return new Response(JSON.stringify({ error: "Missing x-relay-target header" }), { status: 400, headers: { "content-type": "application/json" } });
|
|
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(/\/$/, "");
|
|
310
|
-
if (!ALLOWED_TARGETS.includes(cleanTarget)) return new Response(JSON.stringify({ error: "Forbidden target" }), { status: 403, headers: { "content-type": "application/json" } });
|
|
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" } });
|
|
314
|
-
const headers = new Headers(request.headers);
|
|
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));
|
|
316
|
-
try {
|
|
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);
|
|
320
|
-
return new Response(response.body, { status: response.status, headers: response.headers });
|
|
321
|
-
} catch (error) {
|
|
322
|
-
return new Response(JSON.stringify({ error: String(error) }), { status: 502, headers: { "content-type": "application/json" } });
|
|
323
|
-
}
|
|
324
|
-
});`;
|
|
325
|
-
|
|
326
263
|
function baseRelayName(name: string): string {
|
|
327
264
|
return name
|
|
328
265
|
.toLowerCase()
|
|
@@ -394,13 +331,13 @@ async function denoError(action: string, res: Response, override?: string): Prom
|
|
|
394
331
|
* @param token Cloudflare API token (used in-memory only)
|
|
395
332
|
* @param name Unique worker/script name (sanitized to [a-z0-9-])
|
|
396
333
|
* @param onProgress Optional callback for user-facing progress updates
|
|
397
|
-
* @returns Public
|
|
334
|
+
* @returns Public { url, auth } — relay URL plus the embedded shared secret
|
|
398
335
|
*/
|
|
399
336
|
export async function deployCloudflareWorker(
|
|
400
337
|
token: string,
|
|
401
338
|
name: string,
|
|
402
339
|
onProgress?: (msg: string) => void,
|
|
403
|
-
): Promise<string> {
|
|
340
|
+
): Promise<{ url: string; auth: string }> {
|
|
404
341
|
const auth = { Authorization: `Bearer ${token}` };
|
|
405
342
|
const scriptName = cloudflareScriptName(name);
|
|
406
343
|
|
|
@@ -419,10 +356,13 @@ export async function deployCloudflareWorker(
|
|
|
419
356
|
|
|
420
357
|
// 2. Upload the module worker script (multipart: main module + metadata)
|
|
421
358
|
onProgress?.("Uploading relay worker to Cloudflare…");
|
|
359
|
+
// Per-deployment shared secret: embedded in the worker, persisted on the
|
|
360
|
+
// relay-state entry by the caller, and required via x-relay-auth.
|
|
361
|
+
const relayAuth = randomBytes(24).toString("base64url");
|
|
422
362
|
const formData = new FormData();
|
|
423
363
|
formData.append(
|
|
424
364
|
"index.js",
|
|
425
|
-
new Blob([
|
|
365
|
+
new Blob([buildCloudflareRelayWorker(relayAuth)], { type: "application/javascript+module" }),
|
|
426
366
|
"index.js",
|
|
427
367
|
);
|
|
428
368
|
formData.append(
|
|
@@ -467,7 +407,7 @@ export async function deployCloudflareWorker(
|
|
|
467
407
|
}
|
|
468
408
|
const url = `https://${scriptName}.${subdomain}.workers.dev`;
|
|
469
409
|
log("info", `Cloudflare relay successfully deployed: ${url}`);
|
|
470
|
-
return url;
|
|
410
|
+
return { url, auth: relayAuth };
|
|
471
411
|
}
|
|
472
412
|
|
|
473
413
|
type DenoRevision = {
|
|
@@ -504,13 +444,13 @@ async function firstManagedDenoDomain(token: string): Promise<string | null> {
|
|
|
504
444
|
* @param token Deno Deploy organization access token (used in-memory only)
|
|
505
445
|
* @param name Unique app/project name (sanitized to a valid slug)
|
|
506
446
|
* @param onProgress Optional callback for user-facing progress updates
|
|
507
|
-
* @returns Public
|
|
447
|
+
* @returns Public { url, auth } — relay URL plus the embedded shared secret
|
|
508
448
|
*/
|
|
509
449
|
export async function deployDenoRelay(
|
|
510
450
|
token: string,
|
|
511
451
|
name: string,
|
|
512
452
|
onProgress?: (msg: string) => void,
|
|
513
|
-
): Promise<string> {
|
|
453
|
+
): Promise<{ url: string; auth: string }> {
|
|
514
454
|
const auth = { Authorization: `Bearer ${token}` };
|
|
515
455
|
const jsonHeaders = { ...auth, "Content-Type": "application/json" };
|
|
516
456
|
const slug = denoProjectName(name);
|
|
@@ -553,12 +493,15 @@ export async function deployDenoRelay(
|
|
|
553
493
|
|
|
554
494
|
// 2. Push the relay source as a single-file revision
|
|
555
495
|
onProgress?.("Uploading relay script to Deno Deploy…");
|
|
496
|
+
// Per-deployment shared secret: embedded in the worker, persisted on the
|
|
497
|
+
// relay-state entry by the caller, and required via x-relay-auth.
|
|
498
|
+
const relayAuth = randomBytes(24).toString("base64url");
|
|
556
499
|
const deployRes = await fetch(`${DENO_API}/apps/${appId}/deploy`, {
|
|
557
500
|
method: "POST",
|
|
558
501
|
headers: jsonHeaders,
|
|
559
502
|
body: JSON.stringify({
|
|
560
503
|
assets: {
|
|
561
|
-
"main.ts": { kind: "file", content:
|
|
504
|
+
"main.ts": { kind: "file", content: buildDenoRelayScript(relayAuth), encoding: "utf-8" },
|
|
562
505
|
},
|
|
563
506
|
}),
|
|
564
507
|
});
|
|
@@ -616,7 +559,7 @@ export async function deployDenoRelay(
|
|
|
616
559
|
if (routed) {
|
|
617
560
|
const url = `https://${routed}`;
|
|
618
561
|
log("info", `Deno Deploy relay successfully deployed: ${url}`);
|
|
619
|
-
return url;
|
|
562
|
+
return { url, auth: relayAuth };
|
|
620
563
|
}
|
|
621
564
|
const managed = await firstManagedDenoDomain(token);
|
|
622
565
|
if (!managed) {
|
|
@@ -626,5 +569,5 @@ export async function deployDenoRelay(
|
|
|
626
569
|
}
|
|
627
570
|
const url = `https://${slug}.${managed}`;
|
|
628
571
|
log("info", `Deno Deploy relay successfully deployed: ${url}`);
|
|
629
|
-
return url;
|
|
572
|
+
return { url, auth: relayAuth };
|
|
630
573
|
}
|
package/src/health.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type * as http from "node:http";
|
|
|
7
7
|
import { ALL_MODELS } from "./models.ts";
|
|
8
8
|
import { getActiveRelayState, getRelayHealth, isRelayHealthy } from "./relay-state.ts";
|
|
9
9
|
import { PORT } from "./config.ts";
|
|
10
|
+
import { getClientIP } from "./proxy.ts";
|
|
10
11
|
|
|
11
12
|
export interface HealthRelayInfo {
|
|
12
13
|
url: string;
|
|
@@ -58,12 +59,6 @@ function isLoopbackIP(ip: string): boolean {
|
|
|
58
59
|
return clean === "127.0.0.1" || clean === "::1" || clean === "localhost";
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
function getClientIPFromReq(req: http.IncomingMessage): string {
|
|
62
|
-
const addr = (req.socket as unknown as { remoteAddress?: string })?.remoteAddress;
|
|
63
|
-
if (!addr) return "unknown";
|
|
64
|
-
return addr.startsWith("::ffff:") ? addr.slice(7) : addr;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
62
|
/**
|
|
68
63
|
* Handle loopback health requests.
|
|
69
64
|
* Returns true if request was a health endpoint (handled, response already sent).
|
|
@@ -87,7 +82,7 @@ export function handleHealthRequest(
|
|
|
87
82
|
return false;
|
|
88
83
|
}
|
|
89
84
|
|
|
90
|
-
const clientIP =
|
|
85
|
+
const clientIP = getClientIP(req);
|
|
91
86
|
if (!isLoopbackIP(clientIP)) {
|
|
92
87
|
const body = JSON.stringify({ error: "forbidden" });
|
|
93
88
|
res.writeHead(403, { "content-type": "application/json", "content-length": Buffer.byteLength(body) });
|
package/src/index.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* pi-freeflow — Modular, high-resiliency LLM extension for Pi & Oh My Pi (OMP)
|
|
3
3
|
*
|
|
4
|
-
* Provides access to
|
|
4
|
+
* Provides access to 25 free models (7 OpenCode Zen + 18 KiloCode Gateway) with:
|
|
5
5
|
* - Single-port daemon reuse on 28180 across concurrent subagents
|
|
6
6
|
* - Multi-cloud rolling egress relays (Vercel Edge, Cloudflare, Deno)
|
|
7
7
|
* - 0ms instant startup with verified static catalog and background live health checks
|
|
8
8
|
* - Per-model thinking/reasoning translation and streaming SSE pass-through
|
|
9
9
|
*/
|
|
10
|
-
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
11
12
|
import type * as http from "node:http";
|
|
12
13
|
import {
|
|
13
14
|
getAliveCatalog,
|
|
@@ -17,9 +18,9 @@ import {
|
|
|
17
18
|
setAliveCatalog,
|
|
18
19
|
} from "./catalog.ts";
|
|
19
20
|
import { createCommandSpec, updateStatusBar } from "./commands.ts";
|
|
20
|
-
import {
|
|
21
|
+
import { HOST, LEGACY_PORT, ONBOARDED_FLAG_FILE, PORT } from "./config.ts";
|
|
21
22
|
import { log, logInfo, logWarn } from "./logger.ts";
|
|
22
|
-
import { ALL_MODELS, KILO_MODEL_IDS, MODEL_MAP,
|
|
23
|
+
import { ALL_MODELS, KILO_MODEL_IDS, MODEL_MAP, resolveCanonicalModelId } from "./models.ts";
|
|
23
24
|
import { isProxyAlive, startProxy } from "./proxy.ts";
|
|
24
25
|
import { resetRateLimits } from "./rate-limiter.ts";
|
|
25
26
|
import { checkForUpdateInBackground } from "./update-checker.ts";
|
|
@@ -298,6 +299,23 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
298
299
|
setStatusUi(ctx.ui);
|
|
299
300
|
try { bindWidgetClick(ctx.ui); } catch {}
|
|
300
301
|
|
|
302
|
+
// First-run onboarding: write the flag before notifying so a crash can
|
|
303
|
+
// never re-fire the message; never block session start.
|
|
304
|
+
try {
|
|
305
|
+
if (!fs.existsSync(ONBOARDED_FLAG_FILE)) {
|
|
306
|
+
fs.mkdirSync(path.dirname(ONBOARDED_FLAG_FILE), { recursive: true });
|
|
307
|
+
fs.writeFileSync(ONBOARDED_FLAG_FILE, "1", "utf8");
|
|
308
|
+
const hint =
|
|
309
|
+
freshRelayState.relays.length > 0
|
|
310
|
+
? `Relay pool ready: ${freshRelayState.relays.length} relay(s). Run /freeflow for pool management.`
|
|
311
|
+
: "Relay pool empty — direct mode. Run /freeflow deploy to add your own egress.";
|
|
312
|
+
ctx.ui?.notify?.(
|
|
313
|
+
`freeflow ready: 25 free models via local proxy 127.0.0.1:28180. ${hint}`,
|
|
314
|
+
"info",
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
} catch {}
|
|
318
|
+
|
|
301
319
|
let provider: string | undefined;
|
|
302
320
|
let modelId: string | undefined;
|
|
303
321
|
if (ctx && typeof ctx === "object" && "model" in ctx && ctx.model && typeof ctx.model === "object") {
|