mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-22 05:25:08 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1978a375e3 | ||
|
|
d97cef7b30 | ||
|
|
66dc9949ed | ||
|
|
758c2d703f | ||
|
|
6cf5e950a5 | ||
|
|
3bd19956ff | ||
|
|
fe615cb0d3 | ||
|
|
60930f0ba4 |
@@ -1,5 +1,18 @@
|
||||
# Changelog
|
||||
|
||||
## v3.20.1 — 2026-06-13
|
||||
|
||||
TUI-mode auth hardening: fixes the recurring `Please run /login · API Error: 401` (the PI231 incident) and reaps leaked defunct `claude` sessions. ([#141](https://github.com/dtzp555-max/ocp/pull/141))
|
||||
|
||||
### Fixed
|
||||
|
||||
- **TUI 401 / credential corruption (#141)** — interactive `claude` prefers `~/.claude/.credentials.json` over the `CLAUDE_CODE_OAUTH_TOKEN` env var (unlike `-p` mode, where the env token wins). OCP TUI's per-request spawn + `kill-session` cycle raced claude's single-use refresh-token rotation, corrupting the refresh token to an empty string → permanent 401 that `claude /login` couldn't fix (each new spawn re-corrupted it). This bit Linux/file-based hosts specifically (macOS reads credentials from the Keychain, so Mac mini was immune). **Fix:** when `CLAUDE_CODE_OAUTH_TOKEN` is set, the TUI claude now runs in a credential-free scratch HOME (`<HOME>/.ocp-tui/home`, overridable by `OCP_TUI_HOME`) seeded with onboarding + cwd-trust but **no `.credentials.json`**, so the env token is the only credential and claude never runs the refresh path. Recurrence-proof — a later `claude login` can no longer break TUI. Also: `buildTuiCmd` passes `CLAUDE_CODE_OAUTH_TOKEN` to the spawn, and `reapStaleTuiSessions` reaps defunct `claude` sessions (tmux-server-owned zombies) via `kill-server` when no foreign session remains, plus a 15-min idle-gated periodic reap. When the env token is unset, behaviour is byte-for-byte unchanged (real-home + credentials.json). Two independent fresh-context reviewers (Iron Rule 10) + a live PI231 portability test (works with a corrupt credentials.json present). Authorized by the ADR 0007 PR-D amendment (Class B).
|
||||
|
||||
### Environment variables
|
||||
|
||||
- `CLAUDE_CODE_OAUTH_TOKEN` — when set on a TUI host, TUI authenticates via this long-lived token in a credential-isolated home (recommended; immune to credentials.json corruption).
|
||||
- `OCP_TUI_HOME` — overrides the TUI scratch home; if you previously pointed it at your real home, unset it to get the credential-isolated default.
|
||||
|
||||
## v3.20.0 — 2026-06-10
|
||||
|
||||
TUI-mode billing-safety hardening for the 2026-06-15 Anthropic billing split. A 5-dimension multi-agent audit (adversarial verification + live tests on all three hosts — PI231 / Oracle / Mac mini, claude 2.1.104 / 2.1.114 / 2.1.170) found the TUI subscription-pool path could silently bill the metered Agent SDK pool or poison the cache under realistic failure modes. Three PRs, each with a fresh-context reviewer (Iron Rule 10) and CI; the default path (`CLAUDE_TUI_MODE` unset) is byte-for-byte unchanged.
|
||||
|
||||
@@ -128,11 +128,12 @@ Before each step, tell me what you'll run and wait for confirmation.
|
||||
On any error, diagnose first — don't auto-retry.
|
||||
```
|
||||
|
||||
**LAN mode (server)** — install OCP as a server so your family or multiple devices can share it:
|
||||
**LAN mode (server)** — install OCP as a server so your own devices on the LAN can reach it (Claude Pro/Max are per-user accounts — review Anthropic's Usage Policy before extending access to other people):
|
||||
|
||||
```text
|
||||
I want to install OCP on this device as a LAN server so my family and other
|
||||
devices on the network can share my Claude Pro/Max subscription.
|
||||
I want to install OCP on this device as a LAN server so my own devices on the
|
||||
network can reach my Claude Pro/Max subscription through a local
|
||||
OpenAI-compatible endpoint.
|
||||
|
||||
Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md
|
||||
"Server Setup" → "LAN mode" path:
|
||||
@@ -422,6 +423,7 @@ ocp keys revoke son-ipad # Revoke a key
|
||||
- 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.
|
||||
- **Account terms are your call.** Claude Pro/Max are *per-user* accounts, and Anthropic's Usage Policy governs who may use them. OCP is a localhost protocol adapter for your own tools and devices — it does not change your account terms, and whether any particular sharing setup complies with the Usage Policy is the account holder's responsibility. Review it before extending access to other people.
|
||||
|
||||
**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).)
|
||||
|
||||
@@ -842,6 +844,29 @@ ocp restart
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
### Env var change (e.g. `CLAUDE_BIND`, `CLAUDE_CODE_OAUTH_TOKEN`) doesn't take effect after restart
|
||||
|
||||
On **macOS**, `ocp restart` does a full `launchctl bootout` + `bootstrap` of the agent, which **re-reads the plist `EnvironmentVariables`** — so an env change you made (in `~/Library/LaunchAgents/dev.ocp.proxy.plist`) actually takes effect:
|
||||
|
||||
```bash
|
||||
ocp restart
|
||||
```
|
||||
|
||||
This is deliberate: the older `launchctl kickstart -k` only re-execs the process and **reuses launchd's cached environment**, so plist env edits would be silently ignored. If you ever restart the agent by hand, use bootout+bootstrap, not `kickstart -k`:
|
||||
|
||||
```bash
|
||||
launchctl bootout gui/$(id -u)/dev.ocp.proxy 2>/dev/null
|
||||
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/dev.ocp.proxy.plist
|
||||
```
|
||||
|
||||
Verify the new value reached the running process:
|
||||
|
||||
```bash
|
||||
ps -E -p "$(launchctl print gui/$(id -u)/dev.ocp.proxy 2>/dev/null | awk '/pid =/{print $3}')" | tr ' ' '\n' | grep CLAUDE_
|
||||
```
|
||||
|
||||
On **Linux**, `systemctl --user restart` already re-reads the unit's `EnvironmentFile`, so no special handling is needed.
|
||||
|
||||
### Usage shows "unknown"
|
||||
|
||||
Usually caused by an expired Claude CLI session. Fix:
|
||||
@@ -873,18 +898,21 @@ Future `ocp update` invocations sync automatically.
|
||||
|
||||
### TUI-mode returns `Please run /login · API Error: 401` (re-login doesn't stick)
|
||||
|
||||
A long-running TUI-mode host can get stuck returning a permanent 401 that re-login cannot fix. Root cause: when `CLAUDE_CODE_OAUTH_TOKEN` is **unset**, the interactive `claude` authenticates via `~/.claude/.credentials.json`, whose single-use OAuth refresh token can be corrupted (ending up an empty string) by the per-request spawn + `kill-session` teardown racing claude's token rotation. Re-login writes a fresh token, but the next spawn re-corrupts it.
|
||||
A long-running TUI-mode host can get stuck returning a permanent 401 that re-login cannot fix.
|
||||
|
||||
Fix: set `CLAUDE_CODE_OAUTH_TOKEN` on the OCP host (then restart — on systemd `daemon-reload`, on launchd `bootout`+`bootstrap`; `kickstart -k` does **not** reload env). The TUI `claude` then authenticates via the stable long-lived token and never touches credentials.json. Verify the env reached the process:
|
||||
**Root cause (two layers):** interactive `claude` **prefers `~/.claude/.credentials.json` over the `CLAUDE_CODE_OAUTH_TOKEN` env var** (this is *unlike* the `-p` path, where the env token wins). So (a) a stale/corrupt `credentials.json` **shadows** the env token — passing the token is not enough on its own; and (b) when claude does use `credentials.json`, its single-use OAuth refresh token can be corrupted (ending up an empty string) by the per-request spawn + `kill-session` teardown racing claude's token rotation. Re-login writes a fresh token, but the next spawn re-corrupts it. Proven live on PI231: *env token passed + broken `credentials.json` present → 401; env token passed + `credentials.json` moved aside → works.*
|
||||
|
||||
**Fix:** set `CLAUDE_CODE_OAUTH_TOKEN` on the OCP host and leave `OCP_TUI_HOME` **unset**. OCP then runs the TUI `claude` in a **credential-isolated home** (`$HOME/.ocp-tui/home`) that has **no `credentials.json`** at all, so the env token is the only credential (authoritative — nothing shadows it) and claude never runs the refresh path (so the single-use token can't be corrupted). Then restart — on systemd `daemon-reload`, on launchd `bootout`+`bootstrap`; `kickstart -k` does **not** reload env. Verify the env reached the process and the boot log shows the isolated home:
|
||||
|
||||
```bash
|
||||
# Linux (systemd): confirm the token is in the service env
|
||||
tr '\0' '\n' < /proc/$(pgrep -f server.mjs | head -1)/environ | grep CLAUDE_CODE_OAUTH_TOKEN
|
||||
# Re-login once to repair the credentials file (belt-and-braces), then it stays unused:
|
||||
claude /login
|
||||
# Boot log should read: TUI-mode: ON home=$HOME/.ocp-tui/home ... auth=env-token (credential-isolated home — no credentials.json)
|
||||
```
|
||||
|
||||
See [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) and ADR 0007 PR-C amendment.
|
||||
> If you previously set `OCP_TUI_HOME` to the real home (or any home that contains a `credentials.json`), **unset it** so the credential-isolated default takes effect — otherwise the shadowing `credentials.json` remains in play.
|
||||
|
||||
See [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) and ADR 0007 PR-C / PR-D amendments.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
@@ -898,7 +926,9 @@ See [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) and ADR 0007 PR-
|
||||
| `CLAUDE_BIN` | *(auto-detect)* | Path to claude binary |
|
||||
| `CLAUDE_TIMEOUT` | `600000` | Request timeout (ms, default: 10 min) |
|
||||
| `CLAUDE_HEARTBEAT_INTERVAL` | `0` | Streaming SSE keepalive interval (ms). `0` = disabled. See "Streaming heartbeat" section. |
|
||||
| `CLAUDE_MAX_CONCURRENT` | `8` | Max concurrent claude processes |
|
||||
| `CLAUDE_MAX_CONCURRENT` | `8` | Max concurrent claude processes (`-p`/stream-json path) |
|
||||
| `CLAUDE_MAX_QUEUE` | `16` | Max requests **waiting** for a `-p` concurrency slot. Beyond `CLAUDE_MAX_CONCURRENT`, requests queue (up to this cap) instead of being rejected; when the queue is **also** full, the request gets `HTTP 429` + `Retry-After` (not an opaque 500). Surfaced on `/health.concurrency` + `/health.stats.queueRejections`. |
|
||||
| `CLAUDE_QUEUE_RETRY_AFTER` | `5` | Seconds advertised in the `Retry-After` header on a `-p` concurrency-overflow `429`. |
|
||||
| `CLAUDE_MAX_PROMPT_CHARS` | `150000` | Prompt truncation limit (chars) |
|
||||
| `CLAUDE_SESSION_TTL` | `3600000` | Session expiry (ms, default: 1 hour) |
|
||||
| `CLAUDE_CACHE_TTL` | `0` | Response cache TTL (ms, 0 = disabled). Set to e.g. `300000` for 5-min cache |
|
||||
@@ -909,10 +939,11 @@ See [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) and ADR 0007 PR-
|
||||
| `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. |
|
||||
| `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_CODE_OAUTH_TOKEN` | *(unset)* | OAuth bearer token (highest-precedence credential source). **Recommended for TUI-mode hosts:** when set, the interactive `claude` authenticates via this long-lived token and never touches `~/.claude/.credentials.json`, avoiding the refresh-token corruption that caused a permanent `Please run /login` 401 on a long-running TUI host (see [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) and ADR 0007). The token appears in the pane command (ps-visible) — acceptable for the single-user A-path; the multi-user B-path is refused at boot. |
|
||||
| `CLAUDE_CODE_OAUTH_TOKEN` | *(unset)* | OAuth bearer token (highest-precedence credential source for the `-p` path). **Recommended for TUI-mode hosts:** when set (and `OCP_TUI_HOME` unset), OCP runs the interactive `claude` in a **credential-isolated home** (`$HOME/.ocp-tui/home`, no `credentials.json`) so this long-lived token is the only credential and is authoritative — interactive `claude` otherwise *prefers* `~/.claude/.credentials.json` over the env var, so a stale one shadows the token and its single-use refresh token gets corrupted by the spawn/teardown cycle (the permanent `Please run /login` 401 — see [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) and ADR 0007 PR-D). The token appears in the pane command (ps-visible) — acceptable for the single-user A-path; the multi-user B-path is refused at boot. |
|
||||
| `OCP_SPAWN_REAL_HOME` | *(unset)* | Kill-switch for the default `-p`/stream-json **spawn-home isolation** (latency fix). When unset and an OAuth token is resolvable, OCP runs the per-request `claude` spawn in a **credential-free minimal scratch home** (`$HOME/.ocp/spawn-home`, no `.credentials.json`/`settings.json`/plugins) with a neutral cwd and the env token — so it loads none of the operator's heavy global `~/.claude` (plugins/skills/hooks) or the project `CLAUDE.md`, cutting per-request latency (measured ~10–28s → ~3–7s). Set to `"1"` to force the legacy real-`HOME` spawn (no cwd override) even when a token exists. With **no** resolvable token, OCP falls back to the real `HOME` automatically (zero regression). Active mode is shown at startup and on `/health.spawn`. |
|
||||
| `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. |
|
||||
| `OCP_TUI_HOME` | `$HOME` (real home) | (TUI-mode) `HOME` claude runs under. Default is the operator's real home (shared credentials, existing onboarding). Set to a separate path for scratch-home isolation — see ADR 0007 for the credential-fork caveat. |
|
||||
| `OCP_TUI_HOME` | *(auto)* | (TUI-mode) `HOME` claude runs under. **When unset, OCP picks it for you:** if `CLAUDE_CODE_OAUTH_TOKEN` is set → a **credential-isolated** scratch home `$HOME/.ocp-tui/home` (no `credentials.json`, env-token auth — **recommended**); if no env token → the operator's real home (legacy shared `credentials.json`). Setting this to an **explicit** path overrides the auto-default. The credential handling at that path still follows the env token: **with** the env token it is credential-free (env-token auth, no `credentials.json` written); **without** the env token (and the path ≠ real home) it uses the legacy symlinked-credentials scratch mode, which carries the credential-fork caveat — see ADR 0007. |
|
||||
| `OCP_TUI_ENTRYPOINT` | `cli` | (TUI-mode) Billing-classifier labeling: `cli` (default) pins `cc_entrypoint=cli` deterministically; `auto` lets claude self-classify via TTY detection; `off` leaves the inherited env untouched. Honest only when the spawn is a genuine interactive PTY — see ADR 0007. |
|
||||
| `OCP_TUI_MAX_CONCURRENT` | `2` | (TUI-mode) Max concurrent interactive TUI turns. **Independent** of `CLAUDE_MAX_CONCURRENT` (which bounds the `-p`/stream-json path; TUI never uses it). A TUI turn is heavy (per-request cold-boot of tmux+claude + up to `CLAUDE_TUI_WALLCLOCK_MS` wallclock), so the default is low to keep small hosts (e.g. a Pi 4) alive under a burst. Excess turns **queue** (bounded); a full queue yields a 503. See ADR 0007 PR-B amendment. |
|
||||
| `OCP_SKIP_AUTH_TEST` | *(unset)* | When `=1`, skip the `claude -p` auth probe during `setup.mjs`. After 2026-06-15 this probe draws from the Agent SDK credit pool; set this to avoid burning a metered credit on re-installs or `ocp update` runs. Auth is validated at the first real request. |
|
||||
@@ -962,22 +993,27 @@ mkdir -p ~/.ocp-tui/work # one-time scratch cwd setup
|
||||
|
||||
# Enable
|
||||
export CLAUDE_TUI_MODE=true
|
||||
# STRONGLY RECOMMENDED on a TUI host — authenticate via the long-lived OAuth token
|
||||
# so the interactive claude never touches ~/.claude/.credentials.json (whose single-use
|
||||
# refresh token can get corrupted by the per-request spawn/teardown cycle → permanent
|
||||
# "Please run /login" 401). See the auth note below + ADR 0007.
|
||||
# STRONGLY RECOMMENDED on a TUI host — authenticate via the long-lived OAuth token.
|
||||
# With this set (and OCP_TUI_HOME left UNSET), OCP runs the interactive claude in a
|
||||
# credential-isolated home ($HOME/.ocp-tui/home, no credentials.json), so the env token
|
||||
# is the only credential and is authoritative. This both stops a stale credentials.json
|
||||
# from shadowing the token AND ends the refresh-token corruption that caused a permanent
|
||||
# "Please run /login" 401 (no credentials file → claude never runs the refresh path).
|
||||
# See the auth note below + ADR 0007 PR-D.
|
||||
export CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...
|
||||
# Optionally tune:
|
||||
export CLAUDE_TUI_WALLCLOCK_MS=180000 # 3 min cap for long Opus turns
|
||||
export OCP_TUI_CWD=$HOME/.ocp-tui/work # default; override if needed
|
||||
export OCP_TUI_ENTRYPOINT=cli # default; use 'auto' to observe TTY-derived value
|
||||
# Do NOT set OCP_TUI_HOME for the recommended setup — leaving it unset is what enables
|
||||
# the credential-isolated home. Set it only to opt into the legacy symlinked-creds mode.
|
||||
```
|
||||
|
||||
Then restart OCP. At boot you will see:
|
||||
Then restart OCP. At boot you will see (with the env token set, isolated home auto-selected):
|
||||
|
||||
```
|
||||
⚠️ TUI-mode ON — single-user only; do NOT enable on a multi-user OCP ...
|
||||
TUI-mode: ON home=/home/user cwd=/home/user/.ocp-tui/work wallclock=120000ms
|
||||
TUI-mode: ON home=/home/user/.ocp-tui/home cwd=/home/user/.ocp-tui/work auth=env-token (credential-isolated home — no credentials.json) wallclock=120000ms maxConcurrent=2
|
||||
```
|
||||
|
||||
### What changes / what doesn't
|
||||
@@ -986,7 +1022,7 @@ Then restart OCP. At boot you will see:
|
||||
- **No real token streaming.** TUI-mode buffers the full response then replays it as chunked SSE. You will see a delay then the complete response rather than real-time tokens.
|
||||
- **Cache and singleflight work normally.** TUI-mode writes the buffered response to the cache on success; cache-hits skip the interactive turn entirely.
|
||||
- **The host's `CLAUDE.md` / auto-memory is never injected.** OCP is a proxy — the proxied client (OpenClaw / your IDE) owns its own context and memory. TUI-mode always runs `claude` with `CLAUDE_CODE_DISABLE_CLAUDE_MDS` + `CLAUDE_CODE_DISABLE_AUTO_MEMORY`, so a `CLAUDE.md` on the OCP host can never leak into proxied turns (verified live; see #4). Built-in tool schemas + the interactive system prompt remain (the inherent ~20–35K context floor of interactive mode); MCP is hard-disabled.
|
||||
- **Authenticate via `CLAUDE_CODE_OAUTH_TOKEN` (recommended).** tmux does not forward the parent process's env to the pane, so OCP sets the token explicitly on the spawned `claude` when `CLAUDE_CODE_OAUTH_TOKEN` is present. With the token set, the interactive `claude` authenticates via the stable long-lived token and **never touches `~/.claude/.credentials.json`**. Without it, claude falls back to credentials.json, whose single-use OAuth refresh token can be corrupted by the per-request spawn + `kill-session` teardown racing claude's token rotation — on a long-running host this produced a permanent `Please run /login · API Error: 401` that re-login could not fix (the next spawn re-corrupted it). Setting the token mirrors how the stable hosts already run. (The token is then visible in `ps` on the pane command — acceptable for the single-user A-path; the multi-user B-path is refused at boot.) See ADR 0007 PR-C amendment.
|
||||
- **Authenticate via `CLAUDE_CODE_OAUTH_TOKEN` in a credential-isolated home (recommended).** tmux does not forward the parent process's env to the pane, so OCP sets the token explicitly on the spawned `claude` when `CLAUDE_CODE_OAUTH_TOKEN` is present. But passing the token is **not enough on its own**: interactive `claude` *prefers* `~/.claude/.credentials.json` over the env var (unlike the `-p` path), so a stale `credentials.json` would shadow the token. With the env token set and `OCP_TUI_HOME` unset, OCP therefore runs claude in a **credential-isolated home** (`$HOME/.ocp-tui/home`) that has **no `credentials.json`** — so the env token is the only credential and is authoritative, and claude never runs the token-refresh path (so the single-use refresh token can't be corrupted by the spawn/teardown cycle). On a long-running host the credentials.json path produced a permanent `Please run /login · API Error: 401` that re-login could not fix (the next spawn re-corrupted it); the isolated home ends that at the root. Transcripts land under the same isolated home, so the answer-reader is unaffected. Without the env token, claude falls back to the real home's `credentials.json` (byte-for-byte the previous behaviour). (The token is visible in `ps` on the pane command — acceptable for the single-user A-path; the multi-user B-path is refused at boot.) See ADR 0007 PR-C / PR-D amendments.
|
||||
- **Stale tmux sessions are reaped.** The pane's `claude` is a child of the tmux server (not OCP), so OCP cannot reap it directly; `claude` zombies can otherwise accumulate as `<defunct>` over a long-running host. OCP reaps them at boot and on a 15-min idle sweep by issuing `tmux kill-server` — but **only when no foreign tmux session remains** (it never disrupts a co-hosted `olp-tui-*` instance). See ADR 0007 PR-C amendment.
|
||||
- **Default path unchanged.** Unset `CLAUDE_TUI_MODE` and restart → `callClaude` / `callClaudeStreaming` are used again, byte-for-byte identical to today.
|
||||
- **Concurrency is bounded separately.** TUI turns are heavy (per-request cold-boot + long wallclock), so the TUI path has its own limiter — `OCP_TUI_MAX_CONCURRENT` (default `2`), independent of `CLAUDE_MAX_CONCURRENT`. Excess turns queue; a full queue returns a 503. Tune it up only on a host that can run more interactive `claude` sessions at once.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# ADR 0007 — TUI Interactive Mode (subscription-pool bridge)
|
||||
|
||||
**Date:** 2026-05-31
|
||||
**Status:** Accepted — amended by PR-4 (entrypoint hardening)
|
||||
**Status:** Accepted — amended by PR-4 (entrypoint hardening), PR-B (observability + concurrency), PR-C (env-token auth + defunct-reaping), PR-D (credential-isolated home — corrects PR-C)
|
||||
**Deciders:** project maintainer
|
||||
**Authority:** claude CLI v2.1.158 interactive mode — verified live on the test host that sessions launched without `-p` / `--output-format` carry `cc_entrypoint=cli` (subscription pool), not `cc_entrypoint=sdk-cli` (Agent SDK credit pool). Mechanism verified on cli.js v2.1.104; live-confirmed on v2.1.158.
|
||||
|
||||
@@ -98,12 +98,16 @@ When `CLAUDE_TUI_MODE` is unset (the default), no code path touches `callClaudeT
|
||||
|
||||
Unset `CLAUDE_TUI_MODE` (or set it to any value other than `"true"`) → stream-json path restored immediately on next restart.
|
||||
|
||||
### Home strategy (real-home default)
|
||||
### Home strategy
|
||||
|
||||
`TUI_HOME = OCP_TUI_HOME || HOME` (defaults to the operator's real home).
|
||||
> **Superseded by the PR-D amendment below for the env-token case.** As of PR-D, `TUI_HOME`
|
||||
> is computed by `resolveTuiHome()`: when `CLAUDE_CODE_OAUTH_TOKEN` is set (and `OCP_TUI_HOME`
|
||||
> is unset) the default is a **credential-free scratch home**, not the real home. The
|
||||
> descriptions below remain accurate for the **no-env-token** case and the **explicit
|
||||
> `OCP_TUI_HOME` override** case.
|
||||
|
||||
- **Real-home (default, `OCP_TUI_HOME` unset):** claude runs with the operator's own `~/.claude/` — shared credentials, existing onboarding, no OAuth fork risk. `ensureTuiCwdTrusted` seeds the trust record for the scratch cwd in the real `~/.claude.json` (atomic write).
|
||||
- **Scratch-home opt-in (`OCP_TUI_HOME=<path>`):** a dedicated `HOME` that symlinks `~/.claude/.credentials.json` from the real home (token is never copied) and seeds a stripped `~/.claude.json` (no project history, trusts only the scratch cwd). **Caveat:** claude rewrites `.credentials.json` on OAuth token refresh, replacing the symlink with a regular file — this forks the credentials. Use scratch-home only with a dedicated OAuth or for ephemeral testing.
|
||||
- **Real-home (default when NO env token, `OCP_TUI_HOME` unset):** claude runs with the operator's own `~/.claude/` — shared credentials, existing onboarding, no OAuth fork risk. `ensureTuiCwdTrusted` seeds the trust record for the scratch cwd in the real `~/.claude.json` (atomic write).
|
||||
- **Scratch-home opt-in (`OCP_TUI_HOME=<path>`, no env token):** a dedicated `HOME` that symlinks `~/.claude/.credentials.json` from the real home (token is never copied) and seeds a stripped `~/.claude.json` (no project history, trusts only the scratch cwd). **Caveat:** claude rewrites `.credentials.json` on OAuth token refresh, replacing the symlink with a regular file — this forks the credentials. Use this legacy symlink mode only with a dedicated OAuth or for ephemeral testing. (The PR-D env-token mode avoids this caveat entirely — no credentials file to fork.)
|
||||
|
||||
### Working directory
|
||||
|
||||
@@ -269,6 +273,44 @@ Both changes are **Class B** (OCP-owned TUI spawn). `cli.js` does not perform ei
|
||||
|
||||
---
|
||||
|
||||
## Credential-isolated home for env-token auth (PR-D amendment)
|
||||
|
||||
**Date:** 2026-06-13
|
||||
**Status:** Accepted — amends ADR 0007. **Corrects** the PR-C rationale and the original "Home strategy" section's scratch-home caveat.
|
||||
**Motivation:** PR-C's env-token passing alone did **not** fix the PI231 401. Decisive live evidence (claude 2.1.104, PI231):
|
||||
|
||||
| Condition | Result |
|
||||
|---|---|
|
||||
| env token passed + a broken `~/.claude/.credentials.json` present | **401** (`Please run /login · API Error: 401`) |
|
||||
| env token passed + `credentials.json` moved aside | **works** (real answer) |
|
||||
|
||||
### Corrected root cause
|
||||
|
||||
**Interactive `claude` PREFERS `~/.claude/.credentials.json` over the `CLAUDE_CODE_OAUTH_TOKEN` env var.** A stale/corrupt `credentials.json` therefore **shadows** the env token. (This is *unlike* `-p` mode, where the env token wins — which is why `server.mjs`'s own `getOAuthCredentials()` is unaffected and why PR-C's premise looked sufficient.) So passing the token (PR-C, `buildTuiCmd`) is **necessary but insufficient**: the TUI `claude` must additionally run in a HOME that has **no `credentials.json`**, so the env token is the only credential and is authoritative.
|
||||
|
||||
This also fixes the original incident at the **root**, more completely than PR-C claimed: with no `credentials.json` in the home, claude never runs the token-refresh path at all, so the single-use refresh token can never be rotated — and therefore never corrupted — by the spawn+`kill-session` cycle. The 25-zombie / empty-refresh-token failure mode becomes structurally impossible, not merely avoided.
|
||||
|
||||
### Decision
|
||||
|
||||
When `CLAUDE_CODE_OAUTH_TOKEN` is set, the TUI `claude` runs in a **credential-free scratch home** by default:
|
||||
|
||||
- `resolveTuiHome({ realHome, configuredHome, envTokenSet })` (exported from `lib/tui/session.mjs`, pure) decides the home:
|
||||
- **`OCP_TUI_HOME` set** → that path (explicit override, back-compat — an operator who configured it keeps exactly that home).
|
||||
- **else env token set** → `<realHome>/.ocp-tui/home` — a dedicated scratch home seeded with a minimal `.claude.json` (`hasCompletedOnboarding=true` + trust **only** the scratch cwd) and its own `projects/` dir, and **deliberately NO `.credentials.json`** (no symlink, no copy).
|
||||
- **else (no env token)** → the operator's real home — **byte-for-byte the pre-fix behaviour** for hosts that intentionally rely on `credentials.json`.
|
||||
- `prepareTuiHome(realHome, tuiHome, cwd, { envTokenMode })` gates the credential handling: in `envTokenMode` it creates the scratch `projects/` dir and seeds the minimal trusted `.claude.json` but **never** creates the credentials symlink. `runTuiTurn` sets `envTokenMode = !!CLAUDE_CODE_OAUTH_TOKEN && ehome !== realHome`.
|
||||
- `readTuiTranscript` reads from the **same** home claude runs under (`ehome`), so transcripts land under `<scratch home>/.claude/projects/` and `findTranscriptPath` globs them there — the home is threaded through consistently. (We chose scratch-`HOME` over `CLAUDE_CONFIG_DIR`: the binary supports `CLAUDE_CONFIG_DIR`, but it relocates the transcript root to `<CONFIG_DIR>/projects/` rather than `<HOME>/.claude/projects/`, which would fork the transcript-resolution rule across modes for no benefit. The scratch-HOME lever reuses the existing, tested `prepareTuiHome`/`ehome` plumbing.)
|
||||
|
||||
### This RESOLVES — not reintroduces — the scratch-home caveat
|
||||
|
||||
The original "Home strategy" section and PR-C's `prepareTuiHome` comment warned that scratch-home is unsafe because *claude rewrites a **symlinked** `.credentials.json` on token refresh → forks/corrupts the OAuth credentials*. **That caveat does not apply to env-token mode**: there is no `credentials.json` in the home to fork, and claude never refreshes (it uses the long-lived env token), so there is no rotation and no corruption. The fork risk was inherent to the *symlink* approach; removing the credentials file entirely removes the risk. The legacy symlink mode is retained **only** for an operator who explicitly sets `OCP_TUI_HOME` without an env token, and its caveat is preserved for exactly that path.
|
||||
|
||||
### ALIGNMENT authorization (Class B)
|
||||
|
||||
**Class B** (OCP-owned TUI spawn). `cli.js` has no analogue for the TUI pane's auth/home strategy; authorized by **this ADR (0007)** per `ALIGNMENT.md`'s Class B citation requirement. `server.mjs` is touched only to compute `TUI_HOME` via `resolveTuiHome()` (TUI wiring) and to surface the auth mode in the boot log — no Class A wire surface, no endpoint shape, no `alignment.yml` blacklist token, and no `models.json` entry is touched.
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
+75
-21
@@ -156,39 +156,85 @@ export function ensureTuiCwdTrusted(home, cwd) {
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
|
||||
// Prepare the HOME claude runs under. Two modes:
|
||||
// - real-home (tuiHome === realHome OR falsy): no isolation; just trust the cwd
|
||||
// in the real ~/.claude.json. Opt in by setting OCP_TUI_HOME=$HOME.
|
||||
// - scratch-home: a dedicated HOME that reuses the real OAuth via a SYMLINKED
|
||||
// .credentials.json, with a seeded .claude.json (onboarded real config minus
|
||||
// the user's project history; trusts only the scratch cwd) and its own
|
||||
// projects/ dir — so the real ~/.claude is never mutated or polluted.
|
||||
// Resolve the HOME the TUI `claude` runs under. Three intents, decided by the env
|
||||
// token + an explicit OCP_TUI_HOME override:
|
||||
//
|
||||
// ⚠️ CREDENTIAL CAVEAT (verified live): claude rewrites .credentials.json on token
|
||||
// refresh, REPLACING the symlink with a regular-file copy → the scratch home then
|
||||
// FORKS the OAuth credentials. Because OAuth refresh tokens rotate (single-use), a
|
||||
// refresh in the scratch home can invalidate the token the user's real-home claude
|
||||
// relies on. Therefore scratch-home is safe only with a DEDICATED OAuth or for
|
||||
// ephemeral use; for a shared subscription prefer real-home (tuiHome===realHome),
|
||||
// which shares one .credentials.json — identical to how OCP already spawns claude.
|
||||
// Idempotent + best-effort: any failure degrades toward the dialog/cap, never
|
||||
// corrupts. Run BEFORE the session boots.
|
||||
export function prepareTuiHome(realHome, tuiHome, cwd) {
|
||||
// - ENV-TOKEN MODE (default when CLAUDE_CODE_OAUTH_TOKEN is set AND OCP_TUI_HOME is
|
||||
// unset): a CREDENTIAL-FREE scratch home at `<realHome>/.ocp-tui/home`. There is
|
||||
// deliberately NO .credentials.json (no symlink, no copy), so the only credential
|
||||
// claude can find is the long-lived env token (passed by buildTuiCmd). This is what
|
||||
// actually FORCES env-token auth — see the prepareTuiHome comment for why passing
|
||||
// the token alone is insufficient.
|
||||
// - EXPLICIT OVERRIDE: whatever OCP_TUI_HOME names (back-compat; an operator who set it
|
||||
// keeps exactly that home).
|
||||
// - REAL-HOME (default when the env token is unset): the operator's real home, shared
|
||||
// credentials.json — byte-for-byte the pre-fix behaviour for credentials.json hosts.
|
||||
//
|
||||
// Pure + deterministic so server.mjs and the tests share one decision. `configuredHome`
|
||||
// is the raw OCP_TUI_HOME value (undefined/empty => unset).
|
||||
export const DEFAULT_TUI_SCRATCH_HOME = (realHome) => `${realHome}/.ocp-tui/home`;
|
||||
export function resolveTuiHome({ realHome, configuredHome, envTokenSet }) {
|
||||
if (configuredHome) return configuredHome; // explicit override wins (back-compat)
|
||||
if (envTokenSet) return DEFAULT_TUI_SCRATCH_HOME(realHome); // credential-free scratch
|
||||
return realHome; // legacy real-home default
|
||||
}
|
||||
|
||||
// Prepare the HOME claude runs under. Three modes:
|
||||
// - real-home (tuiHome === realHome OR falsy): no isolation; just trust the cwd
|
||||
// in the real ~/.claude.json. The legacy default when no env token is set.
|
||||
// - ENV-TOKEN scratch-home (envTokenMode === true): a dedicated HOME with a seeded
|
||||
// .claude.json (onboarded + trusts only the scratch cwd) and its own projects/ dir,
|
||||
// and DELIBERATELY NO .credentials.json (no symlink, no copy). claude then has no
|
||||
// credentials file to read, so it authenticates via CLAUDE_CODE_OAUTH_TOKEN (passed
|
||||
// by buildTuiCmd) — which is authoritative precisely because nothing shadows it.
|
||||
// - legacy scratch-home (envTokenMode falsy, tuiHome !== realHome): the historical
|
||||
// mode that SYMLINKS the real .credentials.json. Retained only for an operator who
|
||||
// explicitly set OCP_TUI_HOME without an env token; see the caveat below.
|
||||
//
|
||||
// WHY ENV-TOKEN MODE IS THE FIX (proven live on PI231, claude 2.1.104):
|
||||
// env token passed + a broken ~/.claude/.credentials.json present → 401.
|
||||
// env token passed + credentials.json moved aside → real answer.
|
||||
// Interactive `claude` PREFERS .credentials.json over the env var (unlike `-p`, where the
|
||||
// env token wins), so a stale/corrupt credentials.json SHADOWS the env token. Passing the
|
||||
// token is necessary but insufficient; the TUI claude must run in a HOME with NO
|
||||
// credentials.json so the env token is the only credential. This ALSO ends the refresh-
|
||||
// corruption incident at the root: with no credentials file, claude never runs the token-
|
||||
// refresh path, so the single-use refresh token can never be rotated (and corrupted) by the
|
||||
// spawn+kill cycle. (This RESOLVES — not reintroduces — the ADR 0007 scratch-home concern:
|
||||
// the old caveat was about a SYMLINKED credentials.json being forked on refresh; here there
|
||||
// is no credentials file to fork and no refresh ever happens.)
|
||||
//
|
||||
// ⚠️ LEGACY SCRATCH-HOME CAVEAT (envTokenMode falsy, symlink path): claude rewrites
|
||||
// .credentials.json on token refresh, REPLACING the symlink with a regular-file copy → the
|
||||
// scratch home FORKS the OAuth credentials and a refresh can invalidate the real-home token.
|
||||
// That path is therefore safe only with a DEDICATED OAuth or for ephemeral use. The env-token
|
||||
// mode above avoids this entirely.
|
||||
//
|
||||
// Idempotent + best-effort: any failure degrades toward the dialog/cap, never corrupts.
|
||||
// Run BEFORE the session boots.
|
||||
export function prepareTuiHome(realHome, tuiHome, cwd, { envTokenMode = false } = {}) {
|
||||
if (!tuiHome || tuiHome === realHome) { ensureTuiCwdTrusted(realHome, cwd); return; }
|
||||
try {
|
||||
const claudeDir = `${tuiHome}/.claude`;
|
||||
mkdirSync(`${claudeDir}/projects`, { recursive: true });
|
||||
// Symlink the real credentials (never copy the OAuth token); refresh if missing.
|
||||
if (!envTokenMode) {
|
||||
// Legacy mode ONLY: symlink the real credentials (never copy the token); refresh if
|
||||
// missing. Env-token mode deliberately skips this — no credentials file at all.
|
||||
const link = `${claudeDir}/.credentials.json`;
|
||||
if (!existsSync(link)) {
|
||||
try { symlinkSync(`${realHome}/.claude/.credentials.json`, link); } catch { /* best effort */ }
|
||||
}
|
||||
// Seed .claude.json ONCE (if absent): start from the onboarded real config,
|
||||
// drop the user's project history, trust only the scratch cwd. mode 0600.
|
||||
}
|
||||
// Seed .claude.json ONCE (if absent): onboarded + trust ONLY the scratch cwd.
|
||||
// In env-token mode start from a MINIMAL config (do NOT copy the real ~/.claude.json —
|
||||
// a credential-isolated home should not inherit the operator's account/config state);
|
||||
// in legacy mode carry the onboarded real config minus the user's project history.
|
||||
const seedPath = `${tuiHome}/.claude.json`;
|
||||
if (!existsSync(seedPath)) {
|
||||
let base = {};
|
||||
if (!envTokenMode) {
|
||||
try { base = JSON.parse(readFileSync(`${realHome}/.claude.json`, "utf8")); } catch { /* fresh */ }
|
||||
}
|
||||
base.hasCompletedOnboarding = true;
|
||||
base.projects = { [cwd]: { hasTrustDialogAccepted: true, allowedTools: [] } };
|
||||
writeFileSync(seedPath, JSON.stringify(base, null, 2), { mode: 0o600 });
|
||||
@@ -341,10 +387,18 @@ export async function runTuiTurn({
|
||||
const ehome = home || process.env.HOME; // HOME claude runs under (scratch or real)
|
||||
const rhome = realHome || process.env.HOME; // real home (OAuth + onboarded config source)
|
||||
|
||||
// Env-token-only mode: the env token is set AND claude runs in an isolated home
|
||||
// (ehome !== rhome). In that case the scratch home must be CREDENTIAL-FREE (no
|
||||
// .credentials.json) so the env token — passed by buildTuiCmd — is the only credential
|
||||
// and is therefore authoritative (interactive claude otherwise PREFERS a credentials.json,
|
||||
// shadowing the env token; proven live on PI231). server.mjs derives TUI_HOME via
|
||||
// resolveTuiHome() so this isolated home is the DEFAULT once CLAUDE_CODE_OAUTH_TOKEN is set.
|
||||
const envTokenMode = !!process.env.CLAUDE_CODE_OAUTH_TOKEN && ehome !== rhome;
|
||||
|
||||
// Ensure scratch cwd exists, then prepare the (scratch or real) HOME + trust the
|
||||
// cwd — before claude boots.
|
||||
if (!existsSync(cwd)) mkdirSync(cwd, { recursive: true });
|
||||
prepareTuiHome(rhome, ehome, cwd);
|
||||
prepareTuiHome(rhome, ehome, cwd, { envTokenMode });
|
||||
|
||||
// Write prompt to a temp file (mode 0600) so the content never touches argv.
|
||||
const tmpDir = mkdtempSync(`${tmpdir()}/ocp-tui-`);
|
||||
|
||||
@@ -573,21 +573,42 @@ Usage:
|
||||
ocp restart Restart the Claude proxy service
|
||||
ocp restart gateway Restart the OpenClaw gateway
|
||||
(briefly disconnects all Telegram/Discord bots)
|
||||
|
||||
Note (macOS): restart does a full launchctl bootout + bootstrap, NOT
|
||||
`kickstart -k`. bootout+bootstrap re-reads the plist's EnvironmentVariables,
|
||||
so an env change you made (e.g. CLAUDE_BIND, CLAUDE_CODE_OAUTH_TOKEN) actually
|
||||
takes effect. `kickstart -k` only re-execs the process and reuses launchd's
|
||||
cached env, so env edits would be silently ignored. (Linux systemctl already
|
||||
re-reads its EnvironmentFile on restart.)
|
||||
EOF
|
||||
}
|
||||
|
||||
# macOS only: reload a launchd agent via bootout + bootstrap so plist
|
||||
# EnvironmentVariables are re-read (kickstart -k would reuse the cached env).
|
||||
# Args: <uid> <label> <plist-path>. Returns 0 iff bootstrap succeeds.
|
||||
_launchd_reload() {
|
||||
local uid="$1" label="$2" plist="$3"
|
||||
[[ -f "$plist" ]] || return 1
|
||||
# bootout may legitimately fail if the agent is not currently loaded — that's fine,
|
||||
# we only require the subsequent bootstrap to succeed (the load that re-reads env).
|
||||
launchctl bootout "gui/$uid/$label" 2>/dev/null || true
|
||||
launchctl bootstrap "gui/$uid" "$plist" 2>/dev/null
|
||||
}
|
||||
|
||||
cmd_restart() {
|
||||
if [[ "${1:-}" == "gateway" ]]; then
|
||||
echo "Restarting gateway..."
|
||||
openclaw gateway restart 2>&1
|
||||
else
|
||||
echo "Restarting proxy..."
|
||||
# Try current service name, then legacy, then manual restart
|
||||
# Try current service name, then legacy, then manual restart.
|
||||
# macOS: bootout+bootstrap (re-reads plist EnvironmentVariables — see cmd_restart_help).
|
||||
# Linux: systemctl --user restart already re-reads its EnvironmentFile.
|
||||
local uid
|
||||
uid=$(id -u)
|
||||
if launchctl kickstart -k "gui/$uid/dev.ocp.proxy" 2>/dev/null; then
|
||||
if _launchd_reload "$uid" "dev.ocp.proxy" "$HOME/Library/LaunchAgents/dev.ocp.proxy.plist"; then
|
||||
true
|
||||
elif launchctl kickstart -k "gui/$uid/ai.openclaw.proxy" 2>/dev/null; then
|
||||
elif _launchd_reload "$uid" "ai.openclaw.proxy" "$HOME/Library/LaunchAgents/ai.openclaw.proxy.plist"; then
|
||||
true
|
||||
elif systemctl --user restart ocp-proxy 2>/dev/null; then
|
||||
true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ocp",
|
||||
"version": "3.12.0",
|
||||
"version": "3.16.2",
|
||||
"description": "Slash commands for the OpenClaw Proxy",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
@@ -9,6 +9,7 @@
|
||||
"openclaw": {
|
||||
"type": "plugin",
|
||||
"id": "ocp",
|
||||
"pluginManifest": "openclaw.plugin.json"
|
||||
"pluginManifest": "openclaw.plugin.json",
|
||||
"extensions": ["./index.js"]
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "open-claude-proxy",
|
||||
"version": "3.20.0",
|
||||
"version": "3.20.1",
|
||||
"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": {
|
||||
|
||||
+260
-21
@@ -20,7 +20,10 @@
|
||||
* CLAUDE_MCP_CONFIG — path to MCP server config JSON file
|
||||
* CLAUDE_SESSION_TTL — session TTL in ms (default: 3600000 = 1h)
|
||||
* CLAUDE_MAX_CONCURRENT — max concurrent claude processes, -p/stream-json path (default: 8)
|
||||
* CLAUDE_MAX_QUEUE — max requests waiting for a -p slot before HTTP 429 (default: 16)
|
||||
* OCP_TUI_MAX_CONCURRENT — max concurrent interactive TUI turns, TUI-mode path (default: 2)
|
||||
* OCP_SPAWN_REAL_HOME — "1" forces the -p spawn to use the real HOME (disables the
|
||||
* latency spawn-home isolation; default: isolated when a token exists)
|
||||
* CLAUDE_BREAKER_THRESHOLD — failures in window before circuit opens (default: 6)
|
||||
* CLAUDE_BREAKER_COOLDOWN — base ms to wait before retrying after circuit opens (default: 120000)
|
||||
* CLAUDE_BREAKER_WINDOW — sliding window duration in ms (default: 300000 = 5min)
|
||||
@@ -31,14 +34,14 @@
|
||||
import { createServer } from "node:http";
|
||||
import { spawn, execFileSync } from "node:child_process";
|
||||
import { randomUUID, timingSafeEqual } from "node:crypto";
|
||||
import { readFileSync, readdirSync, accessSync, existsSync, constants, chmodSync, statSync } from "node:fs";
|
||||
import { readFileSync, readdirSync, accessSync, existsSync, constants, chmodSync, statSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
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";
|
||||
import { runTuiTurn, reapStaleTuiSessions, resolveTuiHome } from "./lib/tui/session.mjs";
|
||||
import { detectTuiUpstreamError } from "./lib/tui/transcript.mjs";
|
||||
import { TuiSemaphore, recordTuiEntrypoint, buildTuiHealthBlock } from "./lib/tui/semaphore.mjs";
|
||||
|
||||
@@ -272,6 +275,15 @@ const SYSTEM_PROMPT = process.env.CLAUDE_SYSTEM_PROMPT || "";
|
||||
const MCP_CONFIG = process.env.CLAUDE_MCP_CONFIG || "";
|
||||
let SESSION_TTL = parseInt(process.env.CLAUDE_SESSION_TTL || "3600000", 10);
|
||||
let MAX_CONCURRENT = parseInt(process.env.CLAUDE_MAX_CONCURRENT || "8", 10);
|
||||
// FIX ⑥ (concurrency): bound on requests WAITING for a -p concurrency slot. Beyond
|
||||
// MAX_CONCURRENT, requests queue (up to CLAUDE_MAX_QUEUE) instead of being rejected; when the
|
||||
// queue is ALSO full, the request gets HTTP 429 + Retry-After (not an opaque 500). See
|
||||
// claudeSemaphore / acquireClaudeSlot below.
|
||||
const CLAUDE_MAX_QUEUE = parseInt(process.env.CLAUDE_MAX_QUEUE || "16", 10);
|
||||
// Retry-After seconds advertised on a 429 backpressure response. A claude turn is typically a
|
||||
// few seconds to tens of seconds; a small constant nudge keeps well-behaved clients from
|
||||
// hammering while the queue drains.
|
||||
const CLAUDE_QUEUE_RETRY_AFTER = parseInt(process.env.CLAUDE_QUEUE_RETRY_AFTER || "5", 10);
|
||||
const BREAKER_THRESHOLD = parseInt(process.env.CLAUDE_BREAKER_THRESHOLD || "6", 10);
|
||||
const BREAKER_COOLDOWN = parseInt(process.env.CLAUDE_BREAKER_COOLDOWN || "120000", 10);
|
||||
const BREAKER_WINDOW = parseInt(process.env.CLAUDE_BREAKER_WINDOW || "300000", 10);
|
||||
@@ -279,6 +291,12 @@ const BREAKER_HALF_OPEN_MAX = parseInt(process.env.CLAUDE_BREAKER_HALF_OPEN_MAX
|
||||
const HEARTBEAT_INTERVAL = parseInt(process.env.CLAUDE_HEARTBEAT_INTERVAL || "0", 10);
|
||||
const BIND_ADDRESS = process.env.CLAUDE_BIND || "127.0.0.1";
|
||||
const NO_CONTEXT = process.env.CLAUDE_NO_CONTEXT === "true";
|
||||
// Kill-switch for the FIX-③ default-path spawn-home isolation (see resolveSpawnHome /
|
||||
// spawnHomeMode below). When "1", the -p/stream-json spawn always runs in the operator's
|
||||
// real HOME with no cwd override — byte-for-byte the pre-isolation behaviour — even if an
|
||||
// OAuth token is resolvable. Provided as an escape hatch in case a host depends on the real
|
||||
// HOME's claude config for the spawned process.
|
||||
const SPAWN_REAL_HOME = process.env.OCP_SPAWN_REAL_HOME === "1";
|
||||
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 || "";
|
||||
@@ -300,7 +318,20 @@ let CACHE_TTL = parseInt(process.env.CLAUDE_CACHE_TTL || "0", 10); // 0 = disabl
|
||||
const TUI_MODE = process.env.CLAUDE_TUI_MODE === "true";
|
||||
const TUI_WALLCLOCK_MS = parseInt(process.env.CLAUDE_TUI_WALLCLOCK_MS || "120000", 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;
|
||||
// HOME the interactive claude runs under. resolveTuiHome() decides:
|
||||
// - OCP_TUI_HOME set → that path (explicit override, back-compat).
|
||||
// - else CLAUDE_CODE_OAUTH_TOKEN set → a CREDENTIAL-FREE scratch home
|
||||
// (<HOME>/.ocp-tui/home) with NO .credentials.json, so the env token is the only
|
||||
// credential and is authoritative — interactive claude otherwise PREFERS a
|
||||
// credentials.json over the env var, so a stale one shadows the token (proven live on
|
||||
// PI231) and a refresh on it can corrupt the single-use token. See ADR 0007 PR-D.
|
||||
// - else (no env token) → the operator's real home (legacy credentials.json path,
|
||||
// byte-for-byte unchanged for hosts that intentionally rely on credentials.json).
|
||||
const TUI_HOME = resolveTuiHome({
|
||||
realHome: process.env.HOME,
|
||||
configuredHome: process.env.OCP_TUI_HOME,
|
||||
envTokenSet: !!process.env.CLAUDE_CODE_OAUTH_TOKEN,
|
||||
});
|
||||
const TUI_ENTRYPOINT = process.env.OCP_TUI_ENTRYPOINT || "cli"; // cli|auto|off — see ADR 0007
|
||||
// Independent concurrency bound for the TUI path (audit C-4). Default 2: a TUI turn is
|
||||
// HEAVY (per-request cold-boot of a tmux+claude session + up to TUI_WALLCLOCK_MS=120s of
|
||||
@@ -319,6 +350,116 @@ const tuiStats = {
|
||||
entrypointMismatches: 0, // count of cli-expected-but-got-other turns
|
||||
};
|
||||
|
||||
// ── FIX ③ (latency): default-path (-p / stream-json) spawn-home isolation ──────────────
|
||||
// PROBLEM (measured, not theoretical): OCP's default spawn inherits the operator's real HOME
|
||||
// (loading the global ~/.claude — plugins, skills, hooks) and runs with cwd=~/ocp (loading the
|
||||
// project CLAUDE.md / skills) on EVERY request. Pure Anthropic API floor for haiku "hi" ≈ 1–2s;
|
||||
// the same claude CLI spawned in the operator's real HOME/cwd ≈ 10–28s; a clean minimal HOME +
|
||||
// CLAUDE_CODE_OAUTH_TOKEN ≈ 3–7s and authenticates fine. So the heavy global config is pure
|
||||
// per-request latency tax with no proxy benefit (a proxy must NOT leak the host's context into
|
||||
// the proxied turn — same rationale as NO_CONTEXT / the TUI path's CLAUDE_MDS suppression).
|
||||
//
|
||||
// FIX: when an OAuth token is resolvable, run the default spawn under a CREDENTIAL-FREE minimal
|
||||
// scratch HOME (`<realHome>/.ocp/spawn-home`) with cwd = that same neutral dir, and pass the
|
||||
// resolved token via CLAUDE_CODE_OAUTH_TOKEN so the env token is authoritative. This MIRRORS the
|
||||
// TUI path's resolveTuiHome() env-token mode (lib/tui/session.mjs): for `-p`, the env token wins
|
||||
// over a credentials.json (the opposite of interactive claude), so credential isolation is not
|
||||
// even strictly required for auth here, but a credential-FREE home is still the right shape —
|
||||
// nothing to refresh, nothing to corrupt, no heavy config to load.
|
||||
//
|
||||
// SAFETY: if NO token is resolvable → fall back to the real HOME with no cwd override (zero
|
||||
// regression). OCP_SPAWN_REAL_HOME=1 forces that legacy behaviour even when a token exists.
|
||||
// The scratch home holds NO .credentials.json / NO settings.json / NO plugins — it is created
|
||||
// minimal and (re)cleaned of any settings.json on prepare.
|
||||
const SPAWN_HOME_DIR = `${process.env.HOME}/.ocp/spawn-home`;
|
||||
|
||||
// Idempotently prepare the minimal scratch HOME. Creates the dir if missing and removes any
|
||||
// settings.json that might have crept in, so the spawned claude loads no host settings/plugins.
|
||||
// Best-effort: a failure here degrades toward "dir may be missing", which spawn() tolerates by
|
||||
// erroring loudly — never a silent auth/credential corruption (there are no credentials here).
|
||||
function prepareSpawnHome(dir = SPAWN_HOME_DIR) {
|
||||
try {
|
||||
mkdirSync(`${dir}/.claude`, { recursive: true });
|
||||
// Belt-and-braces: ensure no settings.json/plugins leak in (this home is fully ours).
|
||||
for (const f of [`${dir}/.claude/settings.json`, `${dir}/.claude/settings.local.json`]) {
|
||||
try { if (existsSync(f)) rmSync(f, { force: true }); } catch { /* best effort */ }
|
||||
}
|
||||
} catch { /* best effort — spawn will surface a hard error if the dir is truly unusable */ }
|
||||
}
|
||||
|
||||
// Resolve the default-spawn HOME-isolation decision ONCE, lazily + memoized (so it runs after
|
||||
// getOAuthCredentials is defined regardless of source order, and the token probe happens at most
|
||||
// once). Returns { isolated, home, token } where:
|
||||
// - isolated:true → spawn under SPAWN_HOME_DIR with cwd=SPAWN_HOME_DIR + the env token.
|
||||
// - isolated:false → legacy real-HOME spawn, no cwd override (no token, or kill-switch on).
|
||||
// NEVER logs/returns the token verbatim to any caller that logs; spawnClaudeProcess uses it only
|
||||
// to populate the spawn env. token is null when isolation is off.
|
||||
let _spawnHomeMode = null;
|
||||
function getSpawnHomeMode() {
|
||||
if (_spawnHomeMode) return _spawnHomeMode;
|
||||
if (SPAWN_REAL_HOME) {
|
||||
_spawnHomeMode = { isolated: false, home: null, token: null, reason: "kill-switch (OCP_SPAWN_REAL_HOME=1)" };
|
||||
return _spawnHomeMode;
|
||||
}
|
||||
let token = null;
|
||||
try { token = getOAuthCredentials()?.accessToken || null; } catch { token = null; }
|
||||
if (token) {
|
||||
prepareSpawnHome(SPAWN_HOME_DIR);
|
||||
_spawnHomeMode = { isolated: true, home: SPAWN_HOME_DIR, token, reason: "oauth token resolved" };
|
||||
} else {
|
||||
_spawnHomeMode = { isolated: false, home: null, token: null, reason: "no oauth token resolvable" };
|
||||
}
|
||||
return _spawnHomeMode;
|
||||
}
|
||||
|
||||
// ── FIX ⑥ (concurrency): bounded wait-queue for the -p / stream-json path ──────────────
|
||||
// PROBLEM (proven): spawnClaudeProcess used `if (activeRequests >= MAX_CONCURRENT) throw` →
|
||||
// the client got an opaque 500 AND the rejection was NOT counted in stats (a 15-concurrent
|
||||
// stress run returned 7×500 while /health stats.errors stayed 0). The TUI path already has a
|
||||
// bounded-queue semaphore (TuiSemaphore); the -p path did not.
|
||||
//
|
||||
// FIX: requests beyond MAX_CONCURRENT WAIT on this semaphore (up to CLAUDE_MAX_QUEUE) instead of
|
||||
// being rejected. Only when the queue is ALSO full do we reject — with HTTP 429 + Retry-After
|
||||
// (deterministic backpressure), a distinct `concurrency_queue_full` log, and a stats.queueRejections
|
||||
// counter that shows up on /health. The slot is released on EVERY exit path via the existing
|
||||
// idempotent cleanup() (proc exit/close/error/timeout) — the #37/#40 slot-leak guard.
|
||||
const claudeSemaphore = new TuiSemaphore(MAX_CONCURRENT, { maxQueue: CLAUDE_MAX_QUEUE });
|
||||
|
||||
// Tagged error so callers can map this single overflow case to HTTP 429 (every OTHER throw stays
|
||||
// a 500). Carries retryAfter for the Retry-After header.
|
||||
class ConcurrencyOverflowError extends Error {
|
||||
constructor(message) { super(message); this.name = "ConcurrencyOverflowError"; this.httpStatus = 429; this.retryAfter = CLAUDE_QUEUE_RETRY_AFTER; }
|
||||
}
|
||||
|
||||
// Acquire a -p concurrency slot, queuing if all are busy (up to CLAUDE_MAX_QUEUE). Resolves to a
|
||||
// release() fn that MUST be called exactly once on every exit path (wired into ctx.cleanup()).
|
||||
// Rejects with ConcurrencyOverflowError when the wait-queue is full. Increments stats.queued while
|
||||
// waiting (decremented on acquire) and stats.queueRejections on overflow.
|
||||
async function acquireClaudeSlot() {
|
||||
stats.queued = claudeSemaphore.queued + 1; // reflect this waiter before we (maybe) block
|
||||
try {
|
||||
await claudeSemaphore.acquire();
|
||||
} catch (e) {
|
||||
stats.queued = claudeSemaphore.queued;
|
||||
stats.queueRejections++;
|
||||
logEvent("warn", "concurrency_queue_full", {
|
||||
limit: claudeSemaphore.limit, maxQueue: claudeSemaphore.maxQueue,
|
||||
inflight: claudeSemaphore.inflight, queued: claudeSemaphore.queued,
|
||||
});
|
||||
throw new ConcurrencyOverflowError(
|
||||
`backpressure: concurrency limit (${claudeSemaphore.limit}) reached and wait queue ` +
|
||||
`(${claudeSemaphore.maxQueue}) is full — retry shortly`);
|
||||
}
|
||||
stats.queued = claudeSemaphore.queued;
|
||||
let released = false;
|
||||
return function releaseClaudeSlot() {
|
||||
if (released) return; // idempotent — cleanup() may be reached via multiple proc events
|
||||
released = true;
|
||||
claudeSemaphore.release();
|
||||
stats.queued = claudeSemaphore.queued;
|
||||
};
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -529,6 +670,8 @@ const stats = {
|
||||
sessionHits: 0,
|
||||
sessionMisses: 0,
|
||||
oneOffRequests: 0,
|
||||
queued: 0, // current requests waiting for a -p concurrency slot (FIX ⑥)
|
||||
queueRejections: 0, // total requests rejected with HTTP 429 because the wait-queue was full (FIX ⑥)
|
||||
};
|
||||
const recentErrors = []; // last 20 errors
|
||||
|
||||
@@ -756,11 +899,14 @@ function getModelTier(cliModel) {
|
||||
// (messagesToPrompt), so multi-turn correctness is preserved without sessions.
|
||||
// The sessions Map is retained for stats/logging but no longer drives --resume.
|
||||
// Reference: OLP ADR 0009 Amendment 1 + commit 97e7d16.
|
||||
function spawnClaudeProcess(model, messages, conversationId, keyName) {
|
||||
if (stats.activeRequests >= MAX_CONCURRENT) {
|
||||
throw new Error(`concurrency limit reached (${stats.activeRequests}/${MAX_CONCURRENT})`);
|
||||
}
|
||||
|
||||
// FIX ⑥: concurrency is now bounded by the claudeSemaphore via acquireClaudeSlot(), which the
|
||||
// caller MUST await before calling this, passing the resulting release fn as `releaseSlot`. The
|
||||
// old `if (activeRequests >= MAX_CONCURRENT) throw` gate (→ opaque 500, uncounted) is GONE: at
|
||||
// most MAX_CONCURRENT callers hold a slot when they reach here, so this spawn is always within
|
||||
// budget. releaseSlot is wired into the idempotent cleanup() so the slot is freed on EVERY exit
|
||||
// path (close/error/timeout/abort). Back-compat: releaseSlot defaults to a no-op so any future
|
||||
// internal caller that does its own gating still works.
|
||||
function spawnClaudeProcess(model, messages, conversationId, keyName, releaseSlot = () => {}) {
|
||||
const cliModel = MODEL_MAP[model] || model;
|
||||
|
||||
// Circuit breaker: disabled (see comment at top of breaker section)
|
||||
@@ -797,7 +943,25 @@ function spawnClaudeProcess(model, messages, conversationId, keyName) {
|
||||
env.CLAUDE_CODE_DISABLE_AUTO_MEMORY = "1";
|
||||
}
|
||||
|
||||
const proc = spawn(CLAUDE, cliArgs, { env, stdio: ["pipe", "pipe", "pipe"] });
|
||||
// FIX ③ (latency): default-path spawn-home isolation. When a token is resolvable (and the
|
||||
// OCP_SPAWN_REAL_HOME kill-switch is off), run claude under a credential-free minimal HOME
|
||||
// with cwd = that same neutral dir, so it loads NONE of the operator's global ~/.claude
|
||||
// (plugins/skills/hooks) or the ~/ocp project CLAUDE.md/skills — the measured 10–28s → 3–7s
|
||||
// latency win. The env token is authoritative for `-p` (unlike interactive claude). When no
|
||||
// token is resolvable, falls back to real HOME + inherited cwd (zero regression). See
|
||||
// getSpawnHomeMode() / prepareSpawnHome() above. The DISABLE_CLAUDE_MDS / AUTO_MEMORY flags
|
||||
// are set unconditionally in isolated mode (belt-and-braces; mirrors the TUI path).
|
||||
const spawnHome = getSpawnHomeMode();
|
||||
const spawnOpts = { env, stdio: ["pipe", "pipe", "pipe"] };
|
||||
if (spawnHome.isolated) {
|
||||
env.HOME = spawnHome.home;
|
||||
env.CLAUDE_CODE_OAUTH_TOKEN = spawnHome.token; // env token is authoritative for -p
|
||||
env.CLAUDE_CODE_DISABLE_CLAUDE_MDS = "1";
|
||||
env.CLAUDE_CODE_DISABLE_AUTO_MEMORY = "1";
|
||||
spawnOpts.cwd = spawnHome.home; // neutral cwd: no project CLAUDE.md/skills
|
||||
}
|
||||
|
||||
const proc = spawn(CLAUDE, cliArgs, spawnOpts);
|
||||
activeProcesses.add(proc);
|
||||
|
||||
const t0 = Date.now();
|
||||
@@ -809,6 +973,10 @@ function spawnClaudeProcess(model, messages, conversationId, keyName) {
|
||||
cleaned = true;
|
||||
clearTimeout(overallTimer);
|
||||
stats.activeRequests--;
|
||||
// FIX ⑥: free the concurrency slot for a queued waiter. releaseSlot is itself idempotent,
|
||||
// and cleanup() is guarded by `cleaned`, so the slot is released exactly once on the first
|
||||
// exit path reached (proc 'exit' fires before 'close'; 'error' covers spawn failure).
|
||||
try { releaseSlot(); } catch { /* never let release throw out of cleanup */ }
|
||||
}
|
||||
|
||||
// Guarantee slot release on ANY exit path (normal close, error, timeout kill,
|
||||
@@ -878,12 +1046,18 @@ function spawnClaudeProcess(model, messages, conversationId, keyName) {
|
||||
// We accumulate full text across all content_block_delta events plus the
|
||||
// assistant-aggregate fallback, then resolve with the assembled string.
|
||||
// Reference: OLP ADR 0009 Amendment 1 + commit 97e7d16.
|
||||
function callClaude(model, messages, conversationId, keyName) {
|
||||
async function callClaude(model, messages, conversationId, keyName) {
|
||||
// FIX ⑥: acquire a concurrency slot first (queues up to CLAUDE_MAX_QUEUE; rejects with a
|
||||
// ConcurrencyOverflowError → 429 when the queue is full). The release fn is passed into the
|
||||
// spawn so the idempotent cleanup() frees it on every exit path. If the spawn itself throws
|
||||
// synchronously (before cleanup is wired), release here so the slot never leaks.
|
||||
const releaseSlot = await acquireClaudeSlot();
|
||||
return new Promise((resolve, reject) => {
|
||||
let ctx;
|
||||
try {
|
||||
ctx = spawnClaudeProcess(model, messages, conversationId, keyName);
|
||||
ctx = spawnClaudeProcess(model, messages, conversationId, keyName, releaseSlot);
|
||||
} catch (err) {
|
||||
releaseSlot();
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
@@ -1050,14 +1224,28 @@ function startHeartbeat(res, intervalMs, sessionId) {
|
||||
// We parse line-by-line and forward content_block_delta text events as SSE.
|
||||
// The result event triggers the stop/[DONE] sequence.
|
||||
// Reference: OLP ADR 0009 Amendment 1 + commits 97e7d16, 65f945c.
|
||||
function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}) {
|
||||
async function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}) {
|
||||
const id = `chatcmpl-${randomUUID()}`;
|
||||
const created = Math.floor(Date.now() / 1000);
|
||||
|
||||
// FIX ⑥: acquire a concurrency slot first (queues up to CLAUDE_MAX_QUEUE). On overflow, surface
|
||||
// HTTP 429 + Retry-After (NOT 500). Release is wired into cleanup() for every exit path; if the
|
||||
// spawn throws synchronously before cleanup is wired, release here.
|
||||
let releaseSlot;
|
||||
try {
|
||||
releaseSlot = await acquireClaudeSlot();
|
||||
} catch (err) {
|
||||
if (err instanceof ConcurrencyOverflowError) {
|
||||
return jsonResponse(res, 429, { error: { message: sanitizeError(err.message), type: "rate_limit_error" } }, { "Retry-After": String(err.retryAfter) });
|
||||
}
|
||||
return jsonResponse(res, 500, { error: { message: sanitizeError(err.message), type: "proxy_error" } });
|
||||
}
|
||||
|
||||
let ctx;
|
||||
try {
|
||||
ctx = spawnClaudeProcess(model, messages, conversationId, authInfo.keyName);
|
||||
ctx = spawnClaudeProcess(model, messages, conversationId, authInfo.keyName, releaseSlot);
|
||||
} catch (err) {
|
||||
releaseSlot();
|
||||
return jsonResponse(res, 500, { error: { message: sanitizeError(err.message), type: "proxy_error" } });
|
||||
}
|
||||
|
||||
@@ -1250,12 +1438,23 @@ function sanitizeError(msg) {
|
||||
}
|
||||
|
||||
// ── Response helpers ────────────────────────────────────────────────────
|
||||
function jsonResponse(res, status, data) {
|
||||
function jsonResponse(res, status, data, extraHeaders = null) {
|
||||
if (res.headersSent || res.writableEnded || res.destroyed) return;
|
||||
res.writeHead(status, { "Content-Type": "application/json" });
|
||||
// extraHeaders is optional + additive (e.g. Retry-After on a 429); Content-Type always wins.
|
||||
res.writeHead(status, { ...(extraHeaders || {}), "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
// FIX ⑥: map an upstream error to the right HTTP response. A ConcurrencyOverflowError (the
|
||||
// wait-queue was full) becomes HTTP 429 + Retry-After + rate_limit_error; every other error
|
||||
// stays a 500 proxy_error (byte-for-byte the pre-fix behaviour for non-overflow errors).
|
||||
function respondUpstreamError(res, err) {
|
||||
if (err instanceof ConcurrencyOverflowError) {
|
||||
return jsonResponse(res, 429, { error: { message: sanitizeError(err.message), type: "rate_limit_error" } }, { "Retry-After": String(err.retryAfter) });
|
||||
}
|
||||
return jsonResponse(res, 500, { error: { message: sanitizeError(err.message), type: "proxy_error" } });
|
||||
}
|
||||
|
||||
function sendSSE(res, data, hb) {
|
||||
hb?.reset();
|
||||
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||
@@ -1675,7 +1874,9 @@ function applySettingUpdate(key, value) {
|
||||
|
||||
switch (key) {
|
||||
case "timeout": TIMEOUT = value; break;
|
||||
case "maxConcurrent": MAX_CONCURRENT = value; break;
|
||||
// FIX ⑥: keep the -p wait-queue semaphore's limit in sync with the runtime MAX_CONCURRENT
|
||||
// so a /settings change to maxConcurrent actually changes how many claude procs run at once.
|
||||
case "maxConcurrent": MAX_CONCURRENT = value; claudeSemaphore.limit = Math.max(1, value); break;
|
||||
case "sessionTTL": SESSION_TTL = value; break;
|
||||
case "maxPromptChars": MAX_PROMPT_CHARS = value; break;
|
||||
case "cacheTTL": CACHE_TTL = value; break;
|
||||
@@ -1884,7 +2085,7 @@ async function handleChatCompletions(req, res) {
|
||||
try { res.end(); } catch {}
|
||||
return;
|
||||
}
|
||||
return jsonResponse(res, 500, { error: { message: sanitizeError(err.message), type: "proxy_error" } });
|
||||
return respondUpstreamError(res, err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1901,8 +2102,9 @@ async function handleChatCompletions(req, res) {
|
||||
try { res.end(); } catch {}
|
||||
return;
|
||||
}
|
||||
// Sanitize error: strip internal file paths before sending to client
|
||||
jsonResponse(res, 500, { error: { message: sanitizeError(err.message), type: "proxy_error" } });
|
||||
// Sanitize error: strip internal file paths before sending to client.
|
||||
// FIX ⑥: ConcurrencyOverflowError → 429 + Retry-After; all other errors → 500 (unchanged).
|
||||
respondUpstreamError(res, err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2063,6 +2265,31 @@ const server = createServer(async (req, res) => {
|
||||
circuitBreaker: "disabled",
|
||||
sessions: sessionList,
|
||||
recentErrors: recentErrors.slice(-5),
|
||||
// ── FIX ③ spawn-home isolation surface — ADDITIVE (default -p/stream-json path) ──
|
||||
// Lets the operator confirm the latency-fix isolation is active without inspecting logs.
|
||||
// NEVER includes the token. mode: "isolated-scratch-home" | "real-home". home is the
|
||||
// scratch HOME path when isolated (null otherwise). For TUI_MODE the -p path is unused,
|
||||
// so report it as disabled.
|
||||
spawn: (() => {
|
||||
if (TUI_MODE) return { mode: "tui (default -p path unused)", isolated: false, home: null };
|
||||
const shm = getSpawnHomeMode();
|
||||
return {
|
||||
mode: shm.isolated ? "isolated-scratch-home" : "real-home",
|
||||
isolated: shm.isolated,
|
||||
home: shm.isolated ? shm.home : null,
|
||||
reason: shm.reason,
|
||||
};
|
||||
})(),
|
||||
// ── FIX ⑥ -p concurrency wait-queue surface — ADDITIVE ──
|
||||
// inflight/queued are live; queueRejections is cumulative (also in stats.queueRejections).
|
||||
// Lets the operator see backpressure instead of guessing from opaque 500s.
|
||||
concurrency: {
|
||||
maxConcurrent: MAX_CONCURRENT,
|
||||
maxQueue: claudeSemaphore.maxQueue,
|
||||
inflight: claudeSemaphore.inflight,
|
||||
queued: claudeSemaphore.queued,
|
||||
queueRejections: stats.queueRejections,
|
||||
},
|
||||
// ── TUI observability (audit C-5) — ADDITIVE block (ADR 0007 PR-B amendment) ──
|
||||
// /health is a grandfathered B.2 endpoint (ADR 0006). This block is NEW fields only;
|
||||
// every existing field above is byte-identical → behaviour-preserving for existing
|
||||
@@ -2351,7 +2578,7 @@ server.listen(PORT, BIND_ADDRESS, () => {
|
||||
console.log(`Architecture: on-demand spawning (no pool)`);
|
||||
console.log(`Models: ${MODELS.map((m) => m.id).join(", ")}`);
|
||||
console.log(`Claude binary: ${CLAUDE}`);
|
||||
console.log(`Timeout: ${TIMEOUT / 1000}s | Max concurrent: ${MAX_CONCURRENT}`);
|
||||
console.log(`Timeout: ${TIMEOUT / 1000}s | Max concurrent: ${MAX_CONCURRENT} | Queue: ${CLAUDE_MAX_QUEUE} (429 on overflow)`);
|
||||
console.log(`Circuit breaker: disabled`);
|
||||
console.log(`Tools: ${SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS.join(", ")}`);
|
||||
console.log(`Sessions: TTL=${SESSION_TTL / 1000}s`);
|
||||
@@ -2363,9 +2590,21 @@ server.listen(PORT, BIND_ADDRESS, () => {
|
||||
if (NO_CONTEXT) console.log(`Context: suppressed (CLAUDE_NO_CONTEXT=true — no CLAUDE.md, no auto-memory)`);
|
||||
if (CACHE_TTL > 0) console.log(`Cache: enabled (TTL=${CACHE_TTL / 1000}s)`);
|
||||
else console.log(`Cache: disabled (set CLAUDE_CACHE_TTL to enable)`);
|
||||
// FIX ③: announce default-path (-p/stream-json) spawn-home isolation mode (never logs the token).
|
||||
if (!TUI_MODE) {
|
||||
const shm = getSpawnHomeMode();
|
||||
if (shm.isolated) {
|
||||
console.log(`Spawn home: isolated-scratch-home (${shm.home}, cwd-neutral, env-token auth) — fast path`);
|
||||
} else {
|
||||
console.log(`Spawn home: real-home (${shm.reason}) — set CLAUDE_CODE_OAUTH_TOKEN for the isolated fast path`);
|
||||
}
|
||||
}
|
||||
if (TUI_MODE) {
|
||||
console.warn(`⚠️ TUI-mode ON — single-user only; do NOT enable on a multi-user OCP (guest prompts would run claude with operator filesystem access). See ADR 0007.`);
|
||||
console.log(` TUI-mode: ON home=${TUI_HOME} cwd=${TUI_CWD} wallclock=${TUI_WALLCLOCK_MS}ms maxConcurrent=${TUI_MAX_CONCURRENT}`);
|
||||
const tuiAuth = process.env.CLAUDE_CODE_OAUTH_TOKEN
|
||||
? (TUI_HOME === process.env.HOME ? "env-token (real home — unset OCP_TUI_HOME for credential isolation)" : "env-token (credential-isolated home — no credentials.json)")
|
||||
: "credentials.json (no CLAUDE_CODE_OAUTH_TOKEN — see Troubleshooting #401)";
|
||||
console.log(` TUI-mode: ON home=${TUI_HOME} cwd=${TUI_CWD} auth=${tuiAuth} wallclock=${TUI_WALLCLOCK_MS}ms maxConcurrent=${TUI_MAX_CONCURRENT}`);
|
||||
try {
|
||||
const n = reapStaleTuiSessions();
|
||||
if (n) logEvent("info", "tui_reaped_stale_sessions", { count: n });
|
||||
|
||||
@@ -1882,6 +1882,53 @@ test("prepareTuiHome real mode (tuiHome===realHome): no symlink, just trusts cwd
|
||||
assert.equal(j.projects[cwd].hasTrustDialogAccepted, true); // cwd trusted in real config
|
||||
});
|
||||
|
||||
// ── PR-D: env-token-only credential-isolated home (PI231 401 root fix) ──────
|
||||
// Interactive claude PREFERS ~/.claude/.credentials.json over CLAUDE_CODE_OAUTH_TOKEN, so a
|
||||
// stale/corrupt credentials.json SHADOWS the env token (proven live on PI231 — env token +
|
||||
// broken creds = 401; env token + creds moved aside = works). The fix runs the TUI claude in
|
||||
// a home with NO credentials.json so the env token is authoritative (and no refresh ever
|
||||
// happens → the single-use token can't be corrupted by the spawn+kill cycle).
|
||||
test("prepareTuiHome env-token mode: NO credentials.json (no symlink, no copy), .claude.json seeded", () => {
|
||||
const realHome = hMkdtemp(`${hTmp()}/realT-`);
|
||||
hMkdir(`${realHome}/.claude`, { recursive: true });
|
||||
hWrite(`${realHome}/.claude/.credentials.json`, '{"token":"real-oauth"}'); // real creds DO exist…
|
||||
hWrite(`${realHome}/.claude.json`, JSON.stringify({ theme: "dark", oauthAccount: { uuid: "secret" }, projects: { "/old/secret": { hasTrustDialogAccepted: true } } }));
|
||||
const tuiHome = hMkdtemp(`${hTmp()}/scratchT-`);
|
||||
const cwd = `${tuiHome}/work`;
|
||||
prepareTuiHome(realHome, tuiHome, cwd, { envTokenMode: true });
|
||||
// …but the scratch home has NO credentials file at all — neither symlink nor copy.
|
||||
assert.ok(!hExists(`${tuiHome}/.claude/.credentials.json`), "env-token home must have NO .credentials.json (the whole point — no shadowing, no refresh)");
|
||||
// .claude.json IS seeded: onboarding complete + ONLY the scratch cwd trusted (no dialog hang).
|
||||
const seed = JSON.parse(hRead(`${tuiHome}/.claude.json`, "utf8"));
|
||||
assert.equal(seed.hasCompletedOnboarding, true, "onboarding pre-completed → no onboarding dialog");
|
||||
assert.equal(seed.projects[cwd].hasTrustDialogAccepted, true, "scratch cwd pre-trusted → no trust dialog");
|
||||
// Minimal config: the credential-isolated home does NOT inherit the operator's account state.
|
||||
assert.equal(seed.theme, undefined, "env-token home is minimal — real config not copied in");
|
||||
assert.equal(seed.oauthAccount, undefined, "real account state not carried into the isolated home");
|
||||
assert.equal(seed.projects["/old/secret"], undefined, "operator project history not carried in");
|
||||
assert.ok(hExists(`${tuiHome}/.claude/projects`), "own projects/ dir for transcripts under the same home");
|
||||
});
|
||||
|
||||
console.log("\nresolveTuiHome (env-token credential isolation, PR-D):");
|
||||
import { resolveTuiHome, DEFAULT_TUI_SCRATCH_HOME } from "./lib/tui/session.mjs";
|
||||
|
||||
test("resolveTuiHome: env token set + OCP_TUI_HOME unset → credential-free scratch home", () => {
|
||||
const h = resolveTuiHome({ realHome: "/home/u", configuredHome: undefined, envTokenSet: true });
|
||||
assert.equal(h, DEFAULT_TUI_SCRATCH_HOME("/home/u"));
|
||||
assert.equal(h, "/home/u/.ocp-tui/home");
|
||||
assert.notEqual(h, "/home/u", "must NOT be the real home — real home has the shadowing credentials.json");
|
||||
});
|
||||
|
||||
test("resolveTuiHome: env token UNSET → real home (legacy credentials.json path, unchanged)", () => {
|
||||
const h = resolveTuiHome({ realHome: "/home/u", configuredHome: undefined, envTokenSet: false });
|
||||
assert.equal(h, "/home/u", "no env token → real home, byte-for-byte the pre-fix behaviour");
|
||||
});
|
||||
|
||||
test("resolveTuiHome: explicit OCP_TUI_HOME wins regardless of env token (back-compat)", () => {
|
||||
assert.equal(resolveTuiHome({ realHome: "/home/u", configuredHome: "/custom/home", envTokenSet: true }), "/custom/home");
|
||||
assert.equal(resolveTuiHome({ realHome: "/home/u", configuredHome: "/custom/home", envTokenSet: false }), "/custom/home");
|
||||
});
|
||||
|
||||
// ── resolveTuiEntrypointEnv ───────────────────────────────────────────────
|
||||
import { resolveTuiEntrypointEnv } from "./lib/tui/session.mjs";
|
||||
|
||||
@@ -1999,6 +2046,45 @@ await asyncTest("wait queue is bounded — run() rejects with tui_queue_full whe
|
||||
assert.equal(sem.inflight, 0);
|
||||
});
|
||||
|
||||
console.log("\n-p concurrency wait-queue (FIX ⑥ — same TuiSemaphore reused for the -p path):");
|
||||
|
||||
// server.mjs reuses TuiSemaphore as `claudeSemaphore = new TuiSemaphore(MAX_CONCURRENT,
|
||||
// { maxQueue: CLAUDE_MAX_QUEUE })` and wraps acquire()/release() in acquireClaudeSlot(). These
|
||||
// tests assert the contract that the 429-mapping depends on: requests beyond the limit QUEUE
|
||||
// (not reject), only an overflow past the queue rejects (→ HTTP 429 in server.mjs), and a
|
||||
// released slot is reusable (the #37/#40 slot-leak guard — no leak on normal completion).
|
||||
await asyncTest("FIX ⑥: requests beyond MAX_CONCURRENT queue, not reject (limit=1, queue=1)", async () => {
|
||||
const sem = new TuiSemaphore(1, { maxQueue: 1 }); // mirrors CLAUDE_MAX_CONCURRENT=1, CLAUDE_MAX_QUEUE=1
|
||||
const g1 = deferred();
|
||||
const inflightP = sem.run(async () => { await g1.p; }); // request 1 — holds the only slot
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.equal(sem.inflight, 1, "req1 inflight");
|
||||
const queuedP = sem.run(async () => {}); // request 2 — WAITS (queued), does NOT reject
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.equal(sem.queued, 1, "req2 queued (waits), not rejected → would be served, not 429");
|
||||
// request 3 — queue full → reject (server.mjs maps this single case to 429 + Retry-After)
|
||||
await assert.rejects(sem.run(async () => {}), /tui_queue_full|queue/, "req3 overflows → reject (→429)");
|
||||
g1.resolve();
|
||||
await inflightP; await queuedP;
|
||||
assert.equal(sem.inflight, 0, "all slots released after drain (no leak)");
|
||||
assert.equal(sem.queued, 0, "queue fully drained");
|
||||
});
|
||||
|
||||
await asyncTest("FIX ⑥: slot released on normal completion is immediately reusable (no #37/#40 leak)", async () => {
|
||||
const sem = new TuiSemaphore(1, { maxQueue: 16 }); // mirrors default CLAUDE_MAX_QUEUE=16
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await sem.run(async () => { /* a normal, completing turn */ });
|
||||
assert.equal(sem.inflight, 0, `slot released after turn ${i}`);
|
||||
}
|
||||
// Prove the limit still binds after many acquire/release cycles.
|
||||
const g = deferred();
|
||||
const held = sem.run(async () => { await g.p; });
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.equal(sem.inflight, 1, "limit still enforced after reuse cycles");
|
||||
g.resolve(); await held;
|
||||
assert.equal(sem.inflight, 0);
|
||||
});
|
||||
|
||||
console.log("\nTUI drift observability (C-5):");
|
||||
|
||||
test("recordTuiEntrypoint: observed 'cli' is NOT a mismatch and sets lastEntrypoint", () => {
|
||||
|
||||
Reference in New Issue
Block a user