Compare commits

..
Author SHA1 Message Date
taodengandClaude Opus 4.7 1ae9b5cde6 fix(server): resolve claude binary from nvm/fnm/asdf and PATH fallback
Real-world macOS dev machines using nvm-managed Node hit a startup FATAL
because the hardcoded candidate list in resolveClaude() only covered
homebrew, /usr/local, /usr/bin, and ~/.local/bin. With Claude CLI
installed at $HOME/.nvm/versions/node/<v>/bin/claude, the launchd job
failed without manual CLAUDE_BIN injection.

Fix: extend the candidate list with user-local Node version manager
paths — nvm (with default-alias), fnm, asdf, and npm-prefix-relocated
$HOME/.npm-global/bin. The existing CLAUDE_BIN env override and `which`
fallback are preserved; resolution order is now explicit CLAUDE_BIN >
hardcoded list > nvm/fnm/asdf > which > FATAL (with the message
upgraded to mention CLAUDE_BIN as a hint).

This is OCP-internal binary discovery — there is no `cli.js` operation
to cite. ALIGNMENT.md Rule 2 (the rule that limits OCP to operations
that exist in `cli.js`) does not constrain runtime path discovery for
the OCP server itself.

Smoke tests:
- default (no CLAUDE_BIN): picks /opt/homebrew/bin/claude (unchanged)
- CLAUDE_BIN=/nonexistent/claude: fail-fast preserved
- HOME=/tmp/fakenvm with synthetic .nvm tree: candidate list contains
  the fake nvm path; alias-default unshift logic verified
- npm test: 43/43 unit tests pass
- node --check server.mjs: OK
- alignment.yml blacklist grep: no hits

