mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-22 21:45:08 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7d57f489d |
@@ -29,14 +29,10 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Blacklisted tokens — two kinds (see ALIGNMENT.md "OAuth token-host verification"):
|
||||
# (1) known LLM hallucinations (e.g. the 2026-04-11 /api/oauth/usage drift), and
|
||||
# (2) pinned wrong-host variants of a VERIFIED Class A endpoint (a hit means a
|
||||
# drift to a known-wrong host, not necessarily a hallucination).
|
||||
# Extend only via an ALIGNMENT.md amendment PR. Matched as fixed strings vs server.mjs.
|
||||
# Known-hallucinated tokens. Extend only via an ALIGNMENT.md amendment PR.
|
||||
# Each token is matched as a fixed string against server.mjs only.
|
||||
BLACKLIST=(
|
||||
"api.anthropic.com/api/oauth/usage"
|
||||
"console.anthropic.com/v1/oauth/token"
|
||||
)
|
||||
|
||||
FAIL=0
|
||||
@@ -55,8 +51,8 @@ jobs:
|
||||
============================================================
|
||||
server.mjs contains a token on the OCP alignment blacklist.
|
||||
|
||||
These tokens are either LLM hallucinations that never appeared in cli.js,
|
||||
or pinned wrong-host variants of a verified Class A endpoint (a drift).
|
||||
These tokens were introduced by LLM hallucinations and do
|
||||
not appear in cli.js at any shipped Claude Code version.
|
||||
See ALIGNMENT.md -> "Historical Lesson: The 2026-04-11 Drift"
|
||||
(commit b87992f) for the full incident record.
|
||||
|
||||
|
||||
@@ -52,26 +52,6 @@ The following Rules apply to **Class A operations** (the `cli.js`-mirror surface
|
||||
|
||||
The audit pin is updated once per year (see Annual Alignment Audit) and whenever a drift incident forces a re-verification.
|
||||
|
||||
### OAuth token-host verification (2026-05-31)
|
||||
|
||||
Motivating evidence: the 2026-05-31 code audit (issues #112 / #119 / #123). The OAuth bearer
|
||||
machinery is a Class A surface (Rules 1–5). Because `cli.js` now ships as a
|
||||
compiled binary, the token-refresh host was re-verified against `claude.exe` (Claude Code
|
||||
`2.1.154`) on 2026-05-31 using the compiled-binary protocol — `strings` on the Mach-O, **no
|
||||
live OAuth probe** (a `refresh_token` grant would rotate the operator's real credentials):
|
||||
|
||||
- **Verified host:** `https://platform.claude.com/v1/oauth/token` — present in the binary
|
||||
byte-for-byte, paired with `OAUTH_CLIENT_ID` in the same `prod` config object (matches
|
||||
`server.mjs` `OAUTH_TOKEN_URL` / `OAUTH_CLIENT_ID`). The legacy `console.anthropic.com/v1/oauth`
|
||||
host is absent (0 hits).
|
||||
- **Pinned wrong-host variant:** `console.anthropic.com/v1/oauth/token` is added to the
|
||||
`alignment.yml` blacklist so a future accidental revert to the legacy host hard-fails CI.
|
||||
|
||||
The blacklist therefore now holds two kinds of token: (1) known hallucinations (e.g.
|
||||
`api.anthropic.com/api/oauth/usage`, the 2026-04-11 drift), and (2) pinned wrong-host variants
|
||||
of a *verified* Class A endpoint. A blacklist hit means either a re-introduced hallucination
|
||||
**or** a drift to a known-wrong host — both are alignment failures under Rules 2 and 3.
|
||||
|
||||
---
|
||||
|
||||
## Historical Lesson: The 2026-04-11 Drift
|
||||
|
||||
+1
-63
@@ -1,68 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## v3.18.0 — 2026-06-01
|
||||
|
||||
Hardening release from a multi-agent code audit (1 P0 + 14 P2 + 2 P3 findings, each adversarially verified and independently reviewed) plus three follow-ups (#123–#125). Every change shipped as its own PR with a fresh-context reviewer (Iron Rule 10). The single-user default path (`AUTH_MODE=none`, no TUI) is behavior-identical **except** the `/health` change in #109.
|
||||
|
||||
### Security
|
||||
|
||||
- **#109 (P0)** — `/health` no longer advertises `PROXY_ANONYMOUS_KEY` to remote callers by default. The `anonymousKey` field is gated behind a new `PROXY_ADVERTISE_ANON_KEY=1` opt-in env var; localhost callers are always exempt. Prevents any LAN-reachable device from harvesting a working, quota-spending bearer credential from the unauthenticated `/health` endpoint. **Behavior change:** `ocp-connect` zero-config Path A now requires the server to set `PROXY_ADVERTISE_ANON_KEY=1`; otherwise pass `--key` or use anonymous access.
|
||||
- **#114** — Dashboard escapes all DB-sourced strings (key names, usage rows) before `innerHTML`; the revoke button uses a `data-` attribute + listener instead of an inline `onclick` a quote could break out of; `POST /api/keys` validates key names server-side (`[A-Za-z0-9 ._-]{1,64}`).
|
||||
- **#124** — Dashboard status/plan summary cards escaped too (uniform defense-in-depth over all `innerHTML` sinks).
|
||||
- **#111** — Streaming error paths strip filesystem paths from claude error text / stderr before sending them to clients (`sanitizeError`), matching the non-streaming path.
|
||||
|
||||
### Reliability / correctness
|
||||
|
||||
- **#110** — Non-array `messages` is rejected with a 400 (was silently hanging the connection until socket timeout); OpenAI array `content` is flattened into the prompt instead of dumped as raw JSON; a streamed upstream error now emits an SSE `error` frame instead of a success-looking `finish_reason:"stop"`.
|
||||
- **#111** — `res.on("close")` escalates SIGTERM→SIGKILL on client disconnect (closes a narrow re-occurrence of the #37 concurrency-slot leak on the hottest exit path); `overallTimer` is cleared on semantic completion so a slow-exiting child can't record a spurious post-success timeout; per-key quota is documented as best-effort (bounded overshoot ≤ `MAX_CONCURRENT`, cache hits uncounted).
|
||||
- **#113** — CLI/installer hardening: `ocp-plugin` restart uses the live uid + `dev.ocp.proxy`/`ocp-proxy` labels and drops the unsafe `pkill` fallback; `ocp-connect` quotes + `chmod 600`s the persisted key; `setup.mjs` XML-escapes and newline-validates injected service-unit secrets.
|
||||
|
||||
### Alignment / governance
|
||||
|
||||
- **#112** — OAuth token-refresh host (`platform.claude.com/v1/oauth/token`) re-verified against the compiled cli.js v2.1.154 (`strings`, no live probe) and recorded in `ALIGNMENT.md`; usage-probe and default request model now derive from `models.json` (ADR 0003 SPOT) instead of hardcoded IDs.
|
||||
- **#123** — The legacy `console.anthropic.com/v1/oauth/token` host is pinned in the `alignment.yml` blacklist so a future OAuth-host drift hard-fails CI; the blacklist now documents its dual purpose (known hallucinations + pinned wrong-host variants of a verified Class A endpoint).
|
||||
|
||||
### TUI
|
||||
|
||||
- **#115** — The TUI LAN gate refuses any non-loopback bind (not just literal `0.0.0.0`); the achieved `cc_entrypoint` is asserted each turn and a `tui_entrypoint_mismatch` warning is logged on a silent degrade to the metered sdk-cli pool.
|
||||
|
||||
### Refactor
|
||||
|
||||
- **#125** — `isLoopbackBind` extracted to `lib/net.mjs`, shared by `server.mjs` and the test suite (was duplicated via a copy-paste mirror).
|
||||
|
||||
### New environment variables
|
||||
|
||||
- `PROXY_ADVERTISE_ANON_KEY` — opt-in (default off); advertise `PROXY_ANONYMOUS_KEY` on the public `/health` body for remote zero-config discovery (#109).
|
||||
|
||||
## v3.17.1 — 2026-05-31
|
||||
|
||||
### Fix — code-audit P1/P2 hardening
|
||||
|
||||
Fixes from a multi-agent code audit (3 P1 + 5 P2, adversarially verified). The single-user default path (`AUTH_MODE=none`, no TUI) is behavior-identical.
|
||||
|
||||
**Availability / correctness (P1):**
|
||||
- Guard `proc.stdin` against EPIPE — a fast-failing spawned `claude` (auth error, bad model, large prompt) no longer crashes the single-process daemon.
|
||||
- Add `unhandledRejection`/`uncaughtException`/`clientError` safety nets + wrap all request-body read loops — a client aborting mid-upload no longer crashes the daemon.
|
||||
- TUI transcript reader: only `turn_duration` is terminal (was also `tool_use`), which silently truncated any TUI turn that used a built-in tool.
|
||||
|
||||
**Security gates / cache integrity (P2):**
|
||||
- `AUTH_MODE=multi`: the default spawn now passes `--disallowedTools` (Bash/Read/Write/Edit/…) so a guest prompt cannot drive operator-filesystem tools. Single-user path unchanged.
|
||||
- `/sessions` (DELETE), `/settings` (PATCH), `/logs`, `/usage`, `/status` are now admin-gated (were dispatched before the admin check).
|
||||
- Streaming path no longer caches an `is_error` response as success (cache-poisoning fix).
|
||||
- TUI fail-loud guard extended to `none`+`0.0.0.0` (unless `OCP_TUI_ALLOW_LAN=1`) and `+ PROXY_ANONYMOUS_KEY`.
|
||||
- TUI `send-keys` paste uses `-l` (literal) so a prompt equal to a tmux key token (e.g. `C-c`) is typed, not interpreted.
|
||||
|
||||
---
|
||||
|
||||
## v3.17.0 — 2026-05-31
|
||||
|
||||
### Provider — default claude invocation ported to stream-json + `--system-prompt` (Phase 6c)
|
||||
|
||||
OCP's default (non-TUI) claude spawn moves from `claude -p --output-format text` to `claude --output-format stream-json --verbose --no-session-persistence --system-prompt <wrapper>` (no `-p`). The NDJSON event stream is parsed into the assembled response. Benefits: ~64% per-request cost reduction and anti-hallucination via `--system-prompt` tool-use suppression. Clients see no API change — the OpenAI-compatible request/response shapes are identical. Faithful port of OLP's production-verified implementation; covered by 17 new stream-json parser tests.
|
||||
|
||||
⚠️ **Billing note:** from 2026-06-15 this default path carries `cc_entrypoint=sdk-cli` and bills against the Agent SDK credit pool. Use the new opt-in `CLAUDE_TUI_MODE` (below) to keep traffic on the Pro/Max subscription pool.
|
||||
|
||||
---
|
||||
## Unreleased
|
||||
|
||||
### feat(tui): opt-in CLAUDE_TUI_MODE — serve via interactive claude (cc_entrypoint=cli / subscription pool), single-user only; default stream-json path unchanged
|
||||
|
||||
|
||||
@@ -50,10 +50,6 @@ OCP and the alternatives serve adjacent but distinct needs. Pick the one that fi
|
||||
|
||||
**Plain English**: `claude-code-router` is the routing-and-switching power tool — pick it if you want to mix Anthropic, OpenAI, Gemini, and local models behind one endpoint. `anthropic-proxy` is the minimal forwarder. **OCP focuses on disciplined `cli.js`-aligned forwarding plus subscription multiplexing** — pick it if you want to share one Claude Pro/Max subscription across IDEs, devices, and people, with LAN auth, quotas, and a governance contract that prevents endpoint drift.
|
||||
|
||||
### Related: OLP — Open LLM Proxy
|
||||
|
||||
OCP is Claude-only by design. If you want to spread across **multiple LLM providers** (not just Claude), see the sibling project **[OLP — Open LLM Proxy](https://github.com/dtzp555-max/olp)**: the same spawn-the-provider-CLI approach, but across several provider CLIs behind one OpenAI-compatible endpoint, with intelligent fallback chains. It grew out of OCP in response to Anthropic's 2026-06-15 billing split — the idea being to spread subscription/quota risk across more than one provider. OCP remains the focused, Claude-only option; OLP is the multi-provider one.
|
||||
|
||||
OCP is single-maintainer + LLM-assisted, currently pre-1.0. It runs the maintainer's daily Claude Code workflow. If something breaks, [open an issue](https://github.com/dtzp555-max/ocp/issues).
|
||||
|
||||
## Supported Tools
|
||||
@@ -285,7 +281,7 @@ chmod +x ocp-connect
|
||||
./ocp-connect <server-ip>
|
||||
```
|
||||
|
||||
**Zero-config** — when the server admin has set `PROXY_ANONYMOUS_KEY` *and* opted in with `PROXY_ADVERTISE_ANON_KEY=1` (see [Anonymous Access](#anonymous-access-optional) below), just pass the server IP and nothing else. `ocp-connect` reads the anonymous key from `/health` and uses it automatically. Without the opt-in, `/health` does not expose the key (issue #109); pass `--key` or rely on anonymous access instead:
|
||||
**Zero-config** — when the server admin has set `PROXY_ANONYMOUS_KEY` (see [Anonymous Access](#anonymous-access-optional) below), just pass the server IP and nothing else. `ocp-connect` reads the anonymous key from `/health` and uses it automatically:
|
||||
|
||||
```bash
|
||||
./ocp-connect <server-ip>
|
||||
@@ -370,7 +366,7 @@ OCP Connect v1.3.0
|
||||
The script automatically:
|
||||
- Writes env vars to all relevant shell rc files (`.bashrc`, `.zshrc`)
|
||||
- Sets system-level env vars (`launchctl setenv` on macOS, `environment.d` on Linux)
|
||||
- **Auto-discovers anonymous key** from `/health.anonymousKey` when no `--key` given (v1.3.0+, requires server v3.10.0+; server must also set `PROXY_ADVERTISE_ANON_KEY=1` — see [Anonymous Access](#anonymous-access-optional))
|
||||
- **Auto-discovers anonymous key** from `/health.anonymousKey` when no `--key` given (v1.3.0+, requires server v3.10.0+)
|
||||
- Configures OpenClaw automatically (including per-agent `auth-profiles.json` for multi-agent setups)
|
||||
- Detects Cline, Continue.dev, Cursor, and opencode, and prints setup hints (manual configuration required for these IDEs)
|
||||
|
||||
@@ -409,22 +405,10 @@ ocp keys revoke son-ipad # Revoke a key
|
||||
|------|-----|----------|
|
||||
| `none` | `CLAUDE_AUTH_MODE=none` | Trusted home network, no auth needed |
|
||||
| `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 for usage tracking + quotas (trusted users only — see Deployment model below) |
|
||||
| `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.
|
||||
|
||||
### Deployment model & security (read this)
|
||||
|
||||
**What OCP is built for today: single-user, multi-IDE.** Run OCP as a server on one machine and point all of *your own* IDEs/devices at it — one Claude Pro/Max subscription, used everywhere. This is the primary, solid use case.
|
||||
|
||||
**Sharing with family / a team — honest limits.** You *can* share OCP on a LAN, but be clear about what the auth modes do and don't give you:
|
||||
|
||||
- The per-key modes (`shared` / `multi`) give per-key **usage tracking, quotas, and cache separation** — useful for seeing who used what and capping budgets.
|
||||
- They do **not** give a **security isolation boundary**. The spawned `claude` runs with the **operator's filesystem access** and is *not* sandboxed per key. **Only share with people you fully trust, on a trusted network.**
|
||||
- For simple trusted family sharing, the easiest setup is a single shared **anonymous key** (see [Anonymous Access](#anonymous-access-optional)) — no per-person separation, same trust assumption.
|
||||
|
||||
**Real per-user isolation (sandboxed, multi-tenant-safe) is planned for after 2026-06-15** — per-key ephemeral home + tool lockdown + an OS sandbox. Until then, treat a multi-user OCP as a *trusted-group convenience*, not a security boundary. (This is also why `CLAUDE_TUI_MODE` is single-user-only — see [Subscription-pool (TUI) mode](#subscription-pool-tui-mode).)
|
||||
|
||||
### 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.
|
||||
@@ -440,7 +424,7 @@ node setup.mjs --bind 0.0.0.0 --auth-mode multi
|
||||
|
||||
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) **only to localhost callers** or when the admin has also set `PROXY_ADVERTISE_ANON_KEY=1` (default off — see issue #109). With that opt-in, 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.
|
||||
|
||||
@@ -486,8 +470,6 @@ When a key exceeds its quota, OCP returns HTTP 429 with a structured error:
|
||||
- Admin and anonymous users are never subject to quotas
|
||||
- PATCH is a partial update — omitted fields are left unchanged
|
||||
|
||||
> **Note:** quotas are best-effort. Under concurrent bursts a key can exceed its cap by up to the server's max-concurrency (default 8), and cache hits are not counted toward quota. They cap budgets for cooperative family use, not adversarial abuse.
|
||||
|
||||
### Important Notes
|
||||
|
||||
- All users share your Claude Pro/Max **rate limits** (5h session + 7d weekly)
|
||||
@@ -891,8 +873,7 @@ Future `ocp update` invocations sync automatically.
|
||||
| `CLAUDE_SKIP_PERMISSIONS` | `false` | Bypass all permission checks |
|
||||
| `CLAUDE_NO_CONTEXT` | `false` | Suppress CLAUDE.md and auto-memory injection (pure API mode) |
|
||||
| `PROXY_API_KEY` | *(unset)* | Bearer token for shared-mode authentication |
|
||||
| `PROXY_ANONYMOUS_KEY` | *(unset)* | Well-known anonymous key allowlist (multi mode). When set, this exact string bypasses `validateKey()` and grants public access. Exposed via `/health.anonymousKey` only to localhost, or to all callers when `PROXY_ADVERTISE_ANON_KEY=1`. See [Anonymous Access](#anonymous-access-optional). |
|
||||
| `PROXY_ADVERTISE_ANON_KEY` | *(unset)* | When `=1`, advertise `PROXY_ANONYMOUS_KEY` in the public `/health` body for remote zero-config discovery. Default off — `/health` is unauthenticated, so this exposes the shared key to any LAN-reachable device (issue #109). Localhost always sees it regardless. |
|
||||
| `PROXY_ANONYMOUS_KEY` | *(unset)* | Well-known anonymous key allowlist (multi mode). When set, this exact string bypasses `validateKey()` and grants public access. Exposed via `/health.anonymousKey` so clients auto-discover. See [Anonymous Access](#anonymous-access-optional). |
|
||||
| `CLAUDE_TUI_MODE` | `false` | **Opt-in.** Set to `"true"` to serve requests via interactive `claude` (no `-p` / `--output-format` → `cc_entrypoint=cli`, subscription pool). **Single-user only** — see [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) for the security constraint. |
|
||||
| `CLAUDE_TUI_WALLCLOCK_MS` | `120000` | (TUI-mode) Maximum time in ms to wait for the native transcript to signal turn completion. Increase for long Opus thinking turns. |
|
||||
| `OCP_TUI_CWD` | `$HOME/.ocp-tui/work` | (TUI-mode) Scratch working directory where interactive claude sessions run. Transcripts land under `<HOME>/.claude/projects/<encoded-cwd>/`. Created automatically. |
|
||||
|
||||
+15
-22
@@ -132,10 +132,6 @@ function fmtChars(n) {
|
||||
return n > 1000 ? (n/1000).toFixed(0) + "K" : String(n);
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s ?? "").replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
}
|
||||
|
||||
function barColor(pct) {
|
||||
if (pct >= 80) return "bar-red";
|
||||
if (pct >= 50) return "bar-amber";
|
||||
@@ -148,8 +144,8 @@ async function refreshStatus() {
|
||||
const r = data.requests || {};
|
||||
|
||||
document.getElementById("status-cards").innerHTML = `
|
||||
<div class="card"><div class="label">Status</div><div class="value"><span class="tag ${p.status === 'ok' ? 'tag-ok' : 'tag-err'}">${escapeHtml(p.status || '?')}</span></div><div class="sub">v${escapeHtml(p.version || '?')}</div></div>
|
||||
<div class="card"><div class="label">Uptime</div><div class="value">${escapeHtml(p.uptime || '?')}</div></div>
|
||||
<div class="card"><div class="label">Status</div><div class="value"><span class="tag ${p.status === 'ok' ? 'tag-ok' : 'tag-err'}">${p.status || '?'}</span></div><div class="sub">v${p.version || '?'}</div></div>
|
||||
<div class="card"><div class="label">Uptime</div><div class="value">${p.uptime || '?'}</div></div>
|
||||
<div class="card"><div class="label">Requests</div><div class="value">${r.total || 0}</div><div class="sub">${r.active || 0} active</div></div>
|
||||
<div class="card"><div class="label">Errors</div><div class="value">${r.errors || 0}</div><div class="sub">${r.timeouts || 0} timeouts</div></div>
|
||||
<div class="card"><div class="label">Sessions</div><div class="value">${p.activeSessions || 0}</div></div>
|
||||
@@ -164,15 +160,15 @@ async function refreshStatus() {
|
||||
document.getElementById("plan-cards").innerHTML = `
|
||||
<div class="card">
|
||||
<div class="label">Session (5h)</div>
|
||||
<div class="value">${escapeHtml(s.percent || '?')}</div>
|
||||
<div class="value">${s.percent || '?'}</div>
|
||||
<div class="bar-bg"><div class="bar-fill ${barColor(sPct)}" style="width:${sPct}%"></div></div>
|
||||
<div class="sub">Resets in ${escapeHtml(s.resetsIn || '?')}</div>
|
||||
<div class="sub">Resets in ${s.resetsIn || '?'}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">Weekly (7d)</div>
|
||||
<div class="value">${escapeHtml(w.percent || '?')}</div>
|
||||
<div class="value">${w.percent || '?'}</div>
|
||||
<div class="bar-bg"><div class="bar-fill ${barColor(wPct)}" style="width:${wPct}%"></div></div>
|
||||
<div class="sub">Resets in ${escapeHtml(w.resetsIn || '?')}</div>
|
||||
<div class="sub">Resets in ${w.resetsIn || '?'}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -185,21 +181,21 @@ async function refreshUsage() {
|
||||
const tbody = document.querySelector("#key-usage-table tbody");
|
||||
tbody.innerHTML = (data.byKey || []).map(k => `
|
||||
<tr>
|
||||
<td>${escapeHtml(k.key_name)}</td>
|
||||
<td>${k.key_name}</td>
|
||||
<td>${k.requests}</td>
|
||||
<td>${k.successes}</td>
|
||||
<td>${k.errors}</td>
|
||||
<td>${fmtTime(k.avg_elapsed_ms)}</td>
|
||||
<td class="mono">${escapeHtml(k.last_request || '-')}</td>
|
||||
<td class="mono">${k.last_request || '-'}</td>
|
||||
</tr>
|
||||
`).join("") || '<tr><td colspan="6" style="color:#475569">No usage data yet</td></tr>';
|
||||
|
||||
const rtbody = document.querySelector("#recent-table tbody");
|
||||
rtbody.innerHTML = (data.recent || []).slice(0, 20).map(r => `
|
||||
<tr>
|
||||
<td class="mono">${escapeHtml(r.created_at?.slice(11, 19) || '?')}</td>
|
||||
<td>${escapeHtml(r.key_name)}</td>
|
||||
<td>${escapeHtml(r.model)}</td>
|
||||
<td class="mono">${r.created_at?.slice(11, 19) || '?'}</td>
|
||||
<td>${r.key_name}</td>
|
||||
<td>${r.model}</td>
|
||||
<td>${fmtChars(r.prompt_chars)}</td>
|
||||
<td>${fmtChars(r.response_chars)}</td>
|
||||
<td>${fmtTime(r.elapsed_ms)}</td>
|
||||
@@ -220,16 +216,13 @@ async function refreshKeys() {
|
||||
const tbody = document.querySelector("#keys-table tbody");
|
||||
tbody.innerHTML = (data.keys || []).map(k => `
|
||||
<tr>
|
||||
<td>${escapeHtml(k.name)}</td>
|
||||
<td class="mono">${escapeHtml(k.keyPreview)}</td>
|
||||
<td class="mono">${escapeHtml(k.created_at)}</td>
|
||||
<td>${k.name}</td>
|
||||
<td class="mono">${k.keyPreview}</td>
|
||||
<td class="mono">${k.created_at}</td>
|
||||
<td><span class="tag ${k.revoked ? 'tag-err' : 'tag-ok'}">${k.revoked ? 'revoked' : 'active'}</span></td>
|
||||
<td>${k.revoked ? '' : `<button class="btn btn-sm btn-danger" data-revoke="${escapeHtml(k.name)}">Revoke</button>`}</td>
|
||||
<td>${k.revoked ? '' : `<button class="btn btn-sm btn-danger" onclick="revokeKeyUI('${k.name}')">Revoke</button>`}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
tbody.querySelectorAll("button[data-revoke]").forEach(btn =>
|
||||
btn.addEventListener("click", () => revokeKeyUI(btn.getAttribute("data-revoke")))
|
||||
);
|
||||
} catch(e) { /* not admin */ }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
# OCP Anthropic-Only Sandbox Strategy — Handoff Document
|
||||
|
||||
**Status:** Forward-looking planning doc (not yet a decision)
|
||||
**Date:** 2026-05-29
|
||||
**Audience:** future OCP maintainer / session picking up multi-tenant security work
|
||||
**Provenance:** authored during OLP Phase 7 PR-B re-evaluation; OLP's parallel analysis (multi-provider) lives at `dtzp555-max/olp` `docs/adr/0014-sandbox-runtime-integration.md` Amendment 1 (pending). This OCP-side doc strips the multi-LLM generalization and keeps only what applies to OCP's single-provider (anthropic) deployment.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why this doc exists
|
||||
|
||||
OCP is in maintenance mode (per OLP ADR 0001 supersession of OCP ADR 0005). It is not under active development for new features. However, two things may eventually drive sandbox work in OCP:
|
||||
|
||||
1. **Multi-key OCP deployments.** `OCP_OWNER_TOKEN` + per-key cache namespace already shipped (OCP `lib/keys.mjs`). If multiple human users share an OCP instance, the same multi-tenant filesystem-isolation gap that motivated OLP Phase 7 also exists here.
|
||||
2. **Cloud or shared-host OCP deployments.** Any deployment beyond "single user on their own machine" inherits the threat surface.
|
||||
|
||||
If/when that work starts, this doc is the prior-art capture so the maintainer doesn't repeat OLP's PR-B path (which has a documented dead-end — see § 3.2 below).
|
||||
|
||||
This doc is anthropic-only by design — codex/mistral/etc. multi-LLM concerns are out of scope per OCP ADR 0005.
|
||||
|
||||
---
|
||||
|
||||
## 2. The multi-tenant gap (OCP-specific)
|
||||
|
||||
OCP spawns `claude -p` as the OCP-process user. Every spawned claude instance runs with the OCP user's filesystem permissions. Consequences for a multi-key OCP deployment:
|
||||
|
||||
1. **Cross-key lateral read.** A prompt-injected `cat ~/.ocp/keys/<other-key>.json` reads any other key's manifest (token hash, owner_tier, providers_enabled — not catastrophic since it's only the *hash*, but still identity-attribution surface).
|
||||
2. **OAuth credential exposure.** `~/.claude/.credentials.json` is the Anthropic OAuth refresh token. A prompt-injected read of this file = stealing the subscription that OCP exists to pool.
|
||||
3. **SSH identity exposure.** `~/.ssh/id_*` reachable for lateral movement to other hosts the OCP user can reach.
|
||||
4. **Other host secrets.** Anything else under the OCP user's home is reachable.
|
||||
|
||||
OCP's `ALIGNMENT.md` Class A/B endpoint discipline does not address this — that discipline is wire-level honesty (`cli.js` mirror), not host-level isolation.
|
||||
|
||||
The threat model assumes prompt-injection capability — any caller with a valid OCP key + ability to craft a prompt that elicits a tool call. Default `claude -p` mode includes Read/Bash/etc. tool descriptions in the system prompt; the model is **eager** to use them.
|
||||
|
||||
---
|
||||
|
||||
## 3. Why OLP Phase 7 PR-B is the wrong path to copy
|
||||
|
||||
OLP attempted to wrap `claude -p` spawn in `@anthropic-ai/sandbox-runtime` (outer bubblewrap on Linux, sandbox-exec on macOS). This produced four binding problems documented during OLP's re-evaluation:
|
||||
|
||||
### 3.1 Anthropic's design doesn't expect external sandboxing
|
||||
|
||||
Per Anthropic's [engineering blog on Claude Code sandboxing](https://www.anthropic.com/engineering/claude-code-sandboxing), `sandbox-runtime` is designed to be invoked **by claude code itself** to sandbox **its own** Bash tool / MCP servers / spawn children. It is **not** designed to sandbox claude code as an externally-wrapped process.
|
||||
|
||||
Concretely: claude CLI assumes it can freely read+write its own `$HOME`-derived paths (`~/.claude.json`, `~/.claude/.credentials.json`, `~/.config/claude/`, future state files). When wrapped in `bwrap --ro-bind / /`, those writes hit `EROFS` and claude silently exits with no stdout.
|
||||
|
||||
### 3.2 `~/.claude.json` upstream status is "closed not planned"
|
||||
|
||||
claude CLI writes `~/.claude.json` non-atomically at startup. Upstream issues #28842, #29162, #29217, #28837, #29051, #29250, #7243 all document this. **#29250 is closed as "not planned / duplicate"** — Anthropic is not going to make this file atomic-write because their mental model is that claude runs in an environment that can write its `$HOME`.
|
||||
|
||||
For OCP, this means: any outer-sandbox approach that uses `--ro-bind` on `$HOME` will be a **permanent maintenance treadmill** — every new claude CLI version that adds a state file outside the patched mount paths breaks OCP. OLP's PR-B fold-in tried to patch this by promoting `~/.claude/` to rw, which was insufficient (the actual file is `~/.claude.json` at $HOME root, not inside `~/.claude/`).
|
||||
|
||||
### 3.3 The threat model doesn't justify the cost
|
||||
|
||||
OCP is, per ADR 0005, a personal-and-family-scale tool. The realistic threat surface is misbehaving prompts from family members or self-injected via dependent agents, not adversarial external attackers. The blast radius of a successful cross-key read is bounded (token *hash*, OAuth that's pooled-by-design across all OCP keys).
|
||||
|
||||
A maintenance-mode project investing weeks into outer-sandboxing for a hypothetical threat is a poor cost/benefit. There are cheaper architectures (§ 4 below) that get most of the protection.
|
||||
|
||||
### 3.4 OLP-specific reason that does NOT apply to OCP
|
||||
|
||||
OLP also hit a multi-provider conflict: codex CLI has its own inner bubblewrap that breaks when wrapped in an outer bwrap (openai/codex#16018). **This is not an OCP concern** — OCP only spawns claude. So the multi-provider forcing function for OLP doesn't apply here. The other three reasons (§ 3.1–3.3) are sufficient on their own.
|
||||
|
||||
---
|
||||
|
||||
## 4. Three viable approaches for OCP
|
||||
|
||||
Ranked by "engineering cost vs isolation strength" — pick by deployment context.
|
||||
|
||||
### 4.1 Approach A — Ephemeral `$HOME` via env var (recommended starting point)
|
||||
|
||||
Per-spawn setup:
|
||||
|
||||
```
|
||||
ephemeralRoot=/tmp/ocp-spawn/<keyId>/<reqId>/home
|
||||
mkdir -p $ephemeralRoot/.claude
|
||||
ln -s ~/.claude/.credentials.json $ephemeralRoot/.claude/.credentials.json
|
||||
HOME=$ephemeralRoot claude -p --output-format stream-json ...
|
||||
```
|
||||
|
||||
Mechanics:
|
||||
- claude CLI uses Node's `os.homedir()` which reads `$HOME` env first.
|
||||
- `~/.claude.json` written by claude on startup → lands in `/tmp/ocp-spawn/<keyId>/<reqId>/home/.claude.json` (tmpfs, discarded after spawn).
|
||||
- `~/.claude/.credentials.json` is the OAuth file claude needs — symlinked in read-only from the real one.
|
||||
- Any new state file claude CLI introduces in a future version → also lands in the ephemeral home, no patch needed.
|
||||
|
||||
Threat coverage:
|
||||
- ✅ Solves EROFS upgrade tax permanently — any claude state-file location works because they all land in tmpfs.
|
||||
- ✅ Cross-key OAuth credential isolation — keyA's ephemeral home has only keyA's symlink, but here the symlink target is the SAME real file because OCP shares OAuth (this is fine: shared OAuth is OCP's design, the symlink just keeps the file inaccessible via `cat ~/.claude/.credentials.json` from a different keyId's ephemeral root).
|
||||
- ❌ Does NOT solve cross-key lateral filesystem read via absolute paths. A prompt-injected `cat /home/<ocp-user>/.ocp/keys/<otherKey>.json` still works — `os.homedir()` override doesn't affect absolute-path reads.
|
||||
|
||||
5-minute spike before adopting:
|
||||
|
||||
```bash
|
||||
HOME=/tmp/fake-home-spike claude --print "echo PONG" --no-session-persistence 2>&1
|
||||
ls -la /tmp/fake-home-spike # expect: .claude.json + .claude/ created here
|
||||
find ~/.claude ~/.claude.json -newer /tmp/spike-marker 2>/dev/null # expect: empty
|
||||
```
|
||||
|
||||
If claude falls back to `os.userInfo().homedir` (uses getpwuid_r, ignores HOME env), this approach degrades — fall back to Approach B.
|
||||
|
||||
**Engineering cost:** ~50 LOC in OCP's spawn pipeline (mkdir + symlink + env merge + cleanup-on-exit). No new dependencies.
|
||||
|
||||
### 4.2 Approach B — Outer bubblewrap with `--tmpfs $HOME` + `--ro-bind` credentials
|
||||
|
||||
```
|
||||
bwrap \
|
||||
--ro-bind / / \
|
||||
--tmpfs /home/<ocp-user> \
|
||||
--ro-bind /home/<ocp-user>/.claude/.credentials.json /home/<ocp-user>/.claude/.credentials.json \
|
||||
--ro-bind /home/<ocp-user>/.ocp/keys/<thisKeyId>.json /home/<ocp-user>/.ocp/keys/<thisKeyId>.json \
|
||||
--dev /dev --proc /proc --tmpfs /tmp \
|
||||
claude -p ...
|
||||
```
|
||||
|
||||
This is the canonical bwrap pattern (Flatpak uses exactly this for every sandboxed app — see [Bubblewrap ArchWiki Examples](https://wiki.archlinux.org/title/Bubblewrap/Examples)).
|
||||
|
||||
Threat coverage:
|
||||
- ✅ Solves EROFS upgrade tax (tmpfs accepts any write path).
|
||||
- ✅ Cross-key lateral read prevention — only the current key's manifest is bind-mounted in, others are simply absent from the sandbox view.
|
||||
- ✅ `~/.ssh` and similar identity material absent from sandbox.
|
||||
|
||||
Trade-offs:
|
||||
- bwrap dependency: install `bubblewrap` apt package on host.
|
||||
- Bypasses `@anthropic-ai/sandbox-runtime` library — direct bwrap arg composition. Worth it because sandbox-runtime's outer-wrap design is for short-lived claude-internal subprocesses, not long-running claude CLI itself (per § 3.1).
|
||||
- macOS: not supported by bwrap (macOS would need separate `sandbox-exec` profile, ~50-100 LOC additional work). OCP cross-machine maintainer deploys mostly on Mac mini + Oracle ARM VM — both Linux on the cloud side, Mac mini side may remain unsandboxed if family-trust-zone.
|
||||
|
||||
**Engineering cost:** ~150 LOC for the spawn wrapper + deployment doc updates to require `apt install bubblewrap`. macOS support is a separate ~100 LOC if/when needed.
|
||||
|
||||
### 4.3 Approach C — OverlayFS lowerdir (read-only) + tmpfs upperdir (writable)
|
||||
|
||||
```
|
||||
mount -t overlay overlay \
|
||||
-o lowerdir=/home/<ocp-user>/.claude,upperdir=/tmp/ocp-spawn/<reqId>/upper,workdir=/tmp/ocp-spawn/<reqId>/work \
|
||||
/tmp/ocp-spawn/<reqId>/merged-claude
|
||||
HOME=/tmp/ocp-spawn/<reqId>/home claude -p ...
|
||||
# After spawn: umount + rm -rf
|
||||
```
|
||||
|
||||
Most elegant — claude sees a view identical to its real `~/.claude/`, all writes go to tmpfs upperdir, real `~/.claude/` is never touched.
|
||||
|
||||
Trade-offs:
|
||||
- Requires `CAP_SYS_ADMIN` or rootless-overlayfs (kernel ≥5.11 + user-ns enabled). OCP currently runs as the maintainer's user — no SYS_ADMIN — so this would require either running OCP as root (bad) or rootless-overlayfs setup.
|
||||
- More moving parts (mount/umount per spawn, work-dir lifetime, cleanup-on-crash).
|
||||
|
||||
Better fit if OCP ever moves to a dedicated `ocp` system user with `CAP_SYS_ADMIN` capability via systemd.
|
||||
|
||||
**Engineering cost:** ~120 LOC + kernel/permission preflight check.
|
||||
|
||||
---
|
||||
|
||||
## 5. Cross-key isolation orthogonal layer
|
||||
|
||||
The three approaches above all solve `~/.claude.json` EROFS + state-write isolation. None of them alone solve **cross-key lateral filesystem read via absolute paths** (e.g. prompt-injected `cat /home/<user>/.ocp/keys/<otherKey>.json`).
|
||||
|
||||
For that, two options compose with any of A/B/C:
|
||||
|
||||
### 5.1 Per-spawn `sandbox-runtime` customConfig with `denyRead`
|
||||
|
||||
`@anthropic-ai/sandbox-runtime`'s `wrapWithSandbox(command, binShell?, customConfig?, abortSignal?)` accepts per-call override:
|
||||
|
||||
```
|
||||
const otherKeysWorkspaces = listAllKeyManifestsExcept(thisKeyId)
|
||||
const wrapped = await SandboxManager.wrapWithSandbox(claudeCommand, undefined, {
|
||||
filesystem: {
|
||||
denyRead: [
|
||||
...otherKeysWorkspaces, // all keys except current
|
||||
'/home/<ocp-user>/.ssh',
|
||||
'/home/<ocp-user>/.gnupg',
|
||||
'/home/<ocp-user>/.aws',
|
||||
],
|
||||
allowWrite: [ephemeralRoot, '/tmp'],
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
This adds bwrap deny-paths per-spawn (after sandbox-runtime singleton init). Works in combination with Approach A (the `HOME` env-var override is independent of sandbox-runtime's restrictions).
|
||||
|
||||
Caveat: this re-introduces the outer-bwrap concern from § 3.1 — claude CLI is now wrapped after all. Mitigation: use this only for **cross-key isolation**, not for `$HOME` restriction. The `denyRead` paths are all outside `$HOME`, so claude's `~/.claude.json` write is unaffected.
|
||||
|
||||
### 5.2 Per-OS-user OCP spawning
|
||||
|
||||
Each OCP key gets a dedicated Linux user (`ocp-<keyId>`). Spawn claude as that user via `runuser` or `sudo -u`. OAuth credential shared via Linux group permissions or bind-mount.
|
||||
|
||||
True kernel-level uid isolation. Most robust answer for OCP-as-shared-host scenarios.
|
||||
|
||||
Trade-offs:
|
||||
- Setup script complexity (one-time per key).
|
||||
- Linux-only.
|
||||
- Doesn't fit Mac mini deployment.
|
||||
|
||||
Best fit for a cloud OCP deployment where per-tenant trust isolation matters.
|
||||
|
||||
---
|
||||
|
||||
## 6. Trust model framing
|
||||
|
||||
OCP's authentication layer (`lib/keys.mjs`) provides **attribution** (per-key audit, per-key cache namespace). It does NOT, by itself, provide **isolation** (per-key trust boundary against prompt-injection lateral reads).
|
||||
|
||||
This distinction is worth making explicit in OCP's README "Security" section (it currently isn't). The three tiers:
|
||||
|
||||
| Tier | Trust Model | Sandbox requirement |
|
||||
|---|---|---|
|
||||
| **Single-user** | maintainer's own machine, single OCP token | None — system-user permissions are sufficient |
|
||||
| **Family-trust-zone** | maintainer + family members on shared OCP instance, all parties trusted not to attack each other | Optional — Approach A (ephemeral $HOME) gives cleanup hygiene without changing trust assumptions |
|
||||
| **Shared-host / cloud / external callers** | OCP keys handed to potentially-adversarial callers (CI runners, third-party agents, public demo) | Required — Approach B or C + § 5 cross-key isolation |
|
||||
|
||||
The current OCP deployment fits tier 1 or 2. The work in this doc applies only when promoting to tier 3.
|
||||
|
||||
---
|
||||
|
||||
## 7. Recommendation if/when this work starts
|
||||
|
||||
**Phase 1 — Approach A (ephemeral `$HOME`) only.**
|
||||
- ~50 LOC, no apt deps, works on Mac mini + Linux
|
||||
- Solves the EROFS upgrade tax structurally
|
||||
- Closes cross-key OAuth-credential-file lateral read
|
||||
- Cost-effective hygiene improvement
|
||||
|
||||
**Phase 2 — Approach B (outer bwrap) gated by deployment config.**
|
||||
- Add `~/.ocp/config.json` field `security.sandbox: 'off' | 'tmpfs-home'`
|
||||
- Default off (preserves Mac mini family deployment)
|
||||
- Operator opts in on Linux cloud deployments
|
||||
- Apt prereq documented in deployment guide
|
||||
|
||||
**Phase 3 — § 5 cross-key isolation (only if tier 3 deployment is planned).**
|
||||
- Layer per-spawn customConfig denyRead OR per-OS-user spawning
|
||||
- Treat as separate ADR amendment with its own threat-model evidence
|
||||
|
||||
**Skip Approach C** unless a future requirement forces overlay (low likelihood for OCP scope).
|
||||
|
||||
---
|
||||
|
||||
## 8. Authority citations
|
||||
|
||||
This doc claims findings about claude CLI / `@anthropic-ai/sandbox-runtime` behavior. Sources for verification:
|
||||
|
||||
- [Anthropic engineering — Claude Code sandboxing](https://www.anthropic.com/engineering/claude-code-sandboxing) (sandbox-runtime design intent)
|
||||
- [Anthropic sandbox-runtime GitHub](https://github.com/anthropic-experimental/sandbox-runtime) (wrapWithSandbox API + customConfig per-call signature)
|
||||
- [claude-code#29250 — `.claude.json` non-atomic-write closed-not-planned](https://github.com/anthropics/claude-code/issues/29250)
|
||||
- [claude-code#29162 — read-only `~/.claude.json` startup hang](https://github.com/anthropics/claude-code/issues/29162)
|
||||
- [claude-code#29217 — concurrent-write corruption](https://github.com/anthropics/claude-code/issues/29217)
|
||||
- [claude-code#28842 — Windows startup race](https://github.com/anthropics/claude-code/issues/28842)
|
||||
- [claude-code#7243 — "the .claude.json elephant in the room"](https://github.com/anthropics/claude-code/issues/7243)
|
||||
- [Bubblewrap README](https://github.com/containers/bubblewrap)
|
||||
- [Bubblewrap ArchWiki — Examples section, --tmpfs HOME pattern](https://wiki.archlinux.org/title/Bubblewrap/Examples)
|
||||
- [Sandboxing CLI tools with Bubblewrap — botmonster](https://botmonster.com/self-hosting/sandbox-linux-apps-cli-tools-bubblewrap/)
|
||||
- [OverlayFS kernel documentation](https://docs.kernel.org/filesystems/overlayfs.html)
|
||||
- [OverlayFS ArchWiki](https://wiki.archlinux.org/title/Overlay_filesystem)
|
||||
|
||||
OLP's parallel work (multi-provider generalization of this strategy, including the codex inner-bwrap conflict that does not apply to OCP):
|
||||
|
||||
- `dtzp555-max/olp` `docs/adr/0014-sandbox-runtime-integration.md` (PR-B as-shipped) + Amendment 1 (pending — Solution 1 architecture)
|
||||
- `dtzp555-max/olp` `docs/plans/cloud-deployment-family.md` § 5 (deployment-side trust tier mapping)
|
||||
- archive branch `dtzp555-max/olp:phase-7-pr-b-outer-bwrap-snapshot` captures the outer-bwrap approach as snapshot if anyone wants to revisit it
|
||||
|
||||
---
|
||||
|
||||
## 9. What this doc is NOT
|
||||
|
||||
- Not an ADR. ADRs are decisions; this is a forward-facing strategy doc that becomes an ADR only when work starts and a decision is made.
|
||||
- Not a binding spec. The three approaches are alternatives; the recommendation in § 7 is the maintainer's lean from prior-art analysis, not a constitution.
|
||||
- Not authority for any code change. OCP `ALIGNMENT.md` still requires citation per Class A/B; no sandbox code lands without proper authority pinning when the work eventually starts.
|
||||
- Not a security audit. The threat model is informal — based on prior-art search + incident memory from OLP's parallel session. A real cloud deployment should commission an independent threat model.
|
||||
|
||||
---
|
||||
|
||||
**Authors:** project maintainer (handoff prepared with AI drafting assistance during OLP Phase 7 PR-B re-evaluation, 2026-05-29).
|
||||
@@ -1,9 +0,0 @@
|
||||
// OCP network helpers — shared so server.mjs and tests use one definition. (issue #125)
|
||||
|
||||
// A bind address is "loopback" only if it cannot be reached from another host.
|
||||
// Any other address (0.0.0.0, ::, a concrete LAN/Tailscale IP, etc.) is
|
||||
// network-exposed and must trigger the TUI LAN gate.
|
||||
export function isLoopbackBind(addr) {
|
||||
return addr === "127.0.0.1" || addr === "::1" || addr === "localhost" ||
|
||||
addr === "::ffff:127.0.0.1" || /^127\./.test(addr);
|
||||
}
|
||||
+1
-10
@@ -168,8 +168,6 @@ function buildTuiCmd(claudeBin, model, sessionId) {
|
||||
// 5. Block on the native JSONL transcript (located by session-id) until terminal
|
||||
// marker or wall-clock cap.
|
||||
// 6. Always teardown: kill session + rm temp dir (even on throw).
|
||||
// Returns { text, entrypoint } from readTuiTranscript (entrypoint is the billing-pool
|
||||
// classifier, e.g. "cli", or null if the transcript did not include a turn_duration).
|
||||
export async function runTuiTurn({
|
||||
prompt,
|
||||
model,
|
||||
@@ -225,22 +223,15 @@ export async function runTuiTurn({
|
||||
|
||||
// 2. Submit prompt body via `"$(cat file)"` — byte-safe for any content —
|
||||
// then settle, then send a SEPARATE Enter key event to submit the line.
|
||||
//
|
||||
// The `-l` (literal) flag is required on the paste send-keys call so that
|
||||
// a prompt that happens to equal a tmux key token (e.g. "C-c", "Escape")
|
||||
// is typed literally as text rather than being interpreted as a key binding.
|
||||
// The SEPARATE Enter event below deliberately omits -l so that tmux sends a
|
||||
// real keypress (carriage return) to submit the prompt line.
|
||||
spawnSync(
|
||||
"sh",
|
||||
["-c", `${shq(TMUX)} send-keys -t ${shq(tmuxName)} -l -- "$(cat ${shq(promptFile)})"`],
|
||||
["-c", `${shq(TMUX)} send-keys -t ${shq(tmuxName)} -- "$(cat ${shq(promptFile)})"`],
|
||||
{ env, encoding: "utf8" },
|
||||
);
|
||||
await sleep(PASTE_SETTLE_MS);
|
||||
tmux(["send-keys", "-t", tmuxName, "Enter"]);
|
||||
|
||||
// 3. Block on the native transcript (resolved by session-id) until terminal.
|
||||
// Returns { text, entrypoint } from readTuiTranscript.
|
||||
return await readTuiTranscript({ home: ehome, sessionId, wallclockMs });
|
||||
} finally {
|
||||
// 4. Teardown — always, even on throw.
|
||||
|
||||
+9
-20
@@ -52,18 +52,12 @@ export function parseTranscriptLines(text) {
|
||||
}
|
||||
|
||||
// A line marks the assistant turn complete when it is the turn_duration system
|
||||
// event. That is the ONLY reliable terminal marker in interactive TUI mode.
|
||||
//
|
||||
// Why tool_use is NOT a terminal marker:
|
||||
// In interactive claude, when the model decides to call a tool (stop_reason=
|
||||
// "tool_use"), claude handles the tool call internally and then continues
|
||||
// generating — the turn is NOT complete. The transcript advances to another
|
||||
// assistant entry after the tool result. Only {type:"system",
|
||||
// subtype:"turn_duration"} signals that claude has fully finished the turn.
|
||||
// Treating tool_use as terminal would truncate tool-using turns mid-flight.
|
||||
// event, or an assistant message that stopped to hand off to a tool.
|
||||
export function isTerminalLine(obj) {
|
||||
if (!obj || typeof obj !== "object") return false;
|
||||
return obj.type === "system" && obj.subtype === "turn_duration";
|
||||
if (obj.type === "system" && obj.subtype === "turn_duration") return true;
|
||||
const sr = (obj.message && obj.message.stop_reason) || obj.stop_reason;
|
||||
return sr === "tool_use";
|
||||
}
|
||||
|
||||
// Text of the LAST assistant turn: concatenate its text content blocks
|
||||
@@ -102,12 +96,10 @@ export function verifyEntrypoint(events) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Block until the session transcript is terminal (turn_duration) or
|
||||
// Block until the session transcript is terminal (turn_duration / tool_use) or
|
||||
// the wall-clock cap elapses, polling the file (no fs.watch — robust over NFS /
|
||||
// editors). Returns { text, entrypoint } where text is the latest assistant text
|
||||
// and entrypoint is the billing-pool classifier from the turn_duration line (e.g.
|
||||
// "cli"), or null if not yet present. On cap with text, returns the partial result;
|
||||
// on cap with no text at all, throws.
|
||||
// editors). Returns the latest assistant text. On cap with text, returns the
|
||||
// partial text; on cap with no text at all, throws.
|
||||
//
|
||||
// No quiescence heuristic by design: a long Opus thinking turn stalls transcript
|
||||
// growth and a "file stable for N s" rule would false-abort it (spec §4.3).
|
||||
@@ -117,18 +109,15 @@ export function verifyEntrypoint(events) {
|
||||
export async function readTuiTranscript({ transcriptPath: p, home, sessionId, wallclockMs = 120000, pollMs = 250 }) {
|
||||
const deadline = Date.now() + wallclockMs;
|
||||
let lastText = "";
|
||||
let lastEntrypoint = null;
|
||||
while (Date.now() < deadline) {
|
||||
const resolved = p || findTranscriptPath(home, sessionId);
|
||||
if (resolved && existsSync(resolved)) {
|
||||
const events = parseTranscriptLines(readFileSync(resolved, "utf8"));
|
||||
lastText = extractLatestAssistantText(events) || lastText;
|
||||
const ep = verifyEntrypoint(events);
|
||||
if (ep != null) lastEntrypoint = ep;
|
||||
if (events.some(isTerminalLine)) return { text: lastText, entrypoint: lastEntrypoint };
|
||||
if (events.some(isTerminalLine)) return lastText;
|
||||
}
|
||||
await sleep(pollMs);
|
||||
}
|
||||
if (lastText) return { text: lastText, entrypoint: lastEntrypoint };
|
||||
if (lastText) return lastText;
|
||||
throw new Error("tui_transcript_timeout: no assistant text within wallclock cap");
|
||||
}
|
||||
|
||||
+5
-9
@@ -506,11 +506,9 @@ main() {
|
||||
echo ""
|
||||
|
||||
# Step 2.5: auto-discover anonymous key from /health (issue #12 §14 Path A).
|
||||
# The server advertises anonymousKey in /health ONLY when the admin has set
|
||||
# PROXY_ADVERTISE_ANON_KEY=1 (default off — /health is unauthenticated, so
|
||||
# advertising exposes the shared key to any LAN-reachable device; issue #109).
|
||||
# Localhost callers always receive it regardless. When the field is absent,
|
||||
# ocp-connect falls back to anonymous access / interactive --key (step 3 below).
|
||||
# When the OCP admin set PROXY_ANONYMOUS_KEY, the server advertises it via
|
||||
# /health.anonymousKey. If the user didn't pass --key, use it automatically so
|
||||
# `ocp-connect <host>` works zero-config for OpenClaw multi-agent setups.
|
||||
if [[ -z "$key" ]]; then
|
||||
local anon_key
|
||||
anon_key=$(echo "$health_json" | python3 -c "
|
||||
@@ -634,12 +632,11 @@ PYEOF
|
||||
{
|
||||
echo ""
|
||||
echo "# OCP LAN (added by ocp connect)"
|
||||
echo "export OPENAI_BASE_URL='$base_url/v1'"
|
||||
echo "export OPENAI_BASE_URL=$base_url/v1"
|
||||
if [[ -n "$key" ]]; then
|
||||
echo "export OPENAI_API_KEY='$key'"
|
||||
echo "export OPENAI_API_KEY=$key"
|
||||
fi
|
||||
} >> "$rc_file"
|
||||
chmod 600 "$rc_file" 2>/dev/null || true
|
||||
done
|
||||
|
||||
echo " Shell config:"
|
||||
@@ -672,7 +669,6 @@ PYEOF
|
||||
echo "OPENAI_API_KEY=$key"
|
||||
fi
|
||||
} > "$env_dir/ocp.conf"
|
||||
chmod 600 "$env_dir/ocp.conf" 2>/dev/null || true
|
||||
echo ""
|
||||
echo " System-level (systemd):"
|
||||
echo " ✓ $env_dir/ocp.conf"
|
||||
|
||||
+7
-10
@@ -208,34 +208,31 @@ async function cmdTest() {
|
||||
async function cmdRestart(args) {
|
||||
const target = (args || "").trim().toLowerCase();
|
||||
const { execSync } = await import("node:child_process");
|
||||
const uid = typeof process.getuid === "function" ? process.getuid() : 501;
|
||||
const macProxy = `launchctl kickstart -k gui/${uid}/dev.ocp.proxy`;
|
||||
const macGateway = `launchctl kickstart -k gui/${uid}/ai.openclaw.gateway`;
|
||||
try {
|
||||
if (target === "gateway") {
|
||||
execSync(macGateway, { timeout: 15000 });
|
||||
execSync("launchctl kickstart -k gui/501/ai.openclaw.gateway", { timeout: 15000 });
|
||||
return "✓ Gateway restarted";
|
||||
} else if (target === "all") {
|
||||
execSync(macProxy, { timeout: 15000 });
|
||||
execSync("launchctl kickstart -k gui/501/ai.openclaw.proxy", { timeout: 15000 });
|
||||
// Gateway restart will kill this plugin too, so do it last
|
||||
execSync(macGateway, { timeout: 15000 });
|
||||
execSync("launchctl kickstart -k gui/501/ai.openclaw.gateway", { timeout: 15000 });
|
||||
return "✓ Proxy + Gateway restarted";
|
||||
} else {
|
||||
execSync(macProxy, { timeout: 15000 });
|
||||
execSync("launchctl kickstart -k gui/501/ai.openclaw.proxy", { timeout: 15000 });
|
||||
return "✓ Proxy restarted";
|
||||
}
|
||||
} catch (e) {
|
||||
// Linux: systemd user services
|
||||
// Try systemd for Linux
|
||||
try {
|
||||
if (target === "gateway") {
|
||||
execSync("systemctl --user restart openclaw-gateway", { timeout: 15000 });
|
||||
return "✓ Gateway restarted";
|
||||
} else {
|
||||
execSync("systemctl --user restart ocp-proxy", { timeout: 15000 });
|
||||
execSync("systemctl --user restart openclaw-proxy 2>/dev/null || pkill -f 'node.*server.mjs' && sleep 2 && cd ~/.openclaw/projects/*/; node server.mjs &", { timeout: 15000, shell: true });
|
||||
return "✓ Proxy restarted";
|
||||
}
|
||||
} catch (e2) {
|
||||
return `✗ Restart failed: ${e2.message?.slice(0, 100)}. Run \`ocp restart\` on the server host manually.`;
|
||||
return `✗ Restart failed: ${e2.message?.slice(0, 100)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "open-claude-proxy",
|
||||
"version": "3.18.0",
|
||||
"version": "3.16.4",
|
||||
"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",
|
||||
"bin": {
|
||||
|
||||
@@ -9,8 +9,6 @@
|
||||
// No new dependencies — regex-based, plist <key>X</key><string>Y</string> shape
|
||||
// is stable enough for our hand-written templates in setup.mjs.
|
||||
|
||||
// Note: setup.mjs XML-escapes all injected values before writing (via xmlEscape()),
|
||||
// so raw `<` / `>` / `&` never appear in plist <string> bodies — the [^<]* regex below is safe.
|
||||
const PLIST_KV_RE = /<key>([^<]+)<\/key>\s*<string>([^<]*)<\/string>/g;
|
||||
|
||||
export function parsePlistEnv(plistContent) {
|
||||
|
||||
+62
-235
@@ -36,7 +36,6 @@ import { dirname, join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { validateKey, recordUsage, getUsageByKey, getUsageTimeline, getRecentUsage, createKey, listKeys, revokeKey, closeDb, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, hasCacheControl, singleflight, getInflightStats } from "./keys.mjs";
|
||||
import { DEFAULT_PORT } from "./lib/constants.mjs";
|
||||
import { isLoopbackBind } from "./lib/net.mjs";
|
||||
import { runTuiTurn, reapStaleTuiSessions } from "./lib/tui/session.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
@@ -145,7 +144,7 @@ function extractSystemPrompt(messages) {
|
||||
return OCP_SYSTEM_PROMPT_WRAPPER;
|
||||
}
|
||||
const clientContent = systemMessages.map(m =>
|
||||
contentToText(m.content)
|
||||
typeof m.content === "string" ? m.content : JSON.stringify(m.content)
|
||||
).join("\n\n");
|
||||
return `${OCP_SYSTEM_PROMPT_WRAPPER}\n\n${clientContent}`;
|
||||
}
|
||||
@@ -279,12 +278,6 @@ const NO_CONTEXT = process.env.CLAUDE_NO_CONTEXT === "true";
|
||||
const AUTH_MODE = process.env.CLAUDE_AUTH_MODE || (PROXY_API_KEY ? "shared" : "none");
|
||||
const ADMIN_KEY = process.env.OCP_ADMIN_KEY || "";
|
||||
const PROXY_ANONYMOUS_KEY = process.env.PROXY_ANONYMOUS_KEY || "";
|
||||
// When set to "1", advertise PROXY_ANONYMOUS_KEY in the public /health body so
|
||||
// remote `ocp-connect` devices can zero-config auto-discover it (issue #12 §14 Path A).
|
||||
// Default OFF: /health is unauthenticated, so advertising hands the shared key to any
|
||||
// LAN-reachable device (issue #109 P0). Localhost callers always see it regardless,
|
||||
// since localhost is already fully trusted by the auth path.
|
||||
const ADVERTISE_ANON_KEY = process.env.PROXY_ADVERTISE_ANON_KEY === "1";
|
||||
let CACHE_TTL = parseInt(process.env.CLAUDE_CACHE_TTL || "0", 10); // 0 = disabled, value in ms
|
||||
|
||||
// ── TUI-mode (subscription-pool bridge) — opt-in; default OFF ───────────
|
||||
@@ -300,15 +293,10 @@ const TUI_CWD = process.env.OCP_TUI_CWD || `${process.env.HOME}/.ocp-tui/work`
|
||||
const TUI_HOME = process.env.OCP_TUI_HOME || process.env.HOME;
|
||||
const TUI_ENTRYPOINT = process.env.OCP_TUI_ENTRYPOINT || "cli"; // cli|auto|off — see ADR 0007
|
||||
|
||||
// SECURITY fail-loud: TUI-mode is incompatible with any configuration that allows
|
||||
// non-operator prompts to reach the interactive claude session. Three cases:
|
||||
// 1. AUTH_MODE=multi — guest/anonymous keys can submit prompts.
|
||||
// 2. a non-loopback BIND_ADDRESS — server is network-exposed; any reachable peer
|
||||
// can send prompts unless per-request trust is in place. Override with
|
||||
// OCP_TUI_ALLOW_LAN=1 ONLY if you have a separate network-layer trust (firewall, VPN).
|
||||
// 3. PROXY_ANONYMOUS_KEY set — anonymous callers can submit prompts without a key.
|
||||
// In all three cases TUI runs interactive claude with the OPERATOR's full filesystem
|
||||
// access — home is NOT isolation. Refuse to boot. See ADR 0007.
|
||||
// SECURITY fail-loud: TUI-mode is incompatible with multi-user auth. Under TUI a
|
||||
// guest/anonymous prompt would run interactive claude with the OPERATOR's full
|
||||
// filesystem access (home is NOT isolation). Refuse to boot until B-path isolation
|
||||
// (tools-off + per-key ephemeral home + sandbox) lands. See ADR 0007.
|
||||
if (TUI_MODE && AUTH_MODE === "multi") {
|
||||
console.error(
|
||||
"FATAL: CLAUDE_TUI_MODE=true is incompatible with CLAUDE_AUTH_MODE=multi.\n" +
|
||||
@@ -318,25 +306,6 @@ if (TUI_MODE && AUTH_MODE === "multi") {
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (TUI_MODE && !isLoopbackBind(BIND_ADDRESS) && process.env.OCP_TUI_ALLOW_LAN !== "1") {
|
||||
console.error(
|
||||
`FATAL: CLAUDE_TUI_MODE=true with a non-loopback CLAUDE_BIND (${BIND_ADDRESS}) is unsafe.\n` +
|
||||
" TUI runs interactive claude with operator filesystem access; network-exposed without\n" +
|
||||
" per-request isolation means any reachable peer could drive the operator's claude session.\n" +
|
||||
" Either bind to 127.0.0.1 (default) or set OCP_TUI_ALLOW_LAN=1 if you have a\n" +
|
||||
" separate network-layer trust (firewall/VPN). See docs/adr/0007-tui-interactive-mode.md."
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (TUI_MODE && PROXY_ANONYMOUS_KEY) {
|
||||
console.error(
|
||||
"FATAL: CLAUDE_TUI_MODE=true with PROXY_ANONYMOUS_KEY set is unsafe.\n" +
|
||||
" TUI runs interactive claude with operator filesystem access; anonymous callers\n" +
|
||||
" could drive the operator's claude session without a named key.\n" +
|
||||
" Remove PROXY_ANONYMOUS_KEY or disable TUI-mode. See docs/adr/0007-tui-interactive-mode.md."
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (PROXY_ANONYMOUS_KEY && AUTH_MODE !== "multi") {
|
||||
console.warn("WARNING: PROXY_ANONYMOUS_KEY is set but AUTH_MODE is not 'multi' — anonymous key will be ignored");
|
||||
@@ -597,27 +566,7 @@ function buildCliArgs(cliModel, systemPrompt) {
|
||||
];
|
||||
|
||||
// Permissions
|
||||
// ADR 0007 B-path: in multi-tenant mode, suppress operator-FS tools so a guest
|
||||
// prompt cannot drive Bash/Read/Write/Edit/etc. on the operator's filesystem.
|
||||
// For AUTH_MODE !== "multi" (none/shared — single-operator/trusted), preserve
|
||||
// existing behaviour unchanged.
|
||||
if (AUTH_MODE === "multi") {
|
||||
// Disallow the full operator-FS + web + agent surface. "--disallowedTools" may
|
||||
// be repeated; claude accepts multiple occurrences (TUI path already uses it).
|
||||
args.push(
|
||||
"--disallowedTools", "Bash",
|
||||
"--disallowedTools", "Read",
|
||||
"--disallowedTools", "Write",
|
||||
"--disallowedTools", "Edit",
|
||||
"--disallowedTools", "Glob",
|
||||
"--disallowedTools", "Grep",
|
||||
"--disallowedTools", "WebFetch",
|
||||
"--disallowedTools", "WebSearch",
|
||||
"--disallowedTools", "Agent",
|
||||
"--disallowedTools", "mcp__*",
|
||||
);
|
||||
// Do NOT push --allowedTools in multi mode.
|
||||
} else if (SKIP_PERMISSIONS) {
|
||||
if (SKIP_PERMISSIONS) {
|
||||
args.push("--dangerously-skip-permissions");
|
||||
} else if (ALLOWED_TOOLS.length > 0) {
|
||||
args.push("--allowedTools", ...ALLOWED_TOOLS);
|
||||
@@ -637,22 +586,9 @@ function buildCliArgs(cliModel, systemPrompt) {
|
||||
// This prevents runaway context from gateway-side conversation accumulation.
|
||||
let MAX_PROMPT_CHARS = parseInt(process.env.CLAUDE_MAX_PROMPT_CHARS || "150000", 10);
|
||||
|
||||
// Flatten OpenAI content (string | array of parts) to plain text for the prompt.
|
||||
// Array content: concatenate text parts; replace non-text parts (e.g. image_url)
|
||||
// with a placeholder rather than dumping raw JSON. (issue #110)
|
||||
function contentToText(content) {
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content.map(p =>
|
||||
p && p.type === "text" && typeof p.text === "string" ? p.text : "[non-text content omitted]"
|
||||
).join("");
|
||||
}
|
||||
return content == null ? "" : JSON.stringify(content);
|
||||
}
|
||||
|
||||
function messagesToPrompt(messages) {
|
||||
const full = messages.map((m) => {
|
||||
const text = contentToText(m.content);
|
||||
const text = typeof m.content === "string" ? m.content : JSON.stringify(m.content);
|
||||
if (m.role === "system") return `[System] ${text}`;
|
||||
if (m.role === "assistant") return `[Assistant] ${text}`;
|
||||
return text;
|
||||
@@ -794,11 +730,6 @@ function spawnClaudeProcess(model, messages, conversationId, keyName) {
|
||||
}
|
||||
}
|
||||
|
||||
// Guard stdin writes against EPIPE (child may close stdin before we finish
|
||||
// writing, e.g. early exit on bad model). The ChildProcess "error" event is on
|
||||
// the spawned process, NOT on the stdin Writable — it does not catch this.
|
||||
proc.stdin.on("error", (e) => logEvent("warn", "stdin_write_error", { error: e.message }));
|
||||
|
||||
// Write prompt to stdin immediately
|
||||
proc.stdin.write(prompt);
|
||||
proc.stdin.end();
|
||||
@@ -820,12 +751,7 @@ function spawnClaudeProcess(model, messages, conversationId, keyName) {
|
||||
}
|
||||
}, TIMEOUT);
|
||||
|
||||
// Clear ONLY the request timer (not the slot accounting) when the response has
|
||||
// semantically completed (result/[DONE]) but the child hasn't exited yet — prevents
|
||||
// a spurious post-success timeout. cleanup() (on exit) still clears it idempotently. (issue #111)
|
||||
function clearOverallTimer() { clearTimeout(overallTimer); }
|
||||
|
||||
return { proc, cliModel, conversationId, t0, cleanup, clearOverallTimer, handleSessionFailure, markFirstByte };
|
||||
return { proc, cliModel, conversationId, t0, cleanup, handleSessionFailure, markFirstByte };
|
||||
}
|
||||
|
||||
// ── Call claude CLI (non-streaming) ─────────────────────────────────────
|
||||
@@ -868,7 +794,7 @@ function callClaude(model, messages, conversationId, keyName) {
|
||||
resultEventSeen = true;
|
||||
} else if (parsed.error) {
|
||||
// is_error result — treat as process error
|
||||
reject(new Error(String(parsed.error)));
|
||||
reject(new Error(parsed.error));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -926,14 +852,8 @@ function callClaudeTui(model, messages, _conversationId, _keyName) {
|
||||
cwd: TUI_CWD,
|
||||
wallclockMs: TUI_WALLCLOCK_MS,
|
||||
entrypointMode: TUI_ENTRYPOINT,
|
||||
}).then(({ text, entrypoint }) => {
|
||||
}).then((text) => {
|
||||
recordModelSuccess(cliModel, 0); // elapsed not measurable here; wallclock at reader level
|
||||
// Assert the subscription-pool classification. TUI exists to keep cc_entrypoint=cli
|
||||
// (subscription pool); a silent degrade to sdk-cli (metered Agent SDK pool) would still
|
||||
// return text but cost money — warn loudly so it's visible. (issue #115)
|
||||
if (TUI_ENTRYPOINT === "cli" && entrypoint !== "cli") {
|
||||
logEvent("warn", "tui_entrypoint_mismatch", { expected: "cli", got: entrypoint, model: cliModel });
|
||||
}
|
||||
return text;
|
||||
}).catch((err) => {
|
||||
recordModelError(cliModel, false);
|
||||
@@ -985,10 +905,10 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
|
||||
try {
|
||||
ctx = spawnClaudeProcess(model, messages, conversationId, authInfo.keyName);
|
||||
} catch (err) {
|
||||
return jsonResponse(res, 500, { error: { message: sanitizeError(err.message), type: "proxy_error" } });
|
||||
return jsonResponse(res, 500, { error: { message: err.message, type: "proxy_error" } });
|
||||
}
|
||||
|
||||
const { proc, cliModel, conversationId: convId, t0, cleanup, clearOverallTimer, handleSessionFailure, markFirstByte } = ctx;
|
||||
const { proc, cliModel, conversationId: convId, t0, cleanup, handleSessionFailure, markFirstByte } = ctx;
|
||||
let stderr = "";
|
||||
let headersSent = false;
|
||||
let totalChars = 0;
|
||||
@@ -996,10 +916,6 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
|
||||
let lineBuffer = "";
|
||||
let isFirstDelta = true;
|
||||
let resultEventSeen = false;
|
||||
// Separate flag for is_error result — must NOT be conflated with resultEventSeen.
|
||||
// If errored===true the close handler must not cache the response or record success
|
||||
// (mirrors callClaude which rejects and never caches on is_error).
|
||||
let errored = false;
|
||||
|
||||
function ensureHeaders() {
|
||||
if (res.writableEnded || res.destroyed) return false;
|
||||
@@ -1059,22 +975,19 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
|
||||
res.write("data: [DONE]\n\n");
|
||||
res.end();
|
||||
}
|
||||
clearOverallTimer();
|
||||
|
||||
} else if (parsed.error) {
|
||||
// is_error result — emit error stop; do NOT set resultEventSeen (that would
|
||||
// cause the close handler to record success + write cache). Set errored instead.
|
||||
errored = true;
|
||||
const errStr = String(parsed.error);
|
||||
logEvent("error", "claude_result_error", { model: cliModel, error: errStr.slice(0, 200) });
|
||||
trackError(errStr.slice(0, 200));
|
||||
// is_error result — emit error stop
|
||||
resultEventSeen = true;
|
||||
logEvent("error", "claude_result_error", { model: cliModel, error: parsed.error.slice(0, 200) });
|
||||
trackError(parsed.error.slice(0, 200));
|
||||
if (!headersSent && !res.writableEnded && !res.destroyed) {
|
||||
jsonResponse(res, 500, { error: { message: sanitizeError(errStr), type: "provider_error" } });
|
||||
jsonResponse(res, 500, { error: { message: parsed.error, type: "provider_error" } });
|
||||
} else if (!res.writableEnded && !res.destroyed) {
|
||||
// Headers already sent (eager ensureHeaders) — can't send a JSON 500. Surface the
|
||||
// failure as an SSE error frame so the client can distinguish an upstream error
|
||||
// from a legitimately empty completion, instead of a success-looking finish_reason:"stop". (issue #110)
|
||||
sendSSE(res, { error: { message: sanitizeError(errStr), type: "provider_error" } }, hb);
|
||||
sendSSE(res, {
|
||||
id, object: "chat.completion.chunk", created, model,
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||
}, hb);
|
||||
res.write("data: [DONE]\n\n");
|
||||
res.end();
|
||||
}
|
||||
@@ -1092,33 +1005,29 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
|
||||
|
||||
// Tolerate null exit code when result event was seen (sandbox-wrap noise, same
|
||||
// as OLP commit 2864275 — bwrap shell exits null after model completes).
|
||||
// Also route to the error path when errored===true (is_error result received):
|
||||
// never record success or write cache for an errored response.
|
||||
if ((code !== 0 && !resultEventSeen) || errored) {
|
||||
if (code !== 0 && !resultEventSeen) {
|
||||
recordModelError(cliModel, false);
|
||||
try { recordUsage({ keyId: authInfo.keyId, keyName: authInfo.keyName, model, promptChars: messages.reduce((a, m) => a + contentToText(m.content).length, 0), responseChars: 0, elapsedMs: elapsed, success: false }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
|
||||
logEvent("error", "claude_exit", { model: cliModel, code, signal: signal || "none", elapsed, errored, stderr: stderr.slice(0, 300) });
|
||||
try { recordUsage({ keyId: authInfo.keyId, keyName: authInfo.keyName, model, promptChars: messages.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0), responseChars: 0, elapsedMs: elapsed, success: false }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
|
||||
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 the error was already sent inline (parsed.error branch above), the
|
||||
// response may be writableEnded — nothing more to send.
|
||||
if (!headersSent && !res.writableEnded && !res.destroyed) {
|
||||
jsonResponse(res, 500, { error: { message: sanitizeError(stderr.slice(0, 300) || `claude exit ${code}`), type: "proxy_error" } });
|
||||
jsonResponse(res, 500, { error: { message: stderr.slice(0, 300) || `claude exit ${code}`, type: "proxy_error" } });
|
||||
} else if (!res.writableEnded && !res.destroyed) {
|
||||
// Headers already sent — surface the failure as an SSE error frame instead of a
|
||||
// success-looking finish_reason:"stop", so the client can tell the upstream crashed
|
||||
// rather than returned empty. (issue #110 — sibling of the parsed.error branch above.)
|
||||
sendSSE(res, { error: { message: sanitizeError(stderr.slice(0, 300) || `claude exit ${code}`), type: "proxy_error" } }, hb);
|
||||
sendSSE(res, {
|
||||
id, object: "chat.completion.chunk", created, model,
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||
}, hb);
|
||||
res.write("data: [DONE]\n\n");
|
||||
res.end();
|
||||
}
|
||||
} else {
|
||||
recordModelSuccess(cliModel, elapsed);
|
||||
breakerRecordSuccess(cliModel);
|
||||
try { recordUsage({ keyId: authInfo.keyId, keyName: authInfo.keyName, model, promptChars: messages.reduce((a, m) => a + contentToText(m.content).length, 0), responseChars: totalChars, elapsedMs: elapsed, success: true }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
|
||||
try { recordUsage({ keyId: authInfo.keyId, keyName: authInfo.keyName, model, promptChars: messages.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0), responseChars: totalChars, elapsedMs: elapsed, success: true }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
|
||||
logEvent("info", "claude_ok", { model: cliModel, chars: totalChars, elapsed, session: convId ? convId.slice(0, 12) + "..." : "none" });
|
||||
// Cache write-back for streaming — only on true success (not errored)
|
||||
// Cache write-back for streaming
|
||||
if (CACHE_TTL > 0 && authInfo.cacheHash) {
|
||||
try { setCachedResponse(authInfo.cacheHash, model, cachedContent); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); }
|
||||
}
|
||||
@@ -1146,7 +1055,7 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
|
||||
trackError(err.message);
|
||||
handleSessionFailure();
|
||||
if (!headersSent && !res.writableEnded && !res.destroyed) {
|
||||
jsonResponse(res, 500, { error: { message: sanitizeError(err.message), type: "proxy_error" } });
|
||||
jsonResponse(res, 500, { error: { message: err.message, type: "proxy_error" } });
|
||||
} else if (!res.writableEnded && !res.destroyed) {
|
||||
res.end();
|
||||
}
|
||||
@@ -1155,27 +1064,12 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
|
||||
// If client disconnects, kill the process to free resources
|
||||
res.on("close", () => {
|
||||
hb.stop();
|
||||
// Only escalate when the child is still alive. On the normal-success path res.end()
|
||||
// also fires "close", but the child has usually already exited — skip the spurious
|
||||
// SIGTERM and the 5s kill-timer entirely (a post-exit proc.once("exit") never fires,
|
||||
// so the timer would otherwise leak a closure over proc for 5s per request). (issue #111)
|
||||
if (!proc.killed && proc.exitCode === null && proc.signalCode === null) {
|
||||
if (!proc.killed) {
|
||||
try { proc.kill("SIGTERM"); } catch {}
|
||||
// Mirror the overallTimer escalation (server.mjs ~818): a SIGTERM-resistant child would
|
||||
// otherwise hold its concurrency slot until the request timeout — #37 on the disconnect path. (issue #111)
|
||||
const killTimer = setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 5000);
|
||||
killTimer.unref();
|
||||
proc.once("exit", () => clearTimeout(killTimer));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Strip absolute filesystem paths from an error message before sending it to a client.
|
||||
// claude error_message / stderr routinely embed home-dir / credential-file paths. (issue #111)
|
||||
function sanitizeError(msg) {
|
||||
return String(msg || "Internal error").replace(/\/[\w/.\-]+/g, "[path]");
|
||||
}
|
||||
|
||||
// ── Response helpers ────────────────────────────────────────────────────
|
||||
function jsonResponse(res, status, data) {
|
||||
if (res.headersSent || res.writableEnded || res.destroyed) return;
|
||||
@@ -1229,12 +1123,6 @@ function streamStringAsSSE(res, id, model, content) {
|
||||
|
||||
let usageCache = { data: null, fetchedAt: 0 };
|
||||
const USAGE_CACHE_TTL = 5 * 60 * 1000; // 5 min
|
||||
// ALIGNMENT (Class A — OAuth bearer machinery). Verified against the compiled cli.js
|
||||
// (claude.exe v2.1.154) on 2026-05-31 via `strings`: both OAUTH_CLIENT_ID and
|
||||
// OAUTH_TOKEN_URL appear in the binary byte-for-byte; the legacy host
|
||||
// console.anthropic.com/v1/oauth is absent (0 hits). Re-verify on cli.js major bumps
|
||||
// using the compiled-binary protocol (strings on the Mach-O/ELF; no live OAuth probe —
|
||||
// a refresh-token grant would rotate the operator's real credentials). (issue #112)
|
||||
const OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
||||
const OAUTH_TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
|
||||
|
||||
@@ -1342,7 +1230,7 @@ async function fetchUsageFromApi() {
|
||||
// Minimal /v1/messages request — we only need the response headers.
|
||||
// Mirrors Claude Code cli.js vE4: headers anthropic-ratelimit-unified-{5h,7d}-{utilization,reset}.
|
||||
const body = JSON.stringify({
|
||||
model: modelsConfig.aliases.haiku,
|
||||
model: "claude-haiku-4-5-20251001",
|
||||
max_tokens: 1,
|
||||
messages: [{ role: "user", content: "." }],
|
||||
});
|
||||
@@ -1619,16 +1507,9 @@ async function handleSettings(req, res) {
|
||||
|
||||
// PATCH
|
||||
let body = "";
|
||||
try {
|
||||
for await (const chunk of req) {
|
||||
body += chunk;
|
||||
if (body.length > 10000) return jsonResponse(res, 413, { error: "Body too large" });
|
||||
}
|
||||
} catch (e) {
|
||||
if (!res.headersSent && !res.writableEnded) {
|
||||
try { return jsonResponse(res, 400, { error: { message: "request aborted", type: "invalid_request_error" } }); } catch {}
|
||||
}
|
||||
return;
|
||||
for await (const chunk of req) {
|
||||
body += chunk;
|
||||
if (body.length > 10000) return jsonResponse(res, 413, { error: "Body too large" });
|
||||
}
|
||||
let updates;
|
||||
try { updates = JSON.parse(body); } catch { return jsonResponse(res, 400, { error: "Invalid JSON" }); }
|
||||
@@ -1665,25 +1546,18 @@ const VALID_MODELS = new Set(Object.keys(MODEL_MAP));
|
||||
|
||||
async function handleChatCompletions(req, res) {
|
||||
let body = "";
|
||||
try {
|
||||
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" } });
|
||||
}
|
||||
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" } });
|
||||
}
|
||||
} catch (e) {
|
||||
if (!res.headersSent && !res.writableEnded) {
|
||||
try { return jsonResponse(res, 400, { error: { message: "request aborted", type: "invalid_request_error" } }); } catch {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(body); } catch { return jsonResponse(res, 400, { error: "Invalid JSON" }); }
|
||||
|
||||
const messages = parsed.messages || parsed.input || [{ role: "user", content: parsed.prompt || "" }];
|
||||
const model = parsed.model || modelsConfig.aliases.sonnet;
|
||||
const model = parsed.model || "claude-sonnet-4-6";
|
||||
const stream = parsed.stream;
|
||||
|
||||
// Validate model against known models
|
||||
@@ -1694,15 +1568,8 @@ async function handleChatCompletions(req, res) {
|
||||
// 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 (!Array.isArray(messages) || messages.length === 0) {
|
||||
return jsonResponse(res, 400, { error: { message: "'messages' must be a non-empty array", type: "invalid_request_error" } });
|
||||
}
|
||||
if (!messages?.length) return jsonResponse(res, 400, { error: "messages required" });
|
||||
|
||||
// NOTE: quota is best-effort / eventually-consistent. The gate reads the recorded count
|
||||
// at entry and records only after the upstream completes, so concurrent requests at the
|
||||
// boundary can overshoot the cap by up to MAX_CONCURRENT, and cache hits (served before
|
||||
// recordUsage) are not counted. This is internal family rate-limiting, not a payment
|
||||
// boundary — bounded overshoot is acceptable. (issue #111)
|
||||
// Quota check — only for identified per-key users (not anonymous/admin/local)
|
||||
if (req._authKeyId) {
|
||||
let exceeded;
|
||||
@@ -1757,7 +1624,7 @@ async function handleChatCompletions(req, res) {
|
||||
// Default path (TUI_MODE===false) falls through to callClaudeStreaming below,
|
||||
// which is byte-for-byte unchanged from before this gate was added.
|
||||
const t0TuiStream = Date.now();
|
||||
const promptCharsTuiStream = messages.reduce((a, m) => a + contentToText(m.content).length, 0);
|
||||
const promptCharsTuiStream = messages.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0);
|
||||
try {
|
||||
const content = await callClaudeTui(model, messages, conversationId, req._authKeyName);
|
||||
if (CACHE_TTL > 0 && req._cacheHash) {
|
||||
@@ -1769,7 +1636,8 @@ async function handleChatCompletions(req, res) {
|
||||
return;
|
||||
} catch (err) {
|
||||
if (res.headersSent || res.writableEnded || res.destroyed) { try { res.end(); } catch {} return; }
|
||||
return jsonResponse(res, 500, { error: { message: sanitizeError(err.message), type: "proxy_error" } });
|
||||
const safeMessage = (err.message || "Internal error").replace(/\/[\w/.\-]+/g, "[path]");
|
||||
return jsonResponse(res, 500, { error: { message: safeMessage, type: "proxy_error" } });
|
||||
}
|
||||
}
|
||||
// Default: real stream-json streaming, unchanged.
|
||||
@@ -1777,7 +1645,7 @@ async function handleChatCompletions(req, res) {
|
||||
}
|
||||
|
||||
const t0Usage = Date.now();
|
||||
const promptChars = messages.reduce((a, m) => a + contentToText(m.content).length, 0);
|
||||
const promptChars = messages.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0);
|
||||
|
||||
// Select upstream based on TUI_MODE flag. With TUI_MODE===false (default),
|
||||
// upstreamCall===callClaude — identical to the pre-TUI code path.
|
||||
@@ -1811,7 +1679,8 @@ async function handleChatCompletions(req, res) {
|
||||
try { res.end(); } catch {}
|
||||
return;
|
||||
}
|
||||
return jsonResponse(res, 500, { error: { message: sanitizeError(err.message), type: "proxy_error" } });
|
||||
const safeMessage = (err.message || "Internal error").replace(/\/[\w/.\-]+/g, "[path]");
|
||||
return jsonResponse(res, 500, { error: { message: safeMessage, type: "proxy_error" } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1829,7 +1698,8 @@ async function handleChatCompletions(req, res) {
|
||||
return;
|
||||
}
|
||||
// Sanitize error: strip internal file paths before sending to client
|
||||
jsonResponse(res, 500, { error: { message: sanitizeError(err.message), type: "proxy_error" } });
|
||||
const safeMessage = (err.message || "Internal error").replace(/\/[\w/.\-]+/g, "[path]");
|
||||
jsonResponse(res, 500, { error: { message: safeMessage, type: "proxy_error" } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1926,12 +1796,6 @@ const server = createServer(async (req, res) => {
|
||||
req._authKeyName = authKeyName;
|
||||
req._authKeyId = authKeyId;
|
||||
|
||||
// isAdmin computed here (early, before any admin-gated handler) so that
|
||||
// DELETE /sessions, GET /logs, GET /usage, GET /status, PATCH /settings
|
||||
// can all gate on it. Localhost and explicit admin key are always admin;
|
||||
// in multi-tenant mode only the "admin" named key qualifies.
|
||||
const isAdmin = AUTH_MODE !== "multi" || authKeyName === "admin" || isLocalhost;
|
||||
|
||||
// GET /v1/models
|
||||
if (req.url === "/v1/models" && req.method === "GET") {
|
||||
return jsonResponse(res, 200, {
|
||||
@@ -1975,7 +1839,7 @@ const server = createServer(async (req, res) => {
|
||||
claudeBinary: CLAUDE,
|
||||
claudeBinaryOk: binaryOk,
|
||||
authMode: AUTH_MODE,
|
||||
...((isLocalhost || ADVERTISE_ANON_KEY) ? { anonymousKey: PROXY_ANONYMOUS_KEY || null } : {}),
|
||||
anonymousKey: PROXY_ANONYMOUS_KEY || null,
|
||||
auth: authStatus,
|
||||
config: {
|
||||
timeout: TIMEOUT,
|
||||
@@ -1993,17 +1857,15 @@ const server = createServer(async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// DELETE /sessions — clear all sessions (mutating; admin only)
|
||||
// DELETE /sessions — clear all sessions
|
||||
if (req.url === "/sessions" && req.method === "DELETE") {
|
||||
if (!isAdmin) return jsonResponse(res, 403, { error: { message: "admin only", type: "auth_error" } });
|
||||
const count = sessions.size;
|
||||
sessions.clear();
|
||||
return jsonResponse(res, 200, { cleared: count });
|
||||
}
|
||||
|
||||
// GET /sessions — list active sessions (operator data; admin only)
|
||||
// GET /sessions — list active sessions
|
||||
if (req.url === "/sessions" && req.method === "GET") {
|
||||
if (!isAdmin) return jsonResponse(res, 403, { error: { message: "admin only", type: "auth_error" } });
|
||||
const list = [];
|
||||
for (const [id, s] of sessions) {
|
||||
// id is "${keyName}|${conversationId}"; expose only the public-facing conversationId
|
||||
@@ -2013,51 +1875,37 @@ const server = createServer(async (req, res) => {
|
||||
return jsonResponse(res, 200, { sessions: list });
|
||||
}
|
||||
|
||||
// GET /usage — fetches plan usage from Anthropic API with operator token; admin only
|
||||
// GET /usage — fetch plan usage limits from Anthropic API
|
||||
if (req.url === "/usage" && req.method === "GET") {
|
||||
if (!isAdmin) return jsonResponse(res, 403, { error: { message: "admin only", type: "auth_error" } });
|
||||
return handleUsage(req, res);
|
||||
}
|
||||
|
||||
// GET /logs — recent proxy log entries (errors and key events); admin only
|
||||
// GET /logs — recent proxy log entries (errors and key events)
|
||||
if (req.url?.startsWith("/logs") && req.method === "GET") {
|
||||
if (!isAdmin) return jsonResponse(res, 403, { error: { message: "admin only", type: "auth_error" } });
|
||||
return handleLogs(req, res);
|
||||
}
|
||||
|
||||
// GET /status — combined usage + health summary; uses operator token; admin only
|
||||
// GET /status — combined usage + health summary
|
||||
if (req.url === "/status" && req.method === "GET") {
|
||||
if (!isAdmin) return jsonResponse(res, 403, { error: { message: "admin only", type: "auth_error" } });
|
||||
return handleStatus(req, res);
|
||||
}
|
||||
|
||||
// GET /settings — view current tunable settings (admin only)
|
||||
// PATCH /settings — update settings at runtime (JSON body; admin only, mutating)
|
||||
// GET /settings — view current tunable settings
|
||||
// PATCH /settings — update settings at runtime (JSON body)
|
||||
if (req.url === "/settings" && (req.method === "GET" || req.method === "PATCH")) {
|
||||
if (!isAdmin) return jsonResponse(res, 403, { error: { message: "admin only", type: "auth_error" } });
|
||||
return handleSettings(req, res);
|
||||
}
|
||||
|
||||
// ── Key management API ──
|
||||
// (isAdmin is computed early in the request handler, before the admin-gated routes)
|
||||
const isAdmin = AUTH_MODE !== "multi" || authKeyName === "admin" || isLocalhost;
|
||||
|
||||
if (req.url === "/api/keys" && req.method === "POST") {
|
||||
if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" });
|
||||
let body = "";
|
||||
try {
|
||||
for await (const chunk of req) { body += chunk; if (body.length > 10000) return jsonResponse(res, 413, { error: "Body too large" }); }
|
||||
} catch (e) {
|
||||
if (!res.headersSent && !res.writableEnded) {
|
||||
try { return jsonResponse(res, 400, { error: { message: "request aborted", type: "invalid_request_error" } }); } catch {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
for await (const chunk of req) body += chunk;
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(body); } catch { return jsonResponse(res, 400, { error: "Invalid JSON" }); }
|
||||
const name = parsed.name || `key-${Date.now()}`;
|
||||
if (!/^[A-Za-z0-9 ._-]{1,64}$/.test(name)) {
|
||||
return jsonResponse(res, 400, { error: { message: "Invalid key name: 1-64 chars of letters, digits, space, dot, underscore, hyphen", type: "invalid_request_error" } });
|
||||
}
|
||||
const newKey = createKey(name);
|
||||
return jsonResponse(res, 201, newKey);
|
||||
}
|
||||
@@ -2080,14 +1928,7 @@ const server = createServer(async (req, res) => {
|
||||
if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" });
|
||||
const idOrName = decodeURIComponent(req.url.split("/api/keys/")[1].replace("/quota", ""));
|
||||
let body = "";
|
||||
try {
|
||||
for await (const chunk of req) { body += chunk; if (body.length > 10000) return jsonResponse(res, 413, { error: "Body too large" }); }
|
||||
} catch (e) {
|
||||
if (!res.headersSent && !res.writableEnded) {
|
||||
try { return jsonResponse(res, 400, { error: { message: "request aborted", type: "invalid_request_error" } }); } catch {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
for await (const chunk of req) { body += chunk; if (body.length > 10000) return jsonResponse(res, 413, { error: "Body too large" }); }
|
||||
let quotaBody;
|
||||
try { quotaBody = JSON.parse(body); } catch { return jsonResponse(res, 400, { error: "Invalid JSON" }); }
|
||||
// Validate quota values: must be positive integers or null
|
||||
@@ -2191,20 +2032,6 @@ const server = createServer(async (req, res) => {
|
||||
});
|
||||
|
||||
|
||||
// ── Process-level safety nets ────────────────────────────────────────────
|
||||
// Prevent unhandled async rejections and synchronous exceptions from crashing
|
||||
// the daemon. Each registers once at module level so they are installed before
|
||||
// the first request arrives. These are global no-ops on the happy path.
|
||||
process.on("unhandledRejection", (e) =>
|
||||
logEvent("error", "unhandled_rejection", { error: e && e.message ? e.message : String(e) })
|
||||
);
|
||||
process.on("uncaughtException", (e) =>
|
||||
logEvent("error", "uncaught_exception", { error: e && e.message ? e.message : String(e) })
|
||||
);
|
||||
// Destroy the socket on low-level HTTP parse errors so broken connections
|
||||
// don't accumulate as open file descriptors.
|
||||
server.on("clientError", (err, socket) => { try { socket.destroy(); } catch {} });
|
||||
|
||||
// ── Graceful shutdown ────────────────────────────────────────────────────
|
||||
let shuttingDown = false;
|
||||
|
||||
|
||||
@@ -65,28 +65,6 @@ const OCP_ADMIN_KEY_INJECT = process.env.OCP_ADMIN_KEY || null;
|
||||
// PROXY_ANONYMOUS_KEY — same pattern
|
||||
const PROXY_ANON_KEY_INJECT = process.env.PROXY_ANONYMOUS_KEY || null;
|
||||
|
||||
// ── Inject-value helpers ─────────────────────────────────────────────────
|
||||
// Escape a value for safe inclusion in a plist <string>…</string> body.
|
||||
function xmlEscape(v) {
|
||||
return String(v).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
// Validate an injected service value: no control chars (a newline would inject a
|
||||
// rogue systemd Environment= directive; other control chars corrupt the unit/plist).
|
||||
// Spaces are allowed — filesystem paths (CLAUDE_BIN) may legitimately contain them.
|
||||
function assertSafeInjectValue(name, v) {
|
||||
if (v == null) return v;
|
||||
if (/[\x00-\x1f]/.test(String(v))) {
|
||||
console.error(`FATAL: ${name} contains a newline or control character — refusing to write it into the service unit.`);
|
||||
process.exit(1);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
// Validate all three INJECT values before they are written into any service unit.
|
||||
assertSafeInjectValue("CLAUDE_BIN", CLAUDE_BIN_INJECT);
|
||||
assertSafeInjectValue("OCP_ADMIN_KEY", OCP_ADMIN_KEY_INJECT);
|
||||
assertSafeInjectValue("PROXY_ANONYMOUS_KEY", PROXY_ANON_KEY_INJECT);
|
||||
|
||||
// ── Models: derived from models.json (single source of truth) ──────────
|
||||
const modelsConfig = JSON.parse(readFileSync(join(__dirname, "models.json"), "utf-8"));
|
||||
|
||||
@@ -425,17 +403,17 @@ if (!DRY_RUN) {
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>CLAUDE_PROXY_PORT</key>
|
||||
<string>${xmlEscape(PORT)}</string>
|
||||
<string>${PORT}</string>
|
||||
<key>CLAUDE_BIND</key>
|
||||
<string>${xmlEscape(BIND_ADDRESS)}</string>
|
||||
<string>${BIND_ADDRESS}</string>
|
||||
<key>CLAUDE_AUTH_MODE</key>
|
||||
<string>${xmlEscape(AUTH_MODE_CONFIG)}</string>${CLAUDE_BIN_INJECT ? `
|
||||
<string>${AUTH_MODE_CONFIG}</string>${CLAUDE_BIN_INJECT ? `
|
||||
<key>CLAUDE_BIN</key>
|
||||
<string>${xmlEscape(CLAUDE_BIN_INJECT)}</string>` : ""}${OCP_ADMIN_KEY_INJECT ? `
|
||||
<string>${CLAUDE_BIN_INJECT}</string>` : ""}${OCP_ADMIN_KEY_INJECT ? `
|
||||
<key>OCP_ADMIN_KEY</key>
|
||||
<string>${xmlEscape(OCP_ADMIN_KEY_INJECT)}</string>` : ""}${PROXY_ANON_KEY_INJECT ? `
|
||||
<string>${OCP_ADMIN_KEY_INJECT}</string>` : ""}${PROXY_ANON_KEY_INJECT ? `
|
||||
<key>PROXY_ANONYMOUS_KEY</key>
|
||||
<string>${xmlEscape(PROXY_ANON_KEY_INJECT)}</string>` : ""}
|
||||
<string>${PROXY_ANON_KEY_INJECT}</string>` : ""}
|
||||
</dict>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
|
||||
+8
-296
@@ -4,7 +4,6 @@
|
||||
* Tests database layer functions directly — no server needed.
|
||||
*/
|
||||
import { getDb, createKey, listKeys, validateKey, recordUsage, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, closeDb, hasCacheControl, singleflight, getInflightStats } from "./keys.mjs";
|
||||
import { isLoopbackBind } from "./lib/net.mjs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { strict as assert } from "node:assert";
|
||||
import { unlinkSync } from "node:fs";
|
||||
@@ -859,74 +858,6 @@ test("gcSnapshots keeps last N regardless of age", () => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ── setup.mjs helpers: xmlEscape + assertSafeInjectValue ──
|
||||
// setup.mjs cannot be imported (top-level side effects run the installer).
|
||||
// Replicated verbatim from setup.mjs for unit-testing — keep in sync with source.
|
||||
console.log("\nsetup.mjs inject helpers:");
|
||||
|
||||
function xmlEscape(v) {
|
||||
return String(v).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
function assertSafeInjectValueTest(name, v) {
|
||||
if (v == null) return v;
|
||||
// eslint-disable-next-line no-control-regex
|
||||
if (/[\x00-\x1f]/.test(String(v))) {
|
||||
throw new Error(`FATAL: ${name} contains a newline or control character`);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
test("xmlEscape encodes all five special XML chars", () => {
|
||||
assert.equal(xmlEscape('a<b>&"\''), "a<b>&"'");
|
||||
});
|
||||
|
||||
test("xmlEscape leaves normal ocp_ token untouched", () => {
|
||||
assert.equal(xmlEscape("ocp_abc123"), "ocp_abc123");
|
||||
});
|
||||
|
||||
test("assertSafeInjectValue rejects value with newline", () => {
|
||||
assert.throws(() => assertSafeInjectValueTest("OCP_ADMIN_KEY", "a\nb"), /FATAL/);
|
||||
});
|
||||
|
||||
test("assertSafeInjectValue rejects value with carriage return", () => {
|
||||
assert.throws(() => assertSafeInjectValueTest("OCP_ADMIN_KEY", "a\rb"), /FATAL/);
|
||||
});
|
||||
|
||||
test("assertSafeInjectValue rejects value with a tab (control char)", () => {
|
||||
assert.throws(() => assertSafeInjectValueTest("OCP_ADMIN_KEY", "a\tb"), /FATAL/);
|
||||
});
|
||||
|
||||
test("assertSafeInjectValue ACCEPTS a path with a space (CLAUDE_BIN may legitimately contain one)", () => {
|
||||
assert.equal(assertSafeInjectValueTest("CLAUDE_BIN", "/Users/x/My Apps/node"), "/Users/x/My Apps/node");
|
||||
});
|
||||
|
||||
test("assertSafeInjectValue accepts normal ocp_ token", () => {
|
||||
assert.doesNotThrow(() => assertSafeInjectValueTest("OCP_ADMIN_KEY", "ocp_abc123"));
|
||||
});
|
||||
|
||||
test("assertSafeInjectValue accepts null (omit path)", () => {
|
||||
assert.doesNotThrow(() => assertSafeInjectValueTest("OCP_ADMIN_KEY", null));
|
||||
});
|
||||
|
||||
test("plist-merge round-trips XML-escaped value correctly via mergePlistEnv", () => {
|
||||
// A value written with xmlEscape must survive a merge cycle — the [^<]* regex in
|
||||
// parsePlistEnv only sees the escaped form (no raw < reaches it), so round-trip is safe.
|
||||
const escaped = xmlEscape("a<b>&\"'"); // "a<b>&"'"
|
||||
const template = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>CLAUDE_AUTH_MODE</key>
|
||||
<string>${escaped}</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>`;
|
||||
// mergePlistEnv with no existing plist returns template unchanged.
|
||||
const merged = mergePlistEnv(null, template);
|
||||
assert.ok(merged.includes(escaped), "escaped value should survive unchanged through plist merge");
|
||||
});
|
||||
|
||||
test("gcSnapshots keeps snapshots newer than keepDays regardless of count", () => {
|
||||
const root = mkdtempSync(testJoin(tmpdir(), "ocp-gc-days-"));
|
||||
const dotOcp = testJoin(root, ".ocp");
|
||||
@@ -1378,11 +1309,11 @@ test("parseTranscriptLines skips blank + malformed/partial lines", () => {
|
||||
test("isTerminalLine true on turn_duration", () => {
|
||||
assert.equal(isTerminalLine({ type: "system", subtype: "turn_duration" }), true);
|
||||
});
|
||||
test("isTerminalLine false on stop_reason tool_use (message-wrapped) — tool_use is mid-turn in TUI mode", () => {
|
||||
assert.equal(isTerminalLine({ type: "assistant", message: { stop_reason: "tool_use" } }), false);
|
||||
test("isTerminalLine true on stop_reason tool_use (message-wrapped)", () => {
|
||||
assert.equal(isTerminalLine({ type: "assistant", message: { stop_reason: "tool_use" } }), true);
|
||||
});
|
||||
test("isTerminalLine false on stop_reason tool_use (flat) — claude continues after tool, turn not done", () => {
|
||||
assert.equal(isTerminalLine({ stop_reason: "tool_use" }), false);
|
||||
test("isTerminalLine true on stop_reason tool_use (flat)", () => {
|
||||
assert.equal(isTerminalLine({ stop_reason: "tool_use" }), true);
|
||||
});
|
||||
test("isTerminalLine false on ordinary assistant text line", () => {
|
||||
assert.equal(isTerminalLine({ type: "assistant", message: { content: [{ type: "text", text: "hi" }] } }), false);
|
||||
@@ -1439,11 +1370,10 @@ await asyncTest("readTuiTranscript returns assistant text when terminal marker p
|
||||
const p = `${dir}/s.jsonl`;
|
||||
tuiWriteFile(p, [
|
||||
JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "hello world" }] } }),
|
||||
JSON.stringify({ type: "system", subtype: "turn_duration", durationMs: 1200, entrypoint: "cli" }),
|
||||
JSON.stringify({ type: "system", subtype: "turn_duration", durationMs: 1200 }),
|
||||
].join("\n") + "\n");
|
||||
const out = await readTuiTranscript({ transcriptPath: p, wallclockMs: 2000, pollMs: 50 });
|
||||
assert.equal(out.text, "hello world");
|
||||
assert.equal(out.entrypoint, "cli");
|
||||
assert.equal(out, "hello world");
|
||||
});
|
||||
|
||||
await asyncTest("readTuiTranscript honours wall-clock cap and returns partial text", async () => {
|
||||
@@ -1451,12 +1381,7 @@ await asyncTest("readTuiTranscript honours wall-clock cap and returns partial te
|
||||
const p = `${dir}/s.jsonl`;
|
||||
tuiWriteFile(p, JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: "partial" }] } }) + "\n");
|
||||
const out = await readTuiTranscript({ transcriptPath: p, wallclockMs: 300, pollMs: 50 });
|
||||
assert.equal(out.text, "partial");
|
||||
});
|
||||
|
||||
await asyncTest("readTuiTranscript against real fixture: entrypoint is 'cli'", async () => {
|
||||
const out = await readTuiTranscript({ transcriptPath: "./lib/tui/fixtures/complete-haiku.jsonl", wallclockMs: 2000, pollMs: 50 });
|
||||
assert.equal(out.entrypoint, "cli");
|
||||
assert.equal(out, "partial");
|
||||
});
|
||||
|
||||
await asyncTest("readTuiTranscript throws when no text and cap elapses", async () => {
|
||||
@@ -1607,7 +1532,7 @@ if (process.env.OCP_TUI_LIVE === "1") {
|
||||
cwd: `${process.env.HOME}/.ocp-tui/work`,
|
||||
wallclockMs: 120000,
|
||||
});
|
||||
assert.ok(/PONG/i.test(out.text), `expected PONG, got: ${out.text.slice(0, 200)}`);
|
||||
assert.ok(/PONG/i.test(out), `expected PONG, got: ${out.slice(0, 200)}`);
|
||||
});
|
||||
} else {
|
||||
test("runTuiTurn (live) — SKIPPED (set OCP_TUI_LIVE=1 on PI231 to run)", () => {
|
||||
@@ -1615,219 +1540,6 @@ if (process.env.OCP_TUI_LIVE === "1") {
|
||||
});
|
||||
}
|
||||
|
||||
// ── /health anonymousKey gate (issue #109) ──────────────────────────────────
|
||||
// MIRRORS the predicate in server.mjs (search ADVERTISE_ANON_KEY) — copied
|
||||
// verbatim to avoid importing server.mjs (top-level server.listen() would
|
||||
// start a live HTTP server, per the stream-JSON parser tests convention above).
|
||||
console.log("\n/health anonymousKey gate (issue #109):");
|
||||
|
||||
// Replicate the gating predicate from server.mjs line ~286/1927:
|
||||
// ...((isLocalhost || ADVERTISE_ANON_KEY) ? { anonymousKey: ... } : {})
|
||||
function shouldAdvertiseAnonKey(isLocalhost, advertise) { return isLocalhost || advertise; }
|
||||
|
||||
test("(localhost=false, flag=false) → omit key", () => {
|
||||
assert.equal(shouldAdvertiseAnonKey(false, false), false);
|
||||
});
|
||||
test("(localhost=true, flag=false) → include key (localhost always exempt)", () => {
|
||||
assert.equal(shouldAdvertiseAnonKey(true, false), true);
|
||||
});
|
||||
test("(localhost=false, flag=true) → include key (opt-in set)", () => {
|
||||
assert.equal(shouldAdvertiseAnonKey(false, true), true);
|
||||
});
|
||||
test("(localhost=true, flag=true) → include key (both true)", () => {
|
||||
assert.equal(shouldAdvertiseAnonKey(true, true), true);
|
||||
});
|
||||
|
||||
// ── contentToText helper tests (issue #110) ──────────────────────────────────
|
||||
// MIRRORS server.mjs contentToText — copied verbatim to avoid importing server.mjs
|
||||
// (top-level server.listen() would start a live HTTP server).
|
||||
// Keep in sync with the definition in server.mjs above messagesToPrompt.
|
||||
console.log("\ncontentToText helper (issue #110):");
|
||||
|
||||
function contentToText(content) {
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content.map(p =>
|
||||
p && p.type === "text" && typeof p.text === "string" ? p.text : "[non-text content omitted]"
|
||||
).join("");
|
||||
}
|
||||
return content == null ? "" : JSON.stringify(content);
|
||||
}
|
||||
|
||||
test("contentToText: string input returned unchanged", () => {
|
||||
assert.equal(contentToText("hello"), "hello");
|
||||
});
|
||||
|
||||
test("contentToText: array of text parts concatenated", () => {
|
||||
assert.equal(
|
||||
contentToText([{ type: "text", text: "hello" }, { type: "text", text: " world" }]),
|
||||
"hello world"
|
||||
);
|
||||
});
|
||||
|
||||
test("contentToText: non-text part (image_url) replaced with placeholder", () => {
|
||||
assert.equal(
|
||||
contentToText([{ type: "image_url", image_url: { url: "https://example.com/img.png" } }]),
|
||||
"[non-text content omitted]"
|
||||
);
|
||||
});
|
||||
|
||||
test("contentToText: empty array returns empty string", () => {
|
||||
assert.equal(contentToText([]), "");
|
||||
});
|
||||
|
||||
test("contentToText: null returns empty string", () => {
|
||||
assert.equal(contentToText(null), "");
|
||||
});
|
||||
|
||||
// ── messages guard predicate truth-table (issue #110) ────────────────────────
|
||||
// Mirrors the guard at server.mjs line ~1650: Array.isArray(x) && x.length > 0
|
||||
console.log("\nmessages guard predicate (issue #110):");
|
||||
|
||||
function isValidMessages(x) { return Array.isArray(x) && x.length > 0; }
|
||||
|
||||
test("messages guard: string 'x' → invalid (non-array)", () => {
|
||||
assert.equal(isValidMessages("x"), false);
|
||||
});
|
||||
|
||||
test("messages guard: empty array [] → invalid", () => {
|
||||
assert.equal(isValidMessages([]), false);
|
||||
});
|
||||
|
||||
test("messages guard: [{role:'user',content:'hi'}] → valid", () => {
|
||||
assert.equal(isValidMessages([{ role: "user", content: "hi" }]), true);
|
||||
});
|
||||
|
||||
// ── sanitizeError helper (issue #111) ────────────────────────────────────
|
||||
// Replicated verbatim from server.mjs (cannot import server.mjs).
|
||||
// The SIGKILL-escalation and timer changes are process-lifecycle and are not
|
||||
// unit-testable here (no live-server harness).
|
||||
console.log("\nsanitizeError (issue #111):");
|
||||
|
||||
function sanitizeError(msg) {
|
||||
return String(msg || "Internal error").replace(/\/[\w/.\-]+/g, "[path]");
|
||||
}
|
||||
|
||||
test("sanitizeError: strips home-dir path from message", () => {
|
||||
const result = sanitizeError("failed at /Users/foo/.claude/creds.json");
|
||||
assert.ok(result.includes("[path]"), `expected [path] in: ${result}`);
|
||||
assert.ok(!result.includes("/Users/foo"), `expected /Users/foo stripped, got: ${result}`);
|
||||
});
|
||||
|
||||
test("sanitizeError: null input returns 'Internal error'", () => {
|
||||
assert.equal(sanitizeError(null), "Internal error");
|
||||
});
|
||||
|
||||
test("sanitizeError: message with no path passes through unchanged", () => {
|
||||
assert.equal(sanitizeError("no path here"), "no path here");
|
||||
});
|
||||
|
||||
test("sanitizeError: multiple paths all stripped", () => {
|
||||
const result = sanitizeError("err /a/b and /c/d");
|
||||
assert.ok(!result.includes("/a/b"), `expected /a/b stripped, got: ${result}`);
|
||||
assert.ok(!result.includes("/c/d"), `expected /c/d stripped, got: ${result}`);
|
||||
assert.ok(result.includes("[path]"), `expected [path] in: ${result}`);
|
||||
});
|
||||
|
||||
// ── models.json SPOT wiring (issue #112) ────────────────────────────────────
|
||||
// Asserts that the alias values used by server.mjs (usage probe + default model)
|
||||
// match the expected IDs. A future alias rename that silently breaks these
|
||||
// code paths is caught here.
|
||||
import { readFileSync as spotReadFileSync } from "node:fs";
|
||||
import { fileURLToPath as spotFileURLToPath } from "node:url";
|
||||
import { dirname as spotDirname, join as spotJoin } from "node:path";
|
||||
|
||||
console.log("\nmodels.json SPOT aliases (issue #112):");
|
||||
|
||||
const _spotDir = spotDirname(spotFileURLToPath(import.meta.url));
|
||||
const _spotModels = JSON.parse(spotReadFileSync(spotJoin(_spotDir, "models.json"), "utf8"));
|
||||
|
||||
test("models.json aliases.haiku === 'claude-haiku-4-5-20251001' (usage-probe SPOT)", () => {
|
||||
assert.equal(_spotModels.aliases.haiku, "claude-haiku-4-5-20251001");
|
||||
});
|
||||
|
||||
test("models.json aliases.sonnet === 'claude-sonnet-4-6' (default-request-model SPOT)", () => {
|
||||
assert.equal(_spotModels.aliases.sonnet, "claude-sonnet-4-6");
|
||||
});
|
||||
|
||||
// ── escapeHtml + key-name validator (issue #114) ────────────────────────────
|
||||
// Replicated verbatim from dashboard.html so tests run without a browser.
|
||||
function escapeHtml(s) {
|
||||
return String(s ?? "").replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
}
|
||||
const KEY_NAME_RE = /^[A-Za-z0-9 ._-]{1,64}$/;
|
||||
|
||||
console.log("\nescapeHtml (issue #114):");
|
||||
|
||||
test("escapeHtml: XSS payload → <img not <img", () => {
|
||||
const out = escapeHtml('<img src=x onerror=alert(1)>');
|
||||
assert.ok(out.includes("<img"), `expected <img in: ${out}`);
|
||||
assert.ok(!out.includes("<img"), `expected no raw <img in: ${out}`);
|
||||
});
|
||||
|
||||
test("escapeHtml: single-quote, double-quote, ampersand all escaped", () => {
|
||||
assert.equal(escapeHtml("a'b\"c&d"), "a'b"c&d");
|
||||
});
|
||||
|
||||
test("escapeHtml: null → empty string", () => {
|
||||
assert.equal(escapeHtml(null), "");
|
||||
});
|
||||
|
||||
console.log("\nKey-name validator (issue #114):");
|
||||
|
||||
test("KEY_NAME_RE: 'wife-laptop' → valid", () => {
|
||||
assert.ok(KEY_NAME_RE.test("wife-laptop"));
|
||||
});
|
||||
|
||||
test("KEY_NAME_RE: 'key-1700000000000' → valid", () => {
|
||||
assert.ok(KEY_NAME_RE.test("key-1700000000000"));
|
||||
});
|
||||
|
||||
test("KEY_NAME_RE: '<script>' → invalid", () => {
|
||||
assert.ok(!KEY_NAME_RE.test("<script>"));
|
||||
});
|
||||
|
||||
test("KEY_NAME_RE: \"a'); DROP\" → invalid", () => {
|
||||
assert.ok(!KEY_NAME_RE.test("a'); DROP"));
|
||||
});
|
||||
|
||||
test("KEY_NAME_RE: empty string → invalid", () => {
|
||||
assert.ok(!KEY_NAME_RE.test(""));
|
||||
});
|
||||
|
||||
test("KEY_NAME_RE: 65-char string → invalid", () => {
|
||||
assert.ok(!KEY_NAME_RE.test("x".repeat(65)));
|
||||
});
|
||||
|
||||
// ── isLoopbackBind helper (issue #115, extracted to lib/net.mjs via #125) ──────
|
||||
// Tests the imported lib/net.mjs helper — the real shared definition used by server.mjs.
|
||||
console.log("\nisLoopbackBind helper (issue #115):");
|
||||
|
||||
test("isLoopbackBind: '127.0.0.1' → true", () => {
|
||||
assert.equal(isLoopbackBind("127.0.0.1"), true);
|
||||
});
|
||||
test("isLoopbackBind: '::1' → true", () => {
|
||||
assert.equal(isLoopbackBind("::1"), true);
|
||||
});
|
||||
test("isLoopbackBind: 'localhost' → true", () => {
|
||||
assert.equal(isLoopbackBind("localhost"), true);
|
||||
});
|
||||
test("isLoopbackBind: '127.0.0.5' → true (127.x.x.x range)", () => {
|
||||
assert.equal(isLoopbackBind("127.0.0.5"), true);
|
||||
});
|
||||
test("isLoopbackBind: '0.0.0.0' → false (any-interface)", () => {
|
||||
assert.equal(isLoopbackBind("0.0.0.0"), false);
|
||||
});
|
||||
test("isLoopbackBind: '192.168.1.5' → false (LAN IP)", () => {
|
||||
assert.equal(isLoopbackBind("192.168.1.5"), false);
|
||||
});
|
||||
test("isLoopbackBind: '::' → false (IPv6 any-interface)", () => {
|
||||
assert.equal(isLoopbackBind("::"), false);
|
||||
});
|
||||
test("isLoopbackBind: '100.64.0.1' → false (Tailscale IP)", () => {
|
||||
assert.equal(isLoopbackBind("100.64.0.1"), false);
|
||||
});
|
||||
|
||||
// ── Cleanup ──
|
||||
closeDb();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user