openzoo 0.7.2 → 0.8.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/bin/openzoo.js CHANGED
@@ -4,10 +4,10 @@ const cmd = process.argv[2] || 'proxy';
4
4
  const HELP = `openzoo — local x402-paying proxy + MCP server for openzoo.fun
5
5
 
6
6
  usage:
7
- npx openzoo start the proxy on http://localhost:8402/v1
7
+ npx openzoo start the proxy: http://localhost:8402/v1 (keyless) PLUS a
8
+ public HTTPS url for cloud IDEs (key required, printed at start)
8
9
  npx openzoo mcp stdio MCP server (tools: zoo_ask, zoo_models, zoo_wallet)
9
- npx openzoo tunnel public HTTPS url for cloud IDEs that cannot reach localhost
10
- (installs cloudflared itself; mints a required api key)
10
+ npx openzoo tunnel public-url-only mode (everything key-gated, no keyless localhost)
11
11
  npx openzoo demo ~1M-token needle demo: direct refuses, the zoo answers
12
12
  (run it twice — the second run reuses the bound corpus and is near-free)
13
13
  npx openzoo contexts list corpora bound to the zoo (never re-uploaded)
@@ -35,13 +35,14 @@ env:
35
35
  OPENZOO_NO_CONTEXT_CACHE (0 — set 1 to always ship the full body)
36
36
  OPENZOO_ENABLE_RH (0 — let DEFAULT selection fall through to the Robinhood rail;
37
37
  OPENZOO_RAIL=robinhood forces it without this)
38
- OPENZOO_TUNNEL_MAX_USD (1.00 — tunnel session ceiling) OPENZOO_TUNNEL_TOKEN (pin the api key)`;
38
+ OPENZOO_TUNNEL_MAX_USD (1.00 — public-url session ceiling) OPENZOO_TUNNEL_TOKEN (pin the api key)
39
+ OPENZOO_NO_TUNNEL (0 — set 1 for localhost-only, no public url)`;
39
40
 
40
41
  async function main() {
41
42
  switch (cmd) {
42
43
  case 'proxy':
43
44
  case 'start':
44
- await (await import('../lib/proxy.js')).startProxy();
45
+ await (await import('../lib/proxy.js')).startProxy({ autoTunnel: true });
45
46
  break;
46
47
  case 'mcp':
47
48
  await (await import('../lib/mcp.js')).startMcp();
package/lib/proxy.js CHANGED
@@ -110,13 +110,23 @@ async function maybeCacheCorpus(req, bodyBuf, log) {
110
110
  * becomes the only thing between a stranger and your wallet. Both are off by
111
111
  * default, so localhost behaviour is unchanged.
112
112
  */
113
- export async function startProxy({ silent = false, requireToken = null, sessionMaxUsd = null } = {}) {
113
+ export async function startProxy({ silent = false, requireToken = null, sessionMaxUsd = null, autoTunnel = false } = {}) {
114
114
  const client = new PayClient();
115
115
  const log = silent ? () => {} : (...a) => console.log(...a);
116
116
  let sessionSpent = 0;
117
+ let tunnelSpent = 0;
118
+ // Set once cloudflared is up (see below). Gating keys off the REQUEST's
119
+ // origin, not off whether the URL exists yet, so there is no startup window
120
+ // where public traffic slips through ungated.
121
+ let tunnelGate = null;
117
122
 
118
123
  const server = http.createServer(async (req, res) => {
119
124
  const url = `${config.apiBase}${req.url}`;
125
+ // Requests that arrived over the public quick-tunnel URL carry cloudflared's
126
+ // headers; nothing dialing 127.0.0.1 directly does. That distinction is what
127
+ // lets localhost stay keyless while the SAME port is safely public.
128
+ const viaTunnel = !requireToken && tunnelGate
129
+ && Boolean(req.headers['cf-connecting-ip'] || req.headers['cf-ray']);
120
130
  // Auth first: refuse before reading a body, forwarding, quoting or paying.
121
131
  if (requireToken) {
122
132
  const got = (req.headers.authorization || '').replace(/^Bearer\s+/i, '').trim();
@@ -130,6 +140,18 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
130
140
  return;
131
141
  }
132
142
  }
143
+ if (viaTunnel) {
144
+ const got = (req.headers.authorization || '').replace(/^Bearer\s+/i, '').trim();
145
+ if (got !== tunnelGate.token) {
146
+ log(`public url: 401 ${req.method} ${req.url}`);
147
+ jsonErr(res, 401, 'unauthorized: this openzoo public URL requires the api key printed at startup');
148
+ return;
149
+ }
150
+ if (tunnelSpent >= tunnelGate.sessionMaxUsd) {
151
+ jsonErr(res, 402, `openzoo public-URL session cap reached ($${tunnelGate.sessionMaxUsd}) — restart the proxy or raise OPENZOO_TUNNEL_MAX_USD`);
152
+ return;
153
+ }
154
+ }
133
155
  let bodyBuf;
134
156
  try {
135
157
  bodyBuf = await readBody(req);
@@ -175,11 +197,17 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
175
197
  }
176
198
  const { response, paid, receipt } = result;
177
199
  if (paid && receipt) {
178
- if (receipt.ok && typeof receipt.billedUsd === 'number') sessionSpent += receipt.billedUsd;
200
+ if (receipt.ok && typeof receipt.billedUsd === 'number') {
201
+ sessionSpent += receipt.billedUsd;
202
+ // The public-URL ceiling meters only public-origin spend — your own
203
+ // local calls never eat into it.
204
+ if (viaTunnel) tunnelSpent += receipt.billedUsd;
205
+ }
179
206
  const line = receipt.ok ? receipt.line : `paid retry -> HTTP ${receipt.status}`;
180
- // In tunnel mode the running total is the thing you actually want to
181
- // watch, so it rides on every receipt.
182
- log(requireToken ? `${line} · session $${sessionSpent.toFixed(6)}` : line);
207
+ // Wherever a running total is the thing to watch, it rides the receipt.
208
+ if (requireToken) log(`${line} · session $${sessionSpent.toFixed(6)}`);
209
+ else if (viaTunnel) log(`${line} · public-url session $${tunnelSpent.toFixed(6)}`);
210
+ else log(line);
183
211
  }
184
212
  await relay(res, response);
185
213
  } catch (err) {
@@ -252,5 +280,35 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
252
280
  console.log(` base_url = http://localhost:${config.port}/v1`);
