Compare commits

..
Author SHA1 Message Date
taodengandClaude Opus 4.7 b0db826c9a docs(install): remove false symlink claim, fix Anonymous mode startup command
Three doc inaccuracies surfaced during fresh-state install testing on Pi231 +
MacBook (Round 1+2):

1. README §Server Setup claimed setup.mjs "Symlink `ocp` to /usr/local/bin for
   CLI access" — false. setup.mjs writes start.sh + plist/systemd unit but
   creates no PATH symlink. Removed the line; added a short PATH tip showing
   the user's options (manual symlink to ~/.local/bin or /usr/local/bin, or
   shell alias) right after the install summary.

2. README §Anonymous Access told users to run `ocp start` to enable the
   feature — there is no `ocp start` subcommand. Available commands are
   restart / stop / status / logs / keys / usage / update / lan / health /
   clear / settings (verified via `~/ocp/ocp` enumeration). Replaced with
   the correct flow: export PROXY_ANONYMOUS_KEY, then `node setup.mjs
   --bind 0.0.0.0 --auth-mode multi`.

3. The same paragraph implied that exporting PROXY_ANONYMOUS_KEY in an
   interactive shell is enough to enable anonymous access — but the running
   proxy is auto-started by launchd/systemd from the service unit's own
   env, not from the user's shell. Spelled this out and noted that if OCP
   was installed before exporting the env var, the user must re-run
   setup.mjs (idempotent) so the service unit env is refreshed, then
   `ocp restart`.

The PROXY_ANONYMOUS_KEY mechanism described becomes 100% accurate when
PR B (`fix/setup-inject-service-env`, sibling PR) lands; current setup.mjs
on main does not yet inject this env into the service unit.

