openzoo 0.34.0 → 0.34.2

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.
@@ -326,6 +326,18 @@ export function startCursorBackend({ port = 8443, models, log = () => {} } = {})
326
326
  server.on('tlsClientError', (e, sock) => {
327
327
  log(`cursor-tls: HANDSHAKE FAILED ${e.code || e.message} alpn=${sock?.alpnProtocol || '?'}`);
328
328
  });
329
- server.listen(port, '127.0.0.1', () => log(`cursor-backend: listening on 127.0.0.1:${port} as ${CURSOR_HOSTS[0]}`));
329
+ // BIND BOTH LOOPBACK FAMILIES. lib/hosts.js now pins each host to 127.0.0.1
330
+ // AND ::1 (a v4-only entry let dual-stack clients take the AAAA route straight
331
+ // to the real backend). Listening on v4 alone would turn that bypass into a
332
+ // v6 blackhole — connection refused instead of a wrong answer. Binding the
333
+ // unspecified address '::' with ipv6Only off accepts both on one socket; if
334
+ // the platform refuses (v6 disabled), fall back to v4 so we still work.
335
+ server.on('error', (e) => log(`cursor-backend: server error ${e.code || e.message}`));
336
+ const onUp = (what) => log(`cursor-backend: listening on ${what}:${port} as ${CURSOR_HOSTS[0]}`);
337
+ try {
338
+ server.listen({ port, host: '::', ipv6Only: false }, () => onUp('[::]+127.0.0.1'));
339
+ } catch {
340
+ server.listen(port, '127.0.0.1', () => onUp('127.0.0.1'));
341
+ }
330
342
  return { server, port };
331
343
  }
package/lib/hosts.js CHANGED
@@ -58,11 +58,49 @@ export const AGENT_HOSTS = [
58
58
  'agent-gcpp-apsoutheast.api5.cursor.sh', 'agentn-gcpp-apsoutheast.api5.cursor.sh',
59
59
  ];
60
60
 
