mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-21 21:15:09 +00:00
Two review nits on the way in. setup.mjs:393 — the statement's semicolon had been swallowed INTO the trailing comment, so the line parsed only because ASI rescued it (the next token is `if` on a new line). The repo has no linter, so nothing would have caught it. Comment moved above the statement. test-features.mjs — negative control on the prod-gate probe. The reviewer noticed the test's robustness was INCIDENTAL: because the child env is spread from process.env, it inherits the parent's own OCP_DIR_OVERRIDE, so a future refactor that renamed the var and missed this test's explicit `env` object would still have gone red — but by luck, not by assertion. The child now prints the override it SAW as well as the store it opened, and the test asserts both. The claim is now 'a prod process saw the override and ignored it', not merely 'a prod process opened the right store' (which could pass for the wrong reason). Mutation re-proven after both edits: delete the NODE_ENV gate -> 319 passed, 1 failed; restore -> 320 passed, 0 failed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR
572 lines
24 KiB
JavaScript
Executable File
572 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");
|
|
// mode 0700: with `recursive`, this call can create ~/.ocp ITSELF on a fresh install, and
|
|
// without an explicit mode that parent lands at the umask default (world-listable 0755).
|
|
if (!existsSync(ocpLogsDir)) mkdirSync(ocpLogsDir, { recursive: true, mode: 0o700 });
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
}
|