Doc-only — no `server.mjs` change, no version bump, no `cli.js` citation
required.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 00:45:11 +10:00
6 changed files with 40 additions and 163 deletions
-2
View File
@@ -6,8 +6,6 @@
*Open source from day one, used daily by my family, maintained on nights and weekends. If OCP saves you money too, you can [☕ buy me a coffee](https://buymeacoffee.com/dtzp555) — [full story below](#support-ocp).* *Open source from day one, used daily by my family, maintained on nights and weekends. If OCP saves you money too, you can [☕ buy me a coffee](https://buymeacoffee.com/dtzp555) — [full story below](#support-ocp).*
*If OCP saves you a setup, a ⭐ helps other folks discover it. Issue reports are even more useful — that's the highest-quality feedback this project gets.*
OCP turns your Claude Pro/Max subscription into a standard OpenAI-compatible API on localhost. Any tool that speaks the OpenAI protocol can use it — no separate API key, no extra billing. OCP turns your Claude Pro/Max subscription into a standard OpenAI-compatible API on localhost. Any tool that speaks the OpenAI protocol can use it — no separate API key, no extra billing.
``` ```
Binary file not shown.

Before

Width:  |  Height:  |  Size: 366 KiB

After

Width:  |  Height:  |  Size: 222 KiB

+9 -6
View File
@@ -8,18 +8,21 @@ set -euo pipefail
PROXY="http://127.0.0.1:3456" PROXY="http://127.0.0.1:3456"
# Auth args for multi-key mode: reads from OCP_ADMIN_KEY env or ~/.ocp/admin-key file # Auth header for multi-key mode: reads from OCP_ADMIN_KEY env or ~/.ocp/admin-key file
# Using a bash array preserves word boundaries — no eval needed. _AUTH_HEADER=""
_AUTH_ARGS=()
if [[ -n "${OCP_ADMIN_KEY:-}" ]]; then if [[ -n "${OCP_ADMIN_KEY:-}" ]]; then
_AUTH_ARGS=(-H "Authorization: Bearer $OCP_ADMIN_KEY") _AUTH_HEADER="-H \"Authorization: Bearer $OCP_ADMIN_KEY\""
elif [[ -f "$HOME/.ocp/admin-key" ]]; then elif [[ -f "$HOME/.ocp/admin-key" ]]; then
_AUTH_ARGS=(-H "Authorization: Bearer $(cat "$HOME/.ocp/admin-key")") _AUTH_HEADER="-H \"Authorization: Bearer $(cat "$HOME/.ocp/admin-key")\""
fi fi
# Wrapper: curl with optional auth # Wrapper: curl with optional auth
_curl() { _curl() {
curl "${_AUTH_ARGS[@]}" "$@" if [[ -n "$_AUTH_HEADER" ]]; then
eval curl "$_AUTH_HEADER" "$@"
else
curl "$@"
fi
} }
_json() { python3 -m json.tool 2>/dev/null || cat; } _json() { python3 -m json.tool 2>/dev/null || cat; }
+2 -12
View File
@@ -582,19 +582,11 @@ print(k if k else '')
if [[ "${SHELL:-}" == */fish ]]; then if [[ "${SHELL:-}" == */fish ]]; then
echo " Note: fish shell detected. Writing to ~/.bashrc — add to fish config manually." echo " Note: fish shell detected. Writing to ~/.bashrc — add to fish config manually."
rc_files+=("$HOME/.bashrc") rc_files+=("$HOME/.bashrc")
elif $is_mac; then
# macOS: default shell since Catalina (2019) is zsh.
# Always write ~/.zshrc (create if absent — zsh tolerates an empty file).
# Only write ~/.bashrc if it already exists (don't surprise users with new files).
[[ -f "$HOME/.bashrc" ]] && rc_files+=("$HOME/.bashrc")
# zshrc: always include on macOS; create the file if it doesn't exist yet
[[ -f "$HOME/.zshrc" ]] || touch "$HOME/.zshrc"
rc_files+=("$HOME/.zshrc")
else else
# Linux / other: write to whichever rc files already exist or match current shell # Always write both on macOS (default shell is zsh but some tools source bashrc)
[[ -f "$HOME/.bashrc" || "${SHELL:-}" == */bash ]] && rc_files+=("$HOME/.bashrc") [[ -f "$HOME/.bashrc" || "${SHELL:-}" == */bash ]] && rc_files+=("$HOME/.bashrc")
[[ -f "$HOME/.zshrc" || "${SHELL:-}" == */zsh ]] && rc_files+=("$HOME/.zshrc") [[ -f "$HOME/.zshrc" || "${SHELL:-}" == */zsh ]] && rc_files+=("$HOME/.zshrc")
# If neither exists, fall back to creating one for the current shell # If neither exists, create for current shell
[[ ${#rc_files[@]} -eq 0 ]] && rc_files+=("$HOME/.${SHELL##*/}rc") [[ ${#rc_files[@]} -eq 0 ]] && rc_files+=("$HOME/.${SHELL##*/}rc")
fi fi
@@ -713,9 +705,7 @@ PYEOF
echo "" echo ""
echo " Done. Reload your shell to apply:" echo " Done. Reload your shell to apply:"
for rc_file in "${rc_files[@]}"; do
echo " source $rc_file" echo " source $rc_file"
done
} }
main "$@" main "$@"
+26 -88
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, readdirSync, accessSync, existsSync, constants } from "node:fs"; import { readFileSync, 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,48 +41,8 @@ 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 > nvm/fnm/asdf user-local // Priority: CLAUDE_BIN env > well-known paths > which lookup
// installs > which lookup. Fail-fast if not found — never start with an // Fail-fast if not found — never start with an unresolvable binary.
// 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 {
@@ -94,13 +54,11 @@ 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(home, ".local/bin/claude"), join(process.env.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 {}
@@ -114,8 +72,6 @@ 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);
@@ -202,36 +158,25 @@ const MODEL_MAP = Object.fromEntries([
const MODELS = modelsConfig.models.map(m => ({ id: m.id, name: m.displayName })); const MODELS = modelsConfig.models.map(m => ({ id: m.id, name: m.displayName }));
// ── Session management ────────────────────────────────────────────────── // ── Session management ──────────────────────────────────────────────────
// Maps namespaced session keys to Claude CLI session UUIDs. // Maps conversation IDs (from caller) to Claude CLI session UUIDs.
// Key format: "${keyName}|${conversationId}" — prevents cross-key collision
// when two callers (different API keys or anon + authenticated) use the same
// session_id string. Anonymous callers use "anon"; admin uses "admin".
// Enables --resume for multi-turn conversations, reducing token waste. // Enables --resume for multi-turn conversations, reducing token waste.
const sessions = new Map(); // `${keyName}|${conversationId}` → { uuid, messageCount, lastUsed, model } const sessions = new Map(); // conversationId → { uuid, messageCount, lastUsed, model }
// Build the namespaced key used for all sessions Map operations.
// Returns null when conversationId is falsy (one-off requests bypass session tracking).
function _sessionKey(conversationId, keyName) {
return conversationId ? `${keyName || "anon"}|${conversationId}` : null;
}
const sessionCleanupInterval = setInterval(() => { const sessionCleanupInterval = setInterval(() => {
const now = Date.now(); const now = Date.now();
for (const [id, s] of sessions) { for (const [id, s] of sessions) {
const idleMs = now - s.lastUsed; const idleMs = now - s.lastUsed;
const ageMs = s.firstSeen ? now - s.firstSeen : null; const ageMs = s.firstSeen ? now - s.firstSeen : null;
// id is "${keyName}|${conversationId}"; strip prefix for log output
const convIdShort = id.includes("|") ? id.slice(id.indexOf("|") + 1, id.indexOf("|") + 13) : id.slice(0, 12);
if (idleMs > SESSION_TTL) { if (idleMs > SESSION_TTL) {
sessions.delete(id); sessions.delete(id);
console.log(`[session] expired ${convIdShort}... (idle ${Math.round(idleMs / 60000)}m)`); console.log(`[session] expired ${id.slice(0, 12)}... (idle ${Math.round(idleMs / 60000)}m)`);
logEvent("info", "session_expired", { conversationId: convIdShort + "...", idleMs, ageMs }); logEvent("info", "session_expired", { conversationId: id.slice(0, 12) + "...", idleMs, ageMs });
} else if (ageMs !== null && ageMs > 4 * SESSION_TTL) { } else if (ageMs !== null && ageMs > 4 * SESSION_TTL) {
// #42 evidence-gathering: a session whose firstSeen is more than 4× TTL old // #42 evidence-gathering: a session whose firstSeen is more than 4× TTL old
// but whose lastUsed keeps getting bumped (never idle long enough to expire) // but whose lastUsed keeps getting bumped (never idle long enough to expire)
// is the suspected bug. Log without action so the pattern can be confirmed // is the suspected bug. Log without action so the pattern can be confirmed
// in /logs. Do NOT enforce an absolute age cap here speculatively. // in /logs. Do NOT enforce an absolute age cap here speculatively.
logEvent("warn", "session_long_lived", { conversationId: convIdShort + "...", idleMs, ageMs }); logEvent("warn", "session_long_lived", { conversationId: id.slice(0, 12) + "...", idleMs, ageMs });
} }
} }
}, 60000); }, 60000);
@@ -438,7 +383,7 @@ function getModelTier(cliModel) {
// ── Spawn claude CLI (shared setup) ───────────────────────────────────── // ── Spawn claude CLI (shared setup) ─────────────────────────────────────
// Resolves session logic, builds CLI args, spawns the process, and sets up // Resolves session logic, builds CLI args, spawns the process, and sets up
// timeouts. Returns context object or throws synchronously. // timeouts. Returns context object or throws synchronously.
function spawnClaudeProcess(model, messages, conversationId, keyName) { function spawnClaudeProcess(model, messages, conversationId) {
if (stats.activeRequests >= MAX_CONCURRENT) { if (stats.activeRequests >= MAX_CONCURRENT) {
throw new Error(`concurrency limit reached (${stats.activeRequests}/${MAX_CONCURRENT})`); throw new Error(`concurrency limit reached (${stats.activeRequests}/${MAX_CONCURRENT})`);
} }
@@ -454,11 +399,8 @@ function spawnClaudeProcess(model, messages, conversationId, keyName) {
let prompt; let prompt;
// ── Session logic ── // ── Session logic ──
// sessionKey namespaces the Map key by keyName to prevent cross-caller collision if (conversationId && sessions.has(conversationId)) {
// when two callers with different API keys share the same conversationId string. const session = sessions.get(conversationId);
const sessionKey = _sessionKey(conversationId, keyName);
if (sessionKey && sessions.has(sessionKey)) {
const session = sessions.get(sessionKey);
session.lastUsed = Date.now(); session.lastUsed = Date.now();
sessionInfo = { uuid: session.uuid, resume: true }; sessionInfo = { uuid: session.uuid, resume: true };
stats.sessionHits++; stats.sessionHits++;
@@ -469,17 +411,17 @@ function spawnClaudeProcess(model, messages, conversationId, keyName) {
: ""; : "";
session.messageCount = messages.length; session.messageCount = messages.length;
console.log(`[session] resume conv=${conversationId.slice(0, 12)}... key=${keyName || "anon"} uuid=${session.uuid.slice(0, 8)}... msgs=${messages.length} prompt_chars=${prompt.length}`); console.log(`[session] resume conv=${conversationId.slice(0, 12)}... uuid=${session.uuid.slice(0, 8)}... msgs=${messages.length} prompt_chars=${prompt.length}`);
} else if (sessionKey) { } else if (conversationId) {
const uuid = randomUUID(); const uuid = randomUUID();
const now = Date.now(); const now = Date.now();
sessions.set(sessionKey, { uuid, messageCount: messages.length, firstSeen: now, lastUsed: now, model: cliModel }); sessions.set(conversationId, { uuid, messageCount: messages.length, firstSeen: now, lastUsed: now, model: cliModel });
sessionInfo = { uuid, resume: false }; sessionInfo = { uuid, resume: false };
stats.sessionMisses++; stats.sessionMisses++;
prompt = messagesToPrompt(messages); prompt = messagesToPrompt(messages);
console.log(`[session] new conv=${conversationId.slice(0, 12)}... key=${keyName || "anon"} uuid=${uuid.slice(0, 8)}... msgs=${messages.length}`); console.log(`[session] new conv=${conversationId.slice(0, 12)}... uuid=${uuid.slice(0, 8)}... msgs=${messages.length}`);
} else { } else {
stats.oneOffRequests++; stats.oneOffRequests++;
@@ -524,11 +466,11 @@ function spawnClaudeProcess(model, messages, conversationId, keyName) {
proc.once("exit", cleanup); proc.once("exit", cleanup);
function handleSessionFailure() { function handleSessionFailure() {
if (sessionInfo?.resume && sessionKey) { if (sessionInfo?.resume && conversationId) {
console.warn(`[session] resume failed for ${conversationId.slice(0, 12)}..., removing stale session`); console.warn(`[session] resume failed for ${conversationId.slice(0, 12)}..., removing stale session`);
logEvent("warn", "session_failure", { mode: "resume", conversationId: conversationId.slice(0, 12) + "...", action: "deleted" }); logEvent("warn", "session_failure", { mode: "resume", conversationId: conversationId.slice(0, 12) + "...", action: "deleted" });
sessions.delete(sessionKey); sessions.delete(conversationId);
} else if (sessionInfo && !sessionInfo.resume && sessionKey) { } else if (sessionInfo && !sessionInfo.resume && conversationId) {
// #41 evidence-gathering: session-create failures currently leave a stale entry // #41 evidence-gathering: session-create failures currently leave a stale entry
// in the sessions map. Log without action so the staleness pattern can be // in the sessions map. Log without action so the staleness pattern can be
// confirmed in /logs before any code change. Do NOT delete here speculatively. // confirmed in /logs before any code change. Do NOT delete here speculatively.
@@ -571,11 +513,11 @@ function spawnClaudeProcess(model, messages, conversationId, keyName) {
// On-demand spawning: each request spawns a fresh `claude -p` process. // On-demand spawning: each request spawns a fresh `claude -p` process.
// No pool = no crash loops, no stale workers, no degraded states. // No pool = no crash loops, no stale workers, no degraded states.
// Stdin is written immediately so there's no 3s stdin timeout issue. // Stdin is written immediately so there's no 3s stdin timeout issue.
function callClaude(model, messages, conversationId, keyName) { function callClaude(model, messages, conversationId) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let ctx; let ctx;
try { try {
ctx = spawnClaudeProcess(model, messages, conversationId, keyName); ctx = spawnClaudeProcess(model, messages, conversationId);
} catch (err) { } catch (err) {
return reject(err); return reject(err);
} }
@@ -655,7 +597,7 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
let ctx; let ctx;
try { try {
ctx = spawnClaudeProcess(model, messages, conversationId, authInfo.keyName); ctx = spawnClaudeProcess(model, messages, conversationId);
} catch (err) { } catch (err) {
return jsonResponse(res, 500, { error: { message: err.message, type: "proxy_error" } }); return jsonResponse(res, 500, { error: { message: err.message, type: "proxy_error" } });
} }
@@ -1338,7 +1280,7 @@ async function handleChatCompletions(req, res) {
// will re-read the freshly-populated cache entry here rather than spawning. // will re-read the freshly-populated cache entry here rather than spawning.
const recheck = getCachedResponse(req._cacheHash, CACHE_TTL); const recheck = getCachedResponse(req._cacheHash, CACHE_TTL);
if (recheck) return recheck.response; if (recheck) return recheck.response;
const c = await callClaude(model, messages, conversationId, req._authKeyName); const c = await callClaude(model, messages, conversationId);
try { setCachedResponse(req._cacheHash, model, c); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); } try { setCachedResponse(req._cacheHash, model, c); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); }
return c; return c;
}); });
@@ -1360,7 +1302,7 @@ async function handleChatCompletions(req, res) {
// Fallback: cache disabled (CACHE_TTL=0) or no _cacheHash — original path untouched. // Fallback: cache disabled (CACHE_TTL=0) or no _cacheHash — original path untouched.
try { try {
const content = await callClaude(model, messages, conversationId, req._authKeyName); const content = await callClaude(model, messages, conversationId);
const id = `chatcmpl-${randomUUID()}`; const id = `chatcmpl-${randomUUID()}`;
completionResponse(res, id, model, content); completionResponse(res, id, model, content);
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars, responseChars: content.length, elapsedMs: Date.now() - t0Usage, success: true }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); } try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars, responseChars: content.length, elapsedMs: Date.now() - t0Usage, success: true }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
@@ -1494,10 +1436,8 @@ const server = createServer(async (req, res) => {
const uptimeMs = Date.now() - START_TIME; const uptimeMs = Date.now() - START_TIME;
const sessionList = []; const sessionList = [];
for (const [id, s] of sessions) { for (const [id, s] of sessions) {
// id is "${keyName}|${conversationId}"; expose only the public-facing conversationId
const convId = id.includes("|") ? id.slice(id.indexOf("|") + 1) : id;
sessionList.push({ sessionList.push({
id: convId.slice(0, 12) + "...", id: id.slice(0, 12) + "...",
model: s.model, model: s.model,
messages: s.messageCount, messages: s.messageCount,
idleMs: Date.now() - s.lastUsed, idleMs: Date.now() - s.lastUsed,
@@ -1542,9 +1482,7 @@ const server = createServer(async (req, res) => {
if (req.url === "/sessions" && req.method === "GET") { if (req.url === "/sessions" && req.method === "GET") {
const list = []; const list = [];
for (const [id, s] of sessions) { for (const [id, s] of sessions) {
// id is "${keyName}|${conversationId}"; expose only the public-facing conversationId list.push({ id, uuid: s.uuid, model: s.model, messages: s.messageCount, lastUsed: new Date(s.lastUsed).toISOString() });
const convId = id.includes("|") ? id.slice(id.indexOf("|") + 1) : id;
list.push({ id: convId, uuid: s.uuid, model: s.model, messages: s.messageCount, lastUsed: new Date(s.lastUsed).toISOString() });
} }
return jsonResponse(res, 200, { sessions: list }); return jsonResponse(res, 200, { sessions: list });
} }
+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}