61
- export function isBlocked() {
62
- try {
63
- const txt = fs.readFileSync(HOSTS, 'utf8');
64
- return BACKEND_HOSTS.every((h) => new RegExp(`^\\s*127\\.0\\.0\\.1\\s+${h.replace('.', '\\.')}`, 'm').test(txt));
65
- } catch { return false; }
61
+ /** Every host this tool ever adds — the set `unblock` is responsible for removing. */
62
+ const ALL_MANAGED = [...BACKEND_HOSTS, ...CLAUDE_HOSTS, ...AGENT_HOSTS];
63
+
64
+ /**
65
+ * A HOST IS NOT BLOCKED UNTIL BOTH FAMILIES ARE. Writing only the A record leaves
66
+ * the AAAA intact, and a dual-stack client (Claude desktop / Chromium, happy
67
+ * eyeballs) will simply take the v6 route straight to the real backend. Measured:
68
+ * with `127.0.0.1 api.anthropic.com` in place, `curl -6` still reached
69
+ * 2607:6bc0::10 and the real API answered. Every entry is therefore written and
70
+ * matched as a v4+v6 pair.
71
+ */
72
+ const FAMILIES = ['127.0.0.1', '::1'];
73
+
74
+ const esc = (s) => s.replace(/[.:]/g, '\\$&');
75
+
76
+ /** Matches a hosts line pinning `h` to loopback in either family. */
77
+ function hostLineRe(h, ip) {
78
+ const addr = ip ? esc(ip) : `(?:${FAMILIES.map(esc).join('|')})`;
79
+ return new RegExp(`^\\s*${addr}\\s+${esc(h)}\\b`, 'm');
80
+ }
81
+
82
+ function readHosts() {
83
+ try { return fs.readFileSync(HOSTS, 'utf8'); } catch { return ''; }
84
+ }
85
+
86
+ /**
87
+ * True when EVERY host in `hosts` is pinned to loopback in BOTH families.
88
+ * Defaulting to BACKEND_HOSTS was a latent bug: `openzoo claude --desktop`
89
+ * blocks CLAUDE_HOSTS, so this returned false, and `unblock`'s
90
+ * `if (!isBlocked()) return { already: true }` guard bailed out without ever
91
+ * removing the api.anthropic.com lines it had just written.
92
+ */
93
+ export function isBlocked(hosts = BACKEND_HOSTS) {
94
+ const txt = readHosts();
95
+ if (!txt) return false;
96
+ return hosts.every((h) => FAMILIES.every((ip) => hostLineRe(h, ip).test(txt)));
97
+ }
98
+
99
+ /** True when ANY host this tool manages is still pinned — what `unblock` must test. */
100
+ export function isAnyBlocked() {
101
+ const txt = readHosts();
102
+ if (!txt) return false;
103
+ return ALL_MANAGED.some((h) => hostLineRe(h).test(txt));
66
104
  }
67
105
 
68
106
  /**
@@ -89,11 +127,17 @@ function flushDnsCmd() {
89
127
  export function blockBackend(hosts = BACKEND_HOSTS) {
90
128
  // Add only the hosts NOT already present, so a prior api2-only block still gets
91
129
  // the agent/chat hosts appended (early-returning on isBlocked left them out).
92
- let current = '';
93
- try { current = fs.readFileSync(HOSTS, 'utf8'); } catch { /* new */ }
94
- const missing = hosts.filter((h) => !new RegExp(`^\\s*127\\.0\\.0\\.1\\s+${h.replace(/\./g, '\\.')}\\b`, 'm').test(current));
95
- if (!missing.length) return { already: true };
96
- const entries = missing.map((h) => `127.0.0.1 ${h}`).join('\\n');
130
+ const current = readHosts();
131
+ // A host counts as missing if EITHER family is absent, so a v4-only block
132
+ // written by an older version gets its ::1 line added on the next run.
133
+ const pairs = [];
134
+ for (const h of hosts) {
135
+ for (const ip of FAMILIES) {
136
+ if (!hostLineRe(h, ip).test(current)) pairs.push(`${ip} ${h}`);
137
+ }
138
+ }
139
+ if (!pairs.length) return { already: true };
140
+ const entries = pairs.join('\\n');
97
141
  console.log('');
98
142
  console.log('blocking the editor\'s backend so it cannot re-sync over your model list.');
99
143
  console.log(` hosts : ${hosts.join(', ')} -> 127.0.0.1`);
@@ -118,10 +162,15 @@ export function blockBackend(hosts = BACKEND_HOSTS) {
118
162
  }
119
163
 
