mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-22 05:25:08 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68ddbe17c5 |
@@ -1,31 +1,5 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## v3.14.0 — 2026-05-10
|
|
||||||
|
|
||||||
### Features (security hardening)
|
|
||||||
|
|
||||||
- **Per-key session isolation** (PR #86, S1) — the `sessions` Map in `server.mjs` is now keyed by `${keyName}|${conversationId}` instead of bare `conversationId`. Before this fix, two clients using distinct API keys but the same `session_id` value (e.g. both defaulting to `"default"`) would share the same `cli.js` subprocess and conversation history, creating a cross-tenant leak path. Post-fix each (key, session) pair is isolated end-to-end, extending the per-key cache isolation shipped in v3.13.0 D1 to the session layer.
|
|
||||||
- **On-disk credential file modes 0700/0600** (PR #87, S2) — `setup.mjs` now creates `~/.ocp` at mode 0700 and both `admin-key` and `ocp.db` at mode 0600. An idempotent `reconcileFileModes()` call in `server.mjs` startup tightens any existing installation to these modes automatically on every launch, so existing prod boxes fix themselves without manual `chmod`. Before this fix, all three files were created at the process's default umask (typically world-readable 0644 / 0755), leaving plaintext credentials readable by other local users.
|
|
||||||
- **`/api/usage` default scope = self; admin all-keys requires `?all=true`** (PR #88, S3) — the usage endpoint now applies a least-privilege default: anonymous callers receive only their own rows, non-admin authenticated callers receive only their own rows, and admin callers receive only their own rows unless they explicitly pass `?all=true`. When `?all=true` is used, an audit log line is emitted. Before this fix, any admin-token holder could silently enumerate usage data for every key on the server.
|
|
||||||
|
|
||||||
### Behavior changes
|
|
||||||
|
|
||||||
- **Breaking change for admin tooling**: `/api/usage` no longer returns all-keys data by default. Existing cron jobs, dashboards, or scripts that rely on the admin token seeing all-keys output must add `?all=true` to their request URL after upgrading to v3.14.0.
|
|
||||||
- **File mode reconcile at server startup** logs a one-line notice per path when mode is tightened (e.g. `[security] tightened ~/.ocp/ocp.db → 0600`). No action is required from the operator; the reconcile is idempotent and silent when modes are already correct.
|
|
||||||
- **`sessions` Map key is now `${keyName}|${conversationId}` internally.** No client-visible wire change — the `session_id` field in request/response is unchanged.
|
|
||||||
|
|
||||||
### Verification
|
|
||||||
|
|
||||||
- Stress-test pass: 11/11 phases including S1/S2/S3 security regression checks (Phase E, I, J). 35-minute sustained run, 60 calls, 0 errors, 0 timeouts. RSS dropped 51→47 MB across the window. Per-key cache isolation, singleflight, cache_control bypass, quota enforcement, file-mode reconcile, and scope guard against escalation all verified against running code.
|
|
||||||
|
|
||||||
### Governance
|
|
||||||
|
|
||||||
- All three PRs (#86, #87, #88) include the explicit `cli.js`-citation-not-applicable disclaimer (per PR #75 pattern) since they are OCP-internal access-control, session-state, and file-permission changes with no corresponding `cli.js` operation to cite.
|
|
||||||
|
|
||||||
### No new env vars / no public API surface change beyond the documented breaking change
|
|
||||||
|
|
||||||
This release adds no new env vars or endpoints. The only externally visible change is the `/api/usage` scope guard (breaking for admin all-keys consumers; see Behavior changes above).
|
|
||||||
|
|
||||||
## v3.13.0 — 2026-05-07
|
## v3.13.0 — 2026-05-07
|
||||||
|
|
||||||
### Features (cache layer hardening)
|
### Features (cache layer hardening)
|
||||||
|
|||||||
@@ -6,8 +6,6 @@
|
|||||||
|
|
||||||
*Open source from day one, used daily by my family, maintained on nights and weekends. If OCP saves you money too, you can [☕ buy me a coffee](https://buymeacoffee.com/dtzp555) — [full story below](#support-ocp).*
|
*Open source from day one, used daily by my family, maintained on nights and weekends. If OCP saves you money too, you can [☕ buy me a coffee](https://buymeacoffee.com/dtzp555) — [full story below](#support-ocp).*
|
||||||
|
|
||||||
*If OCP saves you a setup, a ⭐ helps other folks discover it. Issue reports are even more useful — that's the highest-quality feedback this project gets.*
|
|
||||||
|
|
||||||
OCP turns your Claude Pro/Max subscription into a standard OpenAI-compatible API on localhost. Any tool that speaks the OpenAI protocol can use it — no separate API key, no extra billing.
|
OCP turns your Claude Pro/Max subscription into a standard OpenAI-compatible API on localhost. Any tool that speaks the OpenAI protocol can use it — no separate API key, no extra billing.
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -187,8 +185,7 @@ The setup script will:
|
|||||||
1. Verify Claude CLI is installed and authenticated
|
1. Verify Claude CLI is installed and authenticated
|
||||||
2. Start the proxy on port 3456
|
2. Start the proxy on port 3456
|
||||||
3. Install auto-start (launchd on macOS, systemd on Linux)
|
3. Install auto-start (launchd on macOS, systemd on Linux)
|
||||||
|
4. Symlink `ocp` to `/usr/local/bin` for CLI access
|
||||||
After install the `ocp` CLI lives at `~/ocp/ocp`. To put it on your PATH, either symlink it manually (`ln -sf ~/ocp/ocp ~/.local/bin/ocp` if `~/.local/bin` is on your PATH, or `sudo ln -sf ~/ocp/ocp /usr/local/bin/ocp` for a system-wide symlink) or add an alias (`alias ocp=~/ocp/ocp`). Otherwise invoke it as `~/ocp/ocp <subcommand>`. The rest of this README assumes `ocp` is on your PATH.
|
|
||||||
|
|
||||||
**Single-machine use** — just set your IDE to use the proxy:
|
**Single-machine use** — just set your IDE to use the proxy:
|
||||||
```bash
|
```bash
|
||||||
@@ -392,23 +389,17 @@ ocp keys revoke son-ipad # Revoke a key
|
|||||||
| `shared` | `CLAUDE_AUTH_MODE=shared` + `PROXY_API_KEY=xxx` | Everyone shares one key |
|
| `shared` | `CLAUDE_AUTH_MODE=shared` + `PROXY_API_KEY=xxx` | Everyone shares one key |
|
||||||
| `multi` | `CLAUDE_AUTH_MODE=multi` + `OCP_ADMIN_KEY=xxx` | Per-person keys with usage tracking (recommended) |
|
| `multi` | `CLAUDE_AUTH_MODE=multi` + `OCP_ADMIN_KEY=xxx` | Per-person keys with usage tracking (recommended) |
|
||||||
|
|
||||||
> **Usage scope (v3.14.0+):** `/api/usage` returns the caller's own rows by default. Admin callers must pass `?all=true` to retrieve data for all keys; doing so emits an audit log line.
|
|
||||||
|
|
||||||
### Anonymous Access (optional)
|
### Anonymous Access (optional)
|
||||||
|
|
||||||
In `multi` mode, the admin can designate a single well-known "anonymous" key that bypasses `validateKey()` and grants public read/write access. This is useful for letting LAN users (or clients like OpenClaw multi-agent setups) connect without individual per-user keys.
|
In `multi` mode, the admin can designate a single well-known "anonymous" key that bypasses `validateKey()` and grants public read/write access. This is useful for letting LAN users (or clients like OpenClaw multi-agent setups) connect without individual per-user keys.
|
||||||
|
|
||||||
**Enable**:
|
**Enable**:
|
||||||
|
|
||||||
The anonymous key is wired into the service unit (launchd plist on macOS, systemd unit on Linux) at install time. Export `PROXY_ANONYMOUS_KEY` in your shell before running `setup.mjs`, and `setup.mjs` will write it into the service unit env so the auto-started proxy picks it up:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export PROXY_ANONYMOUS_KEY=ocp_public_anon # or any string of your choice
|
export PROXY_ANONYMOUS_KEY=ocp_public_anon # or any string of your choice
|
||||||
node setup.mjs --bind 0.0.0.0 --auth-mode multi
|
ocp start # or however you start the server
|
||||||
```
|
```
|
||||||
|
|
||||||
If OCP is already installed without it, re-export the env var and re-run `node setup.mjs` (the installer is idempotent — it refreshes the service unit). Then `ocp restart` so the running proxy picks up the new env. Setting `PROXY_ANONYMOUS_KEY` only in your interactive shell **does not** affect the auto-started proxy — the service unit is the source of truth for its environment.
|
|
||||||
|
|
||||||
**Client side**: the anonymous key value is exposed via `GET /health` as the field `anonymousKey` (null when not set). Clients like `ocp-connect` can auto-discover and use it, so the end user doesn't need to get a personal key from the admin.
|
**Client side**: the anonymous key value is exposed via `GET /health` as the field `anonymousKey` (null when not set). Clients like `ocp-connect` can auto-discover and use it, so the end user doesn't need to get a personal key from the admin.
|
||||||
|
|
||||||
**Security note**: setting this env var is an **opt-in** to public access — anyone who can reach your OCP endpoint can use it, up to any rate limits you configure. Don't enable this on internet-exposed OCP instances without additional protection.
|
**Security note**: setting this env var is an **opt-in** to public access — anyone who can reach your OCP endpoint can use it, up to any rate limits you configure. Don't enable this on internet-exposed OCP instances without additional protection.
|
||||||
@@ -462,7 +453,6 @@ When a key exceeds its quota, OCP returns HTTP 429 with a structured error:
|
|||||||
- Keys are stored in `~/.ocp/ocp.db` (SQLite, zero external dependencies)
|
- Keys are stored in `~/.ocp/ocp.db` (SQLite, zero external dependencies)
|
||||||
- Admin key is required for key management API endpoints
|
- Admin key is required for key management API endpoints
|
||||||
- The dashboard (`/dashboard`) and health check (`/health`) are always public
|
- The dashboard (`/dashboard`) and health check (`/health`) are always public
|
||||||
- File modes for `~/.ocp` (0700), `admin-key` + `ocp.db` (0600) are auto-tightened at server startup as of v3.14.0
|
|
||||||
|
|
||||||
## Built-in Usage Monitoring
|
## Built-in Usage Monitoring
|
||||||
|
|
||||||
@@ -660,7 +650,7 @@ The canonical list lives in [`models.json`](./models.json) — the single source
|
|||||||
| `/api/keys` | GET/POST | List or create API keys (admin only) |
|
| `/api/keys` | GET/POST | List or create API keys (admin only) |
|
||||||
| `/api/keys/:id` | DELETE | Revoke an API key (admin only) |
|
| `/api/keys/:id` | DELETE | Revoke an API key (admin only) |
|
||||||
| `/api/keys/:id/quota` | GET/PATCH | View or set per-key quota (admin only) |
|
| `/api/keys/:id/quota` | GET/PATCH | View or set per-key quota (admin only) |
|
||||||
| `/api/usage` | GET | Per-key usage stats (`?since=&until=&hours=&limit=`); returns self only by default — pass `?all=true` (admin only) for all-keys data |
|
| `/api/usage` | GET | Per-key usage stats (`?since=&until=&hours=&limit=`) |
|
||||||
| `/cache/stats` | GET | Cache statistics (admin only) |
|
| `/cache/stats` | GET | Cache statistics (admin only) |
|
||||||
| `/cache` | DELETE | Clear response cache (admin only) |
|
| `/cache` | DELETE | Clear response cache (admin only) |
|
||||||
|
|
||||||
|
|||||||
+2
-21
@@ -55,13 +55,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="flex" style="justify-content: space-between; align-items: center;">
|
<h2>Usage by Key</h2>
|
||||||
<h2 style="margin: 0;">Usage by Key</h2>
|
|
||||||
<label id="usage-scope-toggle" class="flex" style="display:none; gap: 0.4rem; font-size: 0.8rem; color: #94a3b8; cursor: pointer;">
|
|
||||||
<input type="checkbox" id="usage-show-all" style="cursor: pointer;">
|
|
||||||
<span>Show all keys</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<table id="key-usage-table">
|
<table id="key-usage-table">
|
||||||
<thead><tr><th>Key</th><th>Requests</th><th>OK</th><th>Err</th><th>Avg Time</th><th>Last Request</th></tr></thead>
|
<thead><tr><th>Key</th><th>Requests</th><th>OK</th><th>Err</th><th>Avg Time</th><th>Last Request</th></tr></thead>
|
||||||
<tbody></tbody>
|
<tbody></tbody>
|
||||||
@@ -176,8 +170,7 @@ async function refreshStatus() {
|
|||||||
|
|
||||||
async function refreshUsage() {
|
async function refreshUsage() {
|
||||||
try {
|
try {
|
||||||
const showAll = localStorage.getItem("ocp_usage_show_all") === "1";
|
const data = await api("/api/usage");
|
||||||
const data = await api(showAll ? "/api/usage?all=true" : "/api/usage");
|
|
||||||
const tbody = document.querySelector("#key-usage-table tbody");
|
const tbody = document.querySelector("#key-usage-table tbody");
|
||||||
tbody.innerHTML = (data.byKey || []).map(k => `
|
tbody.innerHTML = (data.byKey || []).map(k => `
|
||||||
<tr>
|
<tr>
|
||||||
@@ -211,8 +204,6 @@ async function refreshKeys() {
|
|||||||
try {
|
try {
|
||||||
const data = await api("/api/keys");
|
const data = await api("/api/keys");
|
||||||
document.getElementById("key-mgmt-section").style.display = "";
|
document.getElementById("key-mgmt-section").style.display = "";
|
||||||
// Admin-only "Show all keys" toggle for /api/usage scope.
|
|
||||||
document.getElementById("usage-scope-toggle").style.display = "flex";
|
|
||||||
const tbody = document.querySelector("#keys-table tbody");
|
const tbody = document.querySelector("#keys-table tbody");
|
||||||
tbody.innerHTML = (data.keys || []).map(k => `
|
tbody.innerHTML = (data.keys || []).map(k => `
|
||||||
<tr>
|
<tr>
|
||||||
@@ -249,16 +240,6 @@ async function refreshAll() {
|
|||||||
document.getElementById("refresh-indicator").textContent = `Updated ${new Date().toLocaleTimeString()}`;
|
document.getElementById("refresh-indicator").textContent = `Updated ${new Date().toLocaleTimeString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wire "Show all keys" toggle (visibility gated to admin via refreshKeys()).
|
|
||||||
(function setupUsageScopeToggle() {
|
|
||||||
const cb = document.getElementById("usage-show-all");
|
|
||||||
cb.checked = localStorage.getItem("ocp_usage_show_all") === "1";
|
|
||||||
cb.addEventListener("change", () => {
|
|
||||||
localStorage.setItem("ocp_usage_show_all", cb.checked ? "1" : "0");
|
|
||||||
refreshUsage();
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
||||||
refreshAll();
|
refreshAll();
|
||||||
setInterval(refreshAll, 30000);
|
setInterval(refreshAll, 30000);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 366 KiB After Width: | Height: | Size: 222 KiB |
@@ -3,13 +3,11 @@
|
|||||||
import { DatabaseSync } from "node:sqlite";
|
import { DatabaseSync } from "node:sqlite";
|
||||||
import { randomBytes, createHash } from "node:crypto";
|
import { randomBytes, createHash } from "node:crypto";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { mkdirSync, chmodSync } from "node:fs";
|
import { mkdirSync } from "node:fs";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
|
|
||||||
const OCP_DIR = join(homedir(), ".ocp");
|
const OCP_DIR = join(homedir(), ".ocp");
|
||||||
mkdirSync(OCP_DIR, { recursive: true, mode: 0o700 });
|
mkdirSync(OCP_DIR, { recursive: true });
|
||||||
// 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");
|
const DB_PATH = join(OCP_DIR, "ocp.db");
|
||||||
|
|
||||||
let db;
|
let db;
|
||||||
@@ -20,8 +18,6 @@ export function getDb() {
|
|||||||
db.exec("PRAGMA journal_mode = WAL");
|
db.exec("PRAGMA journal_mode = WAL");
|
||||||
db.exec("PRAGMA foreign_keys = ON");
|
db.exec("PRAGMA foreign_keys = ON");
|
||||||
initSchema();
|
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;
|
return db;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,18 +8,21 @@ set -euo pipefail
|
|||||||
|
|
||||||
PROXY="http://127.0.0.1:3456"
|
PROXY="http://127.0.0.1:3456"
|
||||||
|
|
||||||
# Auth args for multi-key mode: reads from OCP_ADMIN_KEY env or ~/.ocp/admin-key file
|
# Auth header for multi-key mode: reads from OCP_ADMIN_KEY env or ~/.ocp/admin-key file
|
||||||
# Using a bash array preserves word boundaries — no eval needed.
|
_AUTH_HEADER=""
|
||||||
_AUTH_ARGS=()
|
|
||||||
if [[ -n "${OCP_ADMIN_KEY:-}" ]]; then
|
if [[ -n "${OCP_ADMIN_KEY:-}" ]]; then
|
||||||
_AUTH_ARGS=(-H "Authorization: Bearer $OCP_ADMIN_KEY")
|
_AUTH_HEADER="-H \"Authorization: Bearer $OCP_ADMIN_KEY\""
|
||||||
elif [[ -f "$HOME/.ocp/admin-key" ]]; then
|
elif [[ -f "$HOME/.ocp/admin-key" ]]; then
|
||||||
_AUTH_ARGS=(-H "Authorization: Bearer $(cat "$HOME/.ocp/admin-key")")
|
_AUTH_HEADER="-H \"Authorization: Bearer $(cat "$HOME/.ocp/admin-key")\""
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Wrapper: curl with optional auth
|
# Wrapper: curl with optional auth
|
||||||
_curl() {
|
_curl() {
|
||||||
curl "${_AUTH_ARGS[@]}" "$@"
|
if [[ -n "$_AUTH_HEADER" ]]; then
|
||||||
|
eval curl "$_AUTH_HEADER" "$@"
|
||||||
|
else
|
||||||
|
curl "$@"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
_json() { python3 -m json.tool 2>/dev/null || cat; }
|
_json() { python3 -m json.tool 2>/dev/null || cat; }
|
||||||
|
|||||||
+3
-13
@@ -582,19 +582,11 @@ print(k if k else '')
|
|||||||
if [[ "${SHELL:-}" == */fish ]]; then
|
if [[ "${SHELL:-}" == */fish ]]; then
|
||||||
echo " Note: fish shell detected. Writing to ~/.bashrc — add to fish config manually."
|
echo " Note: fish shell detected. Writing to ~/.bashrc — add to fish config manually."
|
||||||
rc_files+=("$HOME/.bashrc")
|
rc_files+=("$HOME/.bashrc")
|
||||||
elif $is_mac; then
|
|
||||||
# macOS: default shell since Catalina (2019) is zsh.
|
|
||||||
# Always write ~/.zshrc (create if absent — zsh tolerates an empty file).
|
|
||||||
# Only write ~/.bashrc if it already exists (don't surprise users with new files).
|
|
||||||
[[ -f "$HOME/.bashrc" ]] && rc_files+=("$HOME/.bashrc")
|
|
||||||
# zshrc: always include on macOS; create the file if it doesn't exist yet
|
|
||||||
[[ -f "$HOME/.zshrc" ]] || touch "$HOME/.zshrc"
|
|
||||||
rc_files+=("$HOME/.zshrc")
|
|
||||||
else
|
else
|
||||||
# Linux / other: write to whichever rc files already exist or match current shell
|
# Always write both on macOS (default shell is zsh but some tools source bashrc)
|
||||||
[[ -f "$HOME/.bashrc" || "${SHELL:-}" == */bash ]] && rc_files+=("$HOME/.bashrc")
|
[[ -f "$HOME/.bashrc" || "${SHELL:-}" == */bash ]] && rc_files+=("$HOME/.bashrc")
|
||||||
[[ -f "$HOME/.zshrc" || "${SHELL:-}" == */zsh ]] && rc_files+=("$HOME/.zshrc")
|
[[ -f "$HOME/.zshrc" || "${SHELL:-}" == */zsh ]] && rc_files+=("$HOME/.zshrc")
|
||||||
# If neither exists, fall back to creating one for the current shell
|
# If neither exists, create for current shell
|
||||||
[[ ${#rc_files[@]} -eq 0 ]] && rc_files+=("$HOME/.${SHELL##*/}rc")
|
[[ ${#rc_files[@]} -eq 0 ]] && rc_files+=("$HOME/.${SHELL##*/}rc")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -713,9 +705,7 @@ PYEOF
|
|||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo " Done. Reload your shell to apply:"
|
echo " Done. Reload your shell to apply:"
|
||||||
for rc_file in "${rc_files[@]}"; do
|
echo " source $rc_file"
|
||||||
echo " source $rc_file"
|
|
||||||
done
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "open-claude-proxy",
|
"name": "open-claude-proxy",
|
||||||
"version": "3.14.0",
|
"version": "3.13.0",
|
||||||
"description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.",
|
"description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
// scripts/lib/plist-merge.mjs
|
|
||||||
//
|
|
||||||
// Preserves user-customised env vars when setup.mjs rewrites the unit file.
|
|
||||||
//
|
|
||||||
// Rule:
|
|
||||||
// - keys present in NEW template → template value wins (template is source of truth)
|
|
||||||
// - keys ONLY in EXISTING (not in template) → preserved verbatim
|
|
||||||
//
|
|
||||||
// No new dependencies — regex-based, plist <key>X</key><string>Y</string> shape
|
|
||||||
// is stable enough for our hand-written templates in setup.mjs.
|
|
||||||
|
|
||||||
const PLIST_KV_RE = /<key>([^<]+)<\/key>\s*<string>([^<]*)<\/string>/g;
|
|
||||||
|
|
||||||
export function parsePlistEnv(plistContent) {
|
|
||||||
if (!plistContent) return {};
|
|
||||||
if (Buffer.isBuffer(plistContent)) plistContent = plistContent.toString("utf8");
|
|
||||||
// Restrict to the EnvironmentVariables dict to avoid catching Label, etc.
|
|
||||||
const envBlock = plistContent.match(/<key>EnvironmentVariables<\/key>\s*<dict>([\s\S]*?)<\/dict>/);
|
|
||||||
if (!envBlock) return {};
|
|
||||||
const out = {};
|
|
||||||
let m;
|
|
||||||
PLIST_KV_RE.lastIndex = 0;
|
|
||||||
while ((m = PLIST_KV_RE.exec(envBlock[1])) !== null) {
|
|
||||||
out[m[1]] = m[2];
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mergePlistEnv(existing, template) {
|
|
||||||
if (!existing) return template;
|
|
||||||
const existingEnv = parsePlistEnv(existing);
|
|
||||||
const templateEnv = parsePlistEnv(template);
|
|
||||||
const KNOWN = new Set(Object.keys(templateEnv));
|
|
||||||
|
|
||||||
const preserved = {};
|
|
||||||
for (const [k, v] of Object.entries(existingEnv)) {
|
|
||||||
if (!KNOWN.has(k)) preserved[k] = v;
|
|
||||||
}
|
|
||||||
if (Object.keys(preserved).length === 0) return template;
|
|
||||||
|
|
||||||
const lines = Object.entries(preserved)
|
|
||||||
.map(([k, v]) => ` <key>${k}</key>\n <string>${v}</string>`)
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
// Inject before the closing </dict> of EnvironmentVariables
|
|
||||||
return template.replace(
|
|
||||||
/(<key>EnvironmentVariables<\/key>\s*<dict>[\s\S]*?)(\n\s*<\/dict>)/,
|
|
||||||
`$1\n${lines}$2`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const SYSTEMD_KV_RE = /^Environment=([^=]+)=(.*)$/gm;
|
|
||||||
|
|
||||||
export function parseSystemdEnv(serviceContent) {
|
|
||||||
if (!serviceContent) return {};
|
|
||||||
if (Buffer.isBuffer(serviceContent)) serviceContent = serviceContent.toString("utf8");
|
|
||||||
const out = {};
|
|
||||||
let m;
|
|
||||||
SYSTEMD_KV_RE.lastIndex = 0;
|
|
||||||
while ((m = SYSTEMD_KV_RE.exec(serviceContent)) !== null) {
|
|
||||||
out[m[1]] = m[2];
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mergeSystemdEnv(existing, template) {
|
|
||||||
if (!existing) return template;
|
|
||||||
const existingEnv = parseSystemdEnv(existing);
|
|
||||||
const templateEnv = parseSystemdEnv(template);
|
|
||||||
const KNOWN = new Set(Object.keys(templateEnv));
|
|
||||||
|
|
||||||
const preservedLines = Object.entries(existingEnv)
|
|
||||||
.filter(([k]) => !KNOWN.has(k))
|
|
||||||
.map(([k, v]) => `Environment=${k}=${v}`);
|
|
||||||
if (preservedLines.length === 0) return template;
|
|
||||||
|
|
||||||
// Guard: if template has no Environment= anchor, cannot inject — return template as-is.
|
|
||||||
// (In practice the OCP systemd template always has Environment= lines.)
|
|
||||||
if (!/^Environment=/m.test(template)) return template;
|
|
||||||
|
|
||||||
// Inject after the last existing Environment= line in the template
|
|
||||||
return template.replace(
|
|
||||||
/(^Environment=[^\n]+\n)((?!Environment=).*$)/ms,
|
|
||||||
`$1${preservedLines.join("\n")}\n$2`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+30
-161
@@ -30,7 +30,7 @@
|
|||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
import { spawn, execFileSync } from "node:child_process";
|
import { spawn, execFileSync } from "node:child_process";
|
||||||
import { randomUUID, timingSafeEqual } from "node:crypto";
|
import { randomUUID, timingSafeEqual } from "node:crypto";
|
||||||
import { readFileSync, readdirSync, accessSync, existsSync, constants, chmodSync, statSync } from "node:fs";
|
import { readFileSync, accessSync, existsSync, constants } from "node:fs";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
@@ -41,48 +41,8 @@ const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
|
|||||||
const modelsConfig = JSON.parse(readFileSync(join(__dirname, "models.json"), "utf8"));
|
const modelsConfig = JSON.parse(readFileSync(join(__dirname, "models.json"), "utf8"));
|
||||||
|
|
||||||
// ── Resolve claude binary ───────────────────────────────────────────────
|
// ── Resolve claude binary ───────────────────────────────────────────────
|
||||||
// Priority: CLAUDE_BIN env > well-known paths > nvm/fnm/asdf user-local
|
// Priority: CLAUDE_BIN env > well-known paths > which lookup
|
||||||
// installs > which lookup. Fail-fast if not found — never start with an
|
// Fail-fast if not found — never start with an unresolvable binary.
|
||||||
// unresolvable binary.
|
|
||||||
function _listVersionDirs(parent) {
|
|
||||||
try { return readdirSync(parent); } catch { return []; }
|
|
||||||
}
|
|
||||||
function _collectNodeManagerCandidates(home) {
|
|
||||||
if (!home) return [];
|
|
||||||
const out = [];
|
|
||||||
|
|
||||||
// nvm: $HOME/.nvm/versions/node/<version>/bin/claude
|
|
||||||
const nvmRoot = join(home, ".nvm/versions/node");
|
|
||||||
for (const v of _listVersionDirs(nvmRoot)) {
|
|
||||||
out.push(join(nvmRoot, v, "bin/claude"));
|
|
||||||
}
|
|
||||||
// nvm default alias: resolve $HOME/.nvm/aliases/default if it points to a version
|
|
||||||
try {
|
|
||||||
const aliasFile = join(home, ".nvm/aliases/default");
|
|
||||||
const aliasVer = readFileSync(aliasFile, "utf8").trim();
|
|
||||||
if (aliasVer) {
|
|
||||||
const direct = join(nvmRoot, aliasVer, "bin/claude");
|
|
||||||
if (!out.includes(direct)) out.unshift(direct);
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
// fnm: $HOME/.fnm/node-versions/<version>/installation/bin/claude
|
|
||||||
const fnmRoot = join(home, ".fnm/node-versions");
|
|
||||||
for (const v of _listVersionDirs(fnmRoot)) {
|
|
||||||
out.push(join(fnmRoot, v, "installation/bin/claude"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// asdf: $HOME/.asdf/installs/nodejs/<version>/bin/claude
|
|
||||||
const asdfRoot = join(home, ".asdf/installs/nodejs");
|
|
||||||
for (const v of _listVersionDirs(asdfRoot)) {
|
|
||||||
out.push(join(asdfRoot, v, "bin/claude"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// npm prefix-relocated: $HOME/.npm-global/bin/claude
|
|
||||||
out.push(join(home, ".npm-global/bin/claude"));
|
|
||||||
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
function resolveClaude() {
|
function resolveClaude() {
|
||||||
if (process.env.CLAUDE_BIN) {
|
if (process.env.CLAUDE_BIN) {
|
||||||
try {
|
try {
|
||||||
@@ -94,13 +54,11 @@ function resolveClaude() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const home = process.env.HOME || "";
|
|
||||||
const candidates = [
|
const candidates = [
|
||||||
"/opt/homebrew/bin/claude",
|
"/opt/homebrew/bin/claude",
|
||||||
"/usr/local/bin/claude",
|
"/usr/local/bin/claude",
|
||||||
"/usr/bin/claude",
|
"/usr/bin/claude",
|
||||||
join(home, ".local/bin/claude"),
|
join(process.env.HOME || "", ".local/bin/claude"),
|
||||||
..._collectNodeManagerCandidates(home),
|
|
||||||
];
|
];
|
||||||
for (const p of candidates) {
|
for (const p of candidates) {
|
||||||
try { accessSync(p, constants.X_OK); console.warn(`[init] CLAUDE_BIN not set, resolved to ${p}`); return p; } catch {}
|
try { accessSync(p, constants.X_OK); console.warn(`[init] CLAUDE_BIN not set, resolved to ${p}`); return p; } catch {}
|
||||||
@@ -114,8 +72,6 @@ function resolveClaude() {
|
|||||||
console.error(
|
console.error(
|
||||||
"FATAL: claude binary not found.\n" +
|
"FATAL: claude binary not found.\n" +
|
||||||
" Set CLAUDE_BIN=/path/to/claude or ensure claude is in PATH.\n" +
|
" Set CLAUDE_BIN=/path/to/claude or ensure claude is in PATH.\n" +
|
||||||
" Hint: if you use nvm/fnm/asdf, set CLAUDE_BIN to the absolute path\n" +
|
|
||||||
" shown by `which claude` in your interactive shell.\n" +
|
|
||||||
" Checked: " + candidates.join(", ")
|
" Checked: " + candidates.join(", ")
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -167,44 +123,6 @@ 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) ──────────────────────────────────────────
|
// ── Circuit breaker (DISABLED) ──────────────────────────────────────────
|
||||||
// Disabled: CLI proxy has its own retry logic, and the breaker was causing
|
// Disabled: CLI proxy has its own retry logic, and the breaker was causing
|
||||||
// cascading failures — once API got briefly slow, ALL agents lost connectivity
|
// cascading failures — once API got briefly slow, ALL agents lost connectivity
|
||||||
@@ -240,36 +158,25 @@ const MODEL_MAP = Object.fromEntries([
|
|||||||
const MODELS = modelsConfig.models.map(m => ({ id: m.id, name: m.displayName }));
|
const MODELS = modelsConfig.models.map(m => ({ id: m.id, name: m.displayName }));
|
||||||
|
|
||||||
// ── Session management ──────────────────────────────────────────────────
|
// ── Session management ──────────────────────────────────────────────────
|
||||||
// Maps namespaced session keys to Claude CLI session UUIDs.
|
// Maps conversation IDs (from caller) 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".
|
|
||||||
// Enables --resume for multi-turn conversations, reducing token waste.
|
// Enables --resume for multi-turn conversations, reducing token waste.
|
||||||
const sessions = new Map(); // `${keyName}|${conversationId}` → { uuid, messageCount, lastUsed, model }
|
const sessions = new Map(); // 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 sessionCleanupInterval = setInterval(() => {
|
const sessionCleanupInterval = setInterval(() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const [id, s] of sessions) {
|
for (const [id, s] of sessions) {
|
||||||
const idleMs = now - s.lastUsed;
|
const idleMs = now - s.lastUsed;
|
||||||
const ageMs = s.firstSeen ? now - s.firstSeen : null;
|
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) {
|
if (idleMs > SESSION_TTL) {
|
||||||
sessions.delete(id);
|
sessions.delete(id);
|
||||||
console.log(`[session] expired ${convIdShort}... (idle ${Math.round(idleMs / 60000)}m)`);
|
console.log(`[session] expired ${id.slice(0, 12)}... (idle ${Math.round(idleMs / 60000)}m)`);
|
||||||
logEvent("info", "session_expired", { conversationId: convIdShort + "...", idleMs, ageMs });
|
logEvent("info", "session_expired", { conversationId: id.slice(0, 12) + "...", idleMs, ageMs });
|
||||||
} else if (ageMs !== null && ageMs > 4 * SESSION_TTL) {
|
} else if (ageMs !== null && ageMs > 4 * SESSION_TTL) {
|
||||||
// #42 evidence-gathering: a session whose firstSeen is more than 4× TTL old
|
// #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)
|
// 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
|
// is the suspected bug. Log without action so the pattern can be confirmed
|
||||||
// in /logs. Do NOT enforce an absolute age cap here speculatively.
|
// 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);
|
}, 60000);
|
||||||
@@ -476,7 +383,7 @@ function getModelTier(cliModel) {
|
|||||||
// ── Spawn claude CLI (shared setup) ─────────────────────────────────────
|
// ── Spawn claude CLI (shared setup) ─────────────────────────────────────
|
||||||
// Resolves session logic, builds CLI args, spawns the process, and sets up
|
// Resolves session logic, builds CLI args, spawns the process, and sets up
|
||||||
// timeouts. Returns context object or throws synchronously.
|
// timeouts. Returns context object or throws synchronously.
|
||||||
function spawnClaudeProcess(model, messages, conversationId, keyName) {
|
function spawnClaudeProcess(model, messages, conversationId) {
|
||||||
if (stats.activeRequests >= MAX_CONCURRENT) {
|
if (stats.activeRequests >= MAX_CONCURRENT) {
|
||||||
throw new Error(`concurrency limit reached (${stats.activeRequests}/${MAX_CONCURRENT})`);
|
throw new Error(`concurrency limit reached (${stats.activeRequests}/${MAX_CONCURRENT})`);
|
||||||
}
|
}
|
||||||
@@ -492,11 +399,8 @@ function spawnClaudeProcess(model, messages, conversationId, keyName) {
|
|||||||
let prompt;
|
let prompt;
|
||||||
|
|
||||||
// ── Session logic ──
|
// ── Session logic ──
|
||||||
// sessionKey namespaces the Map key by keyName to prevent cross-caller collision
|
if (conversationId && sessions.has(conversationId)) {
|
||||||
// when two callers with different API keys share the same conversationId string.
|
const session = sessions.get(conversationId);
|
||||||
const sessionKey = _sessionKey(conversationId, keyName);
|
|
||||||
if (sessionKey && sessions.has(sessionKey)) {
|
|
||||||
const session = sessions.get(sessionKey);
|
|
||||||
session.lastUsed = Date.now();
|
session.lastUsed = Date.now();
|
||||||
sessionInfo = { uuid: session.uuid, resume: true };
|
sessionInfo = { uuid: session.uuid, resume: true };
|
||||||
stats.sessionHits++;
|
stats.sessionHits++;
|
||||||
@@ -507,17 +411,17 @@ function spawnClaudeProcess(model, messages, conversationId, keyName) {
|
|||||||
: "";
|
: "";
|
||||||
session.messageCount = messages.length;
|
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 uuid = randomUUID();
|
||||||
const now = Date.now();
|
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 };
|
sessionInfo = { uuid, resume: false };
|
||||||
stats.sessionMisses++;
|
stats.sessionMisses++;
|
||||||
prompt = messagesToPrompt(messages);
|
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 {
|
} else {
|
||||||
stats.oneOffRequests++;
|
stats.oneOffRequests++;
|
||||||
@@ -562,11 +466,11 @@ function spawnClaudeProcess(model, messages, conversationId, keyName) {
|
|||||||
proc.once("exit", cleanup);
|
proc.once("exit", cleanup);
|
||||||
|
|
||||||
function handleSessionFailure() {
|
function handleSessionFailure() {
|
||||||
if (sessionInfo?.resume && sessionKey) {
|
if (sessionInfo?.resume && conversationId) {
|
||||||
console.warn(`[session] resume failed for ${conversationId.slice(0, 12)}..., removing stale session`);
|
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" });
|
logEvent("warn", "session_failure", { mode: "resume", conversationId: conversationId.slice(0, 12) + "...", action: "deleted" });
|
||||||
sessions.delete(sessionKey);
|
sessions.delete(conversationId);
|
||||||
} else if (sessionInfo && !sessionInfo.resume && sessionKey) {
|
} else if (sessionInfo && !sessionInfo.resume && conversationId) {
|
||||||
// #41 evidence-gathering: session-create failures currently leave a stale entry
|
// #41 evidence-gathering: session-create failures currently leave a stale entry
|
||||||
// in the sessions map. Log without action so the staleness pattern can be
|
// 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.
|
// confirmed in /logs before any code change. Do NOT delete here speculatively.
|
||||||
@@ -609,11 +513,11 @@ function spawnClaudeProcess(model, messages, conversationId, keyName) {
|
|||||||
// On-demand spawning: each request spawns a fresh `claude -p` process.
|
// On-demand spawning: each request spawns a fresh `claude -p` process.
|
||||||
// No pool = no crash loops, no stale workers, no degraded states.
|
// No pool = no crash loops, no stale workers, no degraded states.
|
||||||
// Stdin is written immediately so there's no 3s stdin timeout issue.
|
// 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) => {
|
return new Promise((resolve, reject) => {
|
||||||
let ctx;
|
let ctx;
|
||||||
try {
|
try {
|
||||||
ctx = spawnClaudeProcess(model, messages, conversationId, keyName);
|
ctx = spawnClaudeProcess(model, messages, conversationId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return reject(err);
|
return reject(err);
|
||||||
}
|
}
|
||||||
@@ -693,7 +597,7 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
|
|||||||
|
|
||||||
let ctx;
|
let ctx;
|
||||||
try {
|
try {
|
||||||
ctx = spawnClaudeProcess(model, messages, conversationId, authInfo.keyName);
|
ctx = spawnClaudeProcess(model, messages, conversationId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return jsonResponse(res, 500, { error: { message: err.message, type: "proxy_error" } });
|
return jsonResponse(res, 500, { error: { message: err.message, type: "proxy_error" } });
|
||||||
}
|
}
|
||||||
@@ -1376,7 +1280,7 @@ async function handleChatCompletions(req, res) {
|
|||||||
// will re-read the freshly-populated cache entry here rather than spawning.
|
// will re-read the freshly-populated cache entry here rather than spawning.
|
||||||
const recheck = getCachedResponse(req._cacheHash, CACHE_TTL);
|
const recheck = getCachedResponse(req._cacheHash, CACHE_TTL);
|
||||||
if (recheck) return recheck.response;
|
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 }); }
|
try { setCachedResponse(req._cacheHash, model, c); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); }
|
||||||
return c;
|
return c;
|
||||||
});
|
});
|
||||||
@@ -1398,7 +1302,7 @@ async function handleChatCompletions(req, res) {
|
|||||||
|
|
||||||
// Fallback: cache disabled (CACHE_TTL=0) or no _cacheHash — original path untouched.
|
// Fallback: cache disabled (CACHE_TTL=0) or no _cacheHash — original path untouched.
|
||||||
try {
|
try {
|
||||||
const content = await callClaude(model, messages, conversationId, req._authKeyName);
|
const content = await callClaude(model, messages, conversationId);
|
||||||
const id = `chatcmpl-${randomUUID()}`;
|
const id = `chatcmpl-${randomUUID()}`;
|
||||||
completionResponse(res, id, model, content);
|
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 }); }
|
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 }); }
|
||||||
@@ -1532,10 +1436,8 @@ const server = createServer(async (req, res) => {
|
|||||||
const uptimeMs = Date.now() - START_TIME;
|
const uptimeMs = Date.now() - START_TIME;
|
||||||
const sessionList = [];
|
const sessionList = [];
|
||||||
for (const [id, s] of sessions) {
|
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({
|
sessionList.push({
|
||||||
id: convId.slice(0, 12) + "...",
|
id: id.slice(0, 12) + "...",
|
||||||
model: s.model,
|
model: s.model,
|
||||||
messages: s.messageCount,
|
messages: s.messageCount,
|
||||||
idleMs: Date.now() - s.lastUsed,
|
idleMs: Date.now() - s.lastUsed,
|
||||||
@@ -1580,9 +1482,7 @@ const server = createServer(async (req, res) => {
|
|||||||
if (req.url === "/sessions" && req.method === "GET") {
|
if (req.url === "/sessions" && req.method === "GET") {
|
||||||
const list = [];
|
const list = [];
|
||||||
for (const [id, s] of sessions) {
|
for (const [id, s] of sessions) {
|
||||||
// id is "${keyName}|${conversationId}"; expose only the public-facing conversationId
|
list.push({ id, uuid: s.uuid, model: s.model, messages: s.messageCount, lastUsed: new Date(s.lastUsed).toISOString() });
|
||||||
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() });
|
|
||||||
}
|
}
|
||||||
return jsonResponse(res, 200, { sessions: list });
|
return jsonResponse(res, 200, { sessions: list });
|
||||||
}
|
}
|
||||||
@@ -1672,45 +1572,14 @@ const server = createServer(async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (req.url?.startsWith("/api/usage") && req.method === "GET") {
|
if (req.url?.startsWith("/api/usage") && req.method === "GET") {
|
||||||
// Least-privilege scope rules (security audit follow-up):
|
if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" });
|
||||||
// - non-admin authenticated key → only own rows
|
|
||||||
// - anonymous (PROXY_ANONYMOUS_KEY) → only "anonymous" rows; ?all=true ignored
|
|
||||||
// - admin without ?all=true → only own ("admin") rows
|
|
||||||
// - admin with ?all=true → full byKey/recent (legacy behavior); audited
|
|
||||||
// Authenticated callers are required (anyone reaching here passed the auth gate above);
|
|
||||||
// remote+no-auth requests would have been rejected before this point.
|
|
||||||
const url = new URL(req.url, `http://${BIND_ADDRESS}:${PORT}`);
|
const url = new URL(req.url, `http://${BIND_ADDRESS}:${PORT}`);
|
||||||
const since = url.searchParams.get("since");
|
const since = url.searchParams.get("since");
|
||||||
const until = url.searchParams.get("until");
|
const until = url.searchParams.get("until");
|
||||||
const wantAll = url.searchParams.get("all") === "true";
|
|
||||||
const callerName = req._authKeyName;
|
|
||||||
|
|
||||||
// Anonymous callers may never opt into all-keys view, even if they pass ?all=true.
|
|
||||||
const isAnonCaller = callerName === "anonymous";
|
|
||||||
const fullScope = isAdmin && wantAll && !isAnonCaller;
|
|
||||||
|
|
||||||
// scopeName === null when fullScope is true (no filter); otherwise the key_name to filter by.
|
|
||||||
const scopeName = fullScope ? null : callerName;
|
|
||||||
|
|
||||||
if (fullScope) {
|
|
||||||
logEvent("info", "admin_usage_full_scope", { caller: callerName, ip: req.socket.remoteAddress || null });
|
|
||||||
}
|
|
||||||
|
|
||||||
const byKeyAll = getUsageByKey({ since, until });
|
|
||||||
const recentAll = getRecentUsage(Math.min(parseInt(url.searchParams.get("limit") || "50", 10), 500));
|
|
||||||
const timeline = getUsageTimeline({
|
|
||||||
keyName: scopeName || undefined,
|
|
||||||
hours: Math.min(parseInt(url.searchParams.get("hours") || "24", 10), 720),
|
|
||||||
});
|
|
||||||
|
|
||||||
const byKey = scopeName ? byKeyAll.filter((row) => row.key_name === scopeName) : byKeyAll;
|
|
||||||
const recent = scopeName ? recentAll.filter((row) => row.key_name === scopeName) : recentAll;
|
|
||||||
|
|
||||||
return jsonResponse(res, 200, {
|
return jsonResponse(res, 200, {
|
||||||
byKey,
|
byKey: getUsageByKey({ since, until }),
|
||||||
timeline,
|
timeline: getUsageTimeline({ hours: Math.min(parseInt(url.searchParams.get("hours") || "24", 10), 720) }),
|
||||||
recent,
|
recent: getRecentUsage(Math.min(parseInt(url.searchParams.get("limit") || "50", 10), 500)),
|
||||||
scope: { self: scopeName, all: fullScope },
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +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, chmodSync } from "node:fs";
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, readdirSync } from "node:fs";
|
||||||
import { mergePlistEnv, mergeSystemdEnv } from "./scripts/lib/plist-merge.mjs";
|
|
||||||
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";
|
||||||
@@ -425,15 +424,8 @@ if (!DRY_RUN) {
|
|||||||
</plist>
|
</plist>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const existingPlist = existsSync(plistPath) ? readFileSync(plistPath, "utf8") : null;
|
writeFileSync(plistPath, plistXml);
|
||||||
const finalPlistXml = mergePlistEnv(existingPlist, plistXml);
|
log(`Plist written: ${plistPath}`);
|
||||||
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
|
// Bootout first (in case it was already loaded) then bootstrap
|
||||||
try { execSync(`launchctl bootout gui/$(id -u) "${plistPath}" 2>/dev/null`); } catch { /* ignore */ }
|
try { execSync(`launchctl bootout gui/$(id -u) "${plistPath}" 2>/dev/null`); } catch { /* ignore */ }
|
||||||
@@ -466,15 +458,8 @@ StandardError=append:${logPath}
|
|||||||
WantedBy=default.target
|
WantedBy=default.target
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const existingService = existsSync(servicePath) ? readFileSync(servicePath, "utf8") : null;
|
writeFileSync(servicePath, serviceUnit);
|
||||||
const finalServiceUnit = mergeSystemdEnv(existingService, serviceUnit);
|
log(`Service file written: ${servicePath}`);
|
||||||
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 daemon-reload`);
|
||||||
execSync(`systemctl --user enable ocp-proxy`);
|
execSync(`systemctl --user enable ocp-proxy`);
|
||||||
|
|||||||
@@ -454,126 +454,6 @@ async function runSingleflightTests() {
|
|||||||
|
|
||||||
await runSingleflightTests();
|
await runSingleflightTests();
|
||||||
|
|
||||||
// ── Plist Env Merge Tests ──
|
|
||||||
import { mergePlistEnv, mergeSystemdEnv } from "./scripts/lib/plist-merge.mjs";
|
|
||||||
|
|
||||||
console.log("\nPlist env merge:");
|
|
||||||
|
|
||||||
const SAMPLE_TEMPLATE_PLIST = `<?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>EnvironmentVariables</key>
|
|
||||||
<dict>
|
|
||||||
<key>CLAUDE_PROXY_PORT</key>
|
|
||||||
<string>3478</string>
|
|
||||||
<key>CLAUDE_BIND</key>
|
|
||||||
<string>127.0.0.1</string>
|
|
||||||
<key>CLAUDE_AUTH_MODE</key>
|
|
||||||
<string>multi</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</plist>`;
|
|
||||||
|
|
||||||
const SAMPLE_EXISTING_PLIST = `<?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>EnvironmentVariables</key>
|
|
||||||
<dict>
|
|
||||||
<key>CLAUDE_PROXY_PORT</key>
|
|
||||||
<string>3456</string>
|
|
||||||
<key>CLAUDE_BIND</key>
|
|
||||||
<string>127.0.0.1</string>
|
|
||||||
<key>CLAUDE_AUTH_MODE</key>
|
|
||||||
<string>none</string>
|
|
||||||
<key>CLAUDE_HEARTBEAT_INTERVAL</key>
|
|
||||||
<string>2000</string>
|
|
||||||
<key>CLAUDE_CACHE_TTL</key>
|
|
||||||
<string>600</string>
|
|
||||||
</dict>
|
|
||||||
</dict>
|
|
||||||
</plist>`;
|
|
||||||
|
|
||||||
test("mergePlistEnv preserves unknown user keys", () => {
|
|
||||||
const merged = mergePlistEnv(SAMPLE_EXISTING_PLIST, SAMPLE_TEMPLATE_PLIST);
|
|
||||||
assert.match(merged, /<key>CLAUDE_HEARTBEAT_INTERVAL<\/key>\s*<string>2000<\/string>/);
|
|
||||||
assert.match(merged, /<key>CLAUDE_CACHE_TTL<\/key>\s*<string>600<\/string>/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("mergePlistEnv overrides known template keys", () => {
|
|
||||||
const merged = mergePlistEnv(SAMPLE_EXISTING_PLIST, SAMPLE_TEMPLATE_PLIST);
|
|
||||||
assert.match(merged, /<key>CLAUDE_PROXY_PORT<\/key>\s*<string>3478<\/string>/);
|
|
||||||
assert.match(merged, /<key>CLAUDE_AUTH_MODE<\/key>\s*<string>multi<\/string>/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("mergePlistEnv first-install returns template unchanged when existing is null", () => {
|
|
||||||
const merged = mergePlistEnv(null, SAMPLE_TEMPLATE_PLIST);
|
|
||||||
assert.equal(merged, SAMPLE_TEMPLATE_PLIST);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("mergePlistEnv first-install returns template unchanged when existing is empty", () => {
|
|
||||||
const merged = mergePlistEnv("", SAMPLE_TEMPLATE_PLIST);
|
|
||||||
assert.equal(merged, SAMPLE_TEMPLATE_PLIST);
|
|
||||||
});
|
|
||||||
|
|
||||||
const SAMPLE_TEMPLATE_SYSTEMD = `[Unit]
|
|
||||||
Description=OCP — Open Claude Proxy
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
ExecStart=/usr/bin/node /home/u/ocp/server.mjs
|
|
||||||
Environment=CLAUDE_PROXY_PORT=3478
|
|
||||||
Environment=CLAUDE_BIND=127.0.0.1
|
|
||||||
Environment=CLAUDE_AUTH_MODE=multi
|
|
||||||
Restart=always
|
|
||||||
`;
|
|
||||||
|
|
||||||
const SAMPLE_EXISTING_SYSTEMD = `[Unit]
|
|
||||||
Description=OCP — Open Claude Proxy
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
ExecStart=/usr/bin/node /home/u/ocp/server.mjs
|
|
||||||
Environment=CLAUDE_PROXY_PORT=3456
|
|
||||||
Environment=CLAUDE_BIND=127.0.0.1
|
|
||||||
Environment=CLAUDE_AUTH_MODE=none
|
|
||||||
Environment=CLAUDE_HEARTBEAT_INTERVAL=2000
|
|
||||||
Environment=CLAUDE_CACHE_TTL=600
|
|
||||||
Restart=always
|
|
||||||
`;
|
|
||||||
|
|
||||||
test("mergeSystemdEnv preserves unknown user Environment lines", () => {
|
|
||||||
const merged = mergeSystemdEnv(SAMPLE_EXISTING_SYSTEMD, SAMPLE_TEMPLATE_SYSTEMD);
|
|
||||||
assert.match(merged, /Environment=CLAUDE_HEARTBEAT_INTERVAL=2000/);
|
|
||||||
assert.match(merged, /Environment=CLAUDE_CACHE_TTL=600/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("mergeSystemdEnv overrides known template keys", () => {
|
|
||||||
const merged = mergeSystemdEnv(SAMPLE_EXISTING_SYSTEMD, SAMPLE_TEMPLATE_SYSTEMD);
|
|
||||||
assert.match(merged, /Environment=CLAUDE_PROXY_PORT=3478/);
|
|
||||||
assert.match(merged, /Environment=CLAUDE_AUTH_MODE=multi/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("mergeSystemdEnv first-install returns template unchanged", () => {
|
|
||||||
assert.equal(mergeSystemdEnv(null, SAMPLE_TEMPLATE_SYSTEMD), SAMPLE_TEMPLATE_SYSTEMD);
|
|
||||||
assert.equal(mergeSystemdEnv("", SAMPLE_TEMPLATE_SYSTEMD), SAMPLE_TEMPLATE_SYSTEMD);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("mergePlistEnv is idempotent", () => {
|
|
||||||
const r1 = mergePlistEnv(SAMPLE_EXISTING_PLIST, SAMPLE_TEMPLATE_PLIST);
|
|
||||||
assert.equal(mergePlistEnv(r1, SAMPLE_TEMPLATE_PLIST), r1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("mergeSystemdEnv is idempotent", () => {
|
|
||||||
const r1 = mergeSystemdEnv(SAMPLE_EXISTING_SYSTEMD, SAMPLE_TEMPLATE_SYSTEMD);
|
|
||||||
assert.equal(mergeSystemdEnv(r1, SAMPLE_TEMPLATE_SYSTEMD), r1);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Cleanup ──
|
// ── Cleanup ──
|
||||||
closeDb();
|
closeDb();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user