Compare commits

..
Author SHA1 Message Date
taodengandClaude Sonnet 4.6 eb6cd09bed fix(setup): remove duplicate server spawn; verify health post-install
## Dogfood evidence (Pi231, 2026-05-08)

A user ran `node setup.mjs --bind 0.0.0.0 --auth-mode multi` and got:
- setup.mjs exit 0
- /health responded with authMode:"none" and server bound to 127.0.0.1 only
- ps showed two server.mjs processes: one orphan from setup (wrong config),
  one systemd child restart-looping on EADDRINUSE

## Root cause (two-step conflict)

Step 6 (the deleted block) called `execSync('bash "${startPath}"')` which ran
start.sh's `nohup node server.mjs &` — spawning the server WITHOUT exporting
CLAUDE_BIND or CLAUDE_AUTH_MODE. That server ran with default bind=127.0.0.1
and authMode=none, ignoring the user's CLI flags.

Step 7 then wrote the systemd unit/launchd plist WITH the correct env vars and
bootstrapped the service — but port 3456 was already taken by Step 6's spawn,
causing EADDRINUSE and a silent restart loop. setup.mjs exited 0 because Step 6
had "succeeded" (a server was running, just the wrong one).

## What changed

1. Deleted Step 6 entirely (the `execSync('bash "${startPath}"')` block).
   The systemd/launchd service installed in Step 7 is now the sole authoritative
   start path. start.sh is unchanged and still available for manual non-systemd use.

2. Added Step 8 inside the `if (!DRY_RUN)` block: after Step 7 bootstrap,
   waits 3 s, then GETs http://127.0.0.1:${PORT}/health with a 5 s timeout.
   - On 200 OK: logs version, authMode, and bind socket (best-effort).
   - On failure: prints clear error pointing to service logs, exits 1.
   - Skipped when --no-start is set (existing flag).

