mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-22 05:25:08 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c06b1c7bc7 |
@@ -3,11 +3,13 @@
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { randomBytes, createHash } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { mkdirSync, chmodSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
const OCP_DIR = join(homedir(), ".ocp");
|
||||
mkdirSync(OCP_DIR, { recursive: true });
|
||||
mkdirSync(OCP_DIR, { recursive: true, mode: 0o700 });
|
||||
// Tighten the directory mode in case it already existed with broader permissions.
|
||||
try { chmodSync(OCP_DIR, 0o700); } catch { /* ignore EPERM on pre-existing dirs */ }
|
||||
const DB_PATH = join(OCP_DIR, "ocp.db");
|
||||
|
||||
let db;
|
||||
@@ -18,6 +20,8 @@ export function getDb() {
|
||||
db.exec("PRAGMA journal_mode = WAL");
|
||||
db.exec("PRAGMA foreign_keys = ON");
|
||||
initSchema();
|
||||
// Tighten mode on the DB file (0600) after creation / first open.
|
||||
try { chmodSync(DB_PATH, 0o600); } catch { /* ignore — same-user access still works */ }
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
+61
-41
@@ -30,7 +30,7 @@
|
||||
import { createServer } from "node:http";
|
||||
import { spawn, execFileSync } from "node:child_process";
|
||||
import { randomUUID, timingSafeEqual } from "node:crypto";
|
||||
import { readFileSync, readdirSync, accessSync, existsSync, constants } from "node:fs";
|
||||
import { readFileSync, readdirSync, accessSync, existsSync, constants, chmodSync, statSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
@@ -167,6 +167,44 @@ function logEvent(level, event, data = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Startup file-mode reconciliation ───────────────────────────────────
|
||||
// Idempotently tightens OCP credential-bearing files to 700/600 so that
|
||||
// existing installs (created before this fix) are hardened on next restart.
|
||||
// Wrapped in try/catch — chmod failure must never crash startup.
|
||||
// Does NOT touch systemd units or launchd plists; those are managed by setup.mjs.
|
||||
function _tightenFileModesIfPossible() {
|
||||
const ocpDir = join(homedir(), ".ocp");
|
||||
const targets = [
|
||||
{ path: ocpDir, mode: 0o700, label: "~/.ocp (dir)" },
|
||||
{ path: join(ocpDir, "admin-key"), mode: 0o600, label: "~/.ocp/admin-key" },
|
||||
{ path: join(ocpDir, "ocp.db"), mode: 0o600, label: "~/.ocp/ocp.db" },
|
||||
];
|
||||
let tightened = 0;
|
||||
let alreadyOk = 0;
|
||||
for (const { path, mode, label } of targets) {
|
||||
try {
|
||||
const st = statSync(path);
|
||||
const current = st.mode & 0o777;
|
||||
if (current !== mode) {
|
||||
chmodSync(path, mode);
|
||||
tightened++;
|
||||
} else {
|
||||
alreadyOk++;
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.code !== "ENOENT") {
|
||||
// File exists but chmod failed (e.g. EPERM) — log and move on
|
||||
logEvent("warn", "file_mode_tighten_failed", { path: label, error: e.message });
|
||||
}
|
||||
// ENOENT is fine — file doesn't exist yet
|
||||
}
|
||||
}
|
||||
if (tightened > 0) {
|
||||
logEvent("info", "file_modes_tightened", { tightened, alreadyOk });
|
||||
}
|
||||
}
|
||||
_tightenFileModesIfPossible();
|
||||
|
||||
// ── Circuit breaker (DISABLED) ──────────────────────────────────────────
|
||||
// Disabled: CLI proxy has its own retry logic, and the breaker was causing
|
||||
// cascading failures — once API got briefly slow, ALL agents lost connectivity
|
||||
@@ -202,36 +240,25 @@ const MODEL_MAP = Object.fromEntries([
|
||||
const MODELS = modelsConfig.models.map(m => ({ id: m.id, name: m.displayName }));
|
||||
|
||||
// ── Session management ──────────────────────────────────────────────────
|
||||
// Maps namespaced session keys to Claude CLI session UUIDs.
|
||||
// Key format: "${keyName}|${conversationId}" — prevents cross-key collision
|
||||
// when two callers (different API keys or anon + authenticated) use the same
|
||||
// session_id string. Anonymous callers use "anon"; admin uses "admin".
|
||||
// Maps conversation IDs (from caller) to Claude CLI session UUIDs.
|
||||
// Enables --resume for multi-turn conversations, reducing token waste.
|
||||
const sessions = new Map(); // `${keyName}|${conversationId}` → { uuid, messageCount, lastUsed, model }
|
||||
|
||||
// Build the namespaced key used for all sessions Map operations.
|
||||
// Returns null when conversationId is falsy (one-off requests bypass session tracking).
|
||||
function _sessionKey(conversationId, keyName) {
|
||||
return conversationId ? `${keyName || "anon"}|${conversationId}` : null;
|
||||
}
|
||||
const sessions = new Map(); // conversationId → { uuid, messageCount, lastUsed, model }
|
||||
|
||||
const sessionCleanupInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [id, s] of sessions) {
|
||||
const idleMs = now - s.lastUsed;
|
||||
const ageMs = s.firstSeen ? now - s.firstSeen : null;
|
||||
// id is "${keyName}|${conversationId}"; strip prefix for log output
|
||||
const convIdShort = id.includes("|") ? id.slice(id.indexOf("|") + 1, id.indexOf("|") + 13) : id.slice(0, 12);
|
||||
if (idleMs > SESSION_TTL) {
|
||||
sessions.delete(id);
|
||||
console.log(`[session] expired ${convIdShort}... (idle ${Math.round(idleMs / 60000)}m)`);
|
||||
logEvent("info", "session_expired", { conversationId: convIdShort + "...", idleMs, ageMs });
|
||||
console.log(`[session] expired ${id.slice(0, 12)}... (idle ${Math.round(idleMs / 60000)}m)`);
|
||||
logEvent("info", "session_expired", { conversationId: id.slice(0, 12) + "...", idleMs, ageMs });
|
||||
} else if (ageMs !== null && ageMs > 4 * SESSION_TTL) {
|
||||
// #42 evidence-gathering: a session whose firstSeen is more than 4× TTL old
|
||||
// but whose lastUsed keeps getting bumped (never idle long enough to expire)
|
||||
// is the suspected bug. Log without action so the pattern can be confirmed
|
||||
// in /logs. Do NOT enforce an absolute age cap here speculatively.
|
||||
logEvent("warn", "session_long_lived", { conversationId: convIdShort + "...", idleMs, ageMs });
|
||||
logEvent("warn", "session_long_lived", { conversationId: id.slice(0, 12) + "...", idleMs, ageMs });
|
||||
}
|
||||
}
|
||||
}, 60000);
|
||||
@@ -438,7 +465,7 @@ function getModelTier(cliModel) {
|
||||
// ── 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, keyName) {
|
||||
function spawnClaudeProcess(model, messages, conversationId) {
|
||||
if (stats.activeRequests >= MAX_CONCURRENT) {
|
||||
throw new Error(`concurrency limit reached (${stats.activeRequests}/${MAX_CONCURRENT})`);
|
||||
}
|
||||
@@ -454,11 +481,8 @@ function spawnClaudeProcess(model, messages, conversationId, keyName) {
|
||||
let prompt;
|
||||
|
||||
// ── Session logic ──
|
||||
// sessionKey namespaces the Map key by keyName to prevent cross-caller collision
|
||||
// when two callers with different API keys share the same conversationId string.
|
||||
const sessionKey = _sessionKey(conversationId, keyName);
|
||||
if (sessionKey && sessions.has(sessionKey)) {
|
||||
const session = sessions.get(sessionKey);
|
||||
if (conversationId && sessions.has(conversationId)) {
|
||||
const session = sessions.get(conversationId);
|
||||
session.lastUsed = Date.now();
|
||||
sessionInfo = { uuid: session.uuid, resume: true };
|
||||
stats.sessionHits++;
|
||||
@@ -469,17 +493,17 @@ function spawnClaudeProcess(model, messages, conversationId, keyName) {
|
||||
: "";
|
||||
session.messageCount = messages.length;
|
||||
|
||||
console.log(`[session] resume conv=${conversationId.slice(0, 12)}... key=${keyName || "anon"} uuid=${session.uuid.slice(0, 8)}... msgs=${messages.length} prompt_chars=${prompt.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 (sessionKey) {
|
||||
} else if (conversationId) {
|
||||
const uuid = randomUUID();
|
||||
const now = Date.now();
|
||||
sessions.set(sessionKey, { uuid, messageCount: messages.length, firstSeen: now, lastUsed: now, model: cliModel });
|
||||
sessions.set(conversationId, { uuid, messageCount: messages.length, firstSeen: now, lastUsed: now, model: cliModel });
|
||||
sessionInfo = { uuid, resume: false };
|
||||
stats.sessionMisses++;
|
||||
prompt = messagesToPrompt(messages);
|
||||
|
||||
console.log(`[session] new conv=${conversationId.slice(0, 12)}... key=${keyName || "anon"} uuid=${uuid.slice(0, 8)}... msgs=${messages.length}`);
|
||||
console.log(`[session] new conv=${conversationId.slice(0, 12)}... uuid=${uuid.slice(0, 8)}... msgs=${messages.length}`);
|
||||
|
||||
} else {
|
||||
stats.oneOffRequests++;
|
||||
@@ -524,11 +548,11 @@ function spawnClaudeProcess(model, messages, conversationId, keyName) {
|
||||
proc.once("exit", cleanup);
|
||||
|
||||
function handleSessionFailure() {
|
||||
if (sessionInfo?.resume && sessionKey) {
|
||||
if (sessionInfo?.resume && conversationId) {
|
||||
console.warn(`[session] resume failed for ${conversationId.slice(0, 12)}..., removing stale session`);
|
||||
logEvent("warn", "session_failure", { mode: "resume", conversationId: conversationId.slice(0, 12) + "...", action: "deleted" });
|
||||
sessions.delete(sessionKey);
|
||||
} else if (sessionInfo && !sessionInfo.resume && sessionKey) {
|
||||
sessions.delete(conversationId);
|
||||
} else if (sessionInfo && !sessionInfo.resume && conversationId) {
|
||||
// #41 evidence-gathering: session-create failures currently leave a stale entry
|
||||
// in the sessions map. Log without action so the staleness pattern can be
|
||||
// confirmed in /logs before any code change. Do NOT delete here speculatively.
|
||||
@@ -571,11 +595,11 @@ function spawnClaudeProcess(model, messages, conversationId, keyName) {
|
||||
// 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, keyName) {
|
||||
function callClaude(model, messages, conversationId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let ctx;
|
||||
try {
|
||||
ctx = spawnClaudeProcess(model, messages, conversationId, keyName);
|
||||
ctx = spawnClaudeProcess(model, messages, conversationId);
|
||||
} catch (err) {
|
||||
return reject(err);
|
||||
}
|
||||
@@ -655,7 +679,7 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
|
||||
|
||||
let ctx;
|
||||
try {
|
||||
ctx = spawnClaudeProcess(model, messages, conversationId, authInfo.keyName);
|
||||
ctx = spawnClaudeProcess(model, messages, conversationId);
|
||||
} catch (err) {
|
||||
return jsonResponse(res, 500, { error: { message: err.message, type: "proxy_error" } });
|
||||
}
|
||||
@@ -1338,7 +1362,7 @@ async function handleChatCompletions(req, res) {
|
||||
// will re-read the freshly-populated cache entry here rather than spawning.
|
||||
const recheck = getCachedResponse(req._cacheHash, CACHE_TTL);
|
||||
if (recheck) return recheck.response;
|
||||
const c = await callClaude(model, messages, conversationId, req._authKeyName);
|
||||
const c = await callClaude(model, messages, conversationId);
|
||||
try { setCachedResponse(req._cacheHash, model, c); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); }
|
||||
return c;
|
||||
});
|
||||
@@ -1360,7 +1384,7 @@ async function handleChatCompletions(req, res) {
|
||||
|
||||
// Fallback: cache disabled (CACHE_TTL=0) or no _cacheHash — original path untouched.
|
||||
try {
|
||||
const content = await callClaude(model, messages, conversationId, req._authKeyName);
|
||||
const content = await callClaude(model, messages, conversationId);
|
||||
const id = `chatcmpl-${randomUUID()}`;
|
||||
completionResponse(res, id, model, content);
|
||||
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars, responseChars: content.length, elapsedMs: Date.now() - t0Usage, success: true }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
|
||||
@@ -1494,10 +1518,8 @@ const server = createServer(async (req, res) => {
|
||||
const uptimeMs = Date.now() - START_TIME;
|
||||
const sessionList = [];
|
||||
for (const [id, s] of sessions) {
|
||||
// id is "${keyName}|${conversationId}"; expose only the public-facing conversationId
|
||||
const convId = id.includes("|") ? id.slice(id.indexOf("|") + 1) : id;
|
||||
sessionList.push({
|
||||
id: convId.slice(0, 12) + "...",
|
||||
id: id.slice(0, 12) + "...",
|
||||
model: s.model,
|
||||
messages: s.messageCount,
|
||||
idleMs: Date.now() - s.lastUsed,
|
||||
@@ -1542,9 +1564,7 @@ const server = createServer(async (req, res) => {
|
||||
if (req.url === "/sessions" && req.method === "GET") {
|
||||
const list = [];
|
||||
for (const [id, s] of sessions) {
|
||||
// id is "${keyName}|${conversationId}"; expose only the public-facing conversationId
|
||||
const convId = id.includes("|") ? id.slice(id.indexOf("|") + 1) : id;
|
||||
list.push({ id: convId, uuid: s.uuid, model: s.model, messages: s.messageCount, lastUsed: new Date(s.lastUsed).toISOString() });
|
||||
list.push({ id, uuid: s.uuid, model: s.model, messages: s.messageCount, lastUsed: new Date(s.lastUsed).toISOString() });
|
||||
}
|
||||
return jsonResponse(res, 200, { sessions: list });
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* 4. Creates start.sh for easy launch
|
||||
* 5. Optionally starts the proxy
|
||||
*/
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, readdirSync } from "node:fs";
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, readdirSync, chmodSync } from "node:fs";
|
||||
import { execSync } from "node:child_process";
|
||||
import { join, dirname } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
@@ -425,7 +425,8 @@ if (!DRY_RUN) {
|
||||
`;
|
||||
|
||||
writeFileSync(plistPath, plistXml);
|
||||
log(`Plist written: ${plistPath}`);
|
||||
chmodSync(plistPath, 0o600);
|
||||
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 */ }
|
||||
@@ -459,7 +460,8 @@ WantedBy=default.target
|
||||
`;
|
||||
|
||||
writeFileSync(servicePath, serviceUnit);
|
||||
log(`Service file written: ${servicePath}`);
|
||||
chmodSync(servicePath, 0o600);
|
||||
log(`Service file written: ${servicePath} (mode 600)`);
|
||||
|
||||
execSync(`systemctl --user daemon-reload`);
|
||||
execSync(`systemctl --user enable ocp-proxy`);
|
||||
|
||||
Reference in New Issue
Block a user