openzoo 0.4.1 → 0.5.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 CHANGED
@@ -126,6 +126,39 @@ npx openzoo contexts --forget all # drop everything
126
126
 
127
127
  Opt out with `OPENZOO_NO_CONTEXT_CACHE=1` (always ship the full body); tune the threshold with `OPENZOO_CONTEXT_MIN_CHARS` (default 16384 chars).
128
128
 
129
+ ## Using openzoo from a cloud IDE (Cursor, Windsurf, hosted agents)
130
+
131
+ Some IDEs run their model calls **from their own servers**, not your machine. Point one of those at `http://localhost:8402` and you get, verbatim:
132
+
133
+ ```
134
+ Provider returned error: Access to private networks is forbidden
135
+ ```
136
+
137
+ That is reachability, not payment — their cloud cannot dial your laptop. Give it a public URL instead:
138
+
139
+ ```bash
140
+ npx openzoo tunnel
141
+ ```
142
+
143
+ It installs `cloudflared` itself (one-time, cached in `~/.openzoo/bin`; a Cloudflare account is not needed), opens a quick tunnel to your local proxy, and prints:
144
+
145
+ ```
146
+ base_url = https://<random>.trycloudflare.com/v1
147
+ api_key = oz_<random> # in tunnel mode the key is REAL auth
148
+ ```
149
+
150
+ **Tunnel mode is the one mode where the api key matters.** A public URL in front of a wallet is a public URL in front of your money, so:
151
+
152
+ - every request without `Authorization: Bearer <that key>` is refused **401** before anything is forwarded, quoted or paid;
153
+ - the per-call cap (`OPENZOO_MAX_USD_PER_CALL`, default $0.50) still applies;
154
+ - a session ceiling stops all spending at `OPENZOO_TUNNEL_MAX_USD` (default **$1.00**);
155
+ - every served request prints its receipt and the running session total;
156
+ - the URL dies when you Ctrl-C, and the session's total spend is printed on exit.
157
+
158
+ Pin the key with `OPENZOO_TUNNEL_TOKEN` if your IDE stores it. Keys never leave your machine either way — the tunnel forwards to the same local proxy, which signs with the same local wallet.
159
+
160
+ **MCP clients don't need any of this.** If your tool speaks MCP, use the hosted server at `https://mcp.openzoo.fun/mcp` — cloud-reachable, no local process, mint a wallet with `zoo_wallet`.
161
+
129
162
  ## The wallet model
130
163
 
131
164
  - **Burner, local, yours.** A keypair in `~/.openzoo/wallet.json`, created on first run, chmod 600. Keys never leave your machine — the zoo only ever sees signed transfers.
@@ -152,8 +185,10 @@ The receipt names which base you got; `extra.directUsd` / `extra.savesVsDirect`
152
185
  | rail | network | status |
153
186
  |---|---|---|
154
187
  | **Solana** (default) | `solana:5eykt…` | **live** — Token-2022 `TransferChecked`, partial-signed, gateway pays fees. Tested end-to-end against the production 402. Settlement uses a wrapped settlement mint as internal plumbing; you only ever hold and send USDC or TOKEN. |
155
- | Base | `eip155:8453` | implemented (standard x402 EIP-3009 `transferWithAuthorization`), **live-untested** the zoo's 402s currently offer only Solana rows. |
156
- | Robinhood Chain | `eip155:4663` | experimental, behind `OPENZOO_ENABLE_RH=1` the zoo ships this rail dark and facilitator settlement there is unverified. |
188
+ | Base | `eip155:8453` | **offered by the zoo** — standard x402 EIP-3009 `transferWithAuthorization` against native USDC. Fund the wallet's EVM address with USDC on Base; nothing is converted. Settlement from this package is live-untested. |
189
+ | Robinhood Chain | `eip155:4663` | experimental, behind `OPENZOO_ENABLE_RH=1`. The zoo quotes it, but its settlement asset has no conversion path here (conversion is Solana-only), so there is no plain balance you can fund and have the shim spend — use Solana or Base. |
190
+
191
+ `npx openzoo` prints the rails off a live 402 at startup, and the funding line is derived from those rails — so a new chain shows up without this package shipping again.
157
192
 
158
193
  The rail is chosen from the 402's `accepts[]` itself (Solana first). Amounts are always taken as raw units from the 402, and Solana decimals are read from the mint **on-chain** — never hardcoded. (The zoo's own pasted prompt hardcodes `decimals = 6`; that's wrong for 18-decimal mints and this package deliberately does not copy the bug.)
