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/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
- * Hardened Edge Worker code deployed to Vercel.
13
- * Strictly whitelists OpenCode Zen and KiloCode Gateway endpoints to prevent open proxy abuse.
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
- export const VERCEL_RELAY_WORKER = `// Only the 2 upstreams pi-freeflow talks to. Anything else = open proxy abuse.
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
- export const config = { runtime: "edge" };
56
- export default async function handler(req) {
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
- ["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" });
72
- return new Response(response.body, { status: response.status, headers: response.headers });
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 HTTPS relay URL
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: VERCEL_RELAY_WORKER },
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 *.workers.dev relay URL
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([CLOUDFLARE_RELAY_WORKER], { type: "application/javascript+module" }),
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 *.deno.dev-style (*.deno.net) relay URL
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: DENO_RELAY_SCRIPT, encoding: "utf-8" },
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 = getClientIPFromReq(req);
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 21 free models (7 OpenCode Zen + 14 KiloCode Gateway) with:
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 { DEFAULT_HOST, HOST, LEGACY_PORT, PORT } from "./config.ts";
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, getAllRegisteredModels, resolveCanonicalModelId } from "./models.ts";
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") {