mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-19 09:44:07 +00:00
* fix(test): stop the suite writing live API keys into the operator's real key store
`npm test` wrote real, UNREVOKED rows into ~/.ocp/ocp.db — the SAME sqlite database the
running server reads — two per run, unbounded. On the maintainer's host that had accumulated
**737 test-suite keys against 12 real operator keys** (749 rows total), all revoked=0.
Not a credential leak: createKey() mints `randomBytes(24)` and the suite discards the
plaintext, so nobody holds a usable token. But it is real damage:
- the operator's key store grows by 2 rows on every test run, forever
- `ocp keys list` is unusable (749 rows, 12 of them real)
- the suite is RACY: two concurrent runs (e.g. two review worktrees) share one file, so
listKeys() can miss "test-user-1" and `"quota_daily" in undefined` throws a TypeError
rather than failing cleanly. That is the ~1-in-6 flake in `listKeys includes quota
fields`, reported by a reviewer and initially not reproducible serially — it needs a
concurrent run to surface, which is exactly what four parallel reviewers produced.
Root cause: keys.mjs resolved `OCP_DIR`/`DB_PATH` at MODULE TOP-LEVEL and read no env var.
test-features.mjs carried a comment claiming it could "set env before the first getDb() call"
— it could not, on two counts: nothing in keys.mjs read an env var, and ESM hoists imports, so
the assignment would have run after keys.mjs was already evaluated anyway. The isolation was
never real; it just LOOKED real, which is why it survived.
Fix:
- keys.mjs resolves the dir lazily, inside getDb(), honoring OCP_DIR_OVERRIDE. Deliberately
NOT a generic `OCP_DIR`: pointing a RUNNING server at a different key store silently
changes which credentials authenticate, so this must be awkward to set by accident.
- new test-env.mjs, imported BEFORE keys.mjs, mints a per-run scratch dir. A separate module
is required — ESM hoisting means a statement in the test's own body is too late.
- export getDbPath() so the store's location can be asserted.
- as a side effect, importing keys.mjs no longer creates directories in the operator's home.
Two guards added, both MUTATION-TESTED (revert the override → both fail, 317/2):
- the key store under test is a scratch db, NOT the operator's real ~/.ocp/ocp.db
- listKeys does not depend on rows left behind by an earlier or concurrent run
Proven, not asserted: the real ~/.ocp/ocp.db held at 749 rows across two full test runs
(it previously grew by 2 each run). The 737 existing junk rows are NOT cleaned up here —
that is a destructive change to the maintainer's live database and is his call, not a
side effect of a test fix.
npm test: 319 passed, 0 failed (was 317).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR
* fix(keys): gate the test override behind NODE_ENV so a prod server can never honor it
Review fold-in. The reviewer landed the sharpest possible critique: the first cut closed a
test-hygiene hole by opening a quieter AUTH-CORRECTNESS one, and its only guard against that
was "the variable has an awkward name" — a naming convention plus a comment. That is precisely
the failure mode this whole PR exists to indict (a comment describing an intention that nothing
enforces). It was demonstrated live:
OCP_DIR_OVERRIDE=/tmp/evil-store -> server opens /tmp/evil-store/ocp.db, 0 keys visible
server.mjs imports keys.mjs, and `ocp start`'s nohup fallback inherits the invoking shell's env
— so a maintainer who exported the var while debugging THIS issue and then started the server
would get a server silently authenticating against an empty key store. In AUTH_MODE=multi that
is a total auth outage: every real key 401s, nothing logged, nothing on /health.
F1 — the gate is now the actual guard: OCP_DIR_OVERRIDE is honored ONLY when
NODE_ENV === "test". A production server runs without NODE_ENV and therefore CANNOT be
redirected, however the variable reached its environment. Proven both directions:
no NODE_ENV + OCP_DIR_OVERRIDE=/tmp/evil-store -> /Users/<op>/.ocp/ocp.db (ignored)
NODE_ENV=test + OCP_DIR_OVERRIDE=/tmp/scratch -> /tmp/scratch/ocp.db (honored)
Plus: getDb() now LOGS the store whenever it is not the default. Silence was the other half
of the bug — a server on the wrong key store looks exactly like one on the right store until
every request 401s.
F2 — restore the 0700 guarantee on ~/.ocp. Removing keys.mjs's top-level mkdirSync (a good
change on its own) silently dropped it: prepareSpawnHome (server.mjs:477) does
mkdirSync(recursive) with NO mode, so on a fresh install it can create ~/.ocp as a
world-listable 0755 parent. Verified: 755 via the spawn-home path vs 700 via resolveOcpDir.
The invariant used to be inherited by luck; it is now stated.
F3 — test-env.mjs removes its scratch dir on exit. Otherwise the fix traded unbounded growth in
~/.ocp/ocp.db for unbounded growth in $TMPDIR. Verified: 2 runs, delta 0 dirs.
F4 — closeDb() clears dbPath; getDbPath() no longer hands back a path to a closed db.
F5 — dropped the dead unlinkSync import and explained the leftover HOME normalization.
New test, and it is the one that matters: "a PRODUCTION process (no NODE_ENV) must IGNORE
OCP_DIR_OVERRIDE" — so nothing can re-widen the gate without a red test.
server.mjs IS touched (one mkdirSync mode). Not endpoint-touching: no request handler, endpoint,
header, or wire field — so no cli.js citation applies (ALIGNMENT.md Rule 2 / CLAUDE.md hard-req #1).
Note memory/constitution.md § II lists keys.mjs as a protected file requiring maintainer approval.
npm test: 320 passed, 0 failed. Real ~/.ocp/ocp.db unchanged at 751 rows throughout.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VqgWJcjxrjjL9L9SkpZyXR
* fix(test): make the F1 gate test REAL — it was theatre, and proved it
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
* fix(setup): rescue the semicolon from the comment; assert the child SAW the override
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
---------
Co-authored-by: dtzp555 <dtzp555@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
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);
|
|
}
|
|
}
|
|
}
|