159
194
 
package/bin/openzoo.js CHANGED
@@ -6,6 +6,8 @@ const HELP = `openzoo — local x402-paying proxy + MCP server for openzoo.fun
6
6
  usage:
7
7
  npx openzoo start the proxy on http://localhost:8402/v1
8
8
  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)
9
11
  npx openzoo demo ~1M-token needle demo: direct refuses, the zoo answers
10
12
  (run it twice — the second run reuses the bound corpus and is near-free)
11
13
  npx openzoo contexts list corpora bound to the zoo (never re-uploaded)
@@ -24,7 +26,8 @@ env:
24
26
  OPENZOO_MAX_USD_PER_CALL (0.5) OPENZOO_DEMO_MAX_USD (0.01)
25
27
  OPENZOO_CONTEXT_MIN_CHARS (16384 — bodies bigger than this bind once + reuse)
26
28
  OPENZOO_NO_CONTEXT_CACHE (0 — set 1 to always ship the full body)
27
- OPENZOO_ENABLE_RH (0 — Robinhood Chain rail, experimental)`;
29
+ OPENZOO_ENABLE_RH (0 — Robinhood Chain rail, experimental)
30
+ OPENZOO_TUNNEL_MAX_USD (1.00 — tunnel session ceiling) OPENZOO_TUNNEL_TOKEN (pin the api key)`;
28
31
 
29
32
  async function main() {
30
33
  switch (cmd) {
@@ -35,6 +38,9 @@ async function main() {
35
38
  case 'mcp':
36
39
  await (await import('../lib/mcp.js')).startMcp();
37
40
  break;
41
+ case 'tunnel':
42
+ await (await import('../lib/tunnel.js')).runTunnel();
43
+ break;
38
44
  case 'demo':
39
45
  await (await import('../lib/demo.js')).runDemo();
40
46
  break;
package/lib/config.js CHANGED
@@ -28,9 +28,57 @@ export const FUNDING_ASSETS = [
28
28
  export const USDC_MINT = FUNDING_ASSETS[0].mint;
29
29
  export const TOKEN_MINT = FUNDING_ASSETS[1].mint;
30
30
 
31
- /** The one canonical way to tell a user how to fund. */
31
+ /**
32
+ * The one canonical way to tell a user how to fund the SOLANA rail. Callers
33
+ * pass the Solana address; the rail is named explicitly because the wallet
34
+ * also has an EVM address for the Base / Robinhood rails.
35
+ */
32
36
  export function fundingLine(address) {
33
- return `send a few cents of USDC (${USDC_MINT}) or TOKEN (${TOKEN_MINT}) to ${address}`;
37
+ return `send a few cents of USDC (${USDC_MINT}) or TOKEN (${TOKEN_MINT}) on Solana to ${address}`;
38
+ }
39
+
40
+ /**
41
+ * What a user funds each rail with — UNDERLYING assets only, and only assets
42
+ * this shim can actually spend from. The settlement mints the 402 quotes are
43
+ * internal plumbing and never appear in user-facing copy.
44
+ *
45
+ * Solana: quoted in settlement mints, converted from plain USDC / TOKEN at
46
+ * payment time (lib/wrap.js).
47
+ * Base: quoted in native USDC — funded and spent as-is, no conversion.
48
+ * Robinhood: quoted in a settlement asset with no conversion path on EVM
49
+ * (wrapping is Solana-only), so there is no plain balance a user can fund
50
+ * and have the shim pay from — hence no assets, and no funding line.
51
+ */
52
+ export const RAIL_FUNDING = {
53
+ solana: { label: 'Solana', assets: ['USDC', 'TOKEN'] },
54
+ base: { label: 'Base', assets: ['USDC'] },
55
+ robinhood: { label: 'Robinhood Chain', assets: [] },
56
+ };
57
+
58
+ /**
59
+ * "USDC or TOKEN on Solana · USDC on Base" — the funding hint for exactly the
60
+ * rails a live 402 is offering, derived from liveRails().live. Rails with no
61
+ * fundable underlying, and networks we have no funding copy for (an
62
+ * unrecognised chain the zoo starts quoting), are left out rather than guessed
63
+ * at. Returns '' when nothing is fundable.
64
+ */
65
+ export function railFundingHint(liveRailNames) {
66
+ return (liveRailNames || [])
67
+ .map((rail) => RAIL_FUNDING[rail])
68
+ .filter((spec) => spec?.assets.length)
69
+ .map((spec) => `${spec.assets.join(' or ')} on ${spec.label}`)
70
+ .join(' · ');
71
+ }
72
+
73
+ /**
74
+ * Rails the zoo is quoting right now that this shim cannot pay from a plain
75
+ * funded balance — named so a live rail never silently disappears from the
76
+ * funding advice.
77
+ */
78
+ export function unfundableRails(liveRailNames) {
79
+ return (liveRailNames || [])
80
+ .filter((rail) => RAIL_FUNDING[rail] && !RAIL_FUNDING[rail].assets.length)
81
+ .map((rail) => RAIL_FUNDING[rail].label);
34
82
  }
35
83
 
36
84
  /**
package/lib/info.js CHANGED
@@ -8,7 +8,7 @@ export function printAddress() {
8
8
  const { keypair, evmPrivateKey, created, path } = loadOrCreateWallet();
9
9
  if (created) console.log(`new burner wallet created at ${path} (chmod 600)`);
10
10
  console.log(keypair.publicKey.toBase58());
11
- console.log(`(evm, for Base/RH rails — untested: ${privateKeyToAccount(evmPrivateKey).address})`);
11
+ console.log(`(evm, for the Base / Robinhood rails: ${privateKeyToAccount(evmPrivateKey).address})`);
12
12
  }
13
13
 
14
14
  export async function printBalance() {
package/lib/mcp.js CHANGED
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs';
2
2
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
4
  import { z } from 'zod';
5
- import { config, FUNDING_ASSETS } from './config.js';
5
+ import { config, FUNDING_ASSETS, liveRails, railFundingHint } from './config.js';
6
6
  import { PayClient, QuoteTooHighError, UnderfundedError } from './pay.js';
7
7
  import { tokenBalance } from './x402.js';
8
8
  import { askWithContext, contextCacheDisabled, BIND_MIN_CHARS } from './hrr.js';
@@ -94,10 +94,17 @@ export async function startMcp() {
94
94
  ]);
95
95
  const balances = { SOL: lamports / 1e9 };
96
96
  FUNDING_ASSETS.forEach((a, i) => { balances[a.symbol] = bals[i].ui ?? 0; });
97
+ // Funding advice follows the rails the zoo is quoting right now, not a
98
+ // hardcoded Solana assumption. Fail soft: the probe is advisory.
99
+ const rails = await liveRails().catch(() => null);
100
+ const hint = rails ? railFundingHint(rails.live) : '';
97
101
  return text({
98
102
  solanaAddress: client.address,
99
- fundWith: FUNDING_ASSETS.map((a) => `${a.symbol} (${a.mint})`).join(' or '),
100
- fundHint: 'send a few cents of either to this address — the shim wraps whichever the 402 quotes, at payment time',
103
+ evmAddress: client.evmAddress,
104
+ railsLiveNow: rails?.live ?? null,
105
+ fundWith: hint || `${FUNDING_ASSETS.map((a) => a.symbol).join(' or ')} on Solana`,
106
+ solanaMints: Object.fromEntries(FUNDING_ASSETS.map((a) => [a.symbol, a.mint])),
107
+ fundHint: 'send a few cents of a listed asset to the address for that rail — Solana assets to solanaAddress, Base assets to evmAddress. The shim converts to whatever the 402 quotes, at payment time.',
101
108
  balances,
102
109
  receipts: client.receipts.map((r) => ({ at: r.at, line: r.line })),
103
110
  });
package/lib/proxy.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import http from 'node:http';
2
2
  import { Readable } from 'node:stream';
3
- import { config, FUNDING_ASSETS, fundingLine, liveRails } from './config.js';
3
+ import {
4
+ config, FUNDING_ASSETS, fundingLine, liveRails, railFundingHint, unfundableRails, RAIL_FUNDING,
5
+ } from './config.js';
4
6
  import { PayClient, QuoteTooHighError, UnderfundedError } from './pay.js';
5
7
  import { tokenBalance } from './x402.js';
6
8
  import { bindCorpus, contextCacheDisabled, BIND_MIN_CHARS } from './hrr.js';
@@ -102,12 +104,32 @@ async function maybeCacheCorpus(req, bodyBuf, log) {
102
104
  };
103
105
  }
104
106
 
105
- export async function startProxy({ silent = false } = {}) {
107
+ /**
108
+ * `requireToken` / `sessionMaxUsd` are TUNNEL MODE (see lib/tunnel.js): once the
109
+ * proxy is reachable from the internet, the api key stops being decorative and
110
+ * becomes the only thing between a stranger and your wallet. Both are off by
111
+ * default, so localhost behaviour is unchanged.
112
+ */
113
+ export async function startProxy({ silent = false, requireToken = null, sessionMaxUsd = null } = {}) {
106
114
  const client = new PayClient();
107
115
  const log = silent ? () => {} : (...a) => console.log(...a);
116
+ let sessionSpent = 0;
108
117
 
109
118
  const server = http.createServer(async (req, res) => {
110
119
  const url = `${config.apiBase}${req.url}`;
120
+ // Auth first: refuse before reading a body, forwarding, quoting or paying.
121
+ if (requireToken) {
122
+ const got = (req.headers.authorization || '').replace(/^Bearer\s+/i, '').trim();
123
+ if (got !== requireToken) {
124
+ log(`tunnel: 401 ${req.method} ${req.url} from ${req.socket.remoteAddress}`);
125
+ jsonErr(res, 401, 'unauthorized: this openzoo tunnel requires the api key printed at startup');
126
+ return;
127
+ }
128
+ if (sessionMaxUsd != null && sessionSpent >= sessionMaxUsd) {
129
+ jsonErr(res, 402, `openzoo tunnel session cap reached ($${sessionMaxUsd}) — restart the tunnel or raise OPENZOO_TUNNEL_MAX_USD`);
130
+ return;
131
+ }
132
+ }
111
133
  let bodyBuf;
112
134
  try {
113
135
  bodyBuf = await readBody(req);
@@ -152,7 +174,13 @@ export async function startProxy({ silent = false } = {}) {
152
174
  result = await client.fetch(url, init);
153
175
  }
154
176
  const { response, paid, receipt } = result;
155
- if (paid && receipt) log(receipt.ok ? receipt.line : `paid retry -> HTTP ${receipt.status}`);
177
+ if (paid && receipt) {
178
+ if (receipt.ok && typeof receipt.billedUsd === 'number') sessionSpent += receipt.billedUsd;
179
+ 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);
183
+ }
156
184
  await relay(res, response);
157
185
  } catch (err) {
158
186
  if (err instanceof QuoteTooHighError) {
@@ -194,7 +222,17 @@ export async function startProxy({ silent = false } = {}) {
194
222
  try {
195
223
  const rails = await liveRails();
196
224
  if (rails) {
197
- console.log(`rails live now: ${rails.live.join(' · ')} (fund with USDC or TOKEN on solana)`);
225
+ console.log(`rails live now: ${rails.live.join(' · ')}`);
226
+ // Funding advice is derived from those rails, never hardcoded — the
227
+ // zoo can add a chain without this package shipping again.
228
+ const hint = railFundingHint(rails.live);
229
+ if (hint) console.log(`fund with: ${hint}`);
230
+ const unfundable = unfundableRails(rails.live);
231
+ if (unfundable.length) {
232
+ const fundable = rails.live.filter((r) => RAIL_FUNDING[r]?.assets.length).map((r) => RAIL_FUNDING[r].label);
233
+ const instead = fundable.length ? `pay from ${fundable.join(' or ')} instead` : 'no fundable rail is offered right now';
234
+ console.log(`note: ${unfundable.join(' / ')} is offered by the zoo but not fundable from a plain balance here — ${instead}.`);
235
+ }
198
236
  if (rails.dark.length) {
199
237
  const rh = rails.dark.includes('robinhood') ? ' — robinhood also needs OPENZOO_ENABLE_RH=1' : '';
200
238
  console.log(`rails implemented but not offered by the zoo right now: ${rails.dark.join(' · ')}${rh}`);
@@ -206,5 +244,5 @@ export async function startProxy({ silent = false } = {}) {
206
244
  console.log(` base_url = http://localhost:${config.port}/v1`);
