feat: auto-detect claude binary to prevent ENOENT in launchd (v1.8.0)

The proxy previously defaulted to bare "claude" command which fails in
macOS LaunchAgent where PATH is minimal. Now resolves the binary at
startup via: CLAUDE_BIN env → well-known paths → which fallback.
Fails fast with clear error if binary cannot be found.

Also enhances /health to report claudeBinary and claudeBinaryOk status.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-20 22:35:18 +10:00
co-authored by Claude Opus 4.6
parent 917ff60014
commit 9a22372e28
4 changed files with 595 additions and 5 deletions
+45 -4
View File
@@ -18,17 +18,54 @@
* PROXY_API_KEY — Bearer token for API authentication (optional, if unset auth is disabled)
*/
import { createServer } from "node:http";
import { spawn } from "node:child_process";
import { spawn, execFileSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import { readFileSync, accessSync, constants } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
// ── Resolve claude binary ───────────────────────────────────────────────
// Priority: CLAUDE_BIN env > well-known paths > which lookup
// Fail-fast if not found — never start with an unresolvable binary.
function resolveClaude() {
if (process.env.CLAUDE_BIN) {
try {
accessSync(process.env.CLAUDE_BIN, constants.X_OK);
return process.env.CLAUDE_BIN;
} catch {
console.error(`FATAL: CLAUDE_BIN="${process.env.CLAUDE_BIN}" is set but not executable.`);
process.exit(1);
}
}
const candidates = [
"/opt/homebrew/bin/claude",
"/usr/local/bin/claude",
"/usr/bin/claude",
join(process.env.HOME || "", ".local/bin/claude"),
];
for (const p of candidates) {
try { accessSync(p, constants.X_OK); console.warn(`[init] CLAUDE_BIN not set, resolved to ${p}`); return p; } catch {}
}
try {
const resolved = execFileSync("which", ["claude"], { encoding: "utf8", timeout: 5000 }).trim();
if (resolved) { console.warn(`[init] CLAUDE_BIN not set, resolved via which: ${resolved}`); return resolved; }
} catch {}
console.error(
"FATAL: claude binary not found.\n" +
" Set CLAUDE_BIN=/path/to/claude or ensure claude is in PATH.\n" +
" Checked: " + candidates.join(", ")
);
process.exit(1);
}
const PORT = parseInt(process.env.CLAUDE_PROXY_PORT || "3456", 10);
const CLAUDE = process.env.CLAUDE_BIN || "claude";
const CLAUDE = resolveClaude();
const TIMEOUT = parseInt(process.env.CLAUDE_TIMEOUT || "300000", 10);
const POOL_SIZE = parseInt(process.env.CLAUDE_POOL_SIZE || "1", 10);
const POOL_MAX_IDLE = parseInt(process.env.CLAUDE_POOL_MAX_IDLE || "60000", 10); // max idle time before recycle
@@ -450,11 +487,15 @@ const server = createServer(async (req, res) => {
};
}
const uptimeMs = Date.now() - START_TIME;
let binaryOk = false;
try { accessSync(CLAUDE, constants.X_OK); binaryOk = true; } catch {}
return jsonResponse(res, 200, {
status: "ok",
status: binaryOk ? "ok" : "degraded",
version: VERSION,
uptime: uptimeMs,
uptimeHuman: `${Math.floor(uptimeMs / 3600000)}h ${Math.floor((uptimeMs % 3600000) / 60000)}m ${Math.floor((uptimeMs % 60000) / 1000)}s`,
claudeBinary: CLAUDE,
claudeBinaryOk: binaryOk,
pool: poolStatus,
});
}