120
164
  export function unblockBackend() {
121
- if (!isBlocked()) return { already: true };
165
+ // Test EVERY managed host, not just BACKEND_HOSTS. `openzoo claude --desktop`
166
+ // only ever blocks CLAUDE_HOSTS, so the old `isBlocked()` default reported
167
+ // "nothing to do" and left 127.0.0.1 api.anthropic.com pinned forever — the
168
+ // Claude desktop app then had no route to inference at all once the root
169
+ // backend on :443 was gone.
170
+ if (!isAnyBlocked()) return { already: true };
122
171
  // Remove only OUR lines, never restore wholesale — the user may have edited
123
172
  // /etc/hosts for unrelated reasons since the backup was taken.
124
- const pattern = [...BACKEND_HOSTS, ...AGENT_HOSTS].map((h) => h.replace(/\./g, '\\.')).join('|');
173
+ const pattern = ALL_MANAGED.map((h) => h.replace(/\./g, '\\.')).join('|');
125
174
  if (WIN) {
126
175
  console.log('windows: run in an ADMINISTRATOR PowerShell:');
127
176
  console.log(` Copy-Item "${BACKUP}" "${HOSTS}" -Force; ipconfig /flushdns`);
@@ -129,12 +178,14 @@ export function unblockBackend() {
129
178
  }
130
179
  // BSD sed (macOS) needs -i ''; GNU sed (linux) must NOT have it. Use a temp
131
180
  // file instead so one command is correct on both.
132
- const re = `/^[[:space:]]*127\\.0\\.0\\.1[[:space:]]+(${pattern})[[:space:]]*$/d; /openzoo: force the editor/d`;
181
+ // Both families, or the ::1 lines we now write would survive the unblock.
182
+ const addr = '(127\\.0\\.0\\.1|::1)';
183
+ const re = `/^[[:space:]]*${addr}[[:space:]]+(${pattern})[[:space:]]*$/d; /openzoo: force the editor/d`;
133
184
  const ok = privileged(
134
185
  `sed -E '${re}' ${HOSTS} > ${HOSTS}.oztmp && cat ${HOSTS}.oztmp > ${HOSTS} && rm -f ${HOSTS}.oztmp; `
135
186
  + flushDnsCmd(),
136
187
  );
137
- return { ok, blocked: isBlocked() };
188
+ return { ok, blocked: isAnyBlocked() };
138
189
  }
139
190
 
140
191
  /**
@@ -200,10 +251,29 @@ export function bindBackend443(modelsPath, logPath, log = console.log) {
200
251
  + `pkill -9 -f 'cursor-backend.js' 2>/dev/null; `
201
252
  + `for p in $(lsof -nP -iTCP:443 -sTCP:LISTEN -t 2>/dev/null) $(lsof -nP -iTCP:8443 -sTCP:LISTEN -t 2>/dev/null); do kill -9 "$p" 2>/dev/null; done; `
202
253
  + `for i in 1 2 3 4 5 6 7 8 9 10; do lsof -nP -iTCP:443 -sTCP:LISTEN -t >/dev/null 2>&1 || break; sleep 0.5; done; `
203
- + `nohup ${memEnv} '${node}' '${script}' 443 '${modelsPath}' '${logPath}' >/dev/null 2>&1 & `
254
+ // NEVER >/dev/null THE ROOT BACKEND. It is the only privileged half, it is
255
+ // spawned detached, and if it dies at startup (port taken, bad models file,
256
+ // module error) discarding its output leaves no evidence anywhere — the
257
+ // failure mode this cost a session to find was exactly that: no listener on
258
+ // 443, no cursor-backend.log, nothing to read. Send both streams to the log
259
+ // the caller already passes, so a crash is on disk.
260
+ + `mkdir -p "$(dirname '${logPath}')" 2>/dev/null; `
261
+ + `nohup ${memEnv} '${node}' '${script}' 443 '${modelsPath}' '${logPath}' >>'${logPath}' 2>&1 & `
204
262
  + 'sleep 1; true',
205
263
  );
206
- return { ok };
264
+ // Report whether it ACTUALLY came up rather than whether sudo exited 0 —
265
+ // `nohup ... &` always succeeds, so `ok` alone said nothing about the bind.
266
+ const listening = spawnSync('sh', ['-c',
267
+ 'for i in 1 2 3 4 5 6 7 8; do lsof -nP -iTCP:443 -sTCP:LISTEN -t >/dev/null 2>&1 && exit 0; sleep 0.5; done; exit 1',
268
+ ]).status === 0;
269
+ if (!listening) {
270
+ log(` backend did NOT bind :443 — see ${logPath}`);
271
+ try {
272
+ const tail = fs.readFileSync(logPath, 'utf8').trim().split('\n').slice(-8);
273
+ for (const l of tail) log(` ${l}`);
274
+ } catch { log(' (no log written — the process died before it could open one)'); }
275
+ }
276
+ return { ok: ok && listening, listening };
207
277
  }
208
278
 
209
279
  export function unbindBackend443() {
package/lib/launch.js CHANGED
@@ -163,7 +163,7 @@ export async function launchClaude(argv) {
163
163
  // Opt out with --no-intercept (then it just opens, on the subscription).
164
164
  if (process.platform !== 'win32' && !argv.includes('--no-intercept')) {
165
165
  try {
166
- const { blockBackend, bindBackend443, CLAUDE_HOSTS } = await import('./hosts.js');
166
+ const { blockBackend, bindBackend443, unblockBackend, CLAUDE_HOSTS } = await import('./hosts.js');
167
167
  const { ensureCert } = await import('./cursorbackend.js');
168
168
  ensureCert(console.error);
169
169
  const r = blockBackend(CLAUDE_HOSTS);
@@ -172,22 +172,41 @@ export async function launchClaude(argv) {
172
172
  const modelsFile = path.join(os.tmpdir(), 'openzoo-claude-models.json');
173
173
  fs.writeFileSync(modelsFile, '[]');
174
174
  const backendLog = path.join(os.homedir(), '.openzoo', 'cursor-backend.log');
175
- bindBackend443(modelsFile, backendLog, console.error);
176
- await new Promise((res) => setTimeout(res, 800));
177
- console.error('openzoo: backend bound on :443 Claude desktop inference now forwards to the zoo.');
178
- console.error(' undo any time with: npx openzoo unblock');
175
+ const b = bindBackend443(modelsFile, backendLog, console.error);
176
+ // DO NOT CLAIM SUCCESS UNCONDITIONALLY. This line used to print even when
177
+ // nothing was listening, so the app launched into a blackholed host with a
178
+ // reassuring message the exact reason the last failure was so hard to see.
179
+ if (b.listening) {
180
+ console.error('openzoo: backend bound on :443 — Claude desktop inference now forwards to the zoo.');
181
+ console.error(' undo any time with: npx openzoo unblock');
182
+ } else {
183
+ console.error('openzoo: backend FAILED to bind :443 — api.anthropic.com is blackholed with');
184
+ console.error(' nothing answering it, so Claude would not reach inference at all.');
185
+ console.error(' restoring your hosts file so the app keeps working.');
186
+ try { unblockBackend(); } catch { /* best effort */ }
187
+ }
179
188
  } catch (e) { console.error(`openzoo: desktop interception setup failed (${e.message}) — opening app plain`); }