207
245
  console.log(' api_key = sk-openzoo (any value works; the zoo takes payment, not keys)');
208
246
  }
209
- return { server, client };
247
+ return { server, client, spent: () => sessionSpent };
210
248
  }
package/lib/tunnel.js ADDED
@@ -0,0 +1,156 @@
1
+ /**
2
+ * `npx openzoo tunnel` — make the local paying proxy reachable from a CLOUD-run
3
+ * harness.
4
+ *
5
+ * WHY THIS EXISTS: IDEs that execute model calls from their own servers cannot
6
+ * dial your laptop. Pointing one at http://localhost:8402 returns, verbatim:
7
+ * "Provider returned error: Access to private networks is forbidden"
8
+ * The payment path is fine — the harness just can't reach it. A tunnel gives
9
+ * that harness a public HTTPS URL while your keys stay on this machine.
10
+ *
11
+ * SELF-PROVISIONING: we do not ask you to install anything. If `cloudflared`
12
+ * isn't on PATH we fetch the single static binary for this platform into
13
+ * ~/.openzoo/bin and cache it. Quick tunnels need no Cloudflare account.
14
+ *
15
+ * SECURITY IS THE DESIGN. A public URL in front of a wallet is a public URL in
16
+ * front of your money, so tunnel mode is the ONE mode where the api key is real:
17
+ * - a random bearer token is minted per session and REQUIRED (401 otherwise),
18
+ * checked before anything is forwarded, quoted, or paid;
19
+ * - the per-call cap (OPENZOO_MAX_USD_PER_CALL) still applies, and a session
20
+ * ceiling (OPENZOO_TUNNEL_MAX_USD, default $1) stops paying when reached;
21
+ * - every tunnel-served request is logged with its running spend.
22
+ */
23
+ import { spawn } from 'node:child_process';
24
+ import { createWriteStream } from 'node:fs';
25
+ import fs from 'node:fs';
26
+ import path from 'node:path';
27
+ import os from 'node:os';
28
+ import crypto from 'node:crypto';
29
+ import { pipeline } from 'node:stream/promises';
30
+ import { Readable } from 'node:stream';
31
+
32
+ const BIN_DIR = path.join(os.homedir(), '.openzoo', 'bin');
33
+
34
+ /** cloudflared publishes one static binary per platform; pick ours. */
35
+ function cloudflaredAsset() {
36
+ const p = process.platform;
37
+ const a = process.arch;
38
+ if (p === 'darwin') return { name: 'cloudflared-darwin-amd64.tgz', tgz: true };
39
+ if (p === 'linux') {
40
+ const arch = a === 'arm64' ? 'arm64' : a === 'arm' ? 'arm' : 'amd64';
41
+ return { name: `cloudflared-linux-${arch}`, tgz: false };
42
+ }
43
+ if (p === 'win32') return { name: 'cloudflared-windows-amd64.exe', tgz: false };
44
+ return null;
45
+ }
46
+
47
+ function onPath(bin) {
48
+ const dirs = (process.env.PATH || '').split(path.delimiter);
49
+ for (const d of dirs) {
50
+ const f = path.join(d, bin);
51
+ try { fs.accessSync(f, fs.constants.X_OK); return f; } catch { /* keep looking */ }
52
+ }
53
+ return null;
54
+ }
55
+
56
+ /**
57
+ * Return a usable cloudflared path, downloading it if needed. Order: PATH, our
58
+ * cache, then the official GitHub release. macOS ships a .tgz; the others are
59
+ * bare binaries.
60
+ */
61
+ export async function ensureCloudflared(log = console.log) {
62
+ const found = onPath(process.platform === 'win32' ? 'cloudflared.exe' : 'cloudflared');
63
+ if (found) return found;
64
+
65
+ const cached = path.join(BIN_DIR, process.platform === 'win32' ? 'cloudflared.exe' : 'cloudflared');
66
+ try { fs.accessSync(cached, fs.constants.X_OK); return cached; } catch { /* fetch it */ }
67
+
68
+ const asset = cloudflaredAsset();
69
+ if (!asset) throw new Error(`no cloudflared build for ${process.platform}/${process.arch} — install it manually and re-run`);
70
+
71
+ fs.mkdirSync(BIN_DIR, { recursive: true, mode: 0o700 });
72
+ const url = `https://github.com/cloudflare/cloudflared/releases/latest/download/${asset.name}`;
73
+ log(`fetching cloudflared for ${process.platform}/${process.arch} (one-time, ~35MB)...`);
74
+ const r = await fetch(url, { redirect: 'follow' });
75
+ if (!r.ok) throw new Error(`cloudflared download failed: HTTP ${r.status}`);
76
+
77
+ if (asset.tgz) {
78
+ const tgz = path.join(BIN_DIR, 'cloudflared.tgz');
79
+ await pipeline(Readable.fromWeb(r.body), createWriteStream(tgz));
80
+ await new Promise((resolve, reject) => {
81
+ const t = spawn('tar', ['xzf', tgz, '-C', BIN_DIR], { stdio: 'ignore' });
82
+ t.on('exit', (c) => (c === 0 ? resolve() : reject(new Error(`tar exited ${c}`))));
83
+ t.on('error', reject);
84
+ });
85
+ fs.rmSync(tgz, { force: true });
86
+ } else {
87
+ await pipeline(Readable.fromWeb(r.body), createWriteStream(cached));
88
+ }
89
+ fs.chmodSync(cached, 0o755);
90
+ log(`cloudflared cached at ${cached}`);
91
+ return cached;
92
+ }
93
+
94
+ /** Start a quick tunnel to localhost:<port>; resolve with its public URL. */
95
+ export function startCloudflared(bin, port, log) {
96
+ return new Promise((resolve, reject) => {
97
+ const proc = spawn(bin, ['tunnel', '--url', `http://localhost:${port}`, '--no-autoupdate'], {
98
+ stdio: ['ignore', 'pipe', 'pipe'],
99
+ });
100
+ let settled = false;
101
+ const scan = (buf) => {
102
+ const m = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i.exec(buf.toString());
103
+ if (m && !settled) { settled = true; resolve({ url: m[0], proc }); }
104
+ };
105
+ proc.stdout.on('data', scan);
106
+ proc.stderr.on('data', scan); // cloudflared prints the URL on stderr
107
+ proc.on('error', reject);
108
+ proc.on('exit', (c) => { if (!settled) reject(new Error(`cloudflared exited ${c} before printing a URL`)); });
109
+ setTimeout(() => { if (!settled) reject(new Error('cloudflared did not produce a URL in 60s')); }, 60000);
110
+ });
111
+ }
112
+
113
+ export function mintToken() {
114
+ return process.env.OPENZOO_TUNNEL_TOKEN || `oz_${crypto.randomBytes(24).toString('base64url')}`;
115
+ }
116
+
117
+ export async function runTunnel() {
118
+ const { startProxy } = await import('./proxy.js');
119
+ const { config } = await import('./config.js');
120
+
121
+ const token = mintToken();
122
+ const sessionCap = Number(process.env.OPENZOO_TUNNEL_MAX_USD || 1);
123
+
124
+ // The proxy enforces the gate itself: nothing is forwarded, quoted or paid
125
+ // without the bearer token, and the session ceiling stops spend cold.
126
+ const { spent } = await startProxy({
127
+ silent: true,
128
+ requireToken: token,
129
+ sessionMaxUsd: sessionCap,
130
+ });
131
+
132
+ const bin = await ensureCloudflared();
133
+ const { url, proc } = await startCloudflared(bin, config.port, console.log);
134
+
135
+ console.log('');
136
+ console.log(' ┌─────────────────────────────────────────────────────────────');
137
+ console.log(' │ THIS URL SPENDS REAL MONEY FROM YOUR WALLET.');
138
+ console.log(' │ The token below is the only thing protecting it.');
139
+ console.log(` │ Session ceiling: $${sessionCap.toFixed(2)} — then it stops paying.`);
140
+ console.log(' │ Ctrl-C when you are done; the URL dies with this process.');
141
+ console.log(' └─────────────────────────────────────────────────────────────');
142
+ console.log('');
143
+ console.log('point your cloud IDE at:');
144
+ console.log(` base_url = ${url}/v1`);
145
+ console.log(` api_key = ${token}`);
146
+ console.log('');
147
+
148
+ const shutdown = () => {
149
+ try { proc.kill('SIGTERM'); } catch { /* already gone */ }
150
+ const total = typeof spent === 'function' ? spent() : 0;
151
+ console.log(`\ntunnel closed — session spend $${total.toFixed(6)}`);
152
+ process.exit(0);
153
+ };
154
+ process.on('SIGINT', shutdown);
155
+ process.on('SIGTERM', shutdown);
156
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.4.1",
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 rail live; Base/Robinhood rails experimental.",
3
+ "version": "0.5.0",
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",
7
7
  "bin": {