mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-21 21:15:09 +00:00
The reviewer deleted the entire NODE_ENV gate from keys.mjs and the suite still reported
320 passed, 0 failed. The one test written to stop this bug recurring was the one thing in
the PR that would have let it recur — and it would have merged green, with a false sense of
coverage.
Why it was worthless: it re-implemented the predicate INSIDE THE TEST BODY —
const resolve = (nodeEnv, override) =>
(nodeEnv === "test" ? override : null) || join(homedir(), ".ocp");
— and never called resolveOcpDir(), getDb(), or getDbPath(). It asserted that a closure
defined three lines above behaved as written. A copy of the predicate is not the predicate.
Its own comment said "exercising the same predicate keys.mjs uses" — that phrase was the tell.
This is the same failure class the PR exists to indict (an assertion of an intention that
nothing enforces), reproduced one layer up, in the fix for it. Fourth time in this repo that
a correctly-named test has vouched for nothing.
The real test must run OUT OF PROCESS: the parent is irreversibly NODE_ENV=test by the time
any test runs (test-env.mjs sets it before keys.mjs is imported), so the production path is
simply unreachable in-process. It now spawns a child with no NODE_ENV, the override set, and
HOME redirected to a temp dir (so the real key store is never opened), and asserts what the
REAL keys.mjs actually did.
MUTATION-PROVEN, against the exact revert that used to pass:
delete the whole NODE_ENV gate -> 319 passed, 1 failed
✗ a PRODUCTION process (no NODE_ENV) must IGNORE OCP_DIR_OVERRIDE
restore -> 320 passed, 0 failed
Also folded in: setup.mjs created ~/.ocp at the umask default (755, world-listable) on a fresh
install via the logs dir — pre-existing, self-healing on first server start, now stated
explicitly (mode 0700) rather than left to luck. Same class as the F2 fix.
npm test: 320 passed, 0 failed. Real ~/.ocp/ocp.db unchanged at 751 rows.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR
570 lines
24 KiB
JavaScript
Executable File
570 lines
24 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
/**
|
|
* OCP (Open Claude Proxy) setup
|
|
*
|
|
* Automatically configures OpenClaw to use Claude CLI as a model provider.
|
|
* Run: node setup.mjs [--port N] [--default-model opus|sonnet|haiku] [--dry-run]
|
|
* (default port = DEFAULT_PORT from lib/constants.mjs)
|
|
*
|
|
* What it does:
|
|
* 1. Verifies claude CLI is installed and authenticated
|
|
* 2. Patches openclaw.json — adds claude-local provider + models
|
|
* 3. Patches auth-profiles.json — adds dummy auth entry
|
|
* 4. Creates start.sh for easy launch
|
|
* 5. Optionally starts the proxy
|
|
*/
|
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, readdirSync, chmodSync } from "node:fs";
|
|
import { mergePlistEnv, mergeSystemdEnv } from "./scripts/lib/plist-merge.mjs";
|
|
import { execSync } from "node:child_process";
|
|
import { join, dirname } from "node:path";
|
|
import { homedir } from "node:os";
|
|
import { fileURLToPath } from "node:url";
|
|
import { DEFAULT_PORT } from "./lib/constants.mjs";
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const HOME = homedir();
|
|
const OPENCLAW_DIR = process.env.OPENCLAW_STATE_DIR || join(HOME, ".openclaw");
|
|
const CONFIG_PATH = join(OPENCLAW_DIR, "openclaw.json");
|
|
|
|
// ── Parse args ──────────────────────────────────────────────────────────
|
|
const args = process.argv.slice(2);
|
|
const flag = (name) => args.includes(`--${name}`);
|
|
const opt = (name, fallback) => {
|
|
const i = args.indexOf(`--${name}`);
|
|
return i >= 0 && args[i + 1] ? args[i + 1] : fallback;
|
|
};
|
|
|
|
const PORT = parseInt(opt("port", String(DEFAULT_PORT)), 10);
|
|
const DEFAULT_MODEL = opt("default-model", "opus"); // opus | sonnet | haiku
|
|
const DRY_RUN = flag("dry-run");
|
|
const SKIP_START = flag("no-start");
|
|
const PROVIDER_NAME = opt("provider-name", "claude-local");
|
|
const BIND_ADDRESS = opt("bind", "127.0.0.1");
|
|
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;
|
|
|
|
// ── Inject-value helpers ─────────────────────────────────────────────────
|
|
// Escape a value for safe inclusion in a plist <string>…</string> body.
|
|
function xmlEscape(v) {
|
|
return String(v).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
}
|
|
// Validate an injected service value: no control chars (a newline would inject a
|
|
// rogue systemd Environment= directive; other control chars corrupt the unit/plist).
|
|
// Spaces are allowed — filesystem paths (CLAUDE_BIN) may legitimately contain them.
|
|
function assertSafeInjectValue(name, v) {
|
|
if (v == null) return v;
|
|
if (/[\x00-\x1f]/.test(String(v))) {
|
|
console.error(`FATAL: ${name} contains a newline or control character — refusing to write it into the service unit.`);
|
|
process.exit(1);
|
|
}
|
|
return v;
|
|
}
|
|
|
|
// Validate all three INJECT values before they are written into any service unit.
|
|
assertSafeInjectValue("CLAUDE_BIN", CLAUDE_BIN_INJECT);
|
|
assertSafeInjectValue("OCP_ADMIN_KEY", OCP_ADMIN_KEY_INJECT);
|
|
assertSafeInjectValue("PROXY_ANONYMOUS_KEY", PROXY_ANON_KEY_INJECT);
|
|
|
|
// ── Models: derived from models.json (single source of truth) ──────────
|
|
const modelsConfig = JSON.parse(readFileSync(join(__dirname, "models.json"), "utf-8"));
|
|
|
|
const MODEL_ID_MAP = modelsConfig.aliases;
|
|
const DEFAULT_MODEL_ID = MODEL_ID_MAP[DEFAULT_MODEL] || MODEL_ID_MAP.opus;
|
|
|
|
const MODELS = modelsConfig.models.map(m => ({
|
|
id: m.id,
|
|
name: m.openclawName,
|
|
reasoning: m.reasoning,
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: m.contextWindow,
|
|
maxTokens: m.maxTokens,
|
|
}));
|
|
|
|
const MODEL_ALIASES = Object.fromEntries(
|
|
modelsConfig.models.map(m => [`${PROVIDER_NAME}/${m.id}`, { alias: m.displayName }])
|
|
);
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
function log(msg) { console.log(` ✓ ${msg}`); }
|
|
function warn(msg) { console.log(` ⚠ ${msg}`); }
|
|
function fail(msg) { console.error(` ✗ ${msg}`); process.exit(1); }
|
|
|
|
function readJSON(path) {
|
|
return JSON.parse(readFileSync(path, "utf-8"));
|
|
}
|
|
|
|
function writeJSON(path, data) {
|
|
if (DRY_RUN) {
|
|
console.log(` [dry-run] would write ${path}`);
|
|
return;
|
|
}
|
|
writeFileSync(path, JSON.stringify(data, null, 2) + "\n");
|
|
}
|
|
|
|
// ── Step 1: Verify prerequisites ────────────────────────────────────────
|
|
console.log("\n🔍 Checking prerequisites...\n");
|
|
|
|
// Check node version
|
|
const nodeVer = parseInt(process.versions.node.split(".")[0], 10);
|
|
if (nodeVer < 18) fail(`Node.js >= 18 required (found ${process.versions.node})`);
|
|
log(`Node.js ${process.versions.node}`);
|
|
|
|
// Check claude CLI
|
|
try {
|
|
const ver = execSync("claude --version 2>/dev/null", { encoding: "utf-8" }).trim();
|
|
log(`Claude CLI: ${ver}`);
|
|
} catch {
|
|
fail("Claude CLI not found. Install: https://docs.anthropic.com/en/docs/claude-code");
|
|
}
|
|
|
|
// Check claude auth (quick test)
|
|
// NOTE: This probe uses `claude -p` (sdk-cli spawn). After the 2026-06-15 Anthropic billing
|
|
// split, every `claude -p` call draws from the Agent SDK credit pool rather than the
|
|
// Pro/Max subscription. Re-running setup after 6/15 will consume one metered credit.
|
|
// Set OCP_SKIP_AUTH_TEST=1 to skip this probe (auth is still validated at first real request).
|
|
if (process.env.OCP_SKIP_AUTH_TEST === "1") {
|
|
warn("OCP_SKIP_AUTH_TEST=1 — skipping claude auth probe (will be validated at first request).");
|
|
} else {
|
|
try {
|
|
const out = execSync('claude -p --output-format text --no-session-persistence -- "ping"', {
|
|
encoding: "utf-8",
|
|
timeout: 30000,
|
|
env: { ...process.env, CLAUDECODE: undefined, ANTHROPIC_API_KEY: undefined, ANTHROPIC_BASE_URL: undefined, ANTHROPIC_AUTH_TOKEN: undefined },
|
|
}).trim();
|
|
if (out.length > 0) {
|
|
log(`Claude CLI authenticated (test response: "${out.slice(0, 40)}...")`);
|
|
}
|
|
} catch (e) {
|
|
warn(`Claude CLI auth test failed: ${e.message.slice(0, 100)}`);
|
|
warn("Make sure you're logged in: claude login");
|
|
}
|
|
}
|
|
|
|
// Check openclaw config (optional — OCP runs standalone without OpenClaw)
|
|
const OPENCLAW_PRESENT = existsSync(CONFIG_PATH);
|
|
if (OPENCLAW_PRESENT) {
|
|
log(`OpenClaw config: ${CONFIG_PATH}`);
|
|
} else {
|
|
warn(`OpenClaw not detected at ${CONFIG_PATH} — skipping OpenClaw integration.`);
|
|
warn(`To register OCP with OpenClaw later, install OpenClaw and re-run \`node setup.mjs\`,`);
|
|
warn(`or run \`ocp update\` if OpenClaw is installed afterward.`);
|
|
}
|
|
|
|
// ── Step 2: Patch openclaw.json ─────────────────────────────────────────
|
|
if (OPENCLAW_PRESENT) {
|
|
console.log("\n📝 Configuring OpenClaw...\n");
|
|
|
|
const config = readJSON(CONFIG_PATH);
|
|
|
|
// Ensure models.providers exists
|
|
if (!config.models) config.models = {};
|
|
if (!config.models.providers) config.models.providers = {};
|
|
|
|
// Add/update claude-local provider
|
|
config.models.providers[PROVIDER_NAME] = {
|
|
baseUrl: `http://127.0.0.1:${PORT}/v1`,
|
|
api: "openai-completions",
|
|
authHeader: false,
|
|
models: MODELS,
|
|
};
|
|
log(`Provider "${PROVIDER_NAME}" → http://127.0.0.1:${PORT}/v1`);
|
|
|
|
// Ensure auth profile in config
|
|
if (!config.auth) config.auth = {};
|
|
if (!config.auth.profiles) config.auth.profiles = {};
|
|
config.auth.profiles[`${PROVIDER_NAME}:default`] = {
|
|
provider: PROVIDER_NAME,
|
|
mode: "api_key",
|
|
};
|
|
log(`Auth profile "${PROVIDER_NAME}:default" registered`);
|
|
|
|
// Add models to agents.defaults.models
|
|
if (!config.agents) config.agents = {};
|
|
if (!config.agents.defaults) config.agents.defaults = {};
|
|
if (!config.agents.defaults.models) config.agents.defaults.models = {};
|
|
for (const [key, val] of Object.entries(MODEL_ALIASES)) {
|
|
config.agents.defaults.models[key] = val;
|
|
}
|
|
log(`Model aliases added to agents.defaults.models`);
|
|
|
|
// Set idleTimeoutSeconds to 0 — critical for Claude tool-use.
|
|
// When Claude calls tools (Bash, Read, etc.), the token stream pauses for 30-120s.
|
|
// OpenClaw's default idleTimeoutSeconds (60s) kills the connection mid-tool-call,
|
|
// causing exit 143 (SIGTERM) and stuck sessions. Setting to 0 disables the idle timer.
|
|
if (!config.agents.defaults.llm) config.agents.defaults.llm = {};
|
|
if (config.agents.defaults.llm.idleTimeoutSeconds === undefined ||
|
|
config.agents.defaults.llm.idleTimeoutSeconds > 0) {
|
|
config.agents.defaults.llm.idleTimeoutSeconds = 0;
|
|
log(`Set agents.defaults.llm.idleTimeoutSeconds = 0 (prevents tool-call timeouts)`);
|
|
} else {
|
|
log(`idleTimeoutSeconds already configured: ${config.agents.defaults.llm.idleTimeoutSeconds}`);
|
|
}
|
|
|
|
writeJSON(CONFIG_PATH, config);
|
|
log(`Config saved`);
|
|
|
|
// ── Step 3: Patch auth-profiles.json ────────────────────────────────────
|
|
console.log("\n🔑 Configuring auth profiles...\n");
|
|
|
|
// Find all agent auth-profiles.json files
|
|
const agentsDir = join(OPENCLAW_DIR, "agents");
|
|
const agentDirs = existsSync(agentsDir)
|
|
? readdirSync(agentsDir).filter((d) => {
|
|
const ap = join(agentsDir, d, "agent", "auth-profiles.json");
|
|
return existsSync(ap);
|
|
})
|
|
: [];
|
|
|
|
for (const agentId of agentDirs) {
|
|
const apPath = join(agentsDir, agentId, "agent", "auth-profiles.json");
|
|
try {
|
|
const ap = readJSON(apPath);
|
|
if (!ap.profiles) ap.profiles = {};
|
|
|
|
// Add claude-local profile if missing
|
|
if (!ap.profiles[`${PROVIDER_NAME}:default`]) {
|
|
ap.profiles[`${PROVIDER_NAME}:default`] = {
|
|
type: "api_key",
|
|
provider: PROVIDER_NAME,
|
|
key: "local-proxy-no-auth",
|
|
};
|
|
}
|
|
|
|
// Add to lastGood if missing
|
|
if (!ap.lastGood) ap.lastGood = {};
|
|
if (!ap.lastGood[PROVIDER_NAME]) {
|
|
ap.lastGood[PROVIDER_NAME] = `${PROVIDER_NAME}:default`;
|
|
}
|
|
|
|
writeJSON(apPath, ap);
|
|
log(`Agent "${agentId}" auth profile updated`);
|
|
} catch (e) {
|
|
warn(`Skipped agent "${agentId}": ${e.message}`);
|
|
}
|
|
}
|
|
|
|
if (agentDirs.length === 0) {
|
|
warn("No agent auth-profiles.json found — you may need to restart the gateway first");
|
|
}
|
|
}
|
|
|
|
// ── Step 4: Create start.sh ─────────────────────────────────────────────
|
|
console.log("\n🚀 Creating launcher...\n");
|
|
|
|
const serverPath = join(__dirname, "server.mjs");
|
|
const logDir = join(OPENCLAW_DIR, "logs");
|
|
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
|
|
|
|
const startSh = `#!/bin/bash
|
|
# Start OCP (Open Claude Proxy) if not already running
|
|
PORT=\${CLAUDE_PROXY_PORT:-${PORT}}
|
|
if ! lsof -i :\$PORT -sTCP:LISTEN &>/dev/null; then
|
|
unset CLAUDECODE
|
|
nohup node "${serverPath}" \\
|
|
>> "${logDir}/claude-proxy.log" \\
|
|
2>> "${logDir}/claude-proxy.err.log" &
|
|
echo "claude-proxy started on port \$PORT (pid $!)"
|
|
else
|
|
echo "claude-proxy already running on port \$PORT"
|
|
fi
|
|
`;
|
|
|
|
const startPath = join(__dirname, "start.sh");
|
|
if (!DRY_RUN) {
|
|
writeFileSync(startPath, startSh);
|
|
execSync(`chmod +x "${startPath}"`);
|
|
}
|
|
log(`Launcher: ${startPath}`);
|
|
|
|
// ── Step 5: Summary ─────────────────────────────────────────────────────
|
|
const banner = [
|
|
`╔══════════════════════════════════════════════════════════════╗`,
|
|
`║ Setup complete! ║`,
|
|
`╠══════════════════════════════════════════════════════════════╣`,
|
|
`║ ║`,
|
|
`║ Provider: ${PROVIDER_NAME.padEnd(44)}║`,
|
|
`║ Port: ${String(PORT).padEnd(44)}║`,
|
|
`║ Models: ${`see models.json (${MODELS.length} available)`.padEnd(44)}║`,
|
|
`║ Default: ${DEFAULT_MODEL_ID.padEnd(44)}║`,
|
|
`║ ║`,
|
|
`║ Start proxy: ║`,
|
|
`║ bash ${startPath.replace(HOME, "~").padEnd(50)}║`,
|
|
`║ ║`,
|
|
`║ Or directly: ║`,
|
|
`║ node ${serverPath.replace(HOME, "~").padEnd(49)}║`,
|
|
`║ ║`,
|
|
];
|
|
if (OPENCLAW_PRESENT) {
|
|
banner.push(
|
|
`║ Set as default model in openclaw.json: ║`,
|
|
`║ agents.defaults.model.primary = ║`,
|
|
`║ "${PROVIDER_NAME}/${DEFAULT_MODEL_ID}"${" ".repeat(Math.max(0, 30 - PROVIDER_NAME.length - DEFAULT_MODEL_ID.length))}║`,
|
|
`║ ║`,
|
|
`║ Then restart gateway: ║`,
|
|
`║ openclaw gateway restart ║`,
|
|
`║ ║`,
|
|
);
|
|
} else {
|
|
banner.push(
|
|
`║ OpenClaw not detected — running in standalone mode. ║`,
|
|
`║ Point your IDE (Cline / Cursor / Continue / OpenCode / ║`,
|
|
`║ Aider / OpenClaw) at: ║`,
|
|
`║ http://${BIND_ADDRESS}:${String(PORT)}/v1${" ".repeat(Math.max(0, 47 - BIND_ADDRESS.length - String(PORT).length))}║`,
|
|
`║ ║`,
|
|
`║ See README § "Client Setup" for per-IDE instructions. ║`,
|
|
`║ ║`,
|
|
);
|
|
}
|
|
banner.push(`╚══════════════════════════════════════════════════════════════╝`);
|
|
console.log("\n" + banner.join("\n") + "\n");
|
|
|
|
// ── 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) {
|
|
console.log("\n🔄 Installing auto-start on login...\n");
|
|
|
|
const platform = process.platform;
|
|
// Use stable symlink path instead of versioned Cellar path (e.g. /opt/homebrew/opt/node/bin/node
|
|
// instead of /opt/homebrew/Cellar/node/25.8.0/bin/node) so the plist survives node upgrades.
|
|
let nodeBin = process.execPath;
|
|
if (platform === "darwin" && nodeBin.includes("/Cellar/")) {
|
|
const stable = nodeBin.replace(/\/Cellar\/[^/]+\/[^/]+\//, "/opt/");
|
|
if (existsSync(stable)) {
|
|
nodeBin = stable;
|
|
log(`Using stable node path: ${nodeBin}`);
|
|
}
|
|
}
|
|
|
|
// Ensure logs dir exists
|
|
const logsDir = join(OPENCLAW_DIR, "logs");
|
|
if (!existsSync(logsDir)) mkdirSync(logsDir, { recursive: true });
|
|
|
|
// Use neutral service names to avoid OpenClaw gateway's extra-service detection.
|
|
// OpenClaw scans LaunchAgent plists and systemd units for "openclaw" / "clawdbot"
|
|
// markers and flags them as conflicting gateway-like services. Using "dev.ocp.*"
|
|
// and "ocp-proxy" keeps the proxy invisible to that heuristic.
|
|
const OCP_HOME = join(HOME, ".ocp");
|
|
const ocpLogsDir = join(OCP_HOME, "logs");
|
|
if (!existsSync(ocpLogsDir)) mkdirSync(ocpLogsDir, { recursive: true, mode: 0o700 }) // 0700: this call can create ~/.ocp itself on a fresh install;
|
|
|
|
// Uninstall legacy service names if present (upgrade path)
|
|
if (platform === "darwin") {
|
|
const legacyPlist = join(HOME, "Library", "LaunchAgents", "ai.openclaw.proxy.plist");
|
|
if (existsSync(legacyPlist)) {
|
|
try { execSync(`launchctl bootout gui/$(id -u) "${legacyPlist}" 2>/dev/null`); } catch { /* ignore */ }
|
|
try { unlinkSync(legacyPlist); } catch { /* ignore */ }
|
|
log(`Removed legacy plist: ai.openclaw.proxy`);
|
|
}
|
|
} else if (platform === "linux") {
|
|
const legacyService = join(HOME, ".config", "systemd", "user", "openclaw-proxy.service");
|
|
if (existsSync(legacyService)) {
|
|
try { execSync(`systemctl --user stop openclaw-proxy 2>/dev/null`); } catch { /* ignore */ }
|
|
try { execSync(`systemctl --user disable openclaw-proxy 2>/dev/null`); } catch { /* ignore */ }
|
|
try { unlinkSync(legacyService); } catch { /* ignore */ }
|
|
try { execSync(`systemctl --user daemon-reload`); } catch { /* ignore */ }
|
|
log(`Removed legacy systemd service: openclaw-proxy`);
|
|
}
|
|
}
|
|
|
|
if (platform === "darwin") {
|
|
// macOS: launchd
|
|
const plistDir = join(HOME, "Library", "LaunchAgents");
|
|
if (!existsSync(plistDir)) mkdirSync(plistDir, { recursive: true });
|
|
|
|
const plistPath = join(plistDir, "dev.ocp.proxy.plist");
|
|
const logPath = join(ocpLogsDir, "proxy.log");
|
|
|
|
const plistXml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>Label</key>
|
|
<string>dev.ocp.proxy</string>
|
|
<key>ProgramArguments</key>
|
|
<array>
|
|
<string>${nodeBin}</string>
|
|
<string>${serverPath}</string>
|
|
</array>
|
|
<key>EnvironmentVariables</key>
|
|
<dict>
|
|
<key>CLAUDE_PROXY_PORT</key>
|
|
<string>${xmlEscape(PORT)}</string>
|
|
<key>CLAUDE_BIND</key>
|
|
<string>${xmlEscape(BIND_ADDRESS)}</string>
|
|
<key>CLAUDE_AUTH_MODE</key>
|
|
<string>${xmlEscape(AUTH_MODE_CONFIG)}</string>${CLAUDE_BIN_INJECT ? `
|
|
<key>CLAUDE_BIN</key>
|
|
<string>${xmlEscape(CLAUDE_BIN_INJECT)}</string>` : ""}${OCP_ADMIN_KEY_INJECT ? `
|
|
<key>OCP_ADMIN_KEY</key>
|
|
<string>${xmlEscape(OCP_ADMIN_KEY_INJECT)}</string>` : ""}${PROXY_ANON_KEY_INJECT ? `
|
|
<key>PROXY_ANONYMOUS_KEY</key>
|
|
<string>${xmlEscape(PROXY_ANON_KEY_INJECT)}</string>` : ""}
|
|
</dict>
|
|
<key>RunAtLoad</key>
|
|
<true/>
|
|
<key>KeepAlive</key>
|
|
<true/>
|
|
<key>StandardOutPath</key>
|
|
<string>${logPath}</string>
|
|
<key>StandardErrorPath</key>
|
|
<string>${logPath}</string>
|
|
</dict>
|
|
</plist>
|
|
`;
|
|
|
|
const existingPlist = existsSync(plistPath) ? readFileSync(plistPath, "utf8") : null;
|
|
const finalPlistXml = mergePlistEnv(existingPlist, plistXml);
|
|
writeFileSync(plistPath, finalPlistXml);
|
|
chmodSync(plistPath, 0o600);
|
|
if (existingPlist && finalPlistXml !== plistXml) {
|
|
log(`Plist written: ${plistPath} (mode 600, preserved user env vars)`);
|
|
} else {
|
|
log(`Plist written: ${plistPath} (mode 600)`);
|
|
}
|
|
|
|
// Bootout first (in case it was already loaded) then bootstrap
|
|
try { execSync(`launchctl bootout gui/$(id -u) "${plistPath}" 2>/dev/null`); } catch { /* ignore */ }
|
|
execSync(`launchctl bootstrap gui/$(id -u) "${plistPath}"`);
|
|
log(`launchctl loaded dev.ocp.proxy`);
|
|
|
|
} else if (platform === "linux") {
|
|
// Linux: systemd user service
|
|
const systemdDir = join(HOME, ".config", "systemd", "user");
|
|
if (!existsSync(systemdDir)) mkdirSync(systemdDir, { recursive: true });
|
|
|
|
const servicePath = join(systemdDir, "ocp-proxy.service");
|
|
const logPath = join(ocpLogsDir, "proxy.log");
|
|
|
|
const serviceUnit = `[Unit]
|
|
Description=OCP — Open Claude Proxy
|
|
After=network.target
|
|
|
|
[Service]
|
|
ExecStart=${nodeBin} ${serverPath}
|
|
Environment=CLAUDE_PROXY_PORT=${PORT}
|
|
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}` : ""}
|
|
Restart=always
|
|
RestartSec=5
|
|
StandardOutput=append:${logPath}
|
|
StandardError=append:${logPath}
|
|
|
|
[Install]
|
|
WantedBy=default.target
|
|
`;
|
|
|
|
const existingService = existsSync(servicePath) ? readFileSync(servicePath, "utf8") : null;
|
|
const finalServiceUnit = mergeSystemdEnv(existingService, serviceUnit);
|
|
writeFileSync(servicePath, finalServiceUnit);
|
|
chmodSync(servicePath, 0o600);
|
|
if (existingService && finalServiceUnit !== serviceUnit) {
|
|
log(`Service file written: ${servicePath} (mode 600, preserved user env vars)`);
|
|
} else {
|
|
log(`Service file written: ${servicePath} (mode 600)`);
|
|
}
|
|
|
|
execSync(`systemctl --user daemon-reload`);
|
|
execSync(`systemctl --user enable ocp-proxy`);
|
|
execSync(`systemctl --user start ocp-proxy`);
|
|
log(`systemd user service enabled and started`);
|
|
|
|
} else {
|
|
warn(`Auto-start not supported on ${platform} — start manually with: bash ${startPath}`);
|
|
}
|
|
|
|
console.log("\n✅ Auto-start installed — proxy will start automatically on login\n");
|
|
|
|
// ── Step 8: Post-install health verification ───────────────────────────
|
|
if (!SKIP_START) {
|
|
console.log("⏳ Waiting for server to bind...\n");
|
|
await new Promise(r => setTimeout(r, 3000));
|
|
|
|
const healthUrl = `http://127.0.0.1:${PORT}/health`;
|
|
let verified = false;
|
|
try {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), 5000);
|
|
const res = await fetch(healthUrl, { signal: controller.signal });
|
|
clearTimeout(timer);
|
|
|
|
if (res.ok) {
|
|
const body = await res.json().catch(() => ({}));
|
|
console.log(` ✓ Health check passed (${healthUrl})`);
|
|
console.log(` version: ${body.version ?? "unknown"}`);
|
|
console.log(` authMode: ${body.authMode ?? "unknown"}`);
|
|
|
|
// Verify bind socket
|
|
try {
|
|
const bindCheck = process.platform === "linux"
|
|
? execSync(`ss -tlnp 2>/dev/null | grep ':${PORT}'`, { encoding: "utf-8" }).trim()
|
|
: execSync(`lsof -nP -iTCP:${PORT} -sTCP:LISTEN 2>/dev/null`, { encoding: "utf-8" }).trim();
|
|
if (bindCheck) {
|
|
console.log(` bind: ${bindCheck.split("\n")[0]}`);
|
|
}
|
|
} catch { /* bind check is best-effort */ }
|
|
|
|
verified = true;
|
|
} else {
|
|
warn(`Health check returned HTTP ${res.status} — service may not have started cleanly`);
|
|
}
|
|
} catch (e) {
|
|
const isTimeout = e.name === "AbortError" || (e.cause && e.cause.code === "UND_ERR_CONNECT_TIMEOUT");
|
|
warn(`Health check failed: ${isTimeout ? "timeout (5s)" : e.message}`);
|
|
}
|
|
|
|
if (!verified) {
|
|
const logHint = process.platform === "linux"
|
|
? "journalctl --user -u ocp-proxy -n 50"
|
|
: `tail -n 100 ~/.ocp/logs/proxy.log`;
|
|
console.error(`\n ✗ Server did not respond on port ${PORT} within 5 seconds.`);
|
|
console.error(` Check service logs:\n ${logHint}\n`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
}
|