Identified during PR #71 dogfood testing on Pi231 (RPi4 / Debian Bookworm).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 14:52:27 +10:00
+157 -137
View File
@@ -12,7 +12,7 @@
* 4. Creates start.sh for easy launch * 4. Creates start.sh for easy launch
* 5. Optionally starts the proxy * 5. Optionally starts the proxy
*/ */
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, readdirSync } from "node:fs"; import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from "node:fs";
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import { join, dirname } from "node:path"; import { join, dirname } from "node:path";
import { homedir } from "node:os"; import { homedir } from "node:os";
@@ -107,114 +107,108 @@ try {
warn("Make sure you're logged in: claude login"); warn("Make sure you're logged in: claude login");
} }
// Check openclaw config (optional — OCP runs standalone without OpenClaw) // Check openclaw config
const OPENCLAW_PRESENT = existsSync(CONFIG_PATH); if (!existsSync(CONFIG_PATH)) fail(`OpenClaw config not found at ${CONFIG_PATH}`);
if (OPENCLAW_PRESENT) { log(`OpenClaw config: ${CONFIG_PATH}`);
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 ───────────────────────────────────────── // ── Step 2: Patch openclaw.json ─────────────────────────────────────────
if (OPENCLAW_PRESENT) { console.log("\n📝 Configuring OpenClaw...\n");
console.log("\n📝 Configuring OpenClaw...\n");
const config = readJSON(CONFIG_PATH); const config = readJSON(CONFIG_PATH);
// Ensure models.providers exists // Ensure models.providers exists
if (!config.models) config.models = {}; if (!config.models) config.models = {};
if (!config.models.providers) config.models.providers = {}; if (!config.models.providers) config.models.providers = {};
// Add/update claude-local provider // Add/update claude-local provider
config.models.providers[PROVIDER_NAME] = { config.models.providers[PROVIDER_NAME] = {
baseUrl: `http://127.0.0.1:${PORT}/v1`, baseUrl: `http://127.0.0.1:${PORT}/v1`,
api: "openai-completions", api: "openai-completions",
authHeader: false, authHeader: false,
models: MODELS, models: MODELS,
}; };
log(`Provider "${PROVIDER_NAME}" → http://127.0.0.1:${PORT}/v1`); log(`Provider "${PROVIDER_NAME}" → http://127.0.0.1:${PORT}/v1`);
// Ensure auth profile in config // Ensure auth profile in config
if (!config.auth) config.auth = {}; if (!config.auth) config.auth = {};
if (!config.auth.profiles) config.auth.profiles = {}; if (!config.auth.profiles) config.auth.profiles = {};
config.auth.profiles[`${PROVIDER_NAME}:default`] = { config.auth.profiles[`${PROVIDER_NAME}:default`] = {
provider: PROVIDER_NAME, provider: PROVIDER_NAME,
mode: "api_key", mode: "api_key",
}; };
log(`Auth profile "${PROVIDER_NAME}:default" registered`); log(`Auth profile "${PROVIDER_NAME}:default" registered`);
// Add models to agents.defaults.models // Add models to agents.defaults.models
if (!config.agents) config.agents = {}; if (!config.agents) config.agents = {};
if (!config.agents.defaults) config.agents.defaults = {}; if (!config.agents.defaults) config.agents.defaults = {};
if (!config.agents.defaults.models) config.agents.defaults.models = {}; if (!config.agents.defaults.models) config.agents.defaults.models = {};
for (const [key, val] of Object.entries(MODEL_ALIASES)) { for (const [key, val] of Object.entries(MODEL_ALIASES)) {
config.agents.defaults.models[key] = val; config.agents.defaults.models[key] = val;
} }
log(`Model aliases added to agents.defaults.models`); log(`Model aliases added to agents.defaults.models`);
// Set idleTimeoutSeconds to 0 — critical for Claude tool-use. // Set idleTimeoutSeconds to 0 — critical for Claude tool-use.
// When Claude calls tools (Bash, Read, etc.), the token stream pauses for 30-120s. // 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, // 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. // 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) config.agents.defaults.llm = {};
if (config.agents.defaults.llm.idleTimeoutSeconds === undefined || if (config.agents.defaults.llm.idleTimeoutSeconds === undefined ||
config.agents.defaults.llm.idleTimeoutSeconds > 0) { config.agents.defaults.llm.idleTimeoutSeconds > 0) {
config.agents.defaults.llm.idleTimeoutSeconds = 0; config.agents.defaults.llm.idleTimeoutSeconds = 0;
log(`Set agents.defaults.llm.idleTimeoutSeconds = 0 (prevents tool-call timeouts)`); log(`Set agents.defaults.llm.idleTimeoutSeconds = 0 (prevents tool-call timeouts)`);
} else { } else {
log(`idleTimeoutSeconds already configured: ${config.agents.defaults.llm.idleTimeoutSeconds}`); log(`idleTimeoutSeconds already configured: ${config.agents.defaults.llm.idleTimeoutSeconds}`);
} }
writeJSON(CONFIG_PATH, config); writeJSON(CONFIG_PATH, config);
log(`Config saved`); log(`Config saved`);
// ── Step 3: Patch auth-profiles.json ──────────────────────────────────── // ── Step 3: Patch auth-profiles.json ────────────────────────────────────
console.log("\n🔑 Configuring auth profiles...\n"); console.log("\n🔑 Configuring auth profiles...\n");
// Find all agent auth-profiles.json files // Find all agent auth-profiles.json files
const agentsDir = join(OPENCLAW_DIR, "agents"); const agentsDir = join(OPENCLAW_DIR, "agents");
const agentDirs = existsSync(agentsDir) const agentDirs = existsSync(agentsDir)
? readdirSync(agentsDir).filter((d) => { ? readdirSync(agentsDir).filter((d) => {
const ap = join(agentsDir, d, "agent", "auth-profiles.json"); const ap = join(agentsDir, d, "agent", "auth-profiles.json");
return existsSync(ap); return existsSync(ap);
}) })
: []; : [];
for (const agentId of agentDirs) { import { readdirSync } from "node:fs";
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 for (const agentId of agentDirs) {
if (!ap.profiles[`${PROVIDER_NAME}:default`]) { const apPath = join(agentsDir, agentId, "agent", "auth-profiles.json");
ap.profiles[`${PROVIDER_NAME}:default`] = { try {
type: "api_key", const ap = readJSON(apPath);
provider: PROVIDER_NAME, if (!ap.profiles) ap.profiles = {};
key: "local-proxy-no-auth",
};
}
// Add to lastGood if missing // Add claude-local profile if missing
if (!ap.lastGood) ap.lastGood = {}; if (!ap.profiles[`${PROVIDER_NAME}:default`]) {
if (!ap.lastGood[PROVIDER_NAME]) { ap.profiles[`${PROVIDER_NAME}:default`] = {
ap.lastGood[PROVIDER_NAME] = `${PROVIDER_NAME}:default`; type: "api_key",
} provider: PROVIDER_NAME,
key: "local-proxy-no-auth",
writeJSON(apPath, ap); };
log(`Agent "${agentId}" auth profile updated`);
} catch (e) {
warn(`Skipped agent "${agentId}": ${e.message}`);
} }
}
if (agentDirs.length === 0) { // Add to lastGood if missing
warn("No agent auth-profiles.json found — you may need to restart the gateway first"); 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 ───────────────────────────────────────────── // ── Step 4: Create start.sh ─────────────────────────────────────────────
console.log("\n🚀 Creating launcher...\n"); console.log("\n🚀 Creating launcher...\n");
@@ -244,53 +238,31 @@ if (!DRY_RUN) {
log(`Launcher: ${startPath}`); log(`Launcher: ${startPath}`);
// ── Step 5: Summary ───────────────────────────────────────────────────── // ── Step 5: Summary ─────────────────────────────────────────────────────
const banner = [ console.log(`
`╔══════════════════════════════════════════════════════════════╗`, ╔══════════════════════════════════════════════════════════════╗
`║ Setup complete! ║`, ║ Setup complete! ║
`╠══════════════════════════════════════════════════════════════╣`, ╠══════════════════════════════════════════════════════════════╣
`║ ║`, ║ ║
`║ Provider: ${PROVIDER_NAME.padEnd(44)}`, ║ Provider: ${PROVIDER_NAME.padEnd(44)}
`║ Port: ${String(PORT).padEnd(44)}`, ║ Port: ${String(PORT).padEnd(44)}
`║ Models: ${`see models.json (${MODELS.length} available)`.padEnd(44)}`, ║ Models: ${`see models.json (${MODELS.length} available)`.padEnd(44)}
`║ Default: ${DEFAULT_MODEL_ID.padEnd(44)}`, ║ Default: ${DEFAULT_MODEL_ID.padEnd(44)}
`║ ║`, ║ ║
`║ Start proxy: ║`, ║ Start proxy: ║
`║ bash ${startPath.replace(HOME, "~").padEnd(50)}`, ║ bash ${startPath.replace(HOME, "~").padEnd(50)}
`║ ║`, ║ ║
`║ Or directly: ║`, ║ Or directly: ║
`║ node ${serverPath.replace(HOME, "~").padEnd(49)}`, ║ node ${serverPath.replace(HOME, "~").padEnd(49)}
`║ ║`, ║ ║
]; ║ Set as default model in openclaw.json: ║
if (OPENCLAW_PRESENT) { ║ agents.defaults.model.primary = ║
banner.push( ║ "${PROVIDER_NAME}/${DEFAULT_MODEL_ID}"${" ".repeat(Math.max(0, 30 - PROVIDER_NAME.length - DEFAULT_MODEL_ID.length))}
`║ Set as default model in openclaw.json:`,
` agents.defaults.model.primary =`, Then restart gateway:
`║ "${PROVIDER_NAME}/${DEFAULT_MODEL_ID}"${" ".repeat(Math.max(0, 30 - PROVIDER_NAME.length - DEFAULT_MODEL_ID.length))}`, ║ openclaw gateway restart ║
`║ ║`, ║ ║
`║ 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 6: Optionally start ────────────────────────────────────────────
if (!SKIP_START && !DRY_RUN) {
try {
execSync(`bash "${startPath}"`, { stdio: "inherit" });
} catch { /* ignore */ }
}
// ── Step 7: Install auto-start on boot ────────────────────────────────── // ── Step 7: Install auto-start on boot ──────────────────────────────────
if (!DRY_RUN) { if (!DRY_RUN) {
@@ -426,4 +398,52 @@ WantedBy=default.target
} }
console.log("\n✅ Auto-start installed — proxy will start automatically on login\n"); 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);
}
}
} }