180
189
  }
181
190
 
182
191
  if (process.platform === 'darwin') {
183
- // Quit a running instance so the fresh one is not killed by the single-
184
- // instance lock, and so it re-resolves api.anthropic.com to us on launch.
192
+ // Quit a running instance and WAIT until it is actually gone. A fixed 800ms
193
+ // wait raced the single-instance lock Claude was still releasing its file
194
+ // locks (LOCK errors), so the fresh spawn saw a live instance and exited
195
+ // immediately ("didn't launch at all"). Poll pgrep until clear, up to ~6s.
185
196
  try { spawnSync('osascript', ['-e', 'tell application "Claude" to quit'], { stdio: 'ignore', timeout: 4000 }); } catch { /* not running */ }
186
- try { spawnSync('pkill', ['-x', 'Claude'], { stdio: 'ignore' }); } catch { /* already gone */ }
187
- await new Promise((r) => setTimeout(r, 800));
197
+ for (let i = 0; i < 24; i++) {
198
+ const r = spawnSync('pgrep', ['-x', 'Claude'], { encoding: 'utf8' });
199
+ if (!r.stdout || !r.stdout.trim()) break; // gone
200
+ if (i === 8) { try { spawnSync('pkill', ['-9', '-x', 'Claude'], { stdio: 'ignore' }); } catch { /* */ } }
201
+ await new Promise((res) => setTimeout(res, 250));
202
+ }
203
+ await new Promise((r) => setTimeout(r, 400)); // let the lock file release
188
204
  }
189
205
  // Chromium flags: accept the self-signed cert, and MAP the Anthropic hosts to
190
206
  // us at the resolver level (defeats DoH, which ignores /etc/hosts).
207
+ // MAP to 127.0.0.1 for both hosts. The resolver rule replaces the lookup
208
+ // wholesale (no AAAA is returned), so the v6 bypass that /etc/hosts alone left
209
+ // open does not exist on this path — the backend listens on both regardless.
191
210
  const flags = argv.includes('--no-intercept') ? []
192
211
  : ['--ignore-certificate-errors', '--host-resolver-rules=MAP api.anthropic.com 127.0.0.1,MAP api-staging.anthropic.com 127.0.0.1'];
193
212
  console.error('openzoo: launching Claude desktop — inference routes through the zoo (pays x402).');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.34.0",
3
+ "version": "0.34.2",
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",