openzoo 0.49.10 → 0.49.11

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.
Files changed (2) hide show
  1. package/lib/launch.js +97 -7
  2. package/package.json +1 -1
package/lib/launch.js CHANGED
@@ -68,15 +68,63 @@ export function claudeCodeBinDirs(home = os.homedir()) {
68
68
  ];
69
69
  }
70
70
 
71
- /** Resolve the Claude Code TERMINAL CLI. */
72
- export function resolveClaudeCli(env = process.env) {
71
+ /**
72
+ * Is this file something execvp can actually RUN?
73
+ *
74
+ * The +x bit says "you are allowed to run this", not "this is runnable". A
75
+ * truncated download, a half-written install, or a plain text stub all carry
76
+ * the bit happily, and execvp then fails with ENOEXEC — which surfaced to a
77
+ * user as the bare string `openzoo: spawn ENOEXEC` with no path, no cause, and
78
+ * nothing to act on. A script is runnable when it starts with a shebang; a
79
+ * binary when it carries a known image magic. Anything else is a bad candidate
80
+ * and we should keep looking rather than hand it to spawn.
81
+ */
82
+ export function isRunnableExecutable(file) {
83
+ let fd;
84
+ try {
85
+ fd = fs.openSync(file, 'r');
86
+ const buf = Buffer.alloc(4);
87
+ const n = fs.readSync(fd, buf, 0, 4, 0);
88
+ if (n < 2) return false; // empty/truncated
89
+ if (buf[0] === 0x23 && buf[1] === 0x21) return true; // "#!" script
90
+ if (buf[0] === 0x4d && buf[1] === 0x5a) return true; // "MZ" PE (win)
91
+ if (n < 4) return false;
92
+ const be = buf.readUInt32BE(0);
93
+ if (be === 0x7f454c46) return true; // ELF
94
+ // Mach-O thin (32/64, either endianness) and universal/fat.
95
+ if (be === 0xfeedface || be === 0xfeedfacf) return true;
96
+ if (be === 0xcefaedfe || be === 0xcffaedfe) return true;
97
+ if (be === 0xcafebabe || be === 0xbebafeca) return true;
98
+ return false;
99
+ } catch {
100
+ return false;
101
+ } finally {
102
+ if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* ignore */ } }
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Resolve the Claude Code TERMINAL CLI.
108
+ *
109
+ * NOTE THE SEARCH ORDER: claudeCodeBinDirs() is tried BEFORE $PATH, because a
110
+ * Finder-launched process has no shell PATH. The cost is that a broken
111
+ * ~/.local/bin/claude — the exact file a half-finished install leaves behind —
112
+ * outranks a working one further down $PATH. So candidates are now format
113
+ * checked, and a rejected one does not stop the search; it is recorded so the
114
+ * caller can say WHICH file was wrong instead of printing an errno.
115
+ */
116
+ export function resolveClaudeCli(env = process.env, rejected = []) {
73
117
  const name = 'claude' + (process.platform === 'win32' ? '.cmd' : '');
74
118
  const extras = env.OPENZOO_CLAUDE_PATH_ONLY === '1' ? [] : claudeCodeBinDirs();
75
119
  const pathDirs = String(env.PATH || '').split(path.delimiter).filter(Boolean);
76
120
  for (const dir of [...new Set([...extras, ...pathDirs])]) {
77
121
  if (!dir) continue;
78
122
  const f = path.join(dir, name);
79
- try { fs.accessSync(f, fs.constants.X_OK); return f; } catch { /* next */ }
123
+ try { fs.accessSync(f, fs.constants.X_OK); } catch { continue; }
124
+ // A .cmd shim on Windows is dispatched by the shell, not execvp, so the
125
+ // magic-number test does not apply there.
126
+ if (process.platform === 'win32' || isRunnableExecutable(f)) return f;
127
+ rejected.push(f);
80
128
  }
81
129
  return null;
82
130
  }
@@ -231,8 +279,21 @@ export async function launchClaude(argv) {
231
279
  const env = claudeZooEnv(process.env, { base });
232
280
 
233
281
  if (terminal) {
234
- const cli = resolveClaudeCli();
235
- if (!cli) { console.error('openzoo: `claude` CLI not found on PATH — install Claude Code, or drop --terminal for the desktop app'); process.exit(1); }
282
+ const rejected = [];
283
+ const cli = resolveClaudeCli(process.env, rejected);
284
+ if (!cli) {
285
+ if (rejected.length) {
286
+ // Found it, could not run it. Say so — "not found on PATH" is a lie
287
+ // here and sends the user off to reinstall something they already have.
288
+ console.error('openzoo: found `claude` but it is not a runnable executable:');
289
+ for (const f of rejected) console.error(` ${f}`);
290
+ console.error(' (no shebang and no binary magic — usually a truncated or half-finished install)');
291
+ console.error(' fix: reinstall Claude Code, or delete the broken file so another copy on PATH is used.');
292
+ } else {
293
+ console.error('openzoo: `claude` CLI not found on PATH — install Claude Code, or drop --terminal for the desktop app');
294
+ }
295
+ process.exit(1);
296
+ }
236
297
  // ALWAYS-ON HUD via Claude Code's NATIVE status line (the title bar is owned
237
298
  // by Claude Code and gets overwritten, so OSC there is useless). We write a
238
299
  // tiny status script that reads the proxy's /v1/info, and merge a statusLine
@@ -350,9 +411,38 @@ export async function launchClaude(argv) {
350
411
  console.error(' spend : live in the status line below (bottom of screen); receipts in ~/.openzoo/proxy.log');
351
412
  console.error(' every turn pays x402.');
352
413
  console.error('');
353
- const child = spawn(cli, rest, { stdio: 'inherit', env });
414
+ // spawn() THROWS SYNCHRONOUSLY on ENOEXEC — the 'error' listener below is
415
+ // never reached, which is why this reached a user as the unprefixed
416
+ // `openzoo: spawn ENOEXEC` from the top-level handler. Catch it here where
417
+ // `cli` is in scope and the message can name the file.
418
+ let child;
419
+ try {
420
+ child = spawn(cli, rest, { stdio: 'inherit', env });
421
+ } catch (e) {
422
+ try { restoreStatus?.(); } catch { /* ignore */ }
423
+ console.error(`openzoo: cannot execute ${cli} (${e?.code || e?.message})`);
424
+ console.error(' it is marked executable but the OS refused to run it.');
425
+ console.error(` check: head -1 ${cli} and file ${cli}`);
426
+ console.error(' usually a truncated install, a wrong-architecture binary,');
427
+ console.error(' or a shebang pointing at an interpreter that no longer exists.');
428
+ process.exit(1);
429
+ }
354
430
  child.on('exit', (c) => { try { restoreStatus?.(); } catch { /* ignore */ } process.exit(c ?? 0); });
355
- child.on('error', (e) => { console.error(`openzoo: could not launch claude: ${e.message}`); process.exit(1); });
431
+ child.on('error', (e) => {
432
+ // ENOEXEC reaches here only if the format check passed and the kernel
433
+ // still refused it — a wrong-architecture binary, or a shebang pointing
434
+ // at an interpreter that is gone. Name the file either way; the bare
435
+ // errno is unactionable.
436
+ if (e?.code === 'ENOEXEC' || e?.code === 'EACCES') {
437
+ console.error(`openzoo: cannot execute ${cli} (${e.code})`);
438
+ console.error(' the file exists and is marked executable, but the OS refused to run it.');
439
+ console.error(` check: head -1 ${cli} and file ${cli}`);
440
+ console.error(' a shebang pointing at a missing interpreter (e.g. a removed node) does this.');
441
+ } else {
442
+ console.error(`openzoo: could not launch claude (${cli}): ${e.message}`);
443
+ }
444
+ process.exit(1);
445
+ });
356
446
  return;
357
447
  }
358
448
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.49.10",
3
+ "version": "0.49.11",
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",