253
281
  console.log(' api_key = sk-openzoo (any value works; the zoo takes payment, not keys)');
254
282
  }
283
+
284
+ // AUTO-TUNNEL: `npx openzoo` must be end-to-end for cloud IDEs too — their
285
+ // servers cannot dial localhost, so the default command also publishes a
286
+ // quick-tunnel URL. It comes up in the background (never delays localhost),
287
+ // failure degrades to local-only, and public traffic is gated above by
288
+ // token + its own spend ceiling. OPENZOO_NO_TUNNEL=1 opts out.
289
+ if (autoTunnel && process.env.OPENZOO_NO_TUNNEL !== '1') {
290
+ (async () => {
291
+ try {
292
+ const { ensureCloudflared, startCloudflared, mintToken } = await import('./tunnel.js');
293
+ const token = mintToken();
294
+ const cap = Number(process.env.OPENZOO_TUNNEL_MAX_USD || 1);
295
+ const bin = await ensureCloudflared((m) => log(m));
296
+ const { url, proc } = await startCloudflared(bin, config.port, log);
297
+ tunnelGate = { token, sessionMaxUsd: cap };
298
+ const bye = () => { try { proc.kill('SIGTERM'); } catch { /* already gone */ } };
299
+ process.once('SIGINT', () => { bye(); process.exit(0); });
300
+ process.once('SIGTERM', () => { bye(); process.exit(0); });
301
+ process.once('exit', bye);
302
+ log('');
303
+ log('cloud IDE / remote harness? use the public URL (they cannot reach localhost):');
304
+ log(` base_url = ${url}/v1`);
305
+ log(` api_key = ${token}`);
306
+ log(` (key REQUIRED on the public URL — it spends this wallet; capped at $${cap.toFixed(2)}/session,`);
307
+ log(' OPENZOO_TUNNEL_MAX_USD to change, OPENZOO_NO_TUNNEL=1 for localhost-only)');
308
+ } catch (err) {
309
+ log(`public URL unavailable (${err.message}) — localhost still works; OPENZOO_NO_TUNNEL=1 hides this line`);
310
+ }
311
+ })();
312
+ }
255
313
  return { server, client, spent: () => sessionSpent };
256
314
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "description": "Local x402-paying proxy + MCP server for openzoo.fun — point any OpenAI-compatible harness (Cursor, Claude Code, aider, SDKs) at localhost and it pays per call from a local burner wallet. Solana and Base rails live; Robinhood experimental.",
5
5
  "license": "MIT",
6
6
  "type": "module",