Identified during fresh-state Round 2 testing on MacBook.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 00:43:47 +10:00
2 changed files with 50 additions and 58 deletions
+48 -4
View File
@@ -30,7 +30,7 @@
import { createServer } from "node:http"; import { createServer } from "node:http";
import { spawn, execFileSync } from "node:child_process"; import { spawn, execFileSync } from "node:child_process";
import { randomUUID, timingSafeEqual } from "node:crypto"; import { randomUUID, timingSafeEqual } from "node:crypto";
import { readFileSync, accessSync, existsSync, constants } from "node:fs"; import { readFileSync, readdirSync, accessSync, existsSync, constants } from "node:fs";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { homedir } from "node:os"; import { homedir } from "node:os";
@@ -41,8 +41,48 @@ const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
const modelsConfig = JSON.parse(readFileSync(join(__dirname, "models.json"), "utf8")); const modelsConfig = JSON.parse(readFileSync(join(__dirname, "models.json"), "utf8"));
// ── Resolve claude binary ─────────────────────────────────────────────── // ── Resolve claude binary ───────────────────────────────────────────────
// Priority: CLAUDE_BIN env > well-known paths > which lookup // Priority: CLAUDE_BIN env > well-known paths > nvm/fnm/asdf user-local
// Fail-fast if not found — never start with an unresolvable binary. // installs > which lookup. Fail-fast if not found — never start with an
// unresolvable binary.
function _listVersionDirs(parent) {
try { return readdirSync(parent); } catch { return []; }
}
function _collectNodeManagerCandidates(home) {
if (!home) return [];
const out = [];
// nvm: $HOME/.nvm/versions/node/<version>/bin/claude
const nvmRoot = join(home, ".nvm/versions/node");
for (const v of _listVersionDirs(nvmRoot)) {
out.push(join(nvmRoot, v, "bin/claude"));
}
// nvm default alias: resolve $HOME/.nvm/aliases/default if it points to a version
try {
const aliasFile = join(home, ".nvm/aliases/default");
const aliasVer = readFileSync(aliasFile, "utf8").trim();
if (aliasVer) {
const direct = join(nvmRoot, aliasVer, "bin/claude");
if (!out.includes(direct)) out.unshift(direct);
}
} catch {}
// fnm: $HOME/.fnm/node-versions/<version>/installation/bin/claude
const fnmRoot = join(home, ".fnm/node-versions");
for (const v of _listVersionDirs(fnmRoot)) {
out.push(join(fnmRoot, v, "installation/bin/claude"));
}
// asdf: $HOME/.asdf/installs/nodejs/<version>/bin/claude
const asdfRoot = join(home, ".asdf/installs/nodejs");
for (const v of _listVersionDirs(asdfRoot)) {
out.push(join(asdfRoot, v, "bin/claude"));
}
// npm prefix-relocated: $HOME/.npm-global/bin/claude
out.push(join(home, ".npm-global/bin/claude"));
return out;
}
function resolveClaude() { function resolveClaude() {
if (process.env.CLAUDE_BIN) { if (process.env.CLAUDE_BIN) {
try { try {
@@ -54,11 +94,13 @@ function resolveClaude() {
} }
} }
const home = process.env.HOME || "";
const candidates = [ const candidates = [
"/opt/homebrew/bin/claude", "/opt/homebrew/bin/claude",
"/usr/local/bin/claude", "/usr/local/bin/claude",
"/usr/bin/claude", "/usr/bin/claude",
join(process.env.HOME || "", ".local/bin/claude"), join(home, ".local/bin/claude"),
..._collectNodeManagerCandidates(home),
]; ];
for (const p of candidates) { 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 { accessSync(p, constants.X_OK); console.warn(`[init] CLAUDE_BIN not set, resolved to ${p}`); return p; } catch {}
@@ -72,6 +114,8 @@ function resolveClaude() {
console.error( console.error(
"FATAL: claude binary not found.\n" + "FATAL: claude binary not found.\n" +
" Set CLAUDE_BIN=/path/to/claude or ensure claude is in PATH.\n" + " Set CLAUDE_BIN=/path/to/claude or ensure claude is in PATH.\n" +
" Hint: if you use nvm/fnm/asdf, set CLAUDE_BIN to the absolute path\n" +
" shown by `which claude` in your interactive shell.\n" +
" Checked: " + candidates.join(", ") " Checked: " + candidates.join(", ")
); );
process.exit(1); process.exit(1);
+2 -54
View File
@@ -39,29 +39,6 @@ const PROVIDER_NAME = opt("provider-name", "claude-local");
const BIND_ADDRESS = opt("bind", "127.0.0.1"); const BIND_ADDRESS = opt("bind", "127.0.0.1");
const AUTH_MODE_CONFIG = opt("auth-mode", "none"); const AUTH_MODE_CONFIG = opt("auth-mode", "none");
// ── Service-env injection: CLAUDE_BIN, OCP_ADMIN_KEY, PROXY_ANONYMOUS_KEY ──
// These are read from the user's shell env at install time and written into
// the service unit (plist / systemd) so the daemon picks them up on boot.
// CLAUDE_BIN — detect at install time; omit if not found (server.mjs fallback)
let CLAUDE_BIN_INJECT = null;
if (process.env.CLAUDE_BIN) {
CLAUDE_BIN_INJECT = process.env.CLAUDE_BIN;
} else {
try {
const detected = execSync("which claude 2>/dev/null", { encoding: "utf-8" }).trim();
if (detected && existsSync(detected)) {
CLAUDE_BIN_INJECT = detected;
}
} catch { /* which not available or claude not on PATH — omit */ }
}
// OCP_ADMIN_KEY — omit entirely when empty/unset; don't write empty string
const OCP_ADMIN_KEY_INJECT = process.env.OCP_ADMIN_KEY || null;
// PROXY_ANONYMOUS_KEY — same pattern
const PROXY_ANON_KEY_INJECT = process.env.PROXY_ANONYMOUS_KEY || null;
// ── Models: derived from models.json (single source of truth) ────────── // ── Models: derived from models.json (single source of truth) ──────────
const modelsConfig = JSON.parse(readFileSync(join(__dirname, "models.json"), "utf-8")); const modelsConfig = JSON.parse(readFileSync(join(__dirname, "models.json"), "utf-8"));
@@ -309,29 +286,6 @@ banner.push(`╚═════════════════════
console.log("\n" + banner.join("\n") + "\n"); console.log("\n" + banner.join("\n") + "\n");
// ── Step 7: Install auto-start on boot ────────────────────────────────── // ── Step 7: Install auto-start on boot ──────────────────────────────────
// Log service-env injection plan (shown in both dry-run and live mode)
console.log("\n🔧 Service unit env vars to inject:\n");
if (CLAUDE_BIN_INJECT) {
log(`CLAUDE_BIN: ${CLAUDE_BIN_INJECT}`);
} else {
log(`CLAUDE_BIN: (not found — server.mjs will auto-detect at runtime)`);
}
if (OCP_ADMIN_KEY_INJECT) {
log(`OCP_ADMIN_KEY: injected (length: ${OCP_ADMIN_KEY_INJECT.length})`);
} else {
log(`OCP_ADMIN_KEY: (unset — admin endpoints disabled)`);
}
if (PROXY_ANON_KEY_INJECT) {
log(`PROXY_ANONYMOUS_KEY: injected (set)`);
} else {
log(`PROXY_ANONYMOUS_KEY: (unset — anonymous access disabled)`);
}
if (DRY_RUN) {
console.log("\n [dry-run] would write service unit with above env vars\n");
}
if (!DRY_RUN) { if (!DRY_RUN) {
console.log("\n🔄 Installing auto-start on login...\n"); console.log("\n🔄 Installing auto-start on login...\n");
@@ -404,13 +358,7 @@ if (!DRY_RUN) {
<key>CLAUDE_BIND</key> <key>CLAUDE_BIND</key>
<string>${BIND_ADDRESS}</string> <string>${BIND_ADDRESS}</string>
<key>CLAUDE_AUTH_MODE</key> <key>CLAUDE_AUTH_MODE</key>
<string>${AUTH_MODE_CONFIG}</string>${CLAUDE_BIN_INJECT ? ` <string>${AUTH_MODE_CONFIG}</string>
<key>CLAUDE_BIN</key>
<string>${CLAUDE_BIN_INJECT}</string>` : ""}${OCP_ADMIN_KEY_INJECT ? `
<key>OCP_ADMIN_KEY</key>
<string>${OCP_ADMIN_KEY_INJECT}</string>` : ""}${PROXY_ANON_KEY_INJECT ? `
<key>PROXY_ANONYMOUS_KEY</key>
<string>${PROXY_ANON_KEY_INJECT}</string>` : ""}
</dict> </dict>
<key>RunAtLoad</key> <key>RunAtLoad</key>
<true/> <true/>
@@ -448,7 +396,7 @@ After=network.target
ExecStart=${nodeBin} ${serverPath} ExecStart=${nodeBin} ${serverPath}
Environment=CLAUDE_PROXY_PORT=${PORT} Environment=CLAUDE_PROXY_PORT=${PORT}
Environment=CLAUDE_BIND=${BIND_ADDRESS} Environment=CLAUDE_BIND=${BIND_ADDRESS}
Environment=CLAUDE_AUTH_MODE=${AUTH_MODE_CONFIG}${CLAUDE_BIN_INJECT ? `\nEnvironment=CLAUDE_BIN=${CLAUDE_BIN_INJECT}` : ""}${OCP_ADMIN_KEY_INJECT ? `\nEnvironment=OCP_ADMIN_KEY=${OCP_ADMIN_KEY_INJECT}` : ""}${PROXY_ANON_KEY_INJECT ? `\nEnvironment=PROXY_ANONYMOUS_KEY=${PROXY_ANON_KEY_INJECT}` : ""} Environment=CLAUDE_AUTH_MODE=${AUTH_MODE_CONFIG}
Restart=always Restart=always
RestartSec=5 RestartSec=5
StandardOutput=append:${logPath} StandardOutput=append:${logPath}