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.
- package/lib/cursorbackend.js +13 -1
- package/lib/hosts.js +86 -16
- package/lib/launch.js +28 -9
- package/package.json +1 -1
package/lib/cursorbackend.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
184
|
-
// instance lock
|
|
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
|
-
|
|
187
|
-
|
|
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.
|
|
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",
|