diff --git a/package.json b/package.json index 37c6ffd..0592e72 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openclaw-claude-proxy", - "version": "2.4.0", + "version": "2.4.1", "description": "OpenAI-compatible proxy for Claude CLI v2 — per-model circuit breaker, adaptive first-byte timeout, structured logging", "type": "module", "bin": { diff --git a/server.mjs b/server.mjs index 88339fe..fb87387 100644 --- a/server.mjs +++ b/server.mjs @@ -28,7 +28,7 @@ */ import { createServer } from "node:http"; import { spawn, execFileSync } from "node:child_process"; -import { randomUUID } from "node:crypto"; +import { randomUUID, timingSafeEqual } from "node:crypto"; import { readFileSync, accessSync, constants } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; @@ -171,7 +171,7 @@ const MODELS = [ // Enables --resume for multi-turn conversations, reducing token waste. const sessions = new Map(); // conversationId → { uuid, messageCount, lastUsed, model } -setInterval(() => { +const sessionCleanupInterval = setInterval(() => { const now = Date.now(); for (const [id, s] of sessions) { if (now - s.lastUsed > SESSION_TTL) { @@ -181,6 +181,9 @@ setInterval(() => { } }, 60000); +// ── Active child process tracking ──────────────────────────────────────── +const activeProcesses = new Set(); + // ── Stats & diagnostics ───────────────────────────────────────────────── const stats = { totalRequests: 0, @@ -220,7 +223,7 @@ async function checkAuth() { // Check auth on start and every 10 minutes checkAuth(); -setInterval(checkAuth, 600000); +const authCheckInterval = setInterval(checkAuth, 600000); // ── Build CLI arguments ───────────────────────────────────────────────── function buildCliArgs(cliModel, sessionInfo) { @@ -285,162 +288,284 @@ function computeFirstByteTimeout(cliModel, promptLength) { return Math.min(timeout, Math.max(TIMEOUT - 5000, 10000)); } -// ── Call claude CLI ───────────────────────────────────────────────────── -// On-demand spawning: each request spawns a fresh `claude -p` process. -// No pool = no crash loops, no stale workers, no degraded states. -// Stdin is written immediately so there's no 3s stdin timeout issue. -function callClaude(model, messages, conversationId) { - return new Promise((resolve, reject) => { - if (stats.activeRequests >= MAX_CONCURRENT) { - return reject(new Error(`concurrency limit reached (${stats.activeRequests}/${MAX_CONCURRENT})`)); +// ── Spawn claude CLI (shared setup) ───────────────────────────────────── +// Resolves session logic, builds CLI args, spawns the process, and sets up +// timeouts. Returns context object or throws synchronously. +function spawnClaudeProcess(model, messages, conversationId) { + if (stats.activeRequests >= MAX_CONCURRENT) { + throw new Error(`concurrency limit reached (${stats.activeRequests}/${MAX_CONCURRENT})`); + } + + const cliModel = MODEL_MAP[model] || model; + + // Circuit breaker check: fail fast if model is in open state + const breaker = getBreakerState(cliModel); + if (breaker.state === "open") { + const remainingMs = BREAKER_COOLDOWN - (Date.now() - breaker.openedAt); + logEvent("warn", "breaker_rejected", { model: cliModel, remainingCooldownMs: remainingMs }); + throw new Error(`circuit breaker open for ${cliModel}: ${breaker.failures} consecutive timeouts, retry in ${Math.ceil(remainingMs / 1000)}s`); + } + + stats.activeRequests++; + stats.totalRequests++; + + let sessionInfo = null; + let prompt; + + // ── Session logic ── + if (conversationId && sessions.has(conversationId)) { + const session = sessions.get(conversationId); + session.lastUsed = Date.now(); + sessionInfo = { uuid: session.uuid, resume: true }; + stats.sessionHits++; + + const lastUserMsg = [...messages].reverse().find((m) => m.role === "user"); + prompt = lastUserMsg + ? (typeof lastUserMsg.content === "string" ? lastUserMsg.content : JSON.stringify(lastUserMsg.content)) + : ""; + session.messageCount = messages.length; + + console.log(`[session] resume conv=${conversationId.slice(0, 12)}... uuid=${session.uuid.slice(0, 8)}... msgs=${messages.length} prompt_chars=${prompt.length}`); + + } else if (conversationId) { + const uuid = randomUUID(); + sessions.set(conversationId, { uuid, messageCount: messages.length, lastUsed: Date.now(), model: cliModel }); + sessionInfo = { uuid, resume: false }; + stats.sessionMisses++; + prompt = messagesToPrompt(messages); + + console.log(`[session] new conv=${conversationId.slice(0, 12)}... uuid=${uuid.slice(0, 8)}... msgs=${messages.length}`); + + } else { + stats.oneOffRequests++; + prompt = messagesToPrompt(messages); + } + + const cliArgs = buildCliArgs(cliModel, sessionInfo); + + const env = { ...process.env }; + delete env.CLAUDECODE; + delete env.ANTHROPIC_API_KEY; + delete env.ANTHROPIC_BASE_URL; + delete env.ANTHROPIC_AUTH_TOKEN; + + const proc = spawn(CLAUDE, cliArgs, { env, stdio: ["pipe", "pipe", "pipe"] }); + activeProcesses.add(proc); + + const t0 = Date.now(); + const firstByteTimeoutMs = computeFirstByteTimeout(cliModel, prompt.length); + let gotFirstByte = false; + let cleaned = false; + + function cleanup() { + if (cleaned) return; + cleaned = true; + clearTimeout(overallTimer); + clearTimeout(firstByteTimer); + stats.activeRequests--; + } + + function handleSessionFailure() { + if (sessionInfo?.resume && conversationId) { + console.warn(`[session] resume failed for ${conversationId.slice(0, 12)}..., removing stale session`); + sessions.delete(conversationId); } + } - const cliModel = MODEL_MAP[model] || model; - - // Circuit breaker check: fail fast if model is in open state - const breaker = getBreakerState(cliModel); - if (breaker.state === "open") { - const remainingMs = BREAKER_COOLDOWN - (Date.now() - breaker.openedAt); - logEvent("warn", "breaker_rejected", { model: cliModel, remainingCooldownMs: remainingMs }); - return reject(new Error(`circuit breaker open for ${cliModel}: ${breaker.failures} consecutive timeouts, retry in ${Math.ceil(remainingMs / 1000)}s`)); - } - - stats.activeRequests++; - stats.totalRequests++; - - let sessionInfo = null; - let prompt; - - // ── Session logic ── - if (conversationId && sessions.has(conversationId)) { - // Resume existing session: only send the latest user message - const session = sessions.get(conversationId); - session.lastUsed = Date.now(); - sessionInfo = { uuid: session.uuid, resume: true }; - stats.sessionHits++; - - const lastUserMsg = [...messages].reverse().find((m) => m.role === "user"); - prompt = lastUserMsg - ? (typeof lastUserMsg.content === "string" ? lastUserMsg.content : JSON.stringify(lastUserMsg.content)) - : ""; - session.messageCount = messages.length; - - console.log(`[session] resume conv=${conversationId.slice(0, 12)}... uuid=${session.uuid.slice(0, 8)}... msgs=${messages.length} prompt_chars=${prompt.length}`); - - } else if (conversationId) { - // New session: send all messages, persist session for future --resume - const uuid = randomUUID(); - sessions.set(conversationId, { uuid, messageCount: messages.length, lastUsed: Date.now(), model: cliModel }); - sessionInfo = { uuid, resume: false }; - stats.sessionMisses++; - prompt = messagesToPrompt(messages); - - console.log(`[session] new conv=${conversationId.slice(0, 12)}... uuid=${uuid.slice(0, 8)}... msgs=${messages.length}`); - - } else { - // One-off request, no session - stats.oneOffRequests++; - prompt = messagesToPrompt(messages); - } - - const cliArgs = buildCliArgs(cliModel, sessionInfo); - - const env = { ...process.env }; - delete env.CLAUDECODE; - delete env.ANTHROPIC_API_KEY; - delete env.ANTHROPIC_BASE_URL; - delete env.ANTHROPIC_AUTH_TOKEN; - - const proc = spawn(CLAUDE, cliArgs, { env, stdio: ["pipe", "pipe", "pipe"] }); - - let stdout = ""; - let stderr = ""; - const t0 = Date.now(); - const firstByteTimeoutMs = computeFirstByteTimeout(cliModel, prompt.length); - let settled = false; - let gotFirstByte = false; - - function settle(err, result) { - if (settled) return; - settled = true; - clearTimeout(timer); + function markFirstByte() { + if (!gotFirstByte) { + gotFirstByte = true; clearTimeout(firstByteTimer); - stats.activeRequests--; - - if (err) { - trackError(err.message || String(err)); - - // If session resume failed, remove session so next request starts fresh - if (sessionInfo?.resume && conversationId) { - console.warn(`[session] resume failed for ${conversationId.slice(0, 12)}..., removing stale session`); - sessions.delete(conversationId); - } - - reject(err); - } else { - resolve(result); - } + console.log(`[claude] first-byte model=${cliModel} elapsed=${Date.now() - t0}ms`); } + } - proc.stdout.on("data", (d) => { - if (!gotFirstByte) { - gotFirstByte = true; - clearTimeout(firstByteTimer); - console.log(`[claude] first-byte model=${cliModel} elapsed=${Date.now() - t0}ms`); - } - stdout += d; - }); - proc.stderr.on("data", (d) => (stderr += d)); + // Write prompt to stdin immediately + proc.stdin.write(prompt); + proc.stdin.end(); - proc.on("close", (code, signal) => { - const elapsed = Date.now() - t0; - if (settled) { - logEvent("warn", "late_close", { model: cliModel, code, signal: signal || "none", elapsed }); - return; - } - if (code !== 0) { - logEvent("error", "claude_exit", { model: cliModel, code, signal: signal || "none", elapsed, stderr: stderr.slice(0, 300) }); - settle(new Error(stderr.slice(0, 300) || stdout.slice(0, 300) || `claude exit ${code}`)); - } else { - breakerRecordSuccess(cliModel); - logEvent("info", "claude_ok", { model: cliModel, chars: stdout.length, elapsed, session: conversationId ? conversationId.slice(0, 12) + "..." : "none" }); - settle(null, stdout.trim()); - } - }); + logEvent("info", "claude_spawned", { model: cliModel, promptChars: prompt.length, firstByteTimeout: firstByteTimeoutMs, tier: getModelTier(cliModel), session: conversationId ? conversationId.slice(0, 12) + "..." : "none" }); - proc.on("error", (err) => { - console.error(`[claude] spawn error: ${err.message}`); - settle(err); - }); + // First-byte timeout + const firstByteTimer = setTimeout(() => { + if (!gotFirstByte && !cleaned) { + stats.timeouts++; + breakerRecordTimeout(cliModel); + logEvent("error", "first_byte_timeout", { model: cliModel, timeoutMs: firstByteTimeoutMs, promptChars: prompt.length }); + try { proc.kill("SIGTERM"); } catch {} + setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 5000); + } + }, firstByteTimeoutMs); - // Write prompt to stdin immediately — no idle timeout issue - proc.stdin.write(prompt); - proc.stdin.end(); - - logEvent("info", "claude_spawned", { model: cliModel, promptChars: prompt.length, firstByteTimeout: firstByteTimeoutMs, tier: getModelTier(cliModel), session: conversationId ? conversationId.slice(0, 12) + "..." : "none" }); - - // First-byte timeout: abort early if Claude CLI produces no output - const firstByteTimer = setTimeout(() => { - if (!gotFirstByte && !settled) { - stats.timeouts++; - breakerRecordTimeout(cliModel); - logEvent("error", "first_byte_timeout", { model: cliModel, timeoutMs: firstByteTimeoutMs, promptChars: prompt.length }); - try { proc.kill("SIGTERM"); } catch {} - setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 5000); - settle(new Error(`first-byte timeout after ${firstByteTimeoutMs}ms`)); - } - }, firstByteTimeoutMs); - - // Overall request timeout with graceful kill - const timer = setTimeout(() => { - if (settled) return; + // Overall request timeout + const overallTimer = setTimeout(() => { + if (!cleaned) { stats.timeouts++; breakerRecordTimeout(cliModel); logEvent("error", "request_timeout", { model: cliModel, timeoutMs: TIMEOUT }); try { proc.kill("SIGTERM"); } catch {} setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 5000); - settle(new Error(`timeout after ${TIMEOUT}ms`)); - }, TIMEOUT); + } + }, TIMEOUT); + + return { proc, cliModel, conversationId, t0, cleanup, handleSessionFailure, markFirstByte }; +} + +// ── Call claude CLI (non-streaming) ───────────────────────────────────── +// On-demand spawning: each request spawns a fresh `claude -p` process. +// No pool = no crash loops, no stale workers, no degraded states. +// Stdin is written immediately so there's no 3s stdin timeout issue. +function callClaude(model, messages, conversationId) { + return new Promise((resolve, reject) => { + let ctx; + try { + ctx = spawnClaudeProcess(model, messages, conversationId); + } catch (err) { + return reject(err); + } + + const { proc, cliModel, conversationId: convId, t0, cleanup, handleSessionFailure, markFirstByte } = ctx; + let stdout = ""; + let stderr = ""; + + proc.stdout.on("data", (d) => { + markFirstByte(); + stdout += d; + }); + proc.stderr.on("data", (d) => (stderr += d)); + + proc.on("close", (code, signal) => { + activeProcesses.delete(proc); + const elapsed = Date.now() - t0; + cleanup(); + if (code !== 0) { + logEvent("error", "claude_exit", { model: cliModel, code, signal: signal || "none", elapsed, stderr: stderr.slice(0, 300) }); + trackError(stderr.slice(0, 300) || stdout.slice(0, 300) || `claude exit ${code}`); + handleSessionFailure(); + reject(new Error(stderr.slice(0, 300) || stdout.slice(0, 300) || `claude exit ${code}`)); + } else { + breakerRecordSuccess(cliModel); + logEvent("info", "claude_ok", { model: cliModel, chars: stdout.length, elapsed, session: convId ? convId.slice(0, 12) + "..." : "none" }); + resolve(stdout.trim()); + } + }); + + proc.on("error", (err) => { + console.error(`[claude] spawn error: ${err.message}`); + cleanup(); + trackError(err.message); + handleSessionFailure(); + reject(err); + }); + }); +} + +// ── Call claude CLI (real streaming) ───────────────────────────────────── +// Pipes stdout from the claude process directly to SSE chunks as they arrive. +// Each data chunk becomes a proper SSE event with delta content in real time. +function callClaudeStreaming(model, messages, conversationId, res) { + const id = `chatcmpl-${randomUUID()}`; + const created = Math.floor(Date.now() / 1000); + + let ctx; + try { + ctx = spawnClaudeProcess(model, messages, conversationId); + } catch (err) { + return jsonResponse(res, 500, { error: { message: err.message, type: "proxy_error" } }); + } + + const { proc, cliModel, conversationId: convId, t0, cleanup, handleSessionFailure, markFirstByte } = ctx; + let stderr = ""; + let headersSent = false; + let totalChars = 0; + + function ensureHeaders() { + if (headersSent || res.writableEnded || res.destroyed) return false; + headersSent = true; + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }); + // Send initial role chunk + sendSSE(res, { + id, object: "chat.completion.chunk", created, model, + choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }], + }); + return true; + } + + proc.stdout.on("data", (d) => { + markFirstByte(); + const text = d.toString(); + totalChars += text.length; + + if (!ensureHeaders()) return; + + // Stream each chunk as it arrives from the CLI process + sendSSE(res, { + id, object: "chat.completion.chunk", created, model, + choices: [{ index: 0, delta: { content: text }, finish_reason: null }], + }); + }); + + proc.stderr.on("data", (d) => (stderr += d)); + + proc.on("close", (code, signal) => { + activeProcesses.delete(proc); + cleanup(); + const elapsed = Date.now() - t0; + + if (code !== 0) { + logEvent("error", "claude_exit", { model: cliModel, code, signal: signal || "none", elapsed, stderr: stderr.slice(0, 300) }); + trackError(stderr.slice(0, 300) || `claude exit ${code}`); + handleSessionFailure(); + + if (!headersSent && !res.writableEnded && !res.destroyed) { + // No output was sent yet — return a JSON error + jsonResponse(res, 500, { error: { message: stderr.slice(0, 300) || `claude exit ${code}`, type: "proxy_error" } }); + } else if (!res.writableEnded && !res.destroyed) { + // Already streaming — close the stream gracefully + sendSSE(res, { + id, object: "chat.completion.chunk", created, model, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }); + res.write("data: [DONE]\n\n"); + res.end(); + } + } else { + breakerRecordSuccess(cliModel); + logEvent("info", "claude_ok", { model: cliModel, chars: totalChars, elapsed, session: convId ? convId.slice(0, 12) + "..." : "none" }); + + if (!headersSent) ensureHeaders(); + if (!res.writableEnded && !res.destroyed) { + sendSSE(res, { + id, object: "chat.completion.chunk", created, model, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }); + res.write("data: [DONE]\n\n"); + res.end(); + } + } + }); + + proc.on("error", (err) => { + console.error(`[claude] spawn error: ${err.message}`); + cleanup(); + trackError(err.message); + handleSessionFailure(); + if (!headersSent && !res.writableEnded && !res.destroyed) { + jsonResponse(res, 500, { error: { message: err.message, type: "proxy_error" } }); + } else if (!res.writableEnded && !res.destroyed) { + res.end(); + } + }); + + // If client disconnects, kill the process to free resources + res.on("close", () => { + if (!proc.killed) { + try { proc.kill("SIGTERM"); } catch {} + } }); } @@ -455,32 +580,6 @@ function sendSSE(res, data) { res.write(`data: ${JSON.stringify(data)}\n\n`); } -function streamResponse(res, id, model, content) { - if (res.headersSent || res.writableEnded || res.destroyed) return; - res.writeHead(200, { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - "Connection": "keep-alive", - }); - const created = Math.floor(Date.now() / 1000); - sendSSE(res, { - id, object: "chat.completion.chunk", created, model, - choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }], - }); - for (let i = 0; i < content.length; i += 500) { - sendSSE(res, { - id, object: "chat.completion.chunk", created, model, - choices: [{ index: 0, delta: { content: content.slice(i, i + 500) }, finish_reason: null }], - }); - } - sendSSE(res, { - id, object: "chat.completion.chunk", created, model, - choices: [{ index: 0, delta: {}, finish_reason: "stop" }], - }); - res.write("data: [DONE]\n\n"); - res.end(); -} - function completionResponse(res, id, model, content) { jsonResponse(res, 200, { id, object: "chat.completion", @@ -492,9 +591,19 @@ function completionResponse(res, id, model, content) { } // ── Handle chat completions ───────────────────────────────────────────── +const MAX_BODY_SIZE = 5 * 1024 * 1024; // 5 MB + +// Set of all valid model identifiers (canonical IDs + aliases) +const VALID_MODELS = new Set(Object.keys(MODEL_MAP)); + async function handleChatCompletions(req, res) { let body = ""; - for await (const chunk of req) body += chunk; + for await (const chunk of req) { + body += chunk; + if (body.length > MAX_BODY_SIZE) { + return jsonResponse(res, 413, { error: { message: "Request body too large (max 5MB)", type: "invalid_request_error" } }); + } + } let parsed; try { parsed = JSON.parse(body); } catch { return jsonResponse(res, 400, { error: "Invalid JSON" }); } @@ -503,33 +612,43 @@ async function handleChatCompletions(req, res) { const model = parsed.model || "claude-sonnet-4-6"; const stream = parsed.stream; + // Validate model against known models + if (!VALID_MODELS.has(model)) { + return jsonResponse(res, 400, { error: { message: `Unknown model: ${model}. Valid models: ${[...VALID_MODELS].join(", ")}`, type: "invalid_request_error" } }); + } + // Session ID: from request body, header, or null (one-off) const conversationId = parsed.session_id || parsed.conversation_id || req.headers["x-session-id"] || req.headers["x-conversation-id"] || null; if (!messages?.length) return jsonResponse(res, 400, { error: "messages required" }); + if (stream) { + // Real streaming: pipe stdout from claude process directly as SSE chunks + return callClaudeStreaming(model, messages, conversationId, res); + } + try { const content = await callClaude(model, messages, conversationId); const id = `chatcmpl-${randomUUID()}`; - - if (stream) { - streamResponse(res, id, model, content); - } else { - completionResponse(res, id, model, content); - } + completionResponse(res, id, model, content); } catch (err) { console.error(`[proxy] error: ${err.message}`); if (res.headersSent || res.writableEnded || res.destroyed) { try { res.end(); } catch {} return; } - jsonResponse(res, 500, { error: { message: err.message, type: "proxy_error" } }); + // Sanitize error: strip internal file paths before sending to client + const safeMessage = (err.message || "Internal error").replace(/\/[\w/.\-]+/g, "[path]"); + jsonResponse(res, 500, { error: { message: safeMessage, type: "proxy_error" } }); } } // ── HTTP server ───────────────────────────────────────────────────────── const server = createServer(async (req, res) => { - res.setHeader("Access-Control-Allow-Origin", "*"); + // Dynamic CORS: only allow localhost origins + const origin = req.headers["origin"] || ""; + const isLocalhost = /^https?:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/.test(origin); + res.setHeader("Access-Control-Allow-Origin", isLocalhost ? origin : `http://127.0.0.1:${PORT}`); res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"); res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Session-Id, X-Conversation-Id"); if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; } @@ -538,7 +657,9 @@ const server = createServer(async (req, res) => { if (PROXY_API_KEY && req.url !== "/health") { const auth = req.headers["authorization"] || ""; const token = auth.startsWith("Bearer ") ? auth.slice(7) : ""; - if (token !== PROXY_API_KEY) { + const tokenBuf = Buffer.from(token); + const keyBuf = Buffer.from(PROXY_API_KEY); + if (tokenBuf.length !== keyBuf.length || !timingSafeEqual(tokenBuf, keyBuf)) { return jsonResponse(res, 401, { error: { message: "Unauthorized: invalid or missing Bearer token", type: "auth_error" } }); } } @@ -615,17 +736,65 @@ const server = createServer(async (req, res) => { return jsonResponse(res, 200, { sessions: list }); } - // Catch-all POST - if (req.method === "POST") { - return handleChatCompletions(req, res); - } - jsonResponse(res, 404, { error: "Not found. Endpoints: GET /v1/models, POST /v1/chat/completions, GET /health, GET|DELETE /sessions" }); }); + +// ── Graceful shutdown ──────────────────────────────────────────────────── +let shuttingDown = false; + +function gracefulShutdown(signal) { + if (shuttingDown) return; + shuttingDown = true; + logEvent("info", "shutdown_start", { signal }); + + // 1. Stop accepting new connections + server.close(() => { + logEvent("info", "shutdown_server_closed", {}); + }); + + // 2. Clear intervals/timers + clearInterval(sessionCleanupInterval); + clearInterval(authCheckInterval); + + // 3. Kill all active child processes + for (const proc of activeProcesses) { + try { proc.kill("SIGTERM"); } catch {} + } + + // Force-kill any remaining processes after 5s, then exit + const forceExitTimer = setTimeout(() => { + for (const proc of activeProcesses) { + try { proc.kill("SIGKILL"); } catch {} + } + logEvent("warn", "shutdown_forced", { remainingProcesses: activeProcesses.size }); + process.exit(1); + }, 5000); + forceExitTimer.unref(); + + // If no active processes, exit immediately + if (activeProcesses.size === 0) { + logEvent("info", "shutdown_complete", {}); + process.exit(0); + } + + // Wait for active processes to finish + const checkDone = setInterval(() => { + if (activeProcesses.size === 0) { + clearInterval(checkDone); + logEvent("info", "shutdown_complete", {}); + process.exit(0); + } + }, 200); + checkDone.unref(); +} + +process.on("SIGTERM", () => gracefulShutdown("SIGTERM")); +process.on("SIGINT", () => gracefulShutdown("SIGINT")); + // ── Start ─────────────────────────────────────────────────────────────── -server.listen(PORT, "0.0.0.0", () => { - console.log(`openclaw-claude-proxy v${VERSION} listening on http://0.0.0.0:${PORT}`); +server.listen(PORT, "127.0.0.1", () => { + console.log(`openclaw-claude-proxy v${VERSION} listening on http://127.0.0.1:${PORT}`); console.log(`Architecture: on-demand spawning (no pool)`); console.log(`Models: ${MODELS.map((m) => m.id).join(", ")}`); console.log(`Claude binary: ${CLAUDE}`);