mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-22 13:35:08 +00:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
551d4e7db6 | ||
|
|
ddaea4df17 | ||
|
|
fc63b8a49a | ||
|
|
6848f9751c | ||
|
|
5258d5d395 | ||
|
|
6854075c01 | ||
|
|
45152d58b0 | ||
|
|
d96da46fa0 | ||
|
|
2538233059 | ||
|
|
31e5a44099 | ||
|
|
2922d68842 | ||
|
|
38da104b97 | ||
|
|
5aaab5ea28 | ||
|
|
3bd19956ff | ||
|
|
fe615cb0d3 | ||
|
|
60930f0ba4 |
@@ -1,5 +1,52 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v3.21.1 — 2026-07-07
|
||||||
|
|
||||||
|
Patch release: three bug fixes from an independent concurrency/session-lifecycle audit, each its own PR with a fresh-context reviewer (Iron Rule 10). No new `cli.js` wire behavior, no new endpoint, header, or env var; the `/health` field set is unchanged (only value truthfulness improved).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **TUI session-scope / boot-reap (#148)** — `lib/tui/session.mjs`'s tmux session prefix is now scoped per-instance by listen port (`ocp-tui-<port>-`) instead of a bare host-wide `ocp-tui-` constant, so a second OCP instance on the same host (e.g. a temporary verification instance) can no longer have its live TUI sessions reaped or `kill-server`'d by another instance's boot/periodic sweep. The one-time boot reap also claims exact-shape legacy `ocp-tui-<8hex>` sessions (pre-fix naming) once, to clean up zombies left behind across an in-place upgrade.
|
||||||
|
- **`-p` spawn-token mutex + keychain caching (#150)** — the real-HOME token fallback used when the keychain token is within its 5-minute expiry window is now serialized behind a mutex, so concurrent `-p` spawns no longer race the same single-use refresh token against each other (the credential-fork hazard). Added a 30s TTL cache + last-good-label memoization for the keychain read, cutting per-spawn event-loop blocking. The isolation decision (`/health` isolated/real-home reporting) is now re-evaluated per spawn instead of memoized forever, so `/health` no longer misreports a stale decision. New module `lib/spawn-auth.mjs` extracts the pure, unit-testable primitives (mutex, TTL cache, expiry gate, label ordering).
|
||||||
|
- **Concurrency queue / disconnect handling (#149)** — the shared semaphore now honors a runtime-lowered `maxConcurrent` immediately (previously a decrease was silently ignored until in-flight tasks finished on their own) and wakes queued waiters right away when the limit is raised. Queued `-p`/TUI requests are now linked to the client's HTTP connection via `AbortSignal`; a client that disconnects while queued is spliced out of the queue instead of still spawning `claude` once a slot frees. A singleflight follower whose leader disconnected now retries instead of inheriting a spurious 500, and a queued-then-disconnected request is no longer recorded as a usage failure or logged as an error (quiet disconnect handling).
|
||||||
|
|
||||||
|
## v3.21.0 — 2026-06-25
|
||||||
|
|
||||||
|
Cleanup + docs release: TUI dead-code removal, docs honesty, and release prep. No new `cli.js` wire behavior; the default path (`CLAUDE_TUI_MODE` unset) is byte-for-byte unchanged.
|
||||||
|
|
||||||
|
### TUI dead-code / footgun cleanup
|
||||||
|
|
||||||
|
- **A1 — removed inert entrypoint-env path** (`lib/tui/session.mjs`): deleted `resolveTuiEntrypointEnv()` and the redundant env-strip block in `runTuiTurn`. The `{env}` object passed to `spawnSync` (tmux itself) was the wrong target — tmux does NOT forward the spawning process's environment to the pane; the pane's `claude` gets its env exclusively from the `env` prefix string built inside `buildTuiCmd` (verified live 2026-06-01). The spawnSync env is now intentionally minimal (`HOME` only). Behavior is unchanged: `buildTuiCmd` already handled all claude-specific env vars via its prefix string.
|
||||||
|
- **A2 — removed test-only transcript helpers** (`lib/tui/transcript.mjs`): deleted `encodeCwd()` and `transcriptPath()` exports and the tests that pinned them. Production resolves transcripts exclusively via `findTranscriptPath()` (glob by session-id), which is immune to the exact path-encoding rule. No non-test importers existed (grep confirms). A `// TODO` comment near `findTranscriptPath()` notes that a CI fixture-contract test would make claude-schema drift fail loudly.
|
||||||
|
- **A3 — removed headless-unusable `--dangerously-skip-permissions` branch** (`lib/tui/session.mjs` + `README.md`): `OCP_TUI_FULL_TOOLS=1` now always takes the `--allowedTools` path. The removed branch pushed `--dangerously-skip-permissions` when `CLAUDE_SKIP_PERMISSIONS=true`; on claude v2.1.x this triggers an interactive bypass-acceptance screen that a headless tmux pane cannot answer → the turn hangs to the wallclock cap and bricks the pane. The working path is `--allowedTools` + scratch-home `settings.json` `additionalDirectories`. `CLAUDE_SKIP_PERMISSIONS` for the `-p` path is unchanged (still used in `server.mjs`).
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
|
||||||
|
- **Client-tools boundary** (README `§ How It Works`): OCP is a text-prompt bridge only — it does not pass OpenAI `tools`/`functions` or Anthropic `tool_use` blocks to the client. Clients receive assistant TEXT only; client-local tool execution is not supported by design (bypassing `cli.js` = out of scope per `ALIGNMENT.md`).
|
||||||
|
- **ToS honesty** (README `§ Deployment model & security`): pooling one Claude subscription across multiple distinct people may violate Anthropic's Consumer ToS and risk account suspension by the abuse classifier. The defensible framing is "one person, your own devices" — friends/team sharing is not. The prior language ("account terms are your call") was accurate but understated the risk.
|
||||||
|
- **"Why OCP" posture** (README `§ Why OCP?`): new bullet making explicit that OCP drives the official `claude` CLI as-is — no OAuth token extraction, no binary patching, no protocol invention — so traffic looks like genuine Claude Code (`cc_entrypoint=cli`).
|
||||||
|
- **Promotion plan** (`docs/PROMOTION.md`): "stable & visible" strategy covering goal (polish + low-key OSS visibility, NOT growth-hacking given the live ToS/billing risk), pre-requisites (stability first), honest ToS disclosure requirement, items explicitly skipped (multi-backend routing → OLP; gateway model-discovery; raw API passthrough → ALIGNMENT.md scope), TUI toggle as billing-split insurance, and low-key visibility actions. Framed as a recommendation for the maintainer to review, not a committed plan.
|
||||||
|
|
||||||
|
### Previously shipped (v3.20.x) — documented here for completeness
|
||||||
|
|
||||||
|
- **Default `-p` spawn-home isolation** (v3.20.0 / PR-A): per-request `claude` spawns run 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, cutting per-request latency (measured ~10–28s → ~3–7s). Kill-switch: `OCP_SPAWN_REAL_HOME=1`. Active mode shown at startup and on `/health.spawn`.
|
||||||
|
- **Bounded concurrency wait-queue** (v3.20.0 / PR-B): excess `-p` requests queue (up to `CLAUDE_MAX_QUEUE`, default 16) instead of being rejected; a full queue returns `HTTP 429` + `Retry-After` (not an opaque 500). New env vars: `CLAUDE_MAX_QUEUE`, `CLAUDE_QUEUE_RETRY_AFTER`. Surfaced on `/health.concurrency` + `/health.stats.queueRejections`.
|
||||||
|
- **`ocp restart`** macOS `bootout`+`bootstrap` (v3.20.0 / PR-B): safe restart command that forces launchd to re-read the plist (unlike `kickstart -k` which reuses the cached env).
|
||||||
|
- **`/ocp` plugin OpenClaw-2026.5.27 compat** (v3.20.0 / PR-C): gateway plugin updated for the current OpenClaw API version.
|
||||||
|
|
||||||
|
## 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
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ There are several Claude proxy projects. OCP picks a specific lane: **align tigh
|
|||||||
- **SSE heartbeat for long reasoning** ([v3.12.0](https://github.com/dtzp555-max/ocp/releases/tag/v3.12.0), opt-in). If you've ever watched your IDE die at the 60s idle mark during a long Claude tool-use pause — that's nginx/Cloudflare default behavior. OCP emits an SSE comment frame to keep the connection alive without polluting the response. ([PR #49](https://github.com/dtzp555-max/ocp/pull/49))
|
- **SSE heartbeat for long reasoning** ([v3.12.0](https://github.com/dtzp555-max/ocp/releases/tag/v3.12.0), opt-in). If you've ever watched your IDE die at the 60s idle mark during a long Claude tool-use pause — that's nginx/Cloudflare default behavior. OCP emits an SSE comment frame to keep the connection alive without polluting the response. ([PR #49](https://github.com/dtzp555-max/ocp/pull/49))
|
||||||
- **`cli.js` alignment + CI guardrail.** LLM-assisted code drifts easily — it's tempting to invent plausible-looking endpoints that `cli.js` doesn't actually use. [`ALIGNMENT.md`](./ALIGNMENT.md) is binding: every endpoint OCP exposes must cite a `cli.js` line. The [`alignment.yml`](./.github/workflows/alignment.yml) CI workflow blocks PRs that introduce known-hallucinated tokens. The payoff is boring: your setup keeps working when `cli.js` ships its next minor.
|
- **`cli.js` alignment + CI guardrail.** LLM-assisted code drifts easily — it's tempting to invent plausible-looking endpoints that `cli.js` doesn't actually use. [`ALIGNMENT.md`](./ALIGNMENT.md) is binding: every endpoint OCP exposes must cite a `cli.js` line. The [`alignment.yml`](./.github/workflows/alignment.yml) CI workflow blocks PRs that introduce known-hallucinated tokens. The payoff is boring: your setup keeps working when `cli.js` ships its next minor.
|
||||||
- **`models.json` single source of truth** (v3.11.0). Adding a model is one file edit; both `/v1/models` and the OpenClaw bootstrap derive from it. ([PR #30](https://github.com/dtzp555-max/ocp/pull/30))
|
- **`models.json` single source of truth** (v3.11.0). Adding a model is one file edit; both `/v1/models` and the OpenClaw bootstrap derive from it. ([PR #30](https://github.com/dtzp555-max/ocp/pull/30))
|
||||||
|
- **Drives the official CLI as-is, no binary patching.** OCP spawns the official `claude` CLI (or hosts it in an interactive tmux pane for TUI mode) — it does not extract OAuth tokens from memory, patch the binary, or invent protocol extensions. Traffic therefore looks like genuine Claude Code to Anthropic's classifiers (`cc_entrypoint=cli`). See `ALIGNMENT.md` for why this constraint is load-bearing.
|
||||||
|
|
||||||
### Comparison
|
### Comparison
|
||||||
|
|
||||||
@@ -128,11 +129,12 @@ Before each step, tell me what you'll run and wait for confirmation.
|
|||||||
On any error, diagnose first — don't auto-retry.
|
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
|
```text
|
||||||
I want to install OCP on this device as a LAN server so my family and other
|
I want to install OCP on this device as a LAN server so my own devices on the
|
||||||
devices on the network can share my Claude Pro/Max subscription.
|
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
|
Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md
|
||||||
"Server Setup" → "LAN mode" path:
|
"Server Setup" → "LAN mode" path:
|
||||||
@@ -422,6 +424,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.
|
- 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.**
|
- 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.
|
- 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 and ToS — read before sharing with others.** Claude Pro/Max are *per-user* accounts. Pooling a single subscription across **multiple distinct people** may violate Anthropic's Consumer Terms of Service and risk account suspension by the abuse classifier. The defensible framing is **"one person, your own devices"** — sharing with friends or a team is not. OCP does not change your account terms, and whether any particular sharing setup complies with the ToS is the account holder's responsibility. Review Anthropic's Usage Policy 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).)
|
**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).)
|
||||||
|
|
||||||
@@ -698,6 +701,14 @@ Your IDE → OCP (localhost:3456) → claude --output-format stream-json CLI →
|
|||||||
|
|
||||||
OCP translates OpenAI-compatible `/v1/chat/completions` requests into `claude --output-format stream-json` CLI calls. Anthropic sees normal Claude Code usage — no API billing, no separate key needed.
|
OCP translates OpenAI-compatible `/v1/chat/completions` requests into `claude --output-format stream-json` CLI calls. Anthropic sees normal Claude Code usage — no API billing, no separate key needed.
|
||||||
|
|
||||||
|
### Client-tools boundary
|
||||||
|
|
||||||
|
OCP is a **text-prompt bridge** to the official `claude` CLI. It does **not** pass through OpenAI `tools`/`functions` payloads or Anthropic `tool_use` blocks to the client. Clients (Cline, Cursor, OpenClaw, etc.) pointed at OCP receive **assistant TEXT only** — they never get `tool_calls` to execute locally.
|
||||||
|
|
||||||
|
Any tool use happens server-side, under the `--allowedTools` set configured on the OCP host. In default mode (no `CLAUDE_NO_CONTEXT`), the `claude` CLI's own built-in tools are available to the model; in TUI mode, the operator controls the tool surface via `OCP_TUI_FULL_TOOLS`. Either way, the tools run under the operator's credentials on the server, and the client sees only the final text output.
|
||||||
|
|
||||||
|
**Client-local tool execution is not supported by design.** Supporting it would require bypassing the `claude` CLI to call the raw Anthropic API directly — that is a different product, and is out of scope per `ALIGNMENT.md` (every OCP endpoint must correspond to something `cli.js` actually does).
|
||||||
|
|
||||||
## Available Models
|
## Available Models
|
||||||
|
|
||||||
| Model ID | Notes |
|
| Model ID | Notes |
|
||||||
@@ -842,6 +853,29 @@ ocp restart
|
|||||||
openclaw gateway 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"
|
### Usage shows "unknown"
|
||||||
|
|
||||||
Usually caused by an expired Claude CLI session. Fix:
|
Usually caused by an expired Claude CLI session. Fix:
|
||||||
@@ -860,6 +894,10 @@ node ~/ocp/scripts/sync-openclaw.mjs
|
|||||||
|
|
||||||
This is read-only at startup; the warning never blocks the gateway from running.
|
This is read-only at startup; the warning never blocks the gateway from running.
|
||||||
|
|
||||||
|
### A TUI session vanished right after upgrading OCP
|
||||||
|
|
||||||
|
If you ran a pre-3.21.1 OCP instance and a post-3.21.1 instance on the same host at the same time during an upgrade, the new instance's one-time boot reap can, once, kill an old-format (`ocp-tui-<8hex>`) live TUI session belonging to the still-running old instance — restart the affected session (`ocp restart` or re-run your TUI turn) and it will come back under the new instance's port-scoped naming.
|
||||||
|
|
||||||
### OpenClaw shows old models after `ocp update` (v3.10→v3.11 only)
|
### OpenClaw shows old models after `ocp update` (v3.10→v3.11 only)
|
||||||
|
|
||||||
One-time bootstrap quirk for the v3.10.0 → v3.11.0 jump only — the running shell had the old `cmd_update` cached. Run once manually:
|
One-time bootstrap quirk for the v3.10.0 → v3.11.0 jump only — the running shell had the old `cmd_update` cached. Run once manually:
|
||||||
@@ -871,6 +909,24 @@ openclaw gateway restart # so OpenClaw re-reads the config
|
|||||||
|
|
||||||
Future `ocp update` invocations sync automatically.
|
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 (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
|
||||||
|
# Boot log should read: TUI-mode: ON home=$HOME/.ocp-tui/home ... auth=env-token (credential-isolated home — no credentials.json)
|
||||||
|
```
|
||||||
|
|
||||||
|
> 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
|
## Environment Variables
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
@@ -883,7 +939,9 @@ Future `ocp update` invocations sync automatically.
|
|||||||
| `CLAUDE_BIN` | *(auto-detect)* | Path to claude binary |
|
| `CLAUDE_BIN` | *(auto-detect)* | Path to claude binary |
|
||||||
| `CLAUDE_TIMEOUT` | `600000` | Request timeout (ms, default: 10 min) |
|
| `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_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_MAX_PROMPT_CHARS` | `150000` | Prompt truncation limit (chars) |
|
||||||
| `CLAUDE_SESSION_TTL` | `3600000` | Session expiry (ms, default: 1 hour) |
|
| `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 |
|
| `CLAUDE_CACHE_TTL` | `0` | Response cache TTL (ms, 0 = disabled). Set to e.g. `300000` for 5-min cache |
|
||||||
@@ -894,13 +952,16 @@ Future `ocp update` invocations sync automatically.
|
|||||||
| `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_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_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_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 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. |
|
| `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_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_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_EFFORT` | `low` | (TUI-mode) Effort level passed to the interactive `claude` as an explicit `--effort` flag: `low` (default), `medium`, `high`, `xhigh`, `max`, or `inherit` to omit the flag (the pre-flag behaviour: the pane inherits a HOME-dependent effort — the operator's `~/.claude/settings.json` `effortLevel` in real-home mode, claude's built-in default in env-token scratch mode). Explicit `low` cuts measured TTFT p50 by ~40% and collapses run-to-run variance ~15× versus an inherited `xhigh` (see `docs/plans/2026-07-13-tui-latency/`); proxied requests rarely benefit from extended thinking. Banner-verified to stay on the subscription pool (`· Claude Max`). An invalid value logs a warning and falls back to `low`. |
|
||||||
| `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_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. |
|
| `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. |
|
||||||
| `OCP_TUI_FULL_TOOLS` | *(unset)* | (TUI-mode, **single-user only**) When `=1`, grant the interactive session the **same tool surface as the `-p` path** — `--allowedTools` (+ optional `--mcp-config` / `--dangerously-skip-permissions`, read from `CLAUDE_ALLOWED_TOOLS` / `CLAUDE_MCP_CONFIG` / `CLAUDE_SKIP_PERMISSIONS`) — instead of the default MCP-walled, built-in-tools-only set. Lets a trusted single-operator TUI deployment run a **tool-using / MCP agent** (e.g. an OpenClaw assistant) on the subscription pool. Safe because TUI **refuses to boot under `AUTH_MODE=multi`** (hard exit) — no guest key can ever reach the TUI path, so this gate cannot expose tools to an untrusted caller. (Under `AUTH_MODE=shared` + `OCP_TUI_ALLOW_LAN=1`, anyone holding the single shared key reaches it — that is the existing TUI trust model, unchanged.) See [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) and ADR 0007. |
|
| `OCP_TUI_FULL_TOOLS` | *(unset)* | (TUI-mode, **single-user only**) When `=1`, grant the interactive session the **same tool surface as the `-p` path** — `--allowedTools` (+ optional `--mcp-config`, read from `CLAUDE_ALLOWED_TOOLS` / `CLAUDE_MCP_CONFIG`) — instead of the default MCP-walled, built-in-tools-only set. Lets a trusted single-operator TUI deployment run a **tool-using / MCP agent** (e.g. an OpenClaw assistant) on the subscription pool. Safe because TUI **refuses to boot under `AUTH_MODE=multi`** (hard exit) — no guest key can ever reach the TUI path, so this gate cannot expose tools to an untrusted caller. (Under `AUTH_MODE=shared` + `OCP_TUI_ALLOW_LAN=1`, anyone holding the single shared key reaches it — that is the existing TUI trust model, unchanged.) Note: `--dangerously-skip-permissions` / `CLAUDE_SKIP_PERMISSIONS` is **not** supported for TUI — claude v2.1.x shows an interactive bypass-acceptance screen in headless tmux that cannot be answered, bricking the pane. Use scratch-home `settings.json` `additionalDirectories` instead. See [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) and ADR 0007. |
|
||||||
|
|
||||||
### Streaming heartbeat
|
### Streaming heartbeat
|
||||||
|
|
||||||
@@ -946,28 +1007,69 @@ mkdir -p ~/.ocp-tui/work # one-time scratch cwd setup
|
|||||||
|
|
||||||
# Enable
|
# Enable
|
||||||
export CLAUDE_TUI_MODE=true
|
export CLAUDE_TUI_MODE=true
|
||||||
|
# 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:
|
# Optionally tune:
|
||||||
export CLAUDE_TUI_WALLCLOCK_MS=180000 # 3 min cap for long Opus turns
|
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_CWD=$HOME/.ocp-tui/work # default; override if needed
|
||||||
export OCP_TUI_ENTRYPOINT=cli # default; use 'auto' to observe TTY-derived value
|
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 — 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
|
### What changes / what doesn't
|
||||||
|
|
||||||
- **Callers see no API change.** The response is a normal OpenAI completion object or chunked SSE — identical wire format.
|
- **Callers see no API change.** The response is a normal OpenAI completion object or chunked SSE — identical wire format.
|
||||||
- **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.
|
- **No real token streaming *today* — but it is achievable, and planned.** TUI-mode currently buffers the full response then replays it as chunked SSE: you see a delay, then the complete response. This is a limitation of the current implementation, **not** of the path — `claude` fires a `MessageDisplay` hook carrying incremental, byte-faithful `delta`s of the raw reply (they concatenate exactly to the final text, and stay prefix-stable), on the subscription pool, without `-p`. Wiring it into OCP's SSE is tracked as backlog item #2. What is *not* available is token-by-token granularity (the hook fires once per rendered block — roughly one per paragraph, list item, or code block, so the count scales with answer length) — which is plenty for SSE. Evidence: [`docs/plans/2026-07-13-tui-latency/streaming-spike.md`](docs/plans/2026-07-13-tui-latency/streaming-spike.md).
|
||||||
- **Cache and singleflight work normally.** TUI-mode writes the buffered response to the cache on success; cache-hits skip the interactive turn entirely.
|
- **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.
|
- **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` 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.
|
- **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.
|
- **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.
|
||||||
|
|
||||||
|
### ⚠️ Latency: TUI mode has a ~6-second floor, and it is immovable
|
||||||
|
|
||||||
|
**TUI mode cannot serve real-time or interactive-latency consumers.** This is a hard property of the
|
||||||
|
path, stated plainly so you can rule it out before building on it:
|
||||||
|
|
||||||
|
| | measured |
|
||||||
|
|---|---|
|
||||||
|
| **TTFT floor (first token)** | **≈ 6 s** — immovable |
|
||||||
|
| cold boot → input bar ready | ~1 s (per request; not the bottleneck) |
|
||||||
|
| OCP's own overhead above the CLI | ~4 s (n=1 same-turn decomposition) |
|
||||||
|
| direct Anthropic API, same prompt (for scale) | 0.84–1.64 s |
|
||||||
|
|
||||||
|
The ~6 s floor is the `claude` CLI itself: it always injects the full Claude Code system prompt plus
|
||||||
|
its tool definitions before your prompt, on every turn, no matter what you ask. No flag removes it
|
||||||
|
(`--exclude-dynamic-system-prompt-sections` was measured: **no effect** on the floor). Extended
|
||||||
|
thinking is *not* the cause — `OCP_TUI_EFFORT` already defaults to `low`, which is what cuts a
|
||||||
|
formerly-inherited `xhigh` down to this floor and collapses its variance.
|
||||||
|
|
||||||
|
On top of the floor you pay the model's generation time (a function of output length). Progressive
|
||||||
|
output is not wired up **yet** (see "No real token streaming" above — it is achievable and planned),
|
||||||
|
so today a turn returns as one blob once generation completes. Note that streaming, when it lands,
|
||||||
|
will move the *first* byte earlier — it does **not** shorten the turn, and a consumer that needs the
|
||||||
|
complete answer gains nothing from it.
|
||||||
|
|
||||||
|
**Use TUI mode for**: batch, background, and latency-insensitive work where the subscription pool is
|
||||||
|
the point. **Do not use it for**: anything a person is waiting on interactively, or any consumer with
|
||||||
|
a sub-5-second budget. Full measurements and methodology:
|
||||||
|
[`docs/plans/2026-07-13-tui-latency/`](docs/plans/2026-07-13-tui-latency/).
|
||||||
|
|
||||||
### Monitoring drift via `/health`
|
### Monitoring drift via `/health`
|
||||||
|
|
||||||
`GET /health` includes a `tui` block so you can poll for a silent billing-pool drift (the top risk after the 6/15 flip — a lost TTY flipping `cc_entrypoint` from `cli` to the metered `sdk-cli` pool would still return answers but burn metered credits). The block is **always present** (with `enabled:false` when TUI-mode is off):
|
`GET /health` includes a `tui` block so you can poll for a silent billing-pool drift (the top risk after the 6/15 flip — a lost TTY flipping `cc_entrypoint` from `cli` to the metered `sdk-cli` pool would still return answers but burn metered credits). The block is **always present** (with `enabled:false` when TUI-mode is off):
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# OCP Promotion Strategy — "Stable & Visible"
|
||||||
|
|
||||||
|
> **This document is a recommendation for the maintainer to review and adjust, not a committed plan.**
|
||||||
|
> It reflects the project's current posture (post-v3.21.0) and should be revisited whenever
|
||||||
|
> the Anthropic billing / ToS environment changes significantly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Goal: Polish + Low-Key OSS Visibility
|
||||||
|
|
||||||
|
The goal is **stability and quiet discoverability**, not growth-hacking. OCP is a personal power tool
|
||||||
|
that has been open-sourced because others can benefit from it. The right audience finds it via GitHub
|
||||||
|
search, issue threads in related projects, and word of mouth — not viral posts.
|
||||||
|
|
||||||
|
**Explicitly avoid:**
|
||||||
|
|
||||||
|
- HN / Reddit front-page pushes, influencer outreach, or any campaign that would attract a large
|
||||||
|
influx of users before the ToS/billing situation has settled. Anthropic is actively tightening
|
||||||
|
billing and enforcement on subscription-sharing (the June-15 Agent-SDK billing split is
|
||||||
|
*paused*, not cancelled — and consumer-ToS enforcement on multi-person sharing is a live risk).
|
||||||
|
A high-traffic spotlight right now would draw scrutiny that a low-profile project avoids.
|
||||||
|
- Promising features that require bypassing the `claude` CLI (raw API calls, OAuth extraction, etc.)
|
||||||
|
— that would violate `ALIGNMENT.md` and the ToS simultaneously.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Pre-Requisite: Stability First
|
||||||
|
|
||||||
|
Do not promote until the house is in order:
|
||||||
|
|
||||||
|
- [x] The concurrency / latency perf fixes are shipped (v3.20.x–v3.21.0).
|
||||||
|
- [x] Docs honesty is complete (client-tools boundary, ToS sharing disclosure, this doc).
|
||||||
|
- [ ] The June-15 Agent-SDK billing split is either confirmed cancelled or OCP has a confirmed
|
||||||
|
stable path (TUI toggle as insurance — see §5 below).
|
||||||
|
|
||||||
|
Promoting a project that has known rough edges in docs or stability only generates support burden
|
||||||
|
and negative first impressions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Honest ToS Disclosure on Sharing
|
||||||
|
|
||||||
|
Any promotion materials must carry the same disclosure as `README.md § "Deployment model & security"`:
|
||||||
|
|
||||||
|
> Pooling a single Claude subscription across **multiple distinct people** may violate Anthropic's
|
||||||
|
> Consumer Terms of Service and risk account suspension. The defensible framing is "one person,
|
||||||
|
> your own devices". Friends/team sharing is not.
|
||||||
|
|
||||||
|
This framing should appear in any README badge, linked blog post, or issue comment that mentions
|
||||||
|
LAN sharing. It is not a disclaimer that discourages usage — it is honest positioning that protects
|
||||||
|
both the project and its users.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. What to Explicitly Skip
|
||||||
|
|
||||||
|
These items are **not gaps in OCP** — they are deliberate stance decisions:
|
||||||
|
|
||||||
|
- **Multi-backend routing** (routing to OpenAI, Gemini, Llama, etc.) — that is the sibling [OLP
|
||||||
|
project](https://github.com/dtzp555-max/olp)'s role. OCP stays Claude-only by design.
|
||||||
|
- **Gateway model-discovery** (auto-detecting which models a remote server offers) — not needed
|
||||||
|
for OCP's single-provider, single-subscription model. `models.json` is the SPOT.
|
||||||
|
- **Raw Anthropic API passthrough** (bypassing the `claude` CLI) — out of scope per `ALIGNMENT.md`.
|
||||||
|
|
||||||
|
Do not add these to OCP roadmaps or respond to feature requests for them with "planned" — the
|
||||||
|
correct answer is "that's OLP territory" or "out of scope per ALIGNMENT.md".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. TUI Toggle as Insurance
|
||||||
|
|
||||||
|
The `CLAUDE_TUI_MODE` opt-in is the primary mitigation if the June-15 billing split reactivates
|
||||||
|
and makes the default `-p` path draw from the metered Agent SDK credit pool.
|
||||||
|
|
||||||
|
Keep the TUI toggle:
|
||||||
|
- Functional and tested across the three deployment hosts.
|
||||||
|
- Documented in the README, including the security constraints (single-user only).
|
||||||
|
- Easily discoverable for users who get unexpectedly metered.
|
||||||
|
|
||||||
|
If the split reactivates, the recommended operator path is: set `CLAUDE_TUI_MODE=true` +
|
||||||
|
`CLAUDE_CODE_OAUTH_TOKEN` → credential-isolated scratch home → subscription pool. That path is
|
||||||
|
already shipped and documented.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Low-Key Visibility Actions (when §2 pre-requisites are met)
|
||||||
|
|
||||||
|
- Keep the GitHub README polished and honest — it is the primary landing page.
|
||||||
|
- Respond promptly to issues and PRs — the project's reputation is built on reliability, not
|
||||||
|
marketing.
|
||||||
|
- Add OCP to the `awesome-claude` / `awesome-llm-tools` lists if they exist and allow self-PRs
|
||||||
|
— low-effort, targeted, reaches the right audience.
|
||||||
|
- When related projects (Cline, OpenCode, OpenClaw, Continue.dev) post about local Claude proxies,
|
||||||
|
a short factual comment linking to OCP is appropriate — not spam.
|
||||||
|
- Maintain the `CHANGELOG.md` with clear, honest summaries — users who are already running OCP
|
||||||
|
are the best vector for word-of-mouth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Last updated: v3.21.0 cleanup cycle. Maintainer should re-read before any external promotion.*
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# ADR 0007 — TUI Interactive Mode (subscription-pool bridge)
|
# ADR 0007 — TUI Interactive Mode (subscription-pool bridge)
|
||||||
|
|
||||||
**Date:** 2026-05-31
|
**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
|
**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.
|
**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.
|
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).
|
- **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>`):** 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.
|
- **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
|
### Working directory
|
||||||
|
|
||||||
@@ -234,6 +238,79 @@ This amendment **is** that authorization. The argument:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Authentication + defunct-reaping (PR-C amendment)
|
||||||
|
|
||||||
|
**Date:** 2026-06-13
|
||||||
|
**Status:** Accepted — amends ADR 0007.
|
||||||
|
**Motivation:** the PI231 production incident — TUI-mode returned `Please run /login · API Error: 401` for days; re-login never stuck.
|
||||||
|
|
||||||
|
### How the TUI `claude` authenticates
|
||||||
|
|
||||||
|
The spawned interactive `claude` obtains its OAuth bearer in one of two ways, in this order of preference:
|
||||||
|
|
||||||
|
1. **`CLAUDE_CODE_OAUTH_TOKEN` in env (PREFERRED).** If the env var is set on the OCP process, `buildTuiCmd` adds `CLAUDE_CODE_OAUTH_TOKEN=<shq-escaped token>` to the pane command's `env` prefix. claude then authenticates via this long-lived token and **never touches the credentials-refresh path**. This is the stable mode — it is exactly how the oracle and Mac-mini hosts already run (and how `server.mjs`'s own `getOAuthCredentials()` takes the same env at highest precedence). cli.js is **not** the authority here: this is a Class B, OCP-owned TUI spawn — see the Class B citation below.
|
||||||
|
2. **`<HOME>/.claude/.credentials.json` (FALLBACK).** When the env var is unset, claude falls back to the credentials file and its short-lived access token, renewing via the single-use refresh token.
|
||||||
|
|
||||||
|
The token MUST be set explicitly in `buildTuiCmd` because **tmux does not forward the parent process's environment to the pane** (verified live 2026-06-01 — the same reason the whole env is delivered as an `env` prefix). A token sitting in the OCP process env is invisible to the pane unless `buildTuiCmd` re-emits it.
|
||||||
|
|
||||||
|
### Why the fallback path corrupts (the PI231 incident)
|
||||||
|
|
||||||
|
When the env token is absent, every per-request spawn drives claude through the credentials.json refresh path. OAuth refresh tokens are **single-use / rotating**: a refresh consumes the old refresh token and writes a new one. The per-request `kill-session` teardown can race / interrupt claude mid-rotation, and over many spawn+kill cycles the refresh token ended up an **empty string** — at which point renewal is impossible and the host returns a permanent 401. Re-login writes a fresh token, but the next spawn re-corrupts it. **Proof the env-token fix works:** on the broken PI231 host, `CLAUDE_CODE_OAUTH_TOKEN=<oat01 token> claude -p ...` returned a real answer *despite* the corrupt credentials.json (control without the env token = 401).
|
||||||
|
|
||||||
|
**Operator guidance:** set `CLAUDE_CODE_OAUTH_TOKEN` on any TUI-mode host. The credentials.json fallback is retained only for hosts that intentionally rely on it; it is not recommended for a long-running TUI deployment.
|
||||||
|
|
||||||
|
**Security note:** with the token in the pane command, it is visible in `ps`. This is acceptable for the **single-user A-path** (it mirrors the existing plaintext-token practice for `server.mjs`), and the **multi-user B-path is already refused at boot** (`CLAUDE_TUI_MODE=true` + `AUTH_MODE=multi` is a hard FATAL), so a guest can never reach this spawn.
|
||||||
|
|
||||||
|
### Defunct `<claude>` reaping
|
||||||
|
|
||||||
|
The connected leak: the pane's `claude` process is a child of the long-lived **tmux server** daemon, not of the OCP node process (`tmux new-session -d` returns the instant the server forks the pane). Node can therefore never `waitpid()`/reap it — a SIGKILL still needs the *parent* (the tmux server) to reap. `kill-session` destroys the session but leaves the pane's `claude` (and its grandchildren) as `<defunct>` zombies that only the server reaps; over 30 days on PI231 this accumulated to **25 defunct `<claude>`** (a live `tmux kill-server` dropped it 25→3).
|
||||||
|
|
||||||
|
The node-reachable action that *actually reaps* — rather than merely re-signalling — is to stop the tmux server: on server exit the kernel reparents survivors to init (PID 1), which reaps them. `reapStaleTuiSessions` therefore, after killing our own `ocp-tui-*` sessions, issues `kill-server` **only when no foreign session of any prefix remains** (coexistence: never disrupt a co-hosted `olp-tui-*` instance). This runs at boot (existing) and now on a 15-min periodic interval gated on TUI-mode and on the TUI path being idle (`inflight === 0 && queued === 0`) so a live turn's pane is never torn down. Residual: a request whose pane is created in the narrow window between the idle-check and `kill-server` would fail cleanly via the existing honesty gates (rare; documented in the server comment).
|
||||||
|
|
||||||
|
### ALIGNMENT authorization (Class B)
|
||||||
|
|
||||||
|
Both changes are **Class B** (OCP-owned TUI spawn). `cli.js` does not perform either operation — there is no `cli.js` analogue for "how the TUI pane authenticates" or "reaping tmux-server-owned zombies"; this surface is authorized by **this ADR (0007)** per `ALIGNMENT.md`'s Class B citation requirement. No Class A wire surface, no endpoint shape, no `alignment.yml` blacklist token, and no `models.json` entry is touched.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
## Consequences
|
||||||
|
|
||||||
### Positive
|
### Positive
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
# TUI-mode latency: measured floor, and the four things worth fixing
|
||||||
|
|
||||||
|
**Date**: 2026-07-13
|
||||||
|
**Status**: findings + backlog. **Superseded in part** — see the dated update boxes below.
|
||||||
|
Item #1 shipped ([#156](https://github.com/dtzp555-max/ocp/pull/156)); item #2 is **dead**
|
||||||
|
([`streaming-spike.md`](streaming-spike.md)); item #4 measured, **no effect**; item #3 stands.
|
||||||
|
**Measured on**: Mac mini / macOS 26.5.2 / Claude Code **v2.1.207** / Sonnet 5 / Claude Max subscription / **real-home mode** (no `CLAUDE_CODE_OAUTH_TOKEN`, no `OCP_TUI_HOME` in the service env)
|
||||||
|
**Evidence**: [`measurements.jsonl`](measurements.jsonl) — **n=15** (3 configs × 5) · banner captures [`billing-banner.txt`](billing-banner.txt) · harness [`floor.sh`](floor.sh)
|
||||||
|
|
||||||
|
## Why this exists
|
||||||
|
|
||||||
|
An external consumer (the 知音 AI project) benchmarked OCP's prompt path and measured
|
||||||
|
**TTFT p50 ≈ 30–32 s**, and excluded OCP as a backend on that basis. That number is real,
|
||||||
|
but it is *not* the model being slow — this document decomposes where the 30 seconds
|
||||||
|
actually go, and what OCP can do about it.
|
||||||
|
|
||||||
|
**The harness deliberately does not go through OCP.** It spawns `tmux` + `claude` directly
|
||||||
|
(session prefix `zhiyin-floor-`, never `ocp-tui-*`) and polls `tmux capture-pane` for
|
||||||
|
incremental render, so it measures the **true first-token time** of the underlying
|
||||||
|
subscription path — the floor OCP could reach if it were perfect.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Measurements
|
||||||
|
|
||||||
|
All rows in [`measurements.jsonl`](measurements.jsonl); every number below is recomputable from it.
|
||||||
|
|
||||||
|
| Config | n | boot→input-ready (median) | **TTFT (median)** | TTFT range | full answer (median) |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| baseline (inherits global `effortLevel: xhigh`) | 5 | 1.07 s | **10.35 s** | 8.32 – 17.19 s | 11.32 s |
|
||||||
|
| **`--effort low`** | 5 | 1.03 s | **6.17 s** | **5.87 – 6.44 s** | 9.98 s |
|
||||||
|
| `--bare` | 5 | 0.44 s | **no answer at all** (5/5 `ttft_ms: -1`) | — | — |
|
||||||
|
|
||||||
|
> **Not from this harness**: the direct Anthropic API reference figure (TTFT 0.84–1.64 s, n=2)
|
||||||
|
> comes from the 知音 AI project's own smoke test, not from `measurements.jsonl`. It is quoted
|
||||||
|
> only to size the gap; do not look for it in the evidence file.
|
||||||
|
|
||||||
|
### Where the 30 seconds go
|
||||||
|
|
||||||
|
```
|
||||||
|
~1.0 s spawn → claude's input bar is ready ← NOT the bottleneck
|
||||||
|
~6-10 s true TTFT (first token rendered in the pane)
|
||||||
|
~20 s ████ waiting for the whole turn to finish ████ ← this is the 30s
|
||||||
|
```
|
||||||
|
|
||||||
|
`runTuiTurn` blocks on the native transcript until a terminal event (`lib/tui/session.mjs`
|
||||||
|
"Block on the native transcript … until terminal"; `readTuiTranscript` in
|
||||||
|
`lib/tui/transcript.mjs`; ADR 0007 step 4) — i.e. it waits for the **entire turn** to complete
|
||||||
|
before returning anything. There is no streaming path. The ~20 s delta between this harness's
|
||||||
|
real TTFT and OCP's reported 30–32 s is exactly that.
|
||||||
|
|
||||||
|
> **⚠️ 2026-07-13 correction — this decomposition attributes the ~20 s to the wrong thing.** It was
|
||||||
|
> inferred from the external 30–32 s report, never measured *through* OCP. It has since been measured
|
||||||
|
> through a real OCP instance (TUI mode, `claude-sonnet-4-6`, the same ~1850-token prompt, n=5):
|
||||||
|
> **median 11.30 s** before [#156](https://github.com/dtzp555-max/ocp/pull/156), **9.55 s** after.
|
||||||
|
> Same-turn decomposition (baseline row `i=5`): **11.563 s** wall through OCP vs `turn_duration:
|
||||||
|
> 7.319 s` of CLI-internal time on that same turn → **OCP's own overhead ≈ 4.2 s** (n=1), **not
|
||||||
|
> ~20 s**. The rest of any larger number is the model *generating a long answer*,
|
||||||
|
> which the blocking wait does not cause and streaming would not shorten — it would only move the
|
||||||
|
> first byte earlier. The 30–32 s figure therefore reflects a much longer output (and/or the
|
||||||
|
> then-inherited `xhigh` effort), not 20 s of OCP dead time. See
|
||||||
|
> [`streaming-spike.md`](streaming-spike.md) § "What streaming would have bought".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Blocking constraint: `--bare` silently drops you off the subscription pool
|
||||||
|
|
||||||
|
Captured live ([`billing-banner.txt`](billing-banner.txt)) — the startup banner is the **only**
|
||||||
|
reliable indicator:
|
||||||
|
|
||||||
|
```
|
||||||
|
[] | Sonnet 5 with xhigh effort · Claude Max
|
||||||
|
[--effort low] | Sonnet 5 with low effort · Claude Max
|
||||||
|
[--bare] | Sonnet 5 with xhigh effort · API Usage Billing ← ❌
|
||||||
|
```
|
||||||
|
|
||||||
|
`--bare` ("skip hooks, LSP, plugin…") **also skips the subscription-credential resolution
|
||||||
|
path**. It really does cut boot to 0.43–0.45 s — but you are no longer on the subscription,
|
||||||
|
which defeats the entire purpose of TUI mode (ADR 0007 exists solely to reach the
|
||||||
|
subscription pool).
|
||||||
|
|
||||||
|
**The failure is silent.** All 5 `--bare` samples reached input-ready (boot 0.43–0.45 s), were
|
||||||
|
sent the prompt, and then produced **no answer at all** — 60 s timeout, no error, no crash, the
|
||||||
|
pane simply never rendered a token (the API-billing account had no credit balance). Nothing in
|
||||||
|
the transcript or the exit status reveals this.
|
||||||
|
|
||||||
|
**Anyone changing spawn flags must diff the banner line before and after.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backlog — four items, ranked by value ÷ effort
|
||||||
|
|
||||||
|
### 1. Pass `--effort` explicitly on spawn — **do this first**
|
||||||
|
|
||||||
|
`buildTuiCmd` (`lib/tui/session.mjs`) does not pass `--effort` — `grep -rn -- "--effort\|effortLevel" lib/ server.mjs`
|
||||||
|
returns zero hits. What the pane's `claude` ends up using therefore depends on **which HOME mode
|
||||||
|
`resolveTuiHome()` picked**:
|
||||||
|
|
||||||
|
| mode | HOME | effort the pane gets |
|
||||||
|
|---|---|---|
|
||||||
|
| **real-home** (legacy default — *current* service config: no `CLAUDE_CODE_OAUTH_TOKEN`, no `OCP_TUI_HOME`) | `~` | **inherits the operator's `~/.claude/settings.json` → `effortLevel: xhigh` on this host** |
|
||||||
|
| env-token scratch (`CLAUDE_CODE_OAUTH_TOKEN` set — the direction #146/#150 pushed) | `~/.ocp-tui/home` | that settings.json contains only `permissions.additionalDirectories`; `prepareTuiHome()` never writes `effortLevel` → **claude's built-in default** |
|
||||||
|
|
||||||
|
**Scope note**: TUI mode is currently *off* on this host (`CLAUDE_TUI_MODE=false`; `/health` →
|
||||||
|
`"tui": {"enabled": false}`), so live traffic takes the `-p` path today. The statement below is
|
||||||
|
about what happens **when TUI mode is enabled**.
|
||||||
|
|
||||||
|
On the current HOME config, **every TUI request would run extended thinking** — pure waste
|
||||||
|
for the typical "generate this JSON" request, and it makes latency depend on an unrelated global
|
||||||
|
setting the operator may have changed for their own interactive use. And the mode split means
|
||||||
|
the effort level silently changes if the operator ever switches to env-token mode.
|
||||||
|
**Passing `--effort` explicitly fixes both problems at once.**
|
||||||
|
|
||||||
|
- **Effect (real-home, measured)**: TTFT p50 **10.35 s → 6.17 s (−40 %)**, and the spread
|
||||||
|
collapses from 8.32–17.19 s to **5.87–6.44 s**. For a proxy, the variance reduction matters
|
||||||
|
more than the median.
|
||||||
|
- **Cost**: one flag. Suggested: a new `OCP_TUI_EFFORT` env var (default `low`), documented in
|
||||||
|
README § "Environment Variables" per `release_kit.new_feature_doc_expectations`.
|
||||||
|
- **Risk**: none — banner confirms it stays on `Claude Max` (see `billing-banner.txt`).
|
||||||
|
- ⚠️ Do **not** reach for `--bare` to shave boot: see above.
|
||||||
|
|
||||||
|
### 2. Real streaming instead of blocking on turn-terminal — **ACHIEVABLE → [`streaming-spike.md`](streaming-spike.md)**
|
||||||
|
|
||||||
|
> **2026-07-13 update — the prereq spike was run. The answer is YES, but not from either source this
|
||||||
|
> item guessed at.** (a) The transcript grows at *event* granularity (the whole answer lands in one
|
||||||
|
> line, ~0.3 s before terminal) — dead. (b) The pane is a **rendered** view whose `capture-pane` text
|
||||||
|
> no longer contains the answer's source bytes (`## `, `**`, code fences are gone) — dead, and worse
|
||||||
|
> than "lossy": it is *not the model's text*. **But there is a third source neither this backlog nor
|
||||||
|
> the first spike considered: `claude` fires a `MessageDisplay` hook carrying incremental,
|
||||||
|
> byte-faithful `delta`s of the raw reply.** Verified live on a plain interactive TUI spawn (no `-p`),
|
||||||
|
> banner `· Claude Max`: 7 fires spread across generation, `concat(deltas) === T` **byte-exactly**
|
||||||
|
> (579 == 579), `T.startsWith(S)` true at every step, `## ` / `**` / ```` ```javascript ```` all
|
||||||
|
> present in the deltas. Granularity is block-level (~5–7 chunks/answer), not token-level — plenty for
|
||||||
|
> SSE. **Build it.**
|
||||||
|
>
|
||||||
|
> ⚠️ Two corrections to this item as written: the **"~20 s" is wrong** (inferred from an external
|
||||||
|
> report, never measured through OCP — the same-turn decomposition puts OCP's own overhead at **~4 s**,
|
||||||
|
> n=1), and **streaming moves the first byte, not the last** — so a consumer needing the *complete*
|
||||||
|
> answer (the JSON-card case that motivated this) gains **nothing** from it. Build it for
|
||||||
|
> progressively-rendering consumers, not as a throughput win.
|
||||||
|
>
|
||||||
|
> Full evidence + implementer caveats (the hook is `forceSyncExecution` — claude BLOCKS on it):
|
||||||
|
> **[`streaming-spike.md`](streaming-spike.md)**. Original framing preserved below.
|
||||||
|
|
||||||
|
Today `runTuiTurn` blocks on the transcript until the turn is *finished*. The pane is already
|
||||||
|
rendering tokens incrementally the whole time — this harness proves you can observe first token
|
||||||
|
at ~6 s by polling `tmux capture-pane`.
|
||||||
|
|
||||||
|
- **Effect**: turns a 30 s wall into a ~6 s TTFT with progressive output; enables SSE streaming
|
||||||
|
on the OCP endpoint instead of a single blob at the end.
|
||||||
|
- **Cost**: real work. Pane capture is ANSI/redraw-based and lossy for exact text (wrapping,
|
||||||
|
scrollback, spinner lines). Two candidate sources: (a) incremental reads of the transcript
|
||||||
|
JSONL, (b) `capture-pane` diffing with a stable start marker. (a) is much cleaner **if it
|
||||||
|
holds**.
|
||||||
|
- **Prereq spike (do this before designing anything)**: does the transcript JSONL grow *during*
|
||||||
|
a turn, or only at the end? If only at the end, (a) is dead and you are stuck with (b).
|
||||||
|
|
||||||
|
### 3. Warm pane pool — ~1 s
|
||||||
|
|
||||||
|
Every request spawns a fresh tmux session + `claude` (`randomUUID()` + `new-session`, then
|
||||||
|
`kill-session` in `finally`; `grep -rn "pool\|warm\|reuse" lib/tui/*.mjs` → zero hits). Boot to
|
||||||
|
input-ready is ~1.0 s, paid on every request. A pool of pre-booted panes (single-use, replaced in
|
||||||
|
the background) amortizes it to zero for any workload below the pool refill rate.
|
||||||
|
|
||||||
|
- **Effect**: −1.0 s.
|
||||||
|
- **Cost**: moderate; interacts with the session reaper and the per-port prefix scoping added in
|
||||||
|
#148 — pooled panes must not look like zombies to the sweep.
|
||||||
|
- Lower priority than #1 and #2: it is the smallest slice.
|
||||||
|
|
||||||
|
### 4. Trim the prefill — ~~probably not worth it~~ **MEASURED: no detectable benefit. Do not adopt.**
|
||||||
|
|
||||||
|
> **2026-07-13 update.** `--exclude-dynamic-system-prompt-sections` was measured with the same
|
||||||
|
> harness (`floor.sh`, n=5, Sonnet 5, on top of `--effort low`): **TTFT median 6.39 s**
|
||||||
|
> (5.87–10.54 s) vs **6.17 s** (5.87–6.44 s) for `--effort low` alone — i.e. **0.22 s worse, inside
|
||||||
|
> the noise band**, with one worse outlier; dropping that outlier does not change the verdict. n=5
|
||||||
|
> cannot prove "zero", only "no benefit detectable above noise" — but there is also a **mechanistic**
|
||||||
|
> reason not to expect one: `--help` says the flag *"Improves cross-user prompt-cache **reuse**"*, and
|
||||||
|
> **OCP is single-user** — there is no cross-user cache to share, so the flag has nothing to buy here.
|
||||||
|
> The banner stayed on `· Claude Max` (no billing-pool drop), but there is no win to bank. The ~6 s
|
||||||
|
> floor stands as stated below. Raw rows: [`prefill-spike-measurements.jsonl`](prefill-spike-measurements.jsonl).
|
||||||
|
|
||||||
|
|
||||||
|
After #1–#3, the floor is **~6 s**, and it does not go lower. `claude` always injects the full
|
||||||
|
Claude Code system prompt + tool definitions (thousands to tens of thousands of prefill tokens)
|
||||||
|
regardless of what you ask it. `--exclude-dynamic-system-prompt-sections` exists and may shave
|
||||||
|
some of it — **unmeasured**; worth one spike, but do not expect to reach the direct API's
|
||||||
|
~1 s.
|
||||||
|
|
||||||
|
**Consequence to accept, and to state in the README**: even fully optimized, TUI mode has a
|
||||||
|
**~6 s TTFT floor**, so it cannot serve real-time / interactive-latency consumers. It remains
|
||||||
|
appropriate for batch, background, and cost-insensitive-latency use. The 知音 AI project
|
||||||
|
excluded it on this basis (their prompt-latency budget is 2–4 s) *independently* of the ToS
|
||||||
|
question already documented in the README.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reproduction
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# harness never touches OCP's :3456 service or ocp-tui-* sessions, and never kill-server
|
||||||
|
bash docs/plans/2026-07-13-tui-latency/floor.sh 5 # baseline
|
||||||
|
TAG=effort-low EXTRA_ARGS="--effort low" bash .../floor.sh 5 # −40 %
|
||||||
|
TAG=bare EXTRA_ARGS="--bare" bash .../floor.sh 5 # the trap
|
||||||
|
|
||||||
|
# billing-pool check for ANY spawn-flag change — the banner is the only source of truth
|
||||||
|
tmux new-session -d -s probe -x 200 -y 50 -c "$HOME" \
|
||||||
|
"claude --model claude-sonnet-5 --session-id $(uuidgen) <your-flags-here>"
|
||||||
|
sleep 6; tmux capture-pane -p -t probe | grep -E "Claude Max|API Usage Billing"
|
||||||
|
tmux kill-session -t probe
|
||||||
|
```
|
||||||
|
|
||||||
|
## Interaction with OCP while the harness runs
|
||||||
|
|
||||||
|
- **Kill direction is safe both ways**: `reapStaleTuiSessions()` only `kill-session`s names
|
||||||
|
matching `ocp-tui-<port>-`, which `zhiyin-floor-*` never matches; and the harness only
|
||||||
|
`kill-session`s its own single session — it contains **no `kill-server`**.
|
||||||
|
- **One benign interaction** (only when TUI mode is enabled — the reap tick is itself gated on
|
||||||
|
`TUI_MODE`): OCP's periodic `kill-server` (zombie reaping) is gated on
|
||||||
|
`othersRemain` — *any* foreign-prefixed tmux session suppresses it. So while the harness is
|
||||||
|
running, that sweep is skipped. This is the coexistence guard working as designed; it resumes
|
||||||
|
on the next tick.
|
||||||
|
|
||||||
|
## Harness caveats (stated so the numbers are not over-trusted)
|
||||||
|
|
||||||
|
- **n=5 per config**, single host, single model (Sonnet 5), single prompt size (~1850 tokens).
|
||||||
|
Enough to separate 6 s from 10 s from 30 s; **not** enough for a p95.
|
||||||
|
- TTFT is "marker visible in `capture-pane`", which includes tmux render latency (small, but
|
||||||
|
nonzero) — it is an upper bound on the true first-token time.
|
||||||
|
- **The harness's readiness marker is not OCP's.** `floor.sh` waits for `│ >|❯|Try "`; OCP's
|
||||||
|
`tuiInputReady()` matches `/\? for shortcuts/`. These are different events, so the ~1.0 s
|
||||||
|
boot figure is **not** directly comparable to OCP's `BOOT_MS` gate (default cap 4000 ms). It
|
||||||
|
does not affect the conclusions (1 s ≪ 6 s TTFT), but it is not apples-to-apples.
|
||||||
|
- The first version of this harness reported TTFT **0.08 s** — a false positive: the prompt
|
||||||
|
literally contained the marker string it was grepping for, so the match fired the instant the
|
||||||
|
prompt was pasted. Fixed by describing the marker instead of spelling it. **The script exited 0
|
||||||
|
and "successfully" produced 5 samples both times** — exit status proves nothing here.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[] | ▝▜█████▛▘ Sonnet 5 with xhigh effort · Claude Max
|
||||||
|
[--effort low] | ▝▜█████▛▘ Sonnet 5 with low effort · Claude Max
|
||||||
|
[--bare] | ▝▜█████▛▘ Sonnet 5 with xhigh effort · API Usage Billing
|
||||||
Executable
+128
@@ -0,0 +1,128 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# OCP TUI-mode latency floor harness — see README.md in this directory.
|
||||||
|
#
|
||||||
|
# 目的:回答一个问题——如果把 OCP 现有的两个已知开销砍掉
|
||||||
|
# (a) 每请求 spawn + boot(可用预热进程池消除)
|
||||||
|
# (b) 假流式(等 turn_duration 才返回,可用增量读 pane 消除)
|
||||||
|
# 之后,订阅池路径的**真实 TTFT 地板**是多少?
|
||||||
|
#
|
||||||
|
# 判据:地板 ≤ 4s → OCP 作为"省钱选项"可行;> 8s → 死透,不再讨论。
|
||||||
|
#
|
||||||
|
# 红线:
|
||||||
|
# - 不经过生产 OCP 服务(:3456)—— 直接起 tmux+claude,OCP 进程零干扰
|
||||||
|
# - tmux session 前缀用 zhiyin-floor-(**不是** ocp-tui-),避免被 OCP 的
|
||||||
|
# reaper 当成自己的会话杀掉,也避免我们杀到它的
|
||||||
|
# - 用 real HOME(凭据)—— scratch HOME + symlink 凭据会 fork OAuth 导致 401
|
||||||
|
# (见跨机记忆 tui_scratch_home_credential_fork)
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
N=${1:-5}
|
||||||
|
MODEL=${MODEL:-claude-sonnet-5}
|
||||||
|
EXTRA_ARGS=${EXTRA_ARGS:-} # 额外 CLI 参数(如 --effort low --bare)
|
||||||
|
TAG=${TAG:-baseline}
|
||||||
|
OUT=${OUT:-$(dirname "$0")/measurements.jsonl}
|
||||||
|
PROMPT_FILE=$(mktemp)
|
||||||
|
PREFIX="zhiyin-floor"
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$OUT")"
|
||||||
|
|
||||||
|
# ── 构造提示:~2000 token 的假会议转写 + 明确的起始标记 ────────────────
|
||||||
|
# 单行(多行会在 tmux send-keys 时提前触发 Enter)
|
||||||
|
build_prompt() {
|
||||||
|
local seg="Speaker A said the quarterly pipeline is tracking behind plan and the enterprise segment needs a different motion. Speaker B replied that the current onboarding flow loses roughly a third of trial accounts before the first integration is complete. They debated whether the fix belongs in product or in customer success. "
|
||||||
|
local body=""
|
||||||
|
for _ in $(seq 1 22); do body+="$seg"; done
|
||||||
|
printf '%s' "You are a real-time meeting copilot. Meeting transcript so far: $body --- Task: produce ONE prompt card as compact JSON with keys: points (array of 3 short Chinese bullet points), keyline (one English sentence the user can read aloud). IMPORTANT: your reply MUST begin with three hash characters immediately followed by the uppercase word CARD (no space between them), then the JSON. No preamble, no markdown fences." > "$PROMPT_FILE"
|
||||||
|
}
|
||||||
|
build_prompt
|
||||||
|
PROMPT_CHARS=$(wc -c < "$PROMPT_FILE" | tr -d ' ')
|
||||||
|
|
||||||
|
now_ms() { python3 -c 'import time;print(int(time.time()*1000))'; }
|
||||||
|
|
||||||
|
echo "配置: $TAG 参数: [$EXTRA_ARGS]"
|
||||||
|
echo "模型: $MODEL 样本: $N 提示长度: ${PROMPT_CHARS} chars (≈$((PROMPT_CHARS/4)) token)"
|
||||||
|
echo "输出: $OUT"
|
||||||
|
echo
|
||||||
|
|
||||||
|
for i in $(seq 1 "$N"); do
|
||||||
|
SESS="${PREFIX}-$$-$i"
|
||||||
|
SID=$(uuidgen)
|
||||||
|
|
||||||
|
# ── 冷启动:spawn + 等输入框就绪 ─────────────────────────────────
|
||||||
|
T_SPAWN=$(now_ms)
|
||||||
|
tmux new-session -d -s "$SESS" -x 200 -y 50 \
|
||||||
|
-e CLAUDE_CODE_DISABLE_CLAUDE_MDS=1 \
|
||||||
|
-e CLAUDE_CODE_DISABLE_AUTO_MEMORY=1 \
|
||||||
|
-c "$HOME" \
|
||||||
|
"claude --model $MODEL --session-id $SID --strict-mcp-config --disallowedTools 'mcp__*' $EXTRA_ARGS" 2>/dev/null
|
||||||
|
if [ $? -ne 0 ]; then echo "[$i] tmux spawn 失败,跳过"; continue; fi
|
||||||
|
|
||||||
|
# 轮询输入框就绪(claude TUI 的输入提示符)
|
||||||
|
READY=0
|
||||||
|
for _ in $(seq 1 150); do # 上限 15s
|
||||||
|
PANE=$(tmux capture-pane -p -t "$SESS" 2>/dev/null || true)
|
||||||
|
if grep -qE '│ >|❯|Try "' <<<"$PANE"; then READY=1; break; fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
T_READY=$(now_ms)
|
||||||
|
BOOT_MS=$((T_READY - T_SPAWN))
|
||||||
|
if [ "$READY" -ne 1 ]; then
|
||||||
|
echo "[$i] 启动超时(${BOOT_MS}ms),pane 末 3 行:"
|
||||||
|
tmux capture-pane -p -t "$SESS" 2>/dev/null | tail -3 | sed 's/^/ /'
|
||||||
|
tmux kill-session -t "$SESS" 2>/dev/null
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 热态:粘提示 → 回车 → 量首 token ─────────────────────────────
|
||||||
|
tmux send-keys -t "$SESS" -l "$(cat "$PROMPT_FILE")" 2>/dev/null
|
||||||
|
sleep 0.4 # 让粘贴落地(OCP 用 400ms 轮询粒度)
|
||||||
|
T0=$(now_ms)
|
||||||
|
tmux send-keys -t "$SESS" Enter 2>/dev/null
|
||||||
|
|
||||||
|
TTFT_MS=-1
|
||||||
|
for _ in $(seq 1 600); do # 上限 60s
|
||||||
|
if tmux capture-pane -p -t "$SESS" 2>/dev/null | grep -q '###CARD'; then
|
||||||
|
TTFT_MS=$(( $(now_ms) - T0 )); break
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── 完整回答:pane 连续 2s 不再变化 ──────────────────────────────
|
||||||
|
COMPLETE_MS=-1
|
||||||
|
if [ "$TTFT_MS" -ge 0 ]; then
|
||||||
|
LAST=""; STABLE=0
|
||||||
|
for _ in $(seq 1 900); do # 上限 90s
|
||||||
|
CUR=$(tmux capture-pane -p -t "$SESS" 2>/dev/null | cksum)
|
||||||
|
if [ "$CUR" = "$LAST" ]; then
|
||||||
|
STABLE=$((STABLE+1))
|
||||||
|
[ "$STABLE" -ge 20 ] && { COMPLETE_MS=$(( $(now_ms) - T0 - 2000 )); break; }
|
||||||
|
else
|
||||||
|
STABLE=0; LAST="$CUR"
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '{"i":%d,"tag":"%s","model":"%s","extra_args":"%s","prompt_chars":%s,"boot_ms":%d,"ttft_ms":%d,"complete_ms":%d}\n' \
|
||||||
|
"$i" "$TAG" "$MODEL" "$EXTRA_ARGS" "$PROMPT_CHARS" "$BOOT_MS" "$TTFT_MS" "$COMPLETE_MS" | tee -a "$OUT"
|
||||||
|
|
||||||
|
tmux kill-session -t "$SESS" 2>/dev/null
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
rm -f "$PROMPT_FILE"
|
||||||
|
echo
|
||||||
|
echo "=== 汇总 ==="
|
||||||
|
python3 - "$OUT" <<'EOF'
|
||||||
|
import json,sys,statistics
|
||||||
|
rows=[json.loads(l) for l in open(sys.argv[1]) if l.strip()]
|
||||||
|
ok=[r for r in rows if r['ttft_ms']>=0]
|
||||||
|
if not ok: print("无有效样本"); sys.exit()
|
||||||
|
def s(k):
|
||||||
|
v=[r[k] for r in ok if r[k]>=0]
|
||||||
|
return f"n={len(v)} 中位={statistics.median(v)/1000:.2f}s 最小={min(v)/1000:.2f}s 最大={max(v)/1000:.2f}s" if v else "无"
|
||||||
|
print(f" 冷启动 boot : {s('boot_ms')} ← 预热进程池可完全消除")
|
||||||
|
print(f" TTFT(首 token) : {s('ttft_ms')} ★ 这就是地板")
|
||||||
|
print(f" 完整回答 : {s('complete_ms')}")
|
||||||
|
print(f"\n 失败样本: {len(rows)-len(ok)}/{len(rows)}")
|
||||||
|
EOF
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{"i": 1, "tag": "effort-low", "model": "claude-sonnet-5", "extra_args": "--effort low", "prompt_chars": 7451, "boot_ms": 1077, "ttft_ms": 6172, "complete_ms": 9929}
|
||||||
|
{"i": 2, "tag": "effort-low", "model": "claude-sonnet-5", "extra_args": "--effort low", "prompt_chars": 7451, "boot_ms": 1026, "ttft_ms": 6160, "complete_ms": 9996}
|
||||||
|
{"i": 3, "tag": "effort-low", "model": "claude-sonnet-5", "extra_args": "--effort low", "prompt_chars": 7451, "boot_ms": 1010, "ttft_ms": 6437, "complete_ms": 9977}
|
||||||
|
{"i": 4, "tag": "effort-low", "model": "claude-sonnet-5", "extra_args": "--effort low", "prompt_chars": 7451, "boot_ms": 1033, "ttft_ms": 5872, "complete_ms": 9944}
|
||||||
|
{"i": 5, "tag": "effort-low", "model": "claude-sonnet-5", "extra_args": "--effort low", "prompt_chars": 7451, "boot_ms": 1154, "ttft_ms": 6387, "complete_ms": 9993}
|
||||||
|
{"i":1,"tag":"baseline","model":"claude-sonnet-5","extra_args":"","prompt_chars":7451,"boot_ms":1300,"ttft_ms":8321,"complete_ms":9939}
|
||||||
|
{"i":2,"tag":"baseline","model":"claude-sonnet-5","extra_args":"","prompt_chars":7451,"boot_ms":1070,"ttft_ms":10347,"complete_ms":11320}
|
||||||
|
{"i":3,"tag":"baseline","model":"claude-sonnet-5","extra_args":"","prompt_chars":7451,"boot_ms":911,"ttft_ms":13061,"complete_ms":15163}
|
||||||
|
{"i":4,"tag":"baseline","model":"claude-sonnet-5","extra_args":"","prompt_chars":7451,"boot_ms":1441,"ttft_ms":9981,"complete_ms":11066}
|
||||||
|
{"i":5,"tag":"baseline","model":"claude-sonnet-5","extra_args":"","prompt_chars":7451,"boot_ms":1036,"ttft_ms":17189,"complete_ms":17985}
|
||||||
|
{"i":1,"tag":"bare","model":"claude-sonnet-5","extra_args":"--bare","prompt_chars":7451,"boot_ms":429,"ttft_ms":-1,"complete_ms":-1}
|
||||||
|
{"i":2,"tag":"bare","model":"claude-sonnet-5","extra_args":"--bare","prompt_chars":7451,"boot_ms":437,"ttft_ms":-1,"complete_ms":-1}
|
||||||
|
{"i":3,"tag":"bare","model":"claude-sonnet-5","extra_args":"--bare","prompt_chars":7451,"boot_ms":444,"ttft_ms":-1,"complete_ms":-1}
|
||||||
|
{"i":4,"tag":"bare","model":"claude-sonnet-5","extra_args":"--bare","prompt_chars":7451,"boot_ms":446,"ttft_ms":-1,"complete_ms":-1}
|
||||||
|
{"i":5,"tag":"bare","model":"claude-sonnet-5","extra_args":"--bare","prompt_chars":7451,"boot_ms":441,"ttft_ms":-1,"complete_ms":-1}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{"hook_event_name": "MessageDisplay", "index": 0, "final": false, "delta": "## Mutex\n\n"}
|
||||||
|
{"hook_event_name": "MessageDisplay", "index": 1, "final": false, "delta": "A **mutual exclusion lock** prevents concurrent access to a shared resource, ensuring only one thread runs the critical section at a time.\n\n"}
|
||||||
|
{"hook_event_name": "MessageDisplay", "index": 2, "final": false, "delta": "- Acquiring a locked mutex blocks the caller until the current holder releases it.\n"}
|
||||||
|
{"hook_event_name": "MessageDisplay", "index": 3, "final": false, "delta": "- Failing to release a mutex causes a deadlock, freezing all waiting threads.\n\n```javascript\nconst { Mutex } = require('async-mutex');\n\nconst mutex = new Mutex();\n"}
|
||||||
|
{"hook_event_name": "MessageDisplay", "index": 4, "final": false, "delta": "let counter = 0;\n\nasync function increment() {\n const release = await mutex.acquire();\n try {\n"}
|
||||||
|
{"hook_event_name": "MessageDisplay", "index": 5, "final": false, "delta": " counter++; // only one caller here at a time\n } finally {\n release();\n }\n}\n"}
|
||||||
|
{"hook_event_name": "MessageDisplay", "index": 6, "final": true, "delta": "```"}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{"i":1,"tag":"effort-low-exclude-dynamic","model":"claude-sonnet-5","extra_args":"--effort low --exclude-dynamic-system-prompt-sections","prompt_chars":7451,"boot_ms":934,"ttft_ms":5867,"complete_ms":9953}
|
||||||
|
{"i":2,"tag":"effort-low-exclude-dynamic","model":"claude-sonnet-5","extra_args":"--effort low --exclude-dynamic-system-prompt-sections","prompt_chars":7451,"boot_ms":1275,"ttft_ms":6388,"complete_ms":9874}
|
||||||
|
{"i":3,"tag":"effort-low-exclude-dynamic","model":"claude-sonnet-5","extra_args":"--effort low --exclude-dynamic-system-prompt-sections","prompt_chars":7451,"boot_ms":874,"ttft_ms":10537,"complete_ms":11782}
|
||||||
|
{"i":4,"tag":"effort-low-exclude-dynamic","model":"claude-sonnet-5","extra_args":"--effort low --exclude-dynamic-system-prompt-sections","prompt_chars":7451,"boot_ms":1170,"ttft_ms":6379,"complete_ms":9947}
|
||||||
|
{"i":5,"tag":"effort-low-exclude-dynamic","model":"claude-sonnet-5","extra_args":"--effort low --exclude-dynamic-system-prompt-sections","prompt_chars":7451,"boot_ms":1329,"ttft_ms":6443,"complete_ms":9884}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
# Backlog #2 (real streaming): **achievable** — via the `MessageDisplay` hook
|
||||||
|
|
||||||
|
**Date**: 2026-07-13
|
||||||
|
**Status**: prereq-spike result. **Streaming IS achievable on the TUI path**, byte-faithfully, on the
|
||||||
|
subscription pool. Three obvious sources are dead ends; a fourth one works.
|
||||||
|
**Scope**: answers the prereq spike that [`README.md`](README.md) § "Backlog #2" demanded *before* any
|
||||||
|
streaming design:
|
||||||
|
|
||||||
|
> **Prereq spike (do this before designing anything)**: does the transcript JSONL grow *during* a
|
||||||
|
> turn, or only at the end? If only at the end, (a) is dead and you are stuck with (b).
|
||||||
|
|
||||||
|
The answer: **(a) is dead, (b) is dead — and you are not stuck with either.** The CLI exposes its own
|
||||||
|
streaming interface as a **hook**, which the backlog did not consider.
|
||||||
|
|
||||||
|
**Measured on**: Mac mini / Claude Code **v2.1.207** / Sonnet 4.6 + Sonnet 5 / Claude Max /
|
||||||
|
real-home mode. Every claim below is reproducible from the commands given.
|
||||||
|
|
||||||
|
> **Honesty note on how this document was produced.** Its first version concluded the exact opposite —
|
||||||
|
> "streaming is not achievable; the CLI exposes no byte-faithful incremental source" — and was **wrong**.
|
||||||
|
> An adversarial reviewer, commissioned specifically to *refute* it, found `MessageDisplay` on a second
|
||||||
|
> pass; its own first pass had enumerated the hook registry with a truncated grep (it reported 21
|
||||||
|
> events — there are **30**). Both the wrong conclusion and its refutation are preserved here, because
|
||||||
|
> "we checked, it's impossible" is the most expensive kind of claim to get wrong: it closes a door and
|
||||||
|
> nobody re-opens it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ The source that works: the `MessageDisplay` hook
|
||||||
|
|
||||||
|
`claude` fires a **`MessageDisplay`** hook as it renders each block of the assistant's reply. The
|
||||||
|
payload carries the **raw markdown source** of an incremental `delta`, plus a monotonic `index` and a
|
||||||
|
`final` flag:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "hook_event_name": "MessageDisplay",
|
||||||
|
"turn_id": "6cb31d21-…", "message_id": "84ab9832-…",
|
||||||
|
"index": 0, "final": false, "delta": "## Mutex\n\n" }
|
||||||
|
```
|
||||||
|
*(payload also carries `session_id`, `transcript_path`, `prompt_id`, `cwd`)*
|
||||||
|
|
||||||
|
Registered as an ordinary command hook via `--settings` on a **plain interactive TUI spawn** (no `-p`,
|
||||||
|
no `--bare`), `claude-sonnet-4-6`, `--effort low`. Banner verified:
|
||||||
|
`▝▜█████▛▘ Sonnet 4.6 with low effort · Claude Max` — **subscription pool, not metered billing**.
|
||||||
|
|
||||||
|
One live turn — 7 fires, spread across generation:
|
||||||
|
|
||||||
|
```
|
||||||
|
index=0 final=false len= 10 '## Mutex\n\n'
|
||||||
|
index=1 final=false len= 140 'A **mutual exclusion lock** prevents concurrent access to a shar…'
|
||||||
|
index=2 final=false len= 83 '- Acquiring a locked mutex blocks the caller until the current h…'
|
||||||
|
index=3 final=false len= 163 '- Failing to release a mutex causes a deadlock, freezing all wai…'
|
||||||
|
index=4 final=false len= 96 'let counter = 0;\n\nasync function increment() {\n const release =…'
|
||||||
|
index=5 final=false len= 84 ' counter++; // only one caller here at a time\n } finally {\n …'
|
||||||
|
index=6 final=true len= 3 '```'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Every invariant a proxy needs — all hold:**
|
||||||
|
|
||||||
|
| requirement | result |
|
||||||
|
|---|---|
|
||||||
|
| **byte-faithful** — deltas are the model's *source*, not the rendered pane | ✅ `## `, `**`, ```` ```javascript ```` all present in the deltas |
|
||||||
|
| **exactness** — `concat(deltas) === T` (the transcript-authoritative text) | ✅ **true**, 579 == 579 bytes |
|
||||||
|
| **prefix-stable** — `T.startsWith(concat(deltas[0..n]))` at every n | ✅ **true at all 7 steps** |
|
||||||
|
| **incremental** — arrives during generation, not at the end | ✅ 7 fires spread across the turn |
|
||||||
|
| **no `-p`** — stays out of the metered `sdk-cli` pool | ✅ plain interactive TUI |
|
||||||
|
| **subscription pool** | ✅ banner `· Claude Max` |
|
||||||
|
|
||||||
|
This is exactly the contract a streaming design needs: deltas forward straight into SSE
|
||||||
|
`delta.content` chunks, and the transcript's final text `T` stays a cheap end-of-turn assertion
|
||||||
|
(`concat === T`) instead of a reconciliation problem.
|
||||||
|
|
||||||
|
### Caveats for the implementer
|
||||||
|
|
||||||
|
- **Block-level granularity, not token-level** — the hook fires **once per rendered block** (roughly one
|
||||||
|
per paragraph / list item / code block), so the chunk count **scales with answer length**: 7 fires for a
|
||||||
|
~600-byte answer, **18 for a ~2 KB one**. Plenty for SSE (`delta.content` has no minimum size), but do
|
||||||
|
not promise token-by-token output, and do not hard-code any assumption about chunk count.
|
||||||
|
- **🔴 The sink MUST be keyed by `session_id` — this is live TODAY, not a future concern.**
|
||||||
|
`OCP_TUI_MAX_CONCURRENT` defaults to **2**, so **two `claude` processes already run concurrently**. One
|
||||||
|
hook command writing to one shared sink would **interleave deltas from two different turns into one
|
||||||
|
stream** — request A's client receiving request B's text, the worst failure a proxy can have, and one a
|
||||||
|
single-request test will never surface. The payload carries `session_id` (and `turn_id` / `message_id`),
|
||||||
|
so demux is easy: derive the sink path from `session_id` (`<dir>/<session_id>.jsonl`) and read only your
|
||||||
|
own turn's file. This *also* keeps the design **warm-pool compatible**, because a pre-booted pane's
|
||||||
|
session-id is fixed at boot — one static hook script serves every pane. **Test it with ≥2 concurrent
|
||||||
|
streaming requests carrying distinguishable prompts and assert zero cross-contamination.**
|
||||||
|
- **⚠️ `forceSyncExecution: true` in the hook's source — `claude` BLOCKS on the hook.** A slow hook
|
||||||
|
adds latency to *every* delta. The hook must write and exit immediately (e.g. write to a FIFO / unix
|
||||||
|
socket that OCP reads; never work inline). **Measure the added per-delta latency.**
|
||||||
|
- **Thinking blocks appear to be excluded — but this is NOT yet stress-tested. Verify before shipping.**
|
||||||
|
The exclusion is inferred from `content.map(c => c.type === "text" ? c.text : "")` — but that snippet is
|
||||||
|
from the **`final:true`** call site, not the incremental one. Four live turns (incl. two at `--effort
|
||||||
|
high`) showed no thinking text in any delta and `concat === T` held — **but each transcript's thinking
|
||||||
|
block was empty (`thinking:""`, 0 chars)**, so the exclusion was never actually stressed. **The failure
|
||||||
|
mode is severe**: if thinking deltas *do* fire on some config (Opus, `xhigh`), `concat(deltas) !== T`
|
||||||
|
**and OCP streams the model's private reasoning to the caller**. The end-of-turn `concat === T` assertion
|
||||||
|
would *detect* that but **cannot prevent** it — SSE deltas cannot be un-sent. **Before shipping, run a
|
||||||
|
turn on a model+effort that produces substantive thinking** (a hard reasoning prompt on Opus / `xhigh`)
|
||||||
|
and confirm both (a) no thinking text in any delta and (b) `concat === T` still holds.
|
||||||
|
- OCP already owns the spawn (isolated HOME, its own flags), so injecting `--settings` with a
|
||||||
|
`MessageDisplay` hook sits inside the existing architecture.
|
||||||
|
- **`ALIGNMENT.md`**: this consumes `claude`'s **own** hook surface as emitted — forwarding, not
|
||||||
|
inventing. Not a new endpoint, not a fabricated protocol. (Class B / ADR 0007 — the TUI spawn is
|
||||||
|
OCP-owned; no `cli.js` citation applies.)
|
||||||
|
|
||||||
|
### Reproduce in 60 seconds
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# hook script: append the payload (arrives on stdin) and exit immediately
|
||||||
|
printf '#!/bin/bash\ncat >> "$MD_LOG"; printf "\\n" >> "$MD_LOG"; exit 0\n' > /tmp/h.sh && chmod +x /tmp/h.sh
|
||||||
|
echo '{"hooks":{"MessageDisplay":[{"hooks":[{"type":"command","command":"MD_LOG=/tmp/deltas.jsonl /tmp/h.sh"}]}]}}' > /tmp/s.json
|
||||||
|
|
||||||
|
# plain interactive claude in tmux (prefix NOT ocp-tui-*, and never kill-server)
|
||||||
|
tmux new-session -d -s md-probe -x 220 -y 50 \
|
||||||
|
"claude --model claude-sonnet-4-6 --effort low --session-id $(uuidgen) --settings /tmp/s.json"
|
||||||
|
# …wait for '? for shortcuts', paste a markdown-producing prompt, press Enter…
|
||||||
|
|
||||||
|
jq -r '"\(.index) \(.final) \(.delta|@json)"' /tmp/deltas.jsonl # incremental raw-markdown deltas
|
||||||
|
# then assert: concat(deltas) == extractLatestAssistantText(<transcript>.jsonl)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The three dead ends (still worth knowing — they say what NOT to build)
|
||||||
|
|
||||||
|
### (a) Incremental transcript reads — **dead: event granularity, not token granularity**
|
||||||
|
|
||||||
|
The transcript JSONL *does* grow during a turn, but one **whole event at a time**; the assistant's text
|
||||||
|
event is written as **one complete line**, appearing only ~0.3 s before the terminal `turn_duration`.
|
||||||
|
|
||||||
|
Observed (session `efd5b161`, `turn_duration: 7319 ms`):
|
||||||
|
|
||||||
|
```
|
||||||
|
#6 t+0.0s type=user (the prompt)
|
||||||
|
#15 t+4.7s type=assistant blocks=thinking
|
||||||
|
#16 t+7.0s type=assistant blocks=text ← the ENTIRE answer, in one line
|
||||||
|
#21 t+7.3s type=system subtype=turn_duration ← terminal
|
||||||
|
```
|
||||||
|
|
||||||
|
Cross-checked at **20 ms polling + `fs.watch`** (25× finer): a partial line **never touches disk** —
|
||||||
|
one write, `+1` line, carrying the complete answer. Also forced with the undocumented
|
||||||
|
`CLAUDE_CODE_INCLUDE_PARTIAL_MESSAGES=1`: still 1 assistant event, 0 partials (interactive mode has no
|
||||||
|
stream-json *sink* for it to write to).
|
||||||
|
|
||||||
|
**The transcript is still needed** — as the terminal-turn signal, as the authoritative `concat === T`
|
||||||
|
check, and as the input to the existing honesty gates (auth-banner detection, `truncated`). It is just
|
||||||
|
not the *streaming* source.
|
||||||
|
|
||||||
|
### (b) `tmux capture-pane` diffing — **dead: the pane is a RENDERED view, not the text**
|
||||||
|
|
||||||
|
The backlog expected to fall back to this, calling it "lossy … (wrapping, scrollback, spinner lines)".
|
||||||
|
The loss is far worse than formatting noise: **the pane does not contain the answer's source bytes at
|
||||||
|
all.** The TUI *renders* markdown, and `capture-pane -p` strips the ANSI that rendering produced.
|
||||||
|
|
||||||
|
Same turn, same lines:
|
||||||
|
|
||||||
|
```
|
||||||
|
TRANSCRIPT (authoritative T): PANE (capture-pane -p -J -S -500):
|
||||||
|
'## Semaphore' '⏺ Semaphore' ← heading marker gone
|
||||||
|
'' ''
|
||||||
|
'A **semaphore** is a synchro…' ' A semaphore is a synchro…' ← bold markers gone, indented
|
||||||
|
```
|
||||||
|
|
||||||
|
| token in the answer | in `T` | in the pane's answer region |
|
||||||
|
|---|---|---|
|
||||||
|
| `## ` (ATX heading) | yes | **no** — rendered as `⏺` |
|
||||||
|
| `**` (bold markers) | yes | **no** — rendered to ANSI bold, then stripped by `-p` |
|
||||||
|
| ` ```javascript ` (fence + language) | yes | **no** — fence and language tag both gone |
|
||||||
|
| `- ` (list item) | yes | yes |
|
||||||
|
|
||||||
|
*(A literal `**` does appear elsewhere in the pane — in the **prompt echo**, because the prompt asked
|
||||||
|
for bold. Not in the answer.)*
|
||||||
|
|
||||||
|
**`capture-pane -e` (keeping the ANSI) does not rescue it — the inverse is provably non-unique.**
|
||||||
|
With `T` = ``"## Alpha\n\n**bravo**\n\n```javascript\nlet x=1;\n```"``:
|
||||||
|
|
||||||
|
```
|
||||||
|
⏺\e[39m \e[1mAlpha\n\n\e[0m \e[1mbravo\n\n\e[0m \e[34mlet\e[39m x=\e[32m1\e[39m;
|
||||||
|
```
|
||||||
|
|
||||||
|
`## Alpha` → **SGR 1 (bold)**. `**bravo**` → **SGR 1 (bold)**. *Identical ANSI* — an H2 and a bold span
|
||||||
|
are indistinguishable, never mind `**` vs `__`. The fence and its `javascript` tag are consumed by the
|
||||||
|
syntax highlighter into colours; recovering the tag would mean inverting a highlighter, and
|
||||||
|
`let x=1;` is valid in several languages.
|
||||||
|
|
||||||
|
So `T.startsWith(paneText)` is **false** — raw and indent-stripped, on essentially every markdown
|
||||||
|
answer. A proxy streaming pane text would be streaming **something the model did not say**. With
|
||||||
|
`MessageDisplay` available there is no reason to go near it.
|
||||||
|
|
||||||
|
### (c) `--debug-file` — **dead: it logs stream *timing*, never stream *content***
|
||||||
|
|
||||||
|
Worth stating precisely, because a casual check misleads in **both** directions here.
|
||||||
|
|
||||||
|
The default log level is `debug`, which **suppresses every `verbose` site**. Raise it and per-chunk
|
||||||
|
lines *do* appear, spread across generation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CLAUDE_CODE_DEBUG_LOG_LEVEL=verbose claude --debug-file /tmp/d.log …
|
||||||
|
```
|
||||||
|
```
|
||||||
|
05:51:11.088 [VERBOSE] [shoji-engine] yield stream_event/- ← 16 of these, mid-turn,
|
||||||
|
05:51:11.537 [VERBOSE] [shoji-engine] yield stream_event/- over ~3.9 s of generation
|
||||||
|
05:51:15.192 [DEBUG] [shoji-engine] turn 1 end (usage in=575 out=255 api=6736ms stop=end_turn resultLen=857)
|
||||||
|
```
|
||||||
|
|
||||||
|
**But they carry no payload** — the format is `yield <type>/<subtype>`, a bare presence marker. Run with
|
||||||
|
no category filter (i.e. all categories) at verbose level: `content_block_delta` = **0**, `text_delta` =
|
||||||
|
**0**, `content_block_start` / `message_start` = **0**. The only byte-exact text in the log is the
|
||||||
|
end-of-turn `Stop` hook payload (`"last_assistant_message":"## Title\n\n**alpha bravo charlie**"`) —
|
||||||
|
transcript granularity. The log tells you **when** tokens arrive, never **what** they are. It is also
|
||||||
|
~2.7 MB per turn.
|
||||||
|
|
||||||
|
### Also checked, also not the answer
|
||||||
|
|
||||||
|
| candidate | outcome |
|
||||||
|
|---|---|
|
||||||
|
| `--output-format stream-json` (the one interface that emits `text_delta`) | **requires `--print`/`-p`** → `cc_entrypoint=sdk-cli` → the **metered** credit pool, which is exactly what TUI mode exists to avoid. Reproduced live. |
|
||||||
|
| `--input-format stream-json` | `Error: --input-format=stream-json requires output-format=stream-json` → same gate. |
|
||||||
|
| `CLAUDE_CODE_INCLUDE_PARTIAL_MESSAGES=1` (undocumented) | No stream-json sink in interactive mode → no partials. Banner stayed `· Claude Max`. |
|
||||||
|
| `sessionMirror` (undocumented) | Gated on `outputFormat === "stream-json"` → the `-p` family. |
|
||||||
|
| `--sdk-url` (hidden) | Forces stream-json + non-interactive → `sdk-cli`. *(inferred from the minified bundle; not banner-tested)* |
|
||||||
|
| `~/.claude/sessions/<pid>.json` | Registry metadata only (`{pid, sessionId, cwd, status, version, entrypoint:"cli", kind:"interactive"}`). No assistant text. *(Its `entrypoint:"cli"` incidentally confirms the TUI path stays on the subscription pool.)* |
|
||||||
|
| `~/.claude/history.jsonl` | User prompts only; the answer text is absent. |
|
||||||
|
| Asking the model to emit plain text (so the pane renders faithfully) | Would mean **mutating the caller's prompt** — a correctness violation for a proxy, and still not byte-faithful (wrapping + indent remain). Rejected. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Value: what streaming actually buys (read before building)
|
||||||
|
|
||||||
|
Streaming is *possible*. Whether it is *worth it* depends on the consumer, and the honest answer is
|
||||||
|
uncomfortable:
|
||||||
|
|
||||||
|
- **Streaming never makes the answer arrive sooner. It moves the *first* byte, not the *last*.** The
|
||||||
|
final token lands at the same wall-clock moment either way.
|
||||||
|
- So a consumer that must have the **complete** answer before it can act — e.g. one parsing a structured
|
||||||
|
JSON reply, **which is exactly the 知音 AI use case that motivated this entire investigation** — gains
|
||||||
|
**nothing at all**. Only a **progressively-rendering** consumer (a chat UI) gains.
|
||||||
|
|
||||||
|
And the number the backlog attached to this item was wrong:
|
||||||
|
|
||||||
|
- The backlog's "~20 s" was inferred from an external 30–32 s report, **never measured through OCP**.
|
||||||
|
Measured through a real OCP instance (TUI mode, `claude-sonnet-4-6`, ~1850-token prompt, n=5):
|
||||||
|
**median 11.30 s** before [#156](https://github.com/dtzp555-max/ocp/pull/156), **9.55 s** after.
|
||||||
|
- **Same-turn decomposition** (baseline row `i=5`): **11.563 s** wall through OCP vs `turn_duration:
|
||||||
|
7.319 s` of CLI-internal time on that same turn → **OCP's own overhead ≈ 4.2 s** (n=1, baseline
|
||||||
|
`effort=high` config). *Caveats*: n=1; and `turn_duration` is the CLI's internal duration of an
|
||||||
|
**OCP-driven** turn, not a separate "native" baseline. Do **not** subtract this `effort=high` 7.3 s
|
||||||
|
from the `effort=low` 9.55 s median — a low-effort turn generates faster, so mixing them
|
||||||
|
*understates* the overhead.
|
||||||
|
- So OCP's own overhead is **single-digit seconds**, not ~20 s. The rest of any large number is the
|
||||||
|
model generating a long answer — which streaming hides but does not shorten.
|
||||||
|
|
||||||
|
**Recommendation**: build it — the contract is clean and the cost is small — but size the expectation
|
||||||
|
honestly. It is a *perceived-latency* feature for progressively-rendering consumers, not a throughput
|
||||||
|
win, and it does not move the **~6 s TTFT floor** ([`README.md`](README.md)) that rules TUI mode out for
|
||||||
|
interactive-latency consumers regardless.
|
||||||
@@ -382,11 +382,25 @@ export function getCacheStats() {
|
|||||||
// Per ADR 0005 / spec D4: in-process scope only (single Node process per host).
|
// Per ADR 0005 / spec D4: in-process scope only (single Node process per host).
|
||||||
const inflightMap = new Map();
|
const inflightMap = new Map();
|
||||||
|
|
||||||
export function singleflight(hash, fn) {
|
// `retryIf` (optional, audit finding M1): a predicate applied on the FOLLOWER path only.
|
||||||
|
// When a follower joins an existing flight and the shared promise rejects with an error for
|
||||||
|
// which retryIf(err) is true (in practice: the LEADER's client disconnected while queued —
|
||||||
|
// an error that is personal to the leader, not a verdict about the upstream), the follower
|
||||||
|
// does NOT inherit that rejection. Instead it re-enters singleflight with its OWN fn: it
|
||||||
|
// either becomes the new leader (the map entry is already deleted — see the finally below,
|
||||||
|
// which runs before any follower's catch because it is attached upstream of the promise the
|
||||||
|
// followers await) or joins a flight another retrying follower just created. The leader's
|
||||||
|
// own rejection is never retried here — its error belongs to it (leader path returns the
|
||||||
|
// bare promise). Callers that pass no retryIf get the exact pre-M1 share-everything behavior.
|
||||||
|
export function singleflight(hash, fn, retryIf) {
|
||||||
const existing = inflightMap.get(hash);
|
const existing = inflightMap.get(hash);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.requesters++;
|
existing.requesters++;
|
||||||
return existing.promise;
|
if (!retryIf) return existing.promise;
|
||||||
|
return existing.promise.catch((err) => {
|
||||||
|
if (!retryIf(err)) throw err;
|
||||||
|
return singleflight(hash, fn, retryIf);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// Wrap fn() in Promise.resolve().then() so synchronous throws don't escape.
|
// Wrap fn() in Promise.resolve().then() so synchronous throws don't escape.
|
||||||
const promise = Promise.resolve().then(fn).finally(() => {
|
const promise = Promise.resolve().then(fn).finally(() => {
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// Pure, dependency-injected primitives for the `-p` spawn-token resolution + HOME-isolation
|
||||||
|
// layer. Extracted from server.mjs (findings F3 / F5 / F6, 2026-07-07) so the concurrency,
|
||||||
|
// caching and expiry logic is unit-testable WITHOUT booting the server or mocking execFileSync /
|
||||||
|
// child_process.spawn / fs. server.mjs owns all I/O (macOS keychain exec, process spawn, fs);
|
||||||
|
// this module owns only pure decision logic.
|
||||||
|
//
|
||||||
|
// ALIGNMENT NOTE: none of this touches the OAuth wire machinery (no endpoint / header / body).
|
||||||
|
// OCP still NEVER performs a refresh_token grant itself — these helpers only READ + GATE a token
|
||||||
|
// that some other process (the operator's real claude, or a spawned claude under the real HOME)
|
||||||
|
// refreshes. That property is load-bearing (issue #112) and preserved.
|
||||||
|
|
||||||
|
// Promise-chain mutex. `acquire()` resolves to a `release()` fn; the NEXT `acquire()` does not
|
||||||
|
// resolve until the current holder calls its `release()`. Serializes async critical sections
|
||||||
|
// without busy-waiting. release() is idempotent.
|
||||||
|
export function createSerialMutex() {
|
||||||
|
let tail = Promise.resolve();
|
||||||
|
return {
|
||||||
|
acquire() {
|
||||||
|
let release;
|
||||||
|
const gate = new Promise((r) => { release = r; });
|
||||||
|
const prev = tail;
|
||||||
|
tail = tail.then(() => gate);
|
||||||
|
// Hand the caller its release fn only after the previous holder has released.
|
||||||
|
return prev.then(() => {
|
||||||
|
let released = false;
|
||||||
|
return function releaseMutex() { if (!released) { released = true; release(); } };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Short-TTL memo. `get(produce, now)` returns the cached value while `now - storedAt < ttlMs`,
|
||||||
|
// otherwise calls `produce()` and re-stores. A miss that produces null/undefined is STILL stored
|
||||||
|
// (so a genuinely-absent source is not re-probed on every call within the TTL window). `now` is
|
||||||
|
// injectable for testing.
|
||||||
|
export function createTtlCache({ ttlMs }) {
|
||||||
|
let value;
|
||||||
|
let at = -Infinity;
|
||||||
|
let has = false;
|
||||||
|
return {
|
||||||
|
get(produce, now = Date.now()) {
|
||||||
|
if (has && now - at < ttlMs) return value;
|
||||||
|
value = produce();
|
||||||
|
at = now;
|
||||||
|
has = true;
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
clear() { has = false; value = undefined; at = -Infinity; },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pure expiry gate. Returns true when `creds` carries a known expiry that is at/within `bufferMs`
|
||||||
|
// of `now`. Creds WITHOUT `expiresAt` (e.g. long-lived env tokens) are never treated as expiring.
|
||||||
|
// This gate is applied to the CACHED creds on EVERY use — which is precisely why a short-TTL
|
||||||
|
// keychain cache (createTtlCache) cannot reintroduce the #146 forever-stale-token regression: the
|
||||||
|
// cache bounds how often we re-READ the keychain, but the expiry decision is recomputed per use.
|
||||||
|
export function isTokenExpiring(creds, now = Date.now(), bufferMs = 300000) {
|
||||||
|
return !!(creds && creds.expiresAt && now + bufferMs >= creds.expiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order candidate keychain labels so the last-known-good label is tried first (avoids the
|
||||||
|
// wrong-label miss that doubles the `security` exec count on the hot path). Pure: performs no
|
||||||
|
// read. Returns a fresh array; input is not mutated.
|
||||||
|
export function orderLabelsLastGoodFirst(labels, lastGood) {
|
||||||
|
if (!lastGood || !labels.includes(lastGood)) return labels.slice();
|
||||||
|
return [lastGood, ...labels.filter((l) => l !== lastGood)];
|
||||||
|
}
|
||||||
+61
-11
@@ -20,6 +20,15 @@
|
|||||||
//
|
//
|
||||||
// Pure + importable so test-features.mjs can assert the bound directly (no server boot).
|
// Pure + importable so test-features.mjs can assert the bound directly (no server boot).
|
||||||
|
|
||||||
|
// Thrown by acquire() when the caller-supplied AbortSignal fires before a slot was granted
|
||||||
|
// (audit finding F2 — a client that disconnects while queued must never receive a slot; the
|
||||||
|
// queue entry is spliced out, not just flagged, so `queued` accounting stays exact). Distinct
|
||||||
|
// `name` lets callers (server.mjs acquireClaudeSlot) tell "client went away" apart from
|
||||||
|
// "queue is full" without string-matching the message.
|
||||||
|
export class SemaphoreAbortError extends Error {
|
||||||
|
constructor(message) { super(message); this.name = "SemaphoreAbortError"; }
|
||||||
|
}
|
||||||
|
|
||||||
export class TuiSemaphore {
|
export class TuiSemaphore {
|
||||||
// limit: max concurrent slots. maxQueue: max waiters before run() rejects with backpressure.
|
// limit: max concurrent slots. maxQueue: max waiters before run() rejects with backpressure.
|
||||||
constructor(limit, { maxQueue } = {}) {
|
constructor(limit, { maxQueue } = {}) {
|
||||||
@@ -34,9 +43,30 @@ export class TuiSemaphore {
|
|||||||
get inflight() { return this._inflight; }
|
get inflight() { return this._inflight; }
|
||||||
get queued() { return this._waiters.length; }
|
get queued() { return this._waiters.length; }
|
||||||
|
|
||||||
|
// Runtime-adjust the concurrency limit (audit finding F1 — a PATCH /settings maxConcurrent
|
||||||
|
// change must actually take effect, not just be ignored until every currently-inflight task
|
||||||
|
// happens to finish). Lowering the limit is handled lazily by release() (see below) — it
|
||||||
|
// simply stops re-granting until inflight drains under the new, lower limit. Raising the
|
||||||
|
// limit has immediate headroom, so we wake as many queued waiters as now fit.
|
||||||
|
setLimit(limit) {
|
||||||
|
this.limit = Math.max(1, parseInt(limit, 10) || 1);
|
||||||
|
while (this._inflight < this.limit && this._waiters.length > 0) {
|
||||||
|
const next = this._waiters.shift();
|
||||||
|
this._inflight++;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Acquire a slot. Resolves once a slot is free (immediately if under the limit, otherwise
|
// Acquire a slot. Resolves once a slot is free (immediately if under the limit, otherwise
|
||||||
// when an in-flight task releases). Rejects synchronously-ish if the wait queue is full.
|
// when an in-flight task releases). Rejects synchronously-ish if the wait queue is full.
|
||||||
acquire() {
|
// `signal` (optional AbortSignal, F2) lets the caller cancel a QUEUED wait — e.g. wired to
|
||||||
|
// a client's socket "close" event so a request that disconnects before a slot is granted
|
||||||
|
// is removed from the queue instead of eventually being handed a slot for a dead socket.
|
||||||
|
// If `signal` is already aborted, reject immediately without ever touching the queue.
|
||||||
|
acquire(signal) {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
return Promise.reject(new SemaphoreAbortError("acquire aborted before requesting a slot"));
|
||||||
|
}
|
||||||
if (this._inflight < this.limit) {
|
if (this._inflight < this.limit) {
|
||||||
this._inflight++;
|
this._inflight++;
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
@@ -46,24 +76,44 @@ export class TuiSemaphore {
|
|||||||
`tui_queue_full: TUI concurrency limit (${this.limit}) reached and wait queue ` +
|
`tui_queue_full: TUI concurrency limit (${this.limit}) reached and wait queue ` +
|
||||||
`(${this.maxQueue}) is full`));
|
`(${this.maxQueue}) is full`));
|
||||||
}
|
}
|
||||||
return new Promise((resolve) => { this._waiters.push(resolve); });
|
return new Promise((resolve, reject) => {
|
||||||
|
let waiter; // the FIFO entry — captured so onAbort can find + splice exactly this one
|
||||||
|
const onAbort = () => {
|
||||||
|
const idx = this._waiters.indexOf(waiter);
|
||||||
|
if (idx === -1) return; // already granted a slot (shifted out by release()/setLimit) — too late to cancel
|
||||||
|
this._waiters.splice(idx, 1); // remove, not just flag — keeps `queued` accounting exact
|
||||||
|
reject(new SemaphoreAbortError("acquire aborted while queued"));
|
||||||
|
};
|
||||||
|
waiter = () => {
|
||||||
|
signal?.removeEventListener("abort", onAbort);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
signal?.addEventListener("abort", onAbort, { once: true });
|
||||||
|
this._waiters.push(waiter);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release a slot. If a waiter is queued, hand the slot directly to it (inflight stays
|
// Release a slot. Always frees the caller's own slot first, then re-grants it to the next
|
||||||
// constant across the handoff); otherwise decrement.
|
// waiter ONLY if the (post-decrement) inflight count is still under the current limit (F1
|
||||||
|
// fix). This is what makes a runtime-lowered limit actually bite: if the limit was lowered
|
||||||
|
// while over-subscribed, releases stop re-granting and inflight drains toward the new limit
|
||||||
|
// instead of a freed slot being handed straight back out at the old, higher occupancy.
|
||||||
release() {
|
release() {
|
||||||
const next = this._waiters.shift();
|
if (this._inflight > 0) this._inflight--;
|
||||||
if (next) {
|
if (this._inflight < this.limit) {
|
||||||
next(); // the woken waiter already "owns" the slot — inflight unchanged
|
const next = this._waiters.shift();
|
||||||
} else if (this._inflight > 0) {
|
if (next) {
|
||||||
this._inflight--;
|
this._inflight++;
|
||||||
|
next();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run fn() under one slot. Releases in a finally so a throw (PR-A's honesty gates,
|
// Run fn() under one slot. Releases in a finally so a throw (PR-A's honesty gates,
|
||||||
// wallclock truncation, paste-not-landed, tmux spawn failure) NEVER leaks a slot.
|
// wallclock truncation, paste-not-landed, tmux spawn failure) NEVER leaks a slot.
|
||||||
async run(fn) {
|
// `signal` (optional, F2) is forwarded to acquire() so a queued run() can be cancelled.
|
||||||
await this.acquire();
|
async run(fn, signal) {
|
||||||
|
await this.acquire(signal);
|
||||||
try {
|
try {
|
||||||
return await fn();
|
return await fn();
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
+235
-76
@@ -15,24 +15,103 @@ import { tmpdir } from "node:os";
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { readTuiTranscript } from "./transcript.mjs";
|
import { readTuiTranscript } from "./transcript.mjs";
|
||||||
|
|
||||||
export const SESSION_PREFIX = "ocp-tui-"; // per-proxy namespace (coexistence rule)
|
// F7 fix (audit finding, LOW): the prefix used to be a bare, host-wide constant
|
||||||
|
// ("ocp-tui-"), so a SECOND OCP instance on the same host (e.g. a temporary
|
||||||
|
// verification instance stood up alongside production — a real pattern used during
|
||||||
|
// PR #144/#146 verification) would boot-reap and potentially kill-server the OTHER
|
||||||
|
// instance's LIVE sessions: the coexistence guard below only ever spared foreign
|
||||||
|
// PRODUCT prefixes (olp-tui-*), never a second ocp-tui-* instance on a different port.
|
||||||
|
//
|
||||||
|
// Fix: scope the prefix to the instance's own listen port. The port is the natural
|
||||||
|
// stable per-instance discriminator on one host (two OCP instances cannot share a
|
||||||
|
// port), so `ocp-tui-<port>-` uniquely namespaces this instance's sessions and makes
|
||||||
|
// a same-host sibling OCP instance look exactly like a foreign product (olp-tui-*) to
|
||||||
|
// the coexistence guard — its `ocp-tui-<otherPort>-*` sessions never match our own
|
||||||
|
// prefix and are therefore never reaped/kill-server'd by us.
|
||||||
|
//
|
||||||
|
// LEGACY_SESSION_PREFIX / LEGACY_SESSION_NAME_RE describe the OLD bare-prefix shape
|
||||||
|
// (pre-this-fix), retained ONLY for the boot-time legacy-zombie migration handled in
|
||||||
|
// reapStaleTuiSessions (see comment there). No code path in this version ever CREATES
|
||||||
|
// a legacy-shaped session name again — sessionPrefixForPort() is the only session-name
|
||||||
|
// prefix constructor used going forward.
|
||||||
|
export const LEGACY_SESSION_PREFIX = "ocp-tui-";
|
||||||
|
// Exact legacy shape: LEGACY_SESSION_PREFIX + sessionId.slice(0, 8), where sessionId is
|
||||||
|
// a randomUUID() — so the suffix is always exactly 8 lowercase hex characters with NO
|
||||||
|
// further separator. The new port-scoped shape always inserts a "-" between the port
|
||||||
|
// digits and the 8-hex suffix (see sessionPrefixForPort), so this regex can never match
|
||||||
|
// a new-shape name: a new-shape suffix is `<port digits>-<8 hex>` (contains a literal
|
||||||
|
// "-"), which `[0-9a-f]{8}$` anchored immediately after the prefix cannot satisfy.
|
||||||
|
export const LEGACY_SESSION_NAME_RE = /^ocp-tui-[0-9a-f]{8}$/;
|
||||||
|
|
||||||
|
// Build this instance's own session-name prefix, scoped by its listen port so a
|
||||||
|
// second OCP instance on the same host (different port) is never mistaken for "ours".
|
||||||
|
export function sessionPrefixForPort(port) {
|
||||||
|
return `ocp-tui-${port}-`;
|
||||||
|
}
|
||||||
|
|
||||||
const TMUX = process.env.OCP_TUI_TMUX_BIN || "tmux";
|
const TMUX = process.env.OCP_TUI_TMUX_BIN || "tmux";
|
||||||
|
|
||||||
const defaultTmux = (args, opts = {}) =>
|
const defaultTmux = (args, opts = {}) =>
|
||||||
spawnSync(TMUX, args, { encoding: "utf8", ...opts });
|
spawnSync(TMUX, args, { encoding: "utf8", ...opts });
|
||||||
|
|
||||||
// Kill ONLY our own stale sessions. Scoped to SESSION_PREFIX so a co-hosted
|
// Kill ONLY our own stale sessions. Scoped to sessionPrefixForPort(port) so a co-hosted
|
||||||
// OLP test instance's `olp-tui-*` sessions are never touched.
|
// OLP test instance's `olp-tui-*` sessions — AND a co-hosted second OCP instance's
|
||||||
export function reapStaleTuiSessions({ tmux = defaultTmux } = {}) {
|
// `ocp-tui-<otherPort>-*` sessions — are never touched (F7 fix).
|
||||||
|
//
|
||||||
|
// Defunct-reaping (PI231 incident): the pane's `claude` process is a child of the
|
||||||
|
// long-lived tmux SERVER daemon, NOT of the OCP node process — `tmux new-session -d`
|
||||||
|
// returns the instant the server forks the pane, so node never becomes its parent and
|
||||||
|
// therefore can NEVER waitpid()/reap it (a SIGKILL still needs the *parent* to reap, and
|
||||||
|
// here that parent is the tmux server). `kill-session` destroys the session but the server
|
||||||
|
// can leave the pane's `claude` (and any grandchildren claude spawned) as `<defunct>`
|
||||||
|
// zombies that only the server can reap. Over many per-request spawn+teardown cycles these
|
||||||
|
// accumulate (live evidence on PI231: 25 defunct `<claude>` over 30 days; `tmux kill-server`
|
||||||
|
// dropped it 25→3). The only node-reachable action that ACTUALLY reaps them — rather than
|
||||||
|
// merely re-signalling — is to stop the tmux server: when the server exits, the kernel
|
||||||
|
// reparents its surviving children to init (PID 1), which reaps them immediately.
|
||||||
|
//
|
||||||
|
// `port` (required) is this instance's own listen port (server.mjs's PORT / lib/constants.mjs
|
||||||
|
// DEFAULT_PORT resolution) — the SPOT for "which sessions are ours."
|
||||||
|
//
|
||||||
|
// `includeLegacy` (default false): when true, sessions matching the exact OLD bare-prefix
|
||||||
|
// shape (LEGACY_SESSION_NAME_RE) are ALSO treated as ours for kill-session purposes. This is
|
||||||
|
// the boot-time legacy migration: an operator upgrading past this fix could otherwise be left
|
||||||
|
// with orphaned bare-prefix zombie sessions from the PREVIOUS (pre-fix) process generation of
|
||||||
|
// this SAME instance, since no live instance of the new version ever creates that shape again
|
||||||
|
// — a legacy-shaped session found at boot is therefore presumed to be this instance's own
|
||||||
|
// leftover, not a stranger's. Passed true ONLY from the one-time boot-reap call site in
|
||||||
|
// server.mjs; the periodic idle-reap sweep does NOT set it, so a lingering legacy session
|
||||||
|
// during steady-state is conservatively treated as foreign (correctly blocking kill-server)
|
||||||
|
// rather than assumed to be ours on every 15-minute tick. Residual (accepted, documented):
|
||||||
|
// if a genuinely-still-running PRE-FIX OCP instance is coexisting on the same host at the
|
||||||
|
// exact moment a new instance boots, its live legacy-shaped session could be reaped — the
|
||||||
|
// same class of residual risk the audit finding itself accepts ("no live instance of the new
|
||||||
|
// version creates them"); this PR does not regress that scenario, it only removes the far
|
||||||
|
// more common same-version collision (the actual F7 finding).
|
||||||
|
export function reapStaleTuiSessions({ tmux = defaultTmux, port, includeLegacy = false } = {}) {
|
||||||
const r = tmux(["list-sessions", "-F", "#{session_name}"]);
|
const r = tmux(["list-sessions", "-F", "#{session_name}"]);
|
||||||
if (!r || r.status !== 0) return 0; // no tmux server / no sessions
|
if (!r || r.status !== 0) return 0; // no tmux server / no sessions
|
||||||
|
const names = String(r.stdout || "").split("\n").map((s) => s.trim()).filter(Boolean);
|
||||||
|
const ownPrefix = sessionPrefixForPort(port);
|
||||||
let killed = 0;
|
let killed = 0;
|
||||||
for (const name of String(r.stdout || "").split("\n").map((s) => s.trim()).filter(Boolean)) {
|
let othersRemain = false;
|
||||||
if (name.startsWith(SESSION_PREFIX)) {
|
for (const name of names) {
|
||||||
|
const isOwn = name.startsWith(ownPrefix);
|
||||||
|
const isLegacyOwn = includeLegacy && LEGACY_SESSION_NAME_RE.test(name);
|
||||||
|
if (isOwn || isLegacyOwn) {
|
||||||
tmux(["kill-session", "-t", name]);
|
tmux(["kill-session", "-t", name]);
|
||||||
killed++;
|
killed++;
|
||||||
|
} else {
|
||||||
|
othersRemain = true; // a session we do NOT own (olp-tui-*, a sibling ocp-tui-<otherPort>-*,
|
||||||
|
// or — outside includeLegacy — a legacy-shaped name) — never kill-server
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Reap defunct `claude` zombies: safe ONLY when the server is now ours-only/empty.
|
||||||
|
// kill-server is what actually reaps (server exit reparents survivors to init); a
|
||||||
|
// per-session kill cannot, since node is not the zombies' parent.
|
||||||
|
if (!othersRemain) {
|
||||||
|
tmux(["kill-server"]);
|
||||||
|
}
|
||||||
return killed;
|
return killed;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,39 +207,85 @@ export function ensureTuiCwdTrusted(home, cwd) {
|
|||||||
} catch { /* best effort */ }
|
} catch { /* best effort */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare the HOME claude runs under. Two modes:
|
// Resolve the HOME the TUI `claude` runs under. Three intents, decided by the env
|
||||||
// - real-home (tuiHome === realHome OR falsy): no isolation; just trust the cwd
|
// token + an explicit OCP_TUI_HOME override:
|
||||||
// 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.
|
|
||||||
//
|
//
|
||||||
// ⚠️ CREDENTIAL CAVEAT (verified live): claude rewrites .credentials.json on token
|
// - ENV-TOKEN MODE (default when CLAUDE_CODE_OAUTH_TOKEN is set AND OCP_TUI_HOME is
|
||||||
// refresh, REPLACING the symlink with a regular-file copy → the scratch home then
|
// unset): a CREDENTIAL-FREE scratch home at `<realHome>/.ocp-tui/home`. There is
|
||||||
// FORKS the OAuth credentials. Because OAuth refresh tokens rotate (single-use), a
|
// deliberately NO .credentials.json (no symlink, no copy), so the only credential
|
||||||
// refresh in the scratch home can invalidate the token the user's real-home claude
|
// claude can find is the long-lived env token (passed by buildTuiCmd). This is what
|
||||||
// relies on. Therefore scratch-home is safe only with a DEDICATED OAuth or for
|
// actually FORCES env-token auth — see the prepareTuiHome comment for why passing
|
||||||
// ephemeral use; for a shared subscription prefer real-home (tuiHome===realHome),
|
// the token alone is insufficient.
|
||||||
// which shares one .credentials.json — identical to how OCP already spawns claude.
|
// - EXPLICIT OVERRIDE: whatever OCP_TUI_HOME names (back-compat; an operator who set it
|
||||||
// Idempotent + best-effort: any failure degrades toward the dialog/cap, never
|
// keeps exactly that home).
|
||||||
// corrupts. Run BEFORE the session boots.
|
// - REAL-HOME (default when the env token is unset): the operator's real home, shared
|
||||||
export function prepareTuiHome(realHome, tuiHome, cwd) {
|
// 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; }
|
if (!tuiHome || tuiHome === realHome) { ensureTuiCwdTrusted(realHome, cwd); return; }
|
||||||
try {
|
try {
|
||||||
const claudeDir = `${tuiHome}/.claude`;
|
const claudeDir = `${tuiHome}/.claude`;
|
||||||
mkdirSync(`${claudeDir}/projects`, { recursive: true });
|
mkdirSync(`${claudeDir}/projects`, { recursive: true });
|
||||||
// Symlink the real credentials (never copy the OAuth token); refresh if missing.
|
if (!envTokenMode) {
|
||||||
const link = `${claudeDir}/.credentials.json`;
|
// Legacy mode ONLY: symlink the real credentials (never copy the token); refresh if
|
||||||
if (!existsSync(link)) {
|
// missing. Env-token mode deliberately skips this — no credentials file at all.
|
||||||
try { symlinkSync(`${realHome}/.claude/.credentials.json`, link); } catch { /* best effort */ }
|
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,
|
// Seed .claude.json ONCE (if absent): onboarded + trust ONLY the scratch cwd.
|
||||||
// drop the user's project history, trust only the scratch cwd. mode 0600.
|
// 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`;
|
const seedPath = `${tuiHome}/.claude.json`;
|
||||||
if (!existsSync(seedPath)) {
|
if (!existsSync(seedPath)) {
|
||||||
let base = {};
|
let base = {};
|
||||||
try { base = JSON.parse(readFileSync(`${realHome}/.claude.json`, "utf8")); } catch { /* fresh */ }
|
if (!envTokenMode) {
|
||||||
|
try { base = JSON.parse(readFileSync(`${realHome}/.claude.json`, "utf8")); } catch { /* fresh */ }
|
||||||
|
}
|
||||||
base.hasCompletedOnboarding = true;
|
base.hasCompletedOnboarding = true;
|
||||||
base.projects = { [cwd]: { hasTrustDialogAccepted: true, allowedTools: [] } };
|
base.projects = { [cwd]: { hasTrustDialogAccepted: true, allowedTools: [] } };
|
||||||
writeFileSync(seedPath, JSON.stringify(base, null, 2), { mode: 0o600 });
|
writeFileSync(seedPath, JSON.stringify(base, null, 2), { mode: 0o600 });
|
||||||
@@ -170,23 +295,6 @@ export function prepareTuiHome(realHome, tuiHome, cwd) {
|
|||||||
ensureTuiCwdTrusted(tuiHome, cwd);
|
ensureTuiCwdTrusted(tuiHome, cwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Billing-classifier labeling ─────────────────────────────────────────
|
|
||||||
// Resolve CLAUDE_CODE_ENTRYPOINT on the spawn env per mode. ALWAYS deletes any
|
|
||||||
// inherited value first (so a stray entrypoint from OCP's own parent env can never
|
|
||||||
// leak into / mislabel the billing header). Then:
|
|
||||||
// "cli" (default) → set "cli": deterministic subscription-pool classification.
|
|
||||||
// HONEST ONLY because OCP's spawn is a genuine interactive PTY (tmux pane,
|
|
||||||
// no -p, stdout not redirected). Never set "cli" on a non-interactive spawn.
|
|
||||||
// "auto" → leave unset → claude self-classifies via its t$A (TTY → cli). Use to
|
|
||||||
// observe/diagnose the real TTY-derived value.
|
|
||||||
// "off" → leave the env exactly as inherited (diagnostics / honesty audit).
|
|
||||||
export function resolveTuiEntrypointEnv(env, mode = "cli") {
|
|
||||||
if (mode === "off") return env;
|
|
||||||
delete env.CLAUDE_CODE_ENTRYPOINT;
|
|
||||||
if (mode === "cli") env.CLAUDE_CODE_ENTRYPOINT = "cli";
|
|
||||||
return env;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build interactive claude argv: NO -p, NO --output-format (=> cc_entrypoint=cli).
|
// Build interactive claude argv: NO -p, NO --output-format (=> cc_entrypoint=cli).
|
||||||
// MCP hard-disabled: --strict-mcp-config (no --mcp-config) is the only mechanism
|
// MCP hard-disabled: --strict-mcp-config (no --mcp-config) is the only mechanism
|
||||||
// that stops account-attached managed MCP from connecting (spec §5.2 / T6),
|
// that stops account-attached managed MCP from connecting (spec §5.2 / T6),
|
||||||
@@ -217,6 +325,27 @@ export function buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode)
|
|||||||
"CLAUDE_CODE_DISABLE_CLAUDE_MDS=1",
|
"CLAUDE_CODE_DISABLE_CLAUDE_MDS=1",
|
||||||
"CLAUDE_CODE_DISABLE_AUTO_MEMORY=1",
|
"CLAUDE_CODE_DISABLE_AUTO_MEMORY=1",
|
||||||
];
|
];
|
||||||
|
// CLAUDE_CODE_OAUTH_TOKEN: tmux does NOT forward the parent process's env to the pane (the
|
||||||
|
// same reason the whole env is delivered as an `env` prefix above — verified live 2026-06-01),
|
||||||
|
// so the token MUST be set explicitly here or the spawned `claude` never sees it. Without it,
|
||||||
|
// the TUI claude falls back to authenticating via <HOME>/.claude/.credentials.json, whose
|
||||||
|
// single-use refresh token gets corrupted by the per-request spawn + `kill-session` teardown
|
||||||
|
// racing claude's token-rotation write (the PI231 incident: refresh token ended up an empty
|
||||||
|
// string → permanent 401 "Please run /login", re-login re-corrupted on the next spawn). With
|
||||||
|
// the long-lived OAuth token in env, claude authenticates via the token and never touches the
|
||||||
|
// credentials.json refresh path — matching how the stable oracle / Mac-mini hosts already run.
|
||||||
|
//
|
||||||
|
// SECURITY: the token appears in the pane command (ps-visible). This is acceptable for the
|
||||||
|
// single-user A-path — it mirrors the existing plaintext-token practice (server.mjs reads the
|
||||||
|
// same CLAUDE_CODE_OAUTH_TOKEN env at getOAuthCredentials()), and the multi-user B-path is
|
||||||
|
// already refused at boot (TUI + AUTH_MODE=multi is a hard FATAL). Read from process.env here,
|
||||||
|
// consistent with how buildTuiCmd already reads OCP_TUI_FULL_TOOLS / CLAUDE_ALLOWED_TOOLS below.
|
||||||
|
//
|
||||||
|
// When the env is unset (e.g. a host that intentionally relies on credentials.json), no token
|
||||||
|
// is added — behaviour is byte-for-byte unchanged from before this fix.
|
||||||
|
if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
|
||||||
|
sets.push(`CLAUDE_CODE_OAUTH_TOKEN=${shq(process.env.CLAUDE_CODE_OAUTH_TOKEN)}`);
|
||||||
|
}
|
||||||
const unset = ["CLAUDECODE", "ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN"];
|
const unset = ["CLAUDECODE", "ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN"];
|
||||||
if (entrypointMode === "cli") sets.push("CLAUDE_CODE_ENTRYPOINT=cli");
|
if (entrypointMode === "cli") sets.push("CLAUDE_CODE_ENTRYPOINT=cli");
|
||||||
else if (entrypointMode === "auto") unset.push("CLAUDE_CODE_ENTRYPOINT"); // let claude self-classify via TTY
|
else if (entrypointMode === "auto") unset.push("CLAUDE_CODE_ENTRYPOINT"); // let claude self-classify via TTY
|
||||||
@@ -226,37 +355,60 @@ export function buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode)
|
|||||||
// DEFAULT (safe): hard-disable MCP (--strict-mcp-config + --disallowedTools mcp__*);
|
// DEFAULT (safe): hard-disable MCP (--strict-mcp-config + --disallowedTools mcp__*);
|
||||||
// built-in tools stay on, acceptable for single-user A-path.
|
// built-in tools stay on, acceptable for single-user A-path.
|
||||||
// OCP_TUI_FULL_TOOLS=1: grant the SAME tool surface as the -p A-path
|
// OCP_TUI_FULL_TOOLS=1: grant the SAME tool surface as the -p A-path
|
||||||
// (--allowedTools [+ --mcp-config] [+ --dangerously-skip-permissions]), so a
|
// (--allowedTools [+ --mcp-config]), so a SINGLE-USER / trusted TUI deployment can
|
||||||
// SINGLE-USER / trusted TUI deployment can run a tool-using agent (e.g. an OpenClaw
|
// run a tool-using agent (e.g. an OpenClaw assistant that needs Bash/Read/Write/MCP)
|
||||||
// assistant that needs Bash/Read/Write/MCP) on the subscription pool. This mirrors
|
// on the subscription pool. ALWAYS uses --allowedTools (CLAUDE_SKIP_PERMISSIONS /
|
||||||
// buildCliArgs() in server.mjs. Safe to gate ON only because TUI is hard-incompatible
|
// --dangerously-skip-permissions is intentionally removed: claude v2.1.x shows an
|
||||||
// with AUTH_MODE=multi (server.mjs refuses to boot), so it can never widen a guest's
|
// interactive bypass-acceptance screen in headless tmux that nothing can answer →
|
||||||
// surface. Env mirrors server.mjs's CLAUDE_ALLOWED_TOOLS / _SKIP_PERMISSIONS / _MCP_CONFIG.
|
// the turn hangs until the wallclock cap, bricks the pane; not recoverable without a
|
||||||
|
// human at a keyboard). Use scratch-home settings.json additionalDirectories instead.
|
||||||
let toolArgs;
|
let toolArgs;
|
||||||
if (process.env.OCP_TUI_FULL_TOOLS === "1") {
|
if (process.env.OCP_TUI_FULL_TOOLS === "1") {
|
||||||
toolArgs = [];
|
toolArgs = [];
|
||||||
if (process.env.CLAUDE_SKIP_PERMISSIONS === "true") {
|
const allowed = (process.env.CLAUDE_ALLOWED_TOOLS ||
|
||||||
toolArgs.push("--dangerously-skip-permissions");
|
"Bash,Read,Write,Edit,Glob,Grep,WebSearch,WebFetch,Agent")
|
||||||
} else {
|
.split(",").map((s) => s.trim()).filter(Boolean);
|
||||||
const allowed = (process.env.CLAUDE_ALLOWED_TOOLS ||
|
// shq EACH token: buildTuiCmd returns a SHELL STRING (run by tmux via sh -c), unlike
|
||||||
"Bash,Read,Write,Edit,Glob,Grep,WebSearch,WebFetch,Agent")
|
// buildCliArgs which returns an argv array to spawn(). claude accepts scoped specifiers
|
||||||
.split(",").map((s) => s.trim()).filter(Boolean);
|
// like "Bash(npm run test:*)" / "Read(~/**)" whose ( ) * ~ would break/inject the shell
|
||||||
// shq EACH token: buildTuiCmd returns a SHELL STRING (run by tmux via sh -c), unlike
|
// command if pasted bare. (operator-self-injection only — guests can't reach TUI.)
|
||||||
// buildCliArgs which returns an argv array to spawn(). claude accepts scoped specifiers
|
if (allowed.length) toolArgs.push("--allowedTools", ...allowed.map(shq));
|
||||||
// like "Bash(npm run test:*)" / "Read(~/**)" whose ( ) * ~ would break/inject the shell
|
|
||||||
// command if pasted bare. (operator-self-injection only — guests can't reach TUI.)
|
|
||||||
if (allowed.length) toolArgs.push("--allowedTools", ...allowed.map(shq));
|
|
||||||
}
|
|
||||||
if (process.env.CLAUDE_MCP_CONFIG) toolArgs.push("--mcp-config", shq(process.env.CLAUDE_MCP_CONFIG));
|
if (process.env.CLAUDE_MCP_CONFIG) toolArgs.push("--mcp-config", shq(process.env.CLAUDE_MCP_CONFIG));
|
||||||
} else {
|
} else {
|
||||||
toolArgs = ["--strict-mcp-config", "--disallowedTools", shq("mcp__*")];
|
toolArgs = ["--strict-mcp-config", "--disallowedTools", shq("mcp__*")];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Effort: pass --effort EXPLICITLY. Without it, the pane's claude inherits a
|
||||||
|
// HOME-dependent effortLevel — real-home mode inherits the operator's
|
||||||
|
// ~/.claude/settings.json (whatever they set for their own interactive use),
|
||||||
|
// env-token scratch mode inherits claude's built-in default (prepareTuiHome never
|
||||||
|
// writes effortLevel) — so latency silently depends on which HOME mode
|
||||||
|
// resolveTuiHome() picked AND on an unrelated operator setting. Pinning it here
|
||||||
|
// removes both. Measured (docs/plans/2026-07-13-tui-latency): explicit low cuts
|
||||||
|
// direct-spawn TTFT p50 10.35s → 6.17s (−40%) and collapses the spread ~15×;
|
||||||
|
// banner-verified to stay on the subscription pool (`· Claude Max`).
|
||||||
|
// OCP_TUI_EFFORT=inherit restores the pre-flag argv byte-for-byte (no --effort).
|
||||||
|
// An unknown value falls back to the default rather than reaching claude's argv:
|
||||||
|
// a typo'd --effort value must not risk a spawn-time usage error in the pane.
|
||||||
|
const EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"]; // claude 2.1.207 --help
|
||||||
|
const effortRaw = (process.env.OCP_TUI_EFFORT || "low").trim().toLowerCase();
|
||||||
|
let effortArgs;
|
||||||
|
if (effortRaw === "inherit") {
|
||||||
|
effortArgs = [];
|
||||||
|
} else if (EFFORT_LEVELS.includes(effortRaw)) {
|
||||||
|
effortArgs = ["--effort", effortRaw];
|
||||||
|
} else {
|
||||||
|
console.error(`[tui] invalid OCP_TUI_EFFORT=${JSON.stringify(process.env.OCP_TUI_EFFORT)}; using "low" (valid: ${EFFORT_LEVELS.join("|")}, or "inherit" to omit the flag)`);
|
||||||
|
effortArgs = ["--effort", "low"];
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
envPrefix,
|
envPrefix,
|
||||||
shq(claudeBin),
|
shq(claudeBin),
|
||||||
"--model", shq(model),
|
"--model", shq(model),
|
||||||
"--session-id", sessionId,
|
"--session-id", sessionId,
|
||||||
...toolArgs,
|
...toolArgs,
|
||||||
|
...effortArgs,
|
||||||
].join(" ");
|
].join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,34 +435,41 @@ export async function runTuiTurn({
|
|||||||
home,
|
home,
|
||||||
realHome,
|
realHome,
|
||||||
cwd,
|
cwd,
|
||||||
|
port,
|
||||||
wallclockMs = 120000,
|
wallclockMs = 120000,
|
||||||
entrypointMode = "cli",
|
entrypointMode = "cli",
|
||||||
tmux = defaultTmux,
|
tmux = defaultTmux,
|
||||||
}) {
|
}) {
|
||||||
const sessionId = randomUUID();
|
const sessionId = randomUUID();
|
||||||
const tmuxName = SESSION_PREFIX + sessionId.slice(0, 8);
|
// Port-scoped session name (F7 fix) — see sessionPrefixForPort / reapStaleTuiSessions
|
||||||
|
// for why this instance's own listen port is the namespace discriminator.
|
||||||
|
const tmuxName = sessionPrefixForPort(port) + sessionId.slice(0, 8);
|
||||||
const ehome = home || process.env.HOME; // HOME claude runs under (scratch or real)
|
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)
|
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
|
// Ensure scratch cwd exists, then prepare the (scratch or real) HOME + trust the
|
||||||
// cwd — before claude boots.
|
// cwd — before claude boots.
|
||||||
if (!existsSync(cwd)) mkdirSync(cwd, { recursive: true });
|
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.
|
// Write prompt to a temp file (mode 0600) so the content never touches argv.
|
||||||
const tmpDir = mkdtempSync(`${tmpdir()}/ocp-tui-`);
|
const tmpDir = mkdtempSync(`${tmpdir()}/ocp-tui-`);
|
||||||
const promptFile = `${tmpDir}/prompt.txt`;
|
const promptFile = `${tmpDir}/prompt.txt`;
|
||||||
writeFileSync(promptFile, prompt, { mode: 0o600 });
|
writeFileSync(promptFile, prompt, { mode: 0o600 });
|
||||||
|
|
||||||
// Build the env: disable marketplace auto-install, strip any Anthropic / CC
|
// Minimal env for spawnSync (tmux itself). The pane's claude env comes exclusively
|
||||||
// env vars that might interfere with interactive-mode classification.
|
// from the `env` prefix string built inside buildTuiCmd — tmux does NOT forward the
|
||||||
const env = { ...process.env, CLAUDE_CODE_DISABLE_OFFICIAL_MARKETPLACE_AUTOINSTALL: "1" };
|
// spawning process's env to the pane, so the {env} here is intentionally minimal.
|
||||||
delete env.CLAUDECODE;
|
const env = { ...process.env };
|
||||||
delete env.ANTHROPIC_API_KEY;
|
env.HOME = ehome; // tmux needs HOME; all claude-specific vars go via buildTuiCmd prefix
|
||||||
delete env.ANTHROPIC_BASE_URL;
|
|
||||||
delete env.ANTHROPIC_AUTH_TOKEN;
|
|
||||||
env.HOME = ehome; // claude reads credentials + writes the transcript under this HOME
|
|
||||||
resolveTuiEntrypointEnv(env, entrypointMode);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Boot the interactive session inside tmux, rooted at the scratch cwd.
|
// 1. Boot the interactive session inside tmux, rooted at the scratch cwd.
|
||||||
|
|||||||
+2
-15
@@ -9,24 +9,11 @@ import { readFileSync, existsSync, readdirSync } from "node:fs";
|
|||||||
|
|
||||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
// Project-dir encoding: claude replaces every "/" AND every "." with "-".
|
|
||||||
// Verified live (claude v2.1.158): cwd /home/u/.ocp-tui/work is stored under
|
|
||||||
// projects/-home-u--ocp-tui-work/ (the "." in ".ocp-tui" becomes "-", yielding
|
|
||||||
// the double dash). The earlier "/"-only rule was wrong for dotted paths; the
|
|
||||||
// fixture cwd /tmp/tui-test happened to have no dots so it never surfaced.
|
|
||||||
// NOTE: prefer findTranscriptPath() (glob by session-id) for resolution — it is
|
|
||||||
// immune to the exact encoding rule. This helper is kept for the known-path case.
|
|
||||||
export function encodeCwd(cwd) {
|
|
||||||
return cwd.replace(/[/.]/g, "-");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function transcriptPath(home, cwd, sessionId) {
|
|
||||||
return `${home}/.claude/projects/${encodeCwd(cwd)}/${sessionId}.jsonl`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Locate a session's transcript by its UUID across every projects subdir, without
|
// Locate a session's transcript by its UUID across every projects subdir, without
|
||||||
// reconstructing the encoded cwd. Robust to whatever encoding claude applies.
|
// reconstructing the encoded cwd. Robust to whatever encoding claude applies.
|
||||||
// Returns the path, or null if not present yet (it appears once the turn starts).
|
// Returns the path, or null if not present yet (it appears once the turn starts).
|
||||||
|
// TODO: add a CI fixture-contract test (a captured real transcript) so schema drift
|
||||||
|
// in the claude JSONL format fails loudly rather than silently degrading.
|
||||||
export function findTranscriptPath(home, sessionId) {
|
export function findTranscriptPath(home, sessionId) {
|
||||||
if (!home || !sessionId) return null;
|
if (!home || !sessionId) return null;
|
||||||
const root = `${home}/.claude/projects`;
|
const root = `${home}/.claude/projects`;
|
||||||
|
|||||||
@@ -573,21 +573,42 @@ Usage:
|
|||||||
ocp restart Restart the Claude proxy service
|
ocp restart Restart the Claude proxy service
|
||||||
ocp restart gateway Restart the OpenClaw gateway
|
ocp restart gateway Restart the OpenClaw gateway
|
||||||
(briefly disconnects all Telegram/Discord bots)
|
(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
|
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() {
|
cmd_restart() {
|
||||||
if [[ "${1:-}" == "gateway" ]]; then
|
if [[ "${1:-}" == "gateway" ]]; then
|
||||||
echo "Restarting gateway..."
|
echo "Restarting gateway..."
|
||||||
openclaw gateway restart 2>&1
|
openclaw gateway restart 2>&1
|
||||||
else
|
else
|
||||||
echo "Restarting proxy..."
|
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
|
local uid
|
||||||
uid=$(id -u)
|
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
|
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
|
true
|
||||||
elif systemctl --user restart ocp-proxy 2>/dev/null; then
|
elif systemctl --user restart ocp-proxy 2>/dev/null; then
|
||||||
true
|
true
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ocp",
|
"name": "ocp",
|
||||||
"version": "3.12.0",
|
"version": "3.16.2",
|
||||||
"description": "Slash commands for the OpenClaw Proxy",
|
"description": "Slash commands for the OpenClaw Proxy",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
"openclaw": {
|
"openclaw": {
|
||||||
"type": "plugin",
|
"type": "plugin",
|
||||||
"id": "ocp",
|
"id": "ocp",
|
||||||
"pluginManifest": "openclaw.plugin.json"
|
"pluginManifest": "openclaw.plugin.json",
|
||||||
|
"extensions": ["./index.js"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "open-claude-proxy",
|
"name": "open-claude-proxy",
|
||||||
"version": "3.20.0",
|
"version": "3.21.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.",
|
"description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
+583
-56
@@ -20,7 +20,10 @@
|
|||||||
* CLAUDE_MCP_CONFIG — path to MCP server config JSON file
|
* CLAUDE_MCP_CONFIG — path to MCP server config JSON file
|
||||||
* CLAUDE_SESSION_TTL — session TTL in ms (default: 3600000 = 1h)
|
* 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_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_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_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_COOLDOWN — base ms to wait before retrying after circuit opens (default: 120000)
|
||||||
* CLAUDE_BREAKER_WINDOW — sliding window duration in ms (default: 300000 = 5min)
|
* CLAUDE_BREAKER_WINDOW — sliding window duration in ms (default: 300000 = 5min)
|
||||||
@@ -31,16 +34,17 @@
|
|||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
import { spawn, execFileSync } from "node:child_process";
|
import { spawn, execFileSync } from "node:child_process";
|
||||||
import { randomUUID, timingSafeEqual } from "node:crypto";
|
import { randomUUID, timingSafeEqual } from "node:crypto";
|
||||||
import { readFileSync, readdirSync, accessSync, existsSync, constants, chmodSync, statSync } from "node:fs";
|
import { readFileSync, readdirSync, accessSync, existsSync, constants, chmodSync, statSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
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 { 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 { DEFAULT_PORT } from "./lib/constants.mjs";
|
||||||
import { isLoopbackBind } from "./lib/net.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 { detectTuiUpstreamError } from "./lib/tui/transcript.mjs";
|
||||||
import { TuiSemaphore, recordTuiEntrypoint, buildTuiHealthBlock } from "./lib/tui/semaphore.mjs";
|
import { TuiSemaphore, SemaphoreAbortError, recordTuiEntrypoint, buildTuiHealthBlock } from "./lib/tui/semaphore.mjs";
|
||||||
|
import { createSerialMutex, createTtlCache, isTokenExpiring, orderLabelsLastGoodFirst } from "./lib/spawn-auth.mjs";
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
|
const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
|
||||||
@@ -272,6 +276,15 @@ const SYSTEM_PROMPT = process.env.CLAUDE_SYSTEM_PROMPT || "";
|
|||||||
const MCP_CONFIG = process.env.CLAUDE_MCP_CONFIG || "";
|
const MCP_CONFIG = process.env.CLAUDE_MCP_CONFIG || "";
|
||||||
let SESSION_TTL = parseInt(process.env.CLAUDE_SESSION_TTL || "3600000", 10);
|
let SESSION_TTL = parseInt(process.env.CLAUDE_SESSION_TTL || "3600000", 10);
|
||||||
let MAX_CONCURRENT = parseInt(process.env.CLAUDE_MAX_CONCURRENT || "8", 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_THRESHOLD = parseInt(process.env.CLAUDE_BREAKER_THRESHOLD || "6", 10);
|
||||||
const BREAKER_COOLDOWN = parseInt(process.env.CLAUDE_BREAKER_COOLDOWN || "120000", 10);
|
const BREAKER_COOLDOWN = parseInt(process.env.CLAUDE_BREAKER_COOLDOWN || "120000", 10);
|
||||||
const BREAKER_WINDOW = parseInt(process.env.CLAUDE_BREAKER_WINDOW || "300000", 10);
|
const BREAKER_WINDOW = parseInt(process.env.CLAUDE_BREAKER_WINDOW || "300000", 10);
|
||||||
@@ -279,6 +292,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 HEARTBEAT_INTERVAL = parseInt(process.env.CLAUDE_HEARTBEAT_INTERVAL || "0", 10);
|
||||||
const BIND_ADDRESS = process.env.CLAUDE_BIND || "127.0.0.1";
|
const BIND_ADDRESS = process.env.CLAUDE_BIND || "127.0.0.1";
|
||||||
const NO_CONTEXT = process.env.CLAUDE_NO_CONTEXT === "true";
|
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 AUTH_MODE = process.env.CLAUDE_AUTH_MODE || (PROXY_API_KEY ? "shared" : "none");
|
||||||
const ADMIN_KEY = process.env.OCP_ADMIN_KEY || "";
|
const ADMIN_KEY = process.env.OCP_ADMIN_KEY || "";
|
||||||
const PROXY_ANONYMOUS_KEY = process.env.PROXY_ANONYMOUS_KEY || "";
|
const PROXY_ANONYMOUS_KEY = process.env.PROXY_ANONYMOUS_KEY || "";
|
||||||
@@ -300,7 +319,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_MODE = process.env.CLAUDE_TUI_MODE === "true";
|
||||||
const TUI_WALLCLOCK_MS = parseInt(process.env.CLAUDE_TUI_WALLCLOCK_MS || "120000", 10);
|
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_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
|
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
|
// 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
|
// HEAVY (per-request cold-boot of a tmux+claude session + up to TUI_WALLCLOCK_MS=120s of
|
||||||
@@ -319,6 +351,237 @@ const tuiStats = {
|
|||||||
entrypointMismatches: 0, // count of cli-expected-but-got-other turns
|
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. Returns { isolated, home, reason }:
|
||||||
|
// - 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).
|
||||||
|
//
|
||||||
|
// FIX F6 (2026-07-07): this decision is NO LONGER memoized permanently. The previous version
|
||||||
|
// cached it forever at first call, which meant: (a) credentials appearing after startup never
|
||||||
|
// enabled isolation; (b) `rm -rf ~/.ocp/spawn-home` at runtime made every isolated spawn ENOENT
|
||||||
|
// until restart; (c) during a token-expiry stint /health reported isolated:true while spawns
|
||||||
|
// actually ran real-HOME. Re-evaluating per spawn is cheap because F5's 30s keychain TTL cache
|
||||||
|
// backs getOAuthCredentials(). This function is the CONFIG-level decision (isolated iff a token
|
||||||
|
// resolves AND the kill-switch is off) and has NO fs side effects — the per-spawn EFFECTIVE
|
||||||
|
// decision additionally applies the expiry gate (resolveSpawnDecision), and scratch-HOME dir prep
|
||||||
|
// moved to ensureSpawnHome() at the isolated spawn site.
|
||||||
|
//
|
||||||
|
// The token itself is re-resolved FRESH per spawn via resolveSpawnToken(); a memoized token goes
|
||||||
|
// stale when its source rotates (the macOS keychain access token rotates ~hourly, refreshed by the
|
||||||
|
// operator's real claude), which 401'd every isolated spawn for ~31h on 2026-06-26 (#146). OCP
|
||||||
|
// deliberately does NOT refresh the token itself — a refresh-token grant would consume the
|
||||||
|
// single-use refresh token and log out the operator's real claude (issue #112).
|
||||||
|
function getSpawnHomeMode() {
|
||||||
|
if (SPAWN_REAL_HOME) {
|
||||||
|
return { isolated: false, home: null, reason: "kill-switch (OCP_SPAWN_REAL_HOME=1)" };
|
||||||
|
}
|
||||||
|
let hasToken = false;
|
||||||
|
try { hasToken = !!(getOAuthCredentials()?.accessToken); } catch { hasToken = false; }
|
||||||
|
if (hasToken) return { isolated: true, home: SPAWN_HOME_DIR, reason: "oauth token resolved" };
|
||||||
|
return { isolated: false, home: null, reason: "no oauth token resolvable" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIX F6: re-verify the scratch HOME exists before each isolated spawn and re-create it if it was
|
||||||
|
// deleted at runtime (it used to be prepared once at startup, so a runtime deletion made every
|
||||||
|
// isolated spawn fail ENOENT until restart). mkdirSync is recursive+idempotent → cheap to re-run.
|
||||||
|
function ensureSpawnHome(dir = SPAWN_HOME_DIR) {
|
||||||
|
if (!existsSync(`${dir}/.claude`)) prepareSpawnHome(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a FRESH OAuth access token for an isolated spawn. Read-only (keychain / credentials.json
|
||||||
|
// / env) — NEVER refreshes/rotates (see getSpawnHomeMode note). Returns null if none resolvable OR
|
||||||
|
// if a known expiry is within the 5-min buffer (isTokenExpiring): a null return makes the caller
|
||||||
|
// fall back to real HOME, where the spawned claude refreshes the credential natively and self-heals
|
||||||
|
// (the keychain token is then fresh again → next spawn is fast). The env-token path (Linux) carries
|
||||||
|
// no expiresAt → never expiry-gated (those tokens are long-lived).
|
||||||
|
function resolveSpawnToken() {
|
||||||
|
try {
|
||||||
|
const creds = getOAuthCredentials();
|
||||||
|
if (!creds?.accessToken) return null;
|
||||||
|
if (isTokenExpiring(creds)) return null; // 5-min buffer; applied to the CACHED creds every use
|
||||||
|
return creds.accessToken;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIX F3 (2026-07-07): serializes ONLY the real-HOME fallback spawns. Isolated spawns (the common
|
||||||
|
// fast path) never touch this mutex.
|
||||||
|
const realHomeFallbackMutex = createSerialMutex();
|
||||||
|
|
||||||
|
// Resolve the EFFECTIVE per-spawn HOME/token decision. Returns
|
||||||
|
// { isolated, home, token, releaseFallback }
|
||||||
|
// `releaseFallback` is non-null ONLY for a real-HOME fallback holder — the caller MUST call it on
|
||||||
|
// spawn teardown (wired into cleanup()); it releases the serialization mutex. It is null (no-op)
|
||||||
|
// for isolated and stable real-HOME (kill-switch / no-token) spawns.
|
||||||
|
//
|
||||||
|
// This is async so the real-HOME fallback can `await` the mutex; the keychain reads inside stay
|
||||||
|
// synchronous (F5 keeps the call sites off async conversion).
|
||||||
|
async function resolveSpawnDecision() {
|
||||||
|
const shm = getSpawnHomeMode();
|
||||||
|
if (!shm.isolated) return { isolated: false, home: null, token: null, releaseFallback: null };
|
||||||
|
const token = resolveSpawnToken();
|
||||||
|
if (token) {
|
||||||
|
ensureSpawnHome(shm.home);
|
||||||
|
return { isolated: true, home: shm.home, token, releaseFallback: null };
|
||||||
|
}
|
||||||
|
// Token is present but within the 5-min expiry window → we would fall back to real HOME, where
|
||||||
|
// the spawned claude refreshes the credential natively. HAZARD PREVENTED: without serialization,
|
||||||
|
// every concurrent -p spawn inside this window runs claude under the real HOME simultaneously,
|
||||||
|
// and each spawned claude races a `refresh_token` grant against the SAME single-use refresh
|
||||||
|
// token — rotating it out from under the others AND the operator's own real claude (the
|
||||||
|
// credential-fork hazard; #112 / #146 class). Serialize: admit ONE real-HOME spawn at a time.
|
||||||
|
// When the next waiter is admitted (the prior holder torn down → its claude has had its lifetime
|
||||||
|
// to refresh the keychain), re-run resolveSpawnToken(): a now-fresh token means we proceed
|
||||||
|
// ISOLATED and release the mutex immediately, so the queue drains to the fast path instead of
|
||||||
|
// piling every request into the real HOME.
|
||||||
|
const release = await realHomeFallbackMutex.acquire();
|
||||||
|
try {
|
||||||
|
// Drop the 30s keychain TTL cache so the re-check reads FRESH keychain state — otherwise a
|
||||||
|
// waiter admitted right after the prior holder's claude refreshed the token could still see the
|
||||||
|
// stale (expiring) cached creds and needlessly fall back to real HOME again for up to ~30s.
|
||||||
|
invalidateKeychainReadCache();
|
||||||
|
const retry = resolveSpawnToken();
|
||||||
|
if (retry) {
|
||||||
|
release();
|
||||||
|
ensureSpawnHome(shm.home);
|
||||||
|
return { isolated: true, home: shm.home, token: retry, releaseFallback: null };
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
release();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return { isolated: false, home: null, token: null, releaseFallback: release };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tagged error for audit finding F2: the client disconnected while queued (or was already gone
|
||||||
|
// before we even tried to queue it). Distinct from ConcurrencyOverflowError so callers never send
|
||||||
|
// a response on this path — there is no socket left to write to.
|
||||||
|
class RequestDisconnectedError extends Error {
|
||||||
|
constructor(message) { super(message); this.name = "RequestDisconnectedError"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build an AbortSignal that fires when `res` (an http.ServerResponse) closes — i.e. the client
|
||||||
|
// disconnected. Used to cancel a QUEUED concurrency-slot wait (F2) so a client that gives up
|
||||||
|
// before a slot is granted is spliced out of the wait queue instead of eventually spawning a
|
||||||
|
// claude process for a dead socket. If `res` has already closed by the time we get here (its
|
||||||
|
// underlying stream already torn down), the signal is returned pre-aborted so acquire() rejects
|
||||||
|
// immediately without ever touching the queue — the "close already fired before we attach" case.
|
||||||
|
// `detach()` MUST be called once the wait settles (granted or rejected) to avoid a listener leak.
|
||||||
|
function closeSignalFor(res) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
if (!res || typeof res.on !== "function") return { signal: controller.signal, detach() {} };
|
||||||
|
if (res.destroyed) {
|
||||||
|
controller.abort();
|
||||||
|
return { signal: controller.signal, detach() {} };
|
||||||
|
}
|
||||||
|
const onClose = () => controller.abort();
|
||||||
|
res.on("close", onClose);
|
||||||
|
return { signal: controller.signal, detach() { res.removeListener("close", onClose); } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, or with
|
||||||
|
// RequestDisconnectedError when `res` closes before a slot is granted (F2) — the caller must not
|
||||||
|
// spawn claude in that case. `res` is optional (back-compat for any caller without a live response
|
||||||
|
// object); omitting it just means a queued wait can't be cancelled early.
|
||||||
|
//
|
||||||
|
// F8 fix: stats.queued is set from claudeSemaphore.queued AFTER calling acquire() (not before) —
|
||||||
|
// acquire() synchronously updates _inflight/_waiters before its Promise ever resolves, so reading
|
||||||
|
// .queued right after the call already reflects reality. The old code set `queued + 1` BEFORE
|
||||||
|
// calling acquire() to account for "this waiter", which over-reported by 1 whenever the slot was
|
||||||
|
// granted immediately (the common case, not a queue at all).
|
||||||
|
async function acquireClaudeSlot(res) {
|
||||||
|
const { signal, detach } = closeSignalFor(res);
|
||||||
|
const slot = claudeSemaphore.acquire(signal);
|
||||||
|
stats.queued = claudeSemaphore.queued; // accurate: acquire() already updated the queue synchronously
|
||||||
|
try {
|
||||||
|
await slot;
|
||||||
|
} catch (e) {
|
||||||
|
detach();
|
||||||
|
stats.queued = claudeSemaphore.queued;
|
||||||
|
if (e instanceof SemaphoreAbortError) {
|
||||||
|
// Client-driven cancellation, not backpressure — do NOT count it as a queueRejection or
|
||||||
|
// log it as concurrency_queue_full (that log/counter means "the queue itself is full").
|
||||||
|
logEvent("info", "concurrency_wait_cancelled", {
|
||||||
|
reason: "client_disconnected", inflight: claudeSemaphore.inflight, queued: claudeSemaphore.queued,
|
||||||
|
});
|
||||||
|
throw new RequestDisconnectedError("client disconnected while waiting for a concurrency slot");
|
||||||
|
}
|
||||||
|
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`);
|
||||||
|
}
|
||||||
|
detach();
|
||||||
|
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
|
// SECURITY fail-loud: TUI-mode is incompatible with any configuration that allows
|
||||||
// non-operator prompts to reach the interactive claude session. Three cases:
|
// non-operator prompts to reach the interactive claude session. Three cases:
|
||||||
// 1. AUTH_MODE=multi — guest/anonymous keys can submit prompts.
|
// 1. AUTH_MODE=multi — guest/anonymous keys can submit prompts.
|
||||||
@@ -495,6 +758,34 @@ const cacheCleanupInterval = setInterval(() => {
|
|||||||
}
|
}
|
||||||
}, 600000);
|
}, 600000);
|
||||||
|
|
||||||
|
// TUI defunct-session reap (periodic): the boot reap (below) only fires once, but a
|
||||||
|
// long-lived host (PI231 ran 30 days without restart) accumulates defunct `<claude>`
|
||||||
|
// zombies between restarts — the pane's claude is a child of the tmux server, not node,
|
||||||
|
// so only the server can reap it (see reapStaleTuiSessions). We sweep every 15 min, but
|
||||||
|
// ONLY when the TUI path is fully idle: reapStaleTuiSessions may `kill-server`, which would
|
||||||
|
// tear down a live turn's pane, so we skip the sweep while any turn is inflight or queued.
|
||||||
|
// RESIDUAL (documented, accepted): a brand-new request whose pane is created in the narrow
|
||||||
|
// window between this idle-check and kill-server would have its pane torn down and fail the
|
||||||
|
// turn cleanly via runTuiTurn's existing honesty gates (rare; the boot reap is the primary
|
||||||
|
// mechanism and the 15-min cadence makes the window negligible).
|
||||||
|
// Gated on TUI_MODE — zero effect (no kill-server, no list-sessions) when TUI is off.
|
||||||
|
// cli.js does NOT perform this operation (Class B, OCP-owned TUI spawn) — see ADR 0007.
|
||||||
|
const TUI_REAP_INTERVAL_MS = 15 * 60 * 1000;
|
||||||
|
const tuiReapInterval = TUI_MODE ? setInterval(() => {
|
||||||
|
if (tuiSemaphore.inflight > 0 || tuiSemaphore.queued > 0) return; // a turn is live — defer
|
||||||
|
try {
|
||||||
|
// F7 fix: scope to THIS instance's own port; a sibling ocp-tui-<otherPort>-* session
|
||||||
|
// (a second OCP instance on the same host) is treated as foreign, same as olp-tui-*.
|
||||||
|
// includeLegacy is NOT set here — see reapStaleTuiSessions' comment: the periodic sweep
|
||||||
|
// conservatively treats any lingering bare-prefix legacy session as foreign so it can
|
||||||
|
// never trigger kill-server on a steady-state tick; only the one-time boot reap below
|
||||||
|
// claims legacy-shaped zombies.
|
||||||
|
const n = reapStaleTuiSessions({ port: PORT });
|
||||||
|
if (n) logEvent("info", "tui_reaped_stale_sessions", { count: n, trigger: "periodic" });
|
||||||
|
} catch (e) { logEvent("error", "tui_periodic_reap_failed", { error: e.message }); }
|
||||||
|
}, TUI_REAP_INTERVAL_MS) : null;
|
||||||
|
if (tuiReapInterval && typeof tuiReapInterval.unref === "function") tuiReapInterval.unref();
|
||||||
|
|
||||||
// ── Active child process tracking ────────────────────────────────────────
|
// ── Active child process tracking ────────────────────────────────────────
|
||||||
const activeProcesses = new Set();
|
const activeProcesses = new Set();
|
||||||
|
|
||||||
@@ -507,6 +798,8 @@ const stats = {
|
|||||||
sessionHits: 0,
|
sessionHits: 0,
|
||||||
sessionMisses: 0,
|
sessionMisses: 0,
|
||||||
oneOffRequests: 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
|
const recentErrors = []; // last 20 errors
|
||||||
|
|
||||||
@@ -734,11 +1027,14 @@ function getModelTier(cliModel) {
|
|||||||
// (messagesToPrompt), so multi-turn correctness is preserved without sessions.
|
// (messagesToPrompt), so multi-turn correctness is preserved without sessions.
|
||||||
// The sessions Map is retained for stats/logging but no longer drives --resume.
|
// The sessions Map is retained for stats/logging but no longer drives --resume.
|
||||||
// Reference: OLP ADR 0009 Amendment 1 + commit 97e7d16.
|
// Reference: OLP ADR 0009 Amendment 1 + commit 97e7d16.
|
||||||
function spawnClaudeProcess(model, messages, conversationId, keyName) {
|
// FIX ⑥: concurrency is now bounded by the claudeSemaphore via acquireClaudeSlot(), which the
|
||||||
if (stats.activeRequests >= MAX_CONCURRENT) {
|
// caller MUST await before calling this, passing the resulting release fn as `releaseSlot`. The
|
||||||
throw new Error(`concurrency limit reached (${stats.activeRequests}/${MAX_CONCURRENT})`);
|
// 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 = () => {}, spawnDecision = null) {
|
||||||
const cliModel = MODEL_MAP[model] || model;
|
const cliModel = MODEL_MAP[model] || model;
|
||||||
|
|
||||||
// Circuit breaker: disabled (see comment at top of breaker section)
|
// Circuit breaker: disabled (see comment at top of breaker section)
|
||||||
@@ -775,7 +1071,27 @@ function spawnClaudeProcess(model, messages, conversationId, keyName) {
|
|||||||
env.CLAUDE_CODE_DISABLE_AUTO_MEMORY = "1";
|
env.CLAUDE_CODE_DISABLE_AUTO_MEMORY = "1";
|
||||||
}
|
}
|
||||||
|
|
||||||
const proc = spawn(CLAUDE, cliArgs, { env, stdio: ["pipe", "pipe", "pipe"] });
|
// FIX ③ (latency) + F3 (concurrency): apply the pre-resolved per-spawn HOME/token decision.
|
||||||
|
// The decision is resolved ASYNC in the caller (resolveSpawnDecision) so the real-HOME fallback
|
||||||
|
// serialization can await its mutex; here we only apply the result. When isolated, 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 fresh token is resolvable, decision.isolated is false → real HOME
|
||||||
|
// + inherited cwd (zero regression), and the spawned claude resolves+refreshes credentials
|
||||||
|
// natively. The DISABLE_CLAUDE_MDS / AUTO_MEMORY flags are set unconditionally in isolated mode
|
||||||
|
// (belt-and-braces; mirrors the TUI path).
|
||||||
|
const decision = spawnDecision || { isolated: false, releaseFallback: null };
|
||||||
|
const spawnOpts = { env, stdio: ["pipe", "pipe", "pipe"] };
|
||||||
|
if (decision.isolated && decision.token) {
|
||||||
|
env.HOME = decision.home;
|
||||||
|
env.CLAUDE_CODE_OAUTH_TOKEN = decision.token; // env token is authoritative for -p
|
||||||
|
env.CLAUDE_CODE_DISABLE_CLAUDE_MDS = "1";
|
||||||
|
env.CLAUDE_CODE_DISABLE_AUTO_MEMORY = "1";
|
||||||
|
spawnOpts.cwd = decision.home; // neutral cwd: no project CLAUDE.md/skills
|
||||||
|
}
|
||||||
|
|
||||||
|
const proc = spawn(CLAUDE, cliArgs, spawnOpts);
|
||||||
activeProcesses.add(proc);
|
activeProcesses.add(proc);
|
||||||
|
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
@@ -787,6 +1103,15 @@ function spawnClaudeProcess(model, messages, conversationId, keyName) {
|
|||||||
cleaned = true;
|
cleaned = true;
|
||||||
clearTimeout(overallTimer);
|
clearTimeout(overallTimer);
|
||||||
stats.activeRequests--;
|
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 */ }
|
||||||
|
// F3: release the real-HOME fallback serialization mutex (no-op for isolated/normal spawns).
|
||||||
|
// By now this spawn's claude has had its lifetime to refresh the keychain token, so the next
|
||||||
|
// queued fallback waiter re-checks resolveSpawnToken() and proceeds ISOLATED with the now-fresh
|
||||||
|
// token instead of piling into the real HOME. Idempotent; cleanup() is guarded by `cleaned`.
|
||||||
|
try { if (decision.releaseFallback) decision.releaseFallback(); } catch { /* never throw out of cleanup */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Guarantee slot release on ANY exit path (normal close, error, timeout kill,
|
// Guarantee slot release on ANY exit path (normal close, error, timeout kill,
|
||||||
@@ -856,12 +1181,37 @@ function spawnClaudeProcess(model, messages, conversationId, keyName) {
|
|||||||
// We accumulate full text across all content_block_delta events plus the
|
// We accumulate full text across all content_block_delta events plus the
|
||||||
// assistant-aggregate fallback, then resolve with the assembled string.
|
// assistant-aggregate fallback, then resolve with the assembled string.
|
||||||
// Reference: OLP ADR 0009 Amendment 1 + commit 97e7d16.
|
// Reference: OLP ADR 0009 Amendment 1 + commit 97e7d16.
|
||||||
function callClaude(model, messages, conversationId, keyName) {
|
// `res` (optional, F2) is the client's http.ServerResponse — passed through so a queued wait
|
||||||
|
// can be cancelled the moment the client disconnects, instead of spawning claude for a dead
|
||||||
|
// socket once a slot finally frees up.
|
||||||
|
async function callClaude(model, messages, conversationId, keyName, res) {
|
||||||
|
// FIX ⑥: acquire a concurrency slot first (queues up to CLAUDE_MAX_QUEUE; rejects with a
|
||||||
|
// ConcurrencyOverflowError → 429 when the queue is full, or a RequestDisconnectedError (F2)
|
||||||
|
// if the client goes away first). 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.
|
||||||
|
// F2×F3 composition: the slot acquire comes FIRST and is the cancellable step — a client
|
||||||
|
// that disconnects while queued rejects here, BEFORE resolveSpawnDecision() runs, so a
|
||||||
|
// cancelled request can never acquire (or briefly hold) the real-HOME fallback mutex.
|
||||||
|
const releaseSlot = await acquireClaudeSlot(res);
|
||||||
|
// F3: resolve the per-spawn HOME/token decision (may serialize on the real-HOME fallback
|
||||||
|
// mutex). If it throws, release the just-acquired slot before propagating — cleanup() is
|
||||||
|
// not wired yet at this point.
|
||||||
|
let spawnDecision;
|
||||||
|
try {
|
||||||
|
spawnDecision = await resolveSpawnDecision();
|
||||||
|
} catch (err) {
|
||||||
|
releaseSlot();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let ctx;
|
let ctx;
|
||||||
try {
|
try {
|
||||||
ctx = spawnClaudeProcess(model, messages, conversationId, keyName);
|
ctx = spawnClaudeProcess(model, messages, conversationId, keyName, releaseSlot, spawnDecision);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
releaseSlot();
|
||||||
|
// Spawn threw before cleanup() was wired → release the fallback mutex here so it never leaks.
|
||||||
|
try { spawnDecision.releaseFallback?.(); } catch { /* best effort */ }
|
||||||
return reject(err);
|
return reject(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -932,26 +1282,50 @@ function callClaude(model, messages, conversationId, keyName) {
|
|||||||
// flag that could perturb cc_entrypoint classification.
|
// flag that could perturb cc_entrypoint classification.
|
||||||
// Authority: claude CLI v2.1.158 interactive mode (cc_entrypoint=cli).
|
// Authority: claude CLI v2.1.158 interactive mode (cc_entrypoint=cli).
|
||||||
// SECURITY: A-path single-user ONLY — home is NOT isolation (see ADR 0007).
|
// SECURITY: A-path single-user ONLY — home is NOT isolation (see ADR 0007).
|
||||||
function callClaudeTui(model, messages, _conversationId, _keyName) {
|
// `res` (optional, F2) is the client's http.ServerResponse — see closeSignalFor.
|
||||||
|
async function callClaudeTui(model, messages, _conversationId, _keyName, res) {
|
||||||
const cliModel = MODEL_MAP[model] || model;
|
const cliModel = MODEL_MAP[model] || model;
|
||||||
const prompt = messagesToPrompt(messages); // includes system as [System] inline
|
const prompt = messagesToPrompt(messages); // includes system as [System] inline
|
||||||
recordModelRequest(cliModel, prompt.length);
|
recordModelRequest(cliModel, prompt.length);
|
||||||
// C-4: gate the heavy interactive boot behind the TUI semaphore. run() acquires a slot
|
// C-4: gate the heavy interactive boot behind the TUI semaphore (queuing if all slots are
|
||||||
// (queuing if all are busy, up to maxQueue), then releases in a finally so any throw from
|
// busy, up to maxQueue). F2: `signal` (tied to `res` "close") cancels a QUEUED wait the
|
||||||
// runTuiTurn (tmux spawn failure, paste-not-landed) OR from the honesty gates below
|
// instant the client disconnects, so a dead socket never triggers a cold-boot tmux+claude
|
||||||
// (truncation / error banner) can NEVER leak a slot. tuiSemaphore.inflight feeds /health.
|
// spawn; detach() drops the "close" listener as soon as the wait settles rather than
|
||||||
return tuiSemaphore.run(() => runTuiTurn({
|
// holding it for the whole (up to 120s) turn.
|
||||||
prompt,
|
const { signal, detach } = closeSignalFor(res);
|
||||||
model: cliModel,
|
try {
|
||||||
claudeBin: CLAUDE,
|
await tuiSemaphore.acquire(signal);
|
||||||
home: TUI_HOME,
|
} catch (err) {
|
||||||
realHome: process.env.HOME,
|
detach();
|
||||||
cwd: TUI_CWD,
|
if (err instanceof SemaphoreAbortError) {
|
||||||
wallclockMs: TUI_WALLCLOCK_MS,
|
// L1: client-driven cancellation, not an upstream failure — info, not error (mirrors
|
||||||
entrypointMode: TUI_ENTRYPOINT,
|
// acquireClaudeSlot's concurrency_wait_cancelled on the -p path).
|
||||||
}).then(({ text, entrypoint, truncated }) => {
|
logEvent("info", "concurrency_wait_cancelled", {
|
||||||
|
reason: "client_disconnected", path: "tui", inflight: tuiSemaphore.inflight, queued: tuiSemaphore.queued,
|
||||||
|
});
|
||||||
|
throw new RequestDisconnectedError("client disconnected while waiting for a TUI concurrency slot");
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
detach();
|
||||||
|
// release() runs in a finally so any throw from runTuiTurn (tmux spawn failure,
|
||||||
|
// paste-not-landed) OR from the honesty gates below (truncation / error banner) can NEVER
|
||||||
|
// leak a slot. tuiSemaphore.inflight feeds /health.
|
||||||
|
try {
|
||||||
|
const { text, entrypoint, truncated } = await runTuiTurn({
|
||||||
|
prompt,
|
||||||
|
model: cliModel,
|
||||||
|
claudeBin: CLAUDE,
|
||||||
|
home: TUI_HOME,
|
||||||
|
realHome: process.env.HOME,
|
||||||
|
cwd: TUI_CWD,
|
||||||
|
port: PORT, // F7 fix: port-scopes the tmux session name so a sibling OCP instance on a
|
||||||
|
// different port never collides with this instance's reap/kill-server logic.
|
||||||
|
wallclockMs: TUI_WALLCLOCK_MS,
|
||||||
|
entrypointMode: TUI_ENTRYPOINT,
|
||||||
|
});
|
||||||
// ── Honesty gates (issue #133) ─ run BEFORE recordModelSuccess / cache write-back.
|
// ── Honesty gates (issue #133) ─ run BEFORE recordModelSuccess / cache write-back.
|
||||||
// A throw here propagates to the .catch below (recordModelError + reject), so the
|
// A throw here propagates to the catch below (recordModelError + reject), so the
|
||||||
// result never reaches the downstream setCachedResponse / singleflight / SUCCESS path.
|
// result never reaches the downstream setCachedResponse / singleflight / SUCCESS path.
|
||||||
|
|
||||||
// C-2: the wall-clock cap hit with partial text and NO terminal marker — the turn
|
// C-2: the wall-clock cap hit with partial text and NO terminal marker — the turn
|
||||||
@@ -986,10 +1360,12 @@ function callClaudeTui(model, messages, _conversationId, _keyName) {
|
|||||||
logEvent("warn", "tui_entrypoint_mismatch", { expected: "cli", got: entrypoint, model: cliModel });
|
logEvent("warn", "tui_entrypoint_mismatch", { expected: "cli", got: entrypoint, model: cliModel });
|
||||||
}
|
}
|
||||||
return text;
|
return text;
|
||||||
}).catch((err) => {
|
} catch (err) {
|
||||||
recordModelError(cliModel, false);
|
recordModelError(cliModel, false);
|
||||||
throw err;
|
throw err;
|
||||||
}));
|
} finally {
|
||||||
|
tuiSemaphore.release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── SSE heartbeat (opt-in idle watchdog) ────────────────────────────────
|
// ── SSE heartbeat (opt-in idle watchdog) ────────────────────────────────
|
||||||
@@ -1028,14 +1404,44 @@ function startHeartbeat(res, intervalMs, sessionId) {
|
|||||||
// We parse line-by-line and forward content_block_delta text events as SSE.
|
// We parse line-by-line and forward content_block_delta text events as SSE.
|
||||||
// The result event triggers the stop/[DONE] sequence.
|
// The result event triggers the stop/[DONE] sequence.
|
||||||
// Reference: OLP ADR 0009 Amendment 1 + commits 97e7d16, 65f945c.
|
// 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 id = `chatcmpl-${randomUUID()}`;
|
||||||
const created = Math.floor(Date.now() / 1000);
|
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.
|
||||||
|
// F2: pass `res` so a queued wait is cancelled the instant this client disconnects — the client
|
||||||
|
// is already gone in that case, so there is no response to send back.
|
||||||
|
let releaseSlot;
|
||||||
|
try {
|
||||||
|
releaseSlot = await acquireClaudeSlot(res);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof RequestDisconnectedError) return; // client gone — nothing to write to
|
||||||
|
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" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// F3: resolve the per-spawn HOME/token decision (may serialize on the real-HOME fallback
|
||||||
|
// mutex). F2×F3 composition: this runs strictly AFTER the (cancellable) slot acquire, so a
|
||||||
|
// request cancelled while queued never touches the fallback mutex. If it throws, release
|
||||||
|
// the just-acquired slot before responding — cleanup() is not wired yet at this point.
|
||||||
|
let spawnDecision;
|
||||||
|
try {
|
||||||
|
spawnDecision = await resolveSpawnDecision();
|
||||||
|
} catch (err) {
|
||||||
|
releaseSlot();
|
||||||
|
return jsonResponse(res, 500, { error: { message: sanitizeError(err.message), type: "proxy_error" } });
|
||||||
|
}
|
||||||
let ctx;
|
let ctx;
|
||||||
try {
|
try {
|
||||||
ctx = spawnClaudeProcess(model, messages, conversationId, authInfo.keyName);
|
ctx = spawnClaudeProcess(model, messages, conversationId, authInfo.keyName, releaseSlot, spawnDecision);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
releaseSlot();
|
||||||
|
// Spawn threw before cleanup() was wired → release the fallback mutex here so it never leaks.
|
||||||
|
try { spawnDecision.releaseFallback?.(); } catch { /* best effort */ }
|
||||||
return jsonResponse(res, 500, { error: { message: sanitizeError(err.message), type: "proxy_error" } });
|
return jsonResponse(res, 500, { error: { message: sanitizeError(err.message), type: "proxy_error" } });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1228,12 +1634,23 @@ function sanitizeError(msg) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Response helpers ────────────────────────────────────────────────────
|
// ── Response helpers ────────────────────────────────────────────────────
|
||||||
function jsonResponse(res, status, data) {
|
function jsonResponse(res, status, data, extraHeaders = null) {
|
||||||
if (res.headersSent || res.writableEnded || res.destroyed) return;
|
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));
|
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) {
|
function sendSSE(res, data, hb) {
|
||||||
hb?.reset();
|
hb?.reset();
|
||||||
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||||
@@ -1296,6 +1713,51 @@ const OAUTH_REFRESH_MIN_BACKOFF = 60 * 1000;
|
|||||||
const OAUTH_REFRESH_MAX_BACKOFF = 3600 * 1000;
|
const OAUTH_REFRESH_MAX_BACKOFF = 3600 * 1000;
|
||||||
let oauthRefreshBackoff = { nextAttemptAt: 0, currentDelay: OAUTH_REFRESH_MIN_BACKOFF };
|
let oauthRefreshBackoff = { nextAttemptAt: 0, currentDelay: OAUTH_REFRESH_MIN_BACKOFF };
|
||||||
|
|
||||||
|
// FIX F5 (2026-07-07): the macOS keychain read (`security find-generic-password`, up to 5s × 2
|
||||||
|
// labels when the first label misses) ran on EVERY -p spawn's hot path, blocking the event loop
|
||||||
|
// (worst case 10s) and stalling all in-flight SSE streams. Two minimal, sync-preserving mitigations:
|
||||||
|
// (a) memoize the last-good keychain label and try it FIRST → one exec instead of two on the
|
||||||
|
// steady-state path (orderLabelsLastGoodFirst);
|
||||||
|
// (b) a short (30s) TTL cache of the keychain read result (createTtlCache).
|
||||||
|
// SAFETY vs the #146 regression: #146 was a token memoized FOREVER at startup that went stale and
|
||||||
|
// 401'd. This is a 30s TTL (not forever), AND resolveSpawnToken() re-applies the 5-min expiry gate
|
||||||
|
// (isTokenExpiring) to the CACHED creds on EVERY use — the creds object carries `expiresAt`, so a
|
||||||
|
// token expiring within the cache window is still rejected → real-HOME fallback. A short TTL bounds
|
||||||
|
// how often we re-READ the keychain; it does NOT bound how often we re-DECIDE expiry. This is why a
|
||||||
|
// short-TTL keychain cache + a per-use expiry check does not reintroduce the forever-stale bug.
|
||||||
|
const KEYCHAIN_LABELS = ["claude-code-credentials", "Claude Code-credentials"];
|
||||||
|
const KEYCHAIN_CACHE_TTL_MS = 30 * 1000;
|
||||||
|
const _keychainCache = createTtlCache({ ttlMs: KEYCHAIN_CACHE_TTL_MS });
|
||||||
|
let _lastGoodKeychainLabel = null;
|
||||||
|
|
||||||
|
// Read the macOS keychain credentials, label-memoized + short-TTL cached (F5). Sync (execFileSync);
|
||||||
|
// returns the `claudeAiOauth` creds object or null.
|
||||||
|
function readKeychainCreds() {
|
||||||
|
return _keychainCache.get(() => {
|
||||||
|
for (const label of orderLabelsLastGoodFirst(KEYCHAIN_LABELS, _lastGoodKeychainLabel)) {
|
||||||
|
try {
|
||||||
|
const raw = execFileSync("security", [
|
||||||
|
"find-generic-password", "-s", label, "-w"
|
||||||
|
], { encoding: "utf8", timeout: 5000 }).trim();
|
||||||
|
const creds = JSON.parse(raw);
|
||||||
|
if (creds?.claudeAiOauth?.accessToken) {
|
||||||
|
_lastGoodKeychainLabel = label; // remember the winner → try it first next time
|
||||||
|
return creds.claudeAiOauth;
|
||||||
|
}
|
||||||
|
} catch { /* try next label */ }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// F3 drain helper: drop the F5 keychain TTL cache so the NEXT getOAuthCredentials() re-reads the
|
||||||
|
// keychain from scratch. Called under the real-HOME fallback mutex just before the re-check, so a
|
||||||
|
// waiter admitted after the prior holder's claude refreshed the keychain sees the FRESH token
|
||||||
|
// immediately (and proceeds ISOLATED) instead of waiting out the ≤30s TTL on the stale creds.
|
||||||
|
function invalidateKeychainReadCache() {
|
||||||
|
_keychainCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
function getOAuthCredentials() {
|
function getOAuthCredentials() {
|
||||||
// 1. Env var fallback — highest precedence for explicit overrides.
|
// 1. Env var fallback — highest precedence for explicit overrides.
|
||||||
if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
|
if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
|
||||||
@@ -1309,17 +1771,8 @@ function getOAuthCredentials() {
|
|||||||
if (creds?.claudeAiOauth?.accessToken) return creds.claudeAiOauth;
|
if (creds?.claudeAiOauth?.accessToken) return creds.claudeAiOauth;
|
||||||
} catch { /* fall through to macOS keychain */ }
|
} catch { /* fall through to macOS keychain */ }
|
||||||
|
|
||||||
// 3. macOS keychain (both label formats)
|
// 3. macOS keychain (both label formats) — F5: label-memoized + 30s TTL cached (see above).
|
||||||
for (const label of ["claude-code-credentials", "Claude Code-credentials"]) {
|
return readKeychainCreds();
|
||||||
try {
|
|
||||||
const raw = execFileSync("security", [
|
|
||||||
"find-generic-password", "-s", label, "-w"
|
|
||||||
], { encoding: "utf8", timeout: 5000 }).trim();
|
|
||||||
const creds = JSON.parse(raw);
|
|
||||||
if (creds?.claudeAiOauth?.accessToken) return creds.claudeAiOauth;
|
|
||||||
} catch { /* try next */ }
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshOAuthToken(refreshToken) {
|
async function refreshOAuthToken(refreshToken) {
|
||||||
@@ -1653,7 +2106,12 @@ function applySettingUpdate(key, value) {
|
|||||||
|
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case "timeout": TIMEOUT = value; break;
|
case "timeout": TIMEOUT = value; break;
|
||||||
case "maxConcurrent": MAX_CONCURRENT = value; break;
|
// FIX ⑥ + F1: 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 —
|
||||||
|
// in BOTH directions. setLimit() (not a bare `.limit =` assignment) is required: lowering
|
||||||
|
// needs release() to stop over-granting until inflight drains under the new cap, and raising
|
||||||
|
// needs queued waiters woken immediately to use the new headroom. See lib/tui/semaphore.mjs.
|
||||||
|
case "maxConcurrent": MAX_CONCURRENT = value; claudeSemaphore.setLimit(value); break;
|
||||||
case "sessionTTL": SESSION_TTL = value; break;
|
case "sessionTTL": SESSION_TTL = value; break;
|
||||||
case "maxPromptChars": MAX_PROMPT_CHARS = value; break;
|
case "maxPromptChars": MAX_PROMPT_CHARS = value; break;
|
||||||
case "cacheTTL": CACHE_TTL = value; break;
|
case "cacheTTL": CACHE_TTL = value; break;
|
||||||
@@ -1810,7 +2268,7 @@ async function handleChatCompletions(req, res) {
|
|||||||
const t0TuiStream = Date.now();
|
const t0TuiStream = Date.now();
|
||||||
const promptCharsTuiStream = messages.reduce((a, m) => a + contentToText(m.content).length, 0);
|
const promptCharsTuiStream = messages.reduce((a, m) => a + contentToText(m.content).length, 0);
|
||||||
try {
|
try {
|
||||||
const content = await callClaudeTui(model, messages, conversationId, req._authKeyName);
|
const content = await callClaudeTui(model, messages, conversationId, req._authKeyName, res);
|
||||||
if (CACHE_TTL > 0 && req._cacheHash) {
|
if (CACHE_TTL > 0 && req._cacheHash) {
|
||||||
try { setCachedResponse(req._cacheHash, model, content); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); }
|
try { setCachedResponse(req._cacheHash, model, content); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); }
|
||||||
}
|
}
|
||||||
@@ -1847,40 +2305,56 @@ async function handleChatCompletions(req, res) {
|
|||||||
// will re-read the freshly-populated cache entry here rather than spawning.
|
// will re-read the freshly-populated cache entry here rather than spawning.
|
||||||
const recheck = getCachedResponse(req._cacheHash, CACHE_TTL);
|
const recheck = getCachedResponse(req._cacheHash, CACHE_TTL);
|
||||||
if (recheck) return recheck.response;
|
if (recheck) return recheck.response;
|
||||||
const c = await upstreamCall(model, messages, conversationId, req._authKeyName);
|
const c = await upstreamCall(model, messages, conversationId, req._authKeyName, res);
|
||||||
try { setCachedResponse(req._cacheHash, model, c); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); }
|
try { setCachedResponse(req._cacheHash, model, c); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); }
|
||||||
return c;
|
return c;
|
||||||
});
|
},
|
||||||
|
// M1: if the LEADER disconnected while queued (F2), its RequestDisconnectedError is
|
||||||
|
// personal to the leader — a live follower must not inherit it as a spurious 500.
|
||||||
|
// retryIf makes this follower re-enter singleflight with its OWN fn (own res, own
|
||||||
|
// disconnect signal), becoming the new leader or joining a retrying sibling's flight —
|
||||||
|
// but only while OUR client is still connected. If our client is also gone, the
|
||||||
|
// rejection propagates and the RDE early-return in the catch below ends it quietly.
|
||||||
|
(err) => err instanceof RequestDisconnectedError && !res.destroyed);
|
||||||
const id = `chatcmpl-${randomUUID()}`;
|
const id = `chatcmpl-${randomUUID()}`;
|
||||||
completionResponse(res, id, model, content);
|
completionResponse(res, id, model, content);
|
||||||
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars, responseChars: content.length, elapsedMs: Date.now() - t0Usage, success: true }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
|
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars, responseChars: content.length, elapsedMs: Date.now() - t0Usage, success: true }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
|
||||||
return;
|
return;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// L1: a client disconnect while queued is NOT an upstream failure — mirror the
|
||||||
|
// streaming path (which returns without recording anything): no usage-failure row,
|
||||||
|
// no [proxy] error log, no error response (the socket is gone). The disconnect is
|
||||||
|
// already logged at info level (concurrency_wait_cancelled) by acquireClaudeSlot.
|
||||||
|
if (err instanceof RequestDisconnectedError) { try { res.end(); } catch {} return; }
|
||||||
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars, responseChars: 0, elapsedMs: Date.now() - t0Usage, success: false }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
|
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars, responseChars: 0, elapsedMs: Date.now() - t0Usage, success: false }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
|
||||||
console.error(`[proxy] error: ${err.message}`);
|
console.error(`[proxy] error: ${err.message}`);
|
||||||
if (res.headersSent || res.writableEnded || res.destroyed) {
|
if (res.headersSent || res.writableEnded || res.destroyed) {
|
||||||
try { res.end(); } catch {}
|
try { res.end(); } catch {}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
return jsonResponse(res, 500, { error: { message: sanitizeError(err.message), type: "proxy_error" } });
|
return respondUpstreamError(res, err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: cache disabled (CACHE_TTL=0) or no _cacheHash — original path untouched.
|
// Fallback: cache disabled (CACHE_TTL=0) or no _cacheHash — original path untouched.
|
||||||
try {
|
try {
|
||||||
const content = await upstreamCall(model, messages, conversationId, req._authKeyName);
|
const content = await upstreamCall(model, messages, conversationId, req._authKeyName, res);
|
||||||
const id = `chatcmpl-${randomUUID()}`;
|
const id = `chatcmpl-${randomUUID()}`;
|
||||||
completionResponse(res, id, model, content);
|
completionResponse(res, id, model, content);
|
||||||
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars, responseChars: content.length, elapsedMs: Date.now() - t0Usage, success: true }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
|
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars, responseChars: content.length, elapsedMs: Date.now() - t0Usage, success: true }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// L1: disconnect-while-queued — same quiet non-error outcome as the singleflight
|
||||||
|
// path above and the streaming path (see acquireClaudeSlot's info-level log).
|
||||||
|
if (err instanceof RequestDisconnectedError) { try { res.end(); } catch {} return; }
|
||||||
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars, responseChars: 0, elapsedMs: Date.now() - t0Usage, success: false }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
|
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars, responseChars: 0, elapsedMs: Date.now() - t0Usage, success: false }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
|
||||||
console.error(`[proxy] error: ${err.message}`);
|
console.error(`[proxy] error: ${err.message}`);
|
||||||
if (res.headersSent || res.writableEnded || res.destroyed) {
|
if (res.headersSent || res.writableEnded || res.destroyed) {
|
||||||
try { res.end(); } catch {}
|
try { res.end(); } catch {}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Sanitize error: strip internal file paths before sending to client
|
// Sanitize error: strip internal file paths before sending to client.
|
||||||
jsonResponse(res, 500, { error: { message: sanitizeError(err.message), type: "proxy_error" } });
|
// FIX ⑥: ConcurrencyOverflowError → 429 + Retry-After; all other errors → 500 (unchanged).
|
||||||
|
respondUpstreamError(res, err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2041,6 +2515,42 @@ const server = createServer(async (req, res) => {
|
|||||||
circuitBreaker: "disabled",
|
circuitBreaker: "disabled",
|
||||||
sessions: sessionList,
|
sessions: sessionList,
|
||||||
recentErrors: recentErrors.slice(-5),
|
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();
|
||||||
|
// FIX F6: report the EFFECTIVE current decision, not just token PRESENCE. During the
|
||||||
|
// 5-min pre-expiry window the token exists (shm.isolated=true) but resolveSpawnToken()
|
||||||
|
// returns null and spawns actually run real-HOME — so `isolated` MUST also reflect the
|
||||||
|
// expiry gate, or /health lies. The field SET is unchanged (grandfathered B.2 contract,
|
||||||
|
// ADR 0006 — HARD CONSTRAINT: no field add/remove/rename); only the VALUES are made
|
||||||
|
// truthful. resolveSpawnToken() is read-only + backed by F5's 30s keychain cache → cheap.
|
||||||
|
const effIsolated = shm.isolated && resolveSpawnToken() !== null;
|
||||||
|
return {
|
||||||
|
mode: effIsolated ? "isolated-scratch-home" : "real-home",
|
||||||
|
isolated: effIsolated,
|
||||||
|
home: effIsolated ? shm.home : null,
|
||||||
|
reason: effIsolated
|
||||||
|
? shm.reason
|
||||||
|
: (shm.isolated
|
||||||
|
? "oauth token within 5-min expiry window → real-HOME fallback (self-heals on next refresh)"
|
||||||
|
: 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) ──
|
// ── 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;
|
// /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
|
// every existing field above is byte-identical → behaviour-preserving for existing
|
||||||
@@ -2284,6 +2794,7 @@ function gracefulShutdown(signal) {
|
|||||||
clearInterval(sessionCleanupInterval);
|
clearInterval(sessionCleanupInterval);
|
||||||
clearInterval(authCheckInterval);
|
clearInterval(authCheckInterval);
|
||||||
clearInterval(cacheCleanupInterval);
|
clearInterval(cacheCleanupInterval);
|
||||||
|
if (tuiReapInterval) clearInterval(tuiReapInterval);
|
||||||
closeDb();
|
closeDb();
|
||||||
|
|
||||||
// 3. Kill all active child processes
|
// 3. Kill all active child processes
|
||||||
@@ -2328,7 +2839,7 @@ server.listen(PORT, BIND_ADDRESS, () => {
|
|||||||
console.log(`Architecture: on-demand spawning (no pool)`);
|
console.log(`Architecture: on-demand spawning (no pool)`);
|
||||||
console.log(`Models: ${MODELS.map((m) => m.id).join(", ")}`);
|
console.log(`Models: ${MODELS.map((m) => m.id).join(", ")}`);
|
||||||
console.log(`Claude binary: ${CLAUDE}`);
|
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(`Circuit breaker: disabled`);
|
||||||
console.log(`Tools: ${SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS.join(", ")}`);
|
console.log(`Tools: ${SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS.join(", ")}`);
|
||||||
console.log(`Sessions: TTL=${SESSION_TTL / 1000}s`);
|
console.log(`Sessions: TTL=${SESSION_TTL / 1000}s`);
|
||||||
@@ -2340,11 +2851,27 @@ server.listen(PORT, BIND_ADDRESS, () => {
|
|||||||
if (NO_CONTEXT) console.log(`Context: suppressed (CLAUDE_NO_CONTEXT=true — no CLAUDE.md, no auto-memory)`);
|
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)`);
|
if (CACHE_TTL > 0) console.log(`Cache: enabled (TTL=${CACHE_TTL / 1000}s)`);
|
||||||
else console.log(`Cache: disabled (set CLAUDE_CACHE_TTL to enable)`);
|
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) {
|
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.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 {
|
try {
|
||||||
const n = reapStaleTuiSessions();
|
// F7 fix: scope to THIS instance's own port (see reapStaleTuiSessions). includeLegacy:
|
||||||
|
// true ONLY here — the one-time boot reap is the designated point to claim orphaned
|
||||||
|
// bare-prefix ("ocp-tui-<uuid8>") zombie sessions left by a PRE-fix process generation
|
||||||
|
// of this same instance (no live post-fix instance ever creates that shape again).
|
||||||
|
const n = reapStaleTuiSessions({ port: PORT, includeLegacy: true });
|
||||||
if (n) logEvent("info", "tui_reaped_stale_sessions", { count: n });
|
if (n) logEvent("info", "tui_reaped_stale_sessions", { count: n });
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|||||||
+716
-76
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
import { getDb, createKey, listKeys, validateKey, recordUsage, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, closeDb, hasCacheControl, singleflight, getInflightStats } from "./keys.mjs";
|
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 { isLoopbackBind } from "./lib/net.mjs";
|
||||||
|
import { createSerialMutex, createTtlCache, isTokenExpiring, orderLabelsLastGoodFirst } from "./lib/spawn-auth.mjs";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { strict as assert } from "node:assert";
|
import { strict as assert } from "node:assert";
|
||||||
import { unlinkSync } from "node:fs";
|
import { unlinkSync } from "node:fs";
|
||||||
@@ -33,6 +34,17 @@ function test(name, fn) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function testAsync(name, fn) {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
passed++;
|
||||||
|
console.log(` ✓ ${name}`);
|
||||||
|
} catch (e) {
|
||||||
|
failed++;
|
||||||
|
console.log(` ✗ ${name}: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log("\n=== OCP Feature Tests (Quota + Cache) ===\n");
|
console.log("\n=== OCP Feature Tests (Quota + Cache) ===\n");
|
||||||
|
|
||||||
// Initialize DB
|
// Initialize DB
|
||||||
@@ -451,6 +463,52 @@ async function runSingleflightTests() {
|
|||||||
assert.equal(r1, 1);
|
assert.equal(r1, 1);
|
||||||
assert.equal(r2, 2);
|
assert.equal(r2, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 7. M1: leader disconnect while queued must not poison live followers. server.mjs passes
|
||||||
|
// retryIf = (err) => err instanceof RequestDisconnectedError && !res.destroyed — here we
|
||||||
|
// model that with a tagged error class. The leader (no retryIf on its own promise — the
|
||||||
|
// rejection is ITS OWN disconnect) sees the error; the live follower re-executes its OWN
|
||||||
|
// fn and gets a real result instead of a spurious inherited failure.
|
||||||
|
await asyncTest("M1: leader disconnects while queued → live follower re-executes and gets a real result", async () => {
|
||||||
|
class FakeDisconnectError extends Error {}
|
||||||
|
const leaderGate = Promise.withResolvers();
|
||||||
|
let leaderRuns = 0;
|
||||||
|
let followerRuns = 0;
|
||||||
|
const leaderFn = async () => { leaderRuns++; await leaderGate.promise; throw new FakeDisconnectError("leader client gone"); };
|
||||||
|
const followerFn = async () => { followerRuns++; return "real-execution"; };
|
||||||
|
const retryIf = (err) => err instanceof FakeDisconnectError;
|
||||||
|
|
||||||
|
const leaderP = singleflight("sf-m1-leader-dc", leaderFn); // becomes leader
|
||||||
|
const followerP = singleflight("sf-m1-leader-dc", followerFn, retryIf); // joins as follower
|
||||||
|
leaderGate.resolve(); // leader "disconnects" while holding the flight
|
||||||
|
|
||||||
|
await assert.rejects(leaderP, FakeDisconnectError, "the leader itself still sees its own disconnect");
|
||||||
|
assert.equal(await followerP, "real-execution", "follower got a REAL execution, not the leader's disconnect");
|
||||||
|
assert.equal(leaderRuns, 1, "leader fn ran once");
|
||||||
|
assert.equal(followerRuns, 1, "follower re-executed exactly once (as the new leader)");
|
||||||
|
assert.equal(getInflightStats().inflight, 0, "map fully cleaned up after the retry flight settles");
|
||||||
|
});
|
||||||
|
|
||||||
|
// 8. M1 guard: a follower whose retryIf returns false (server.mjs: its OWN client is also
|
||||||
|
// gone) inherits the rejection unchanged — no retry, no masked error. And a follower with
|
||||||
|
// NO retryIf keeps the exact pre-M1 share-everything behavior (test 2 pins the fan-out;
|
||||||
|
// this pins the predicate=false path specifically for the disconnect error).
|
||||||
|
await asyncTest("M1: follower with retryIf=false (own client also gone) inherits the leader's rejection, no retry", async () => {
|
||||||
|
class FakeDisconnectError extends Error {}
|
||||||
|
const gate = Promise.withResolvers();
|
||||||
|
let followerRuns = 0;
|
||||||
|
const leaderFn = async () => { await gate.promise; throw new FakeDisconnectError("leader client gone"); };
|
||||||
|
const followerFn = async () => { followerRuns++; return "should-never-run"; };
|
||||||
|
|
||||||
|
const leaderP = singleflight("sf-m1-both-dc", leaderFn);
|
||||||
|
const followerP = singleflight("sf-m1-both-dc", followerFn, () => false); // own client dead → no retry
|
||||||
|
gate.resolve();
|
||||||
|
|
||||||
|
await assert.rejects(leaderP, FakeDisconnectError);
|
||||||
|
await assert.rejects(followerP, FakeDisconnectError, "rejection propagates unchanged when retryIf says no");
|
||||||
|
assert.equal(followerRuns, 0, "follower fn never executed — no wasted spawn for a dead client");
|
||||||
|
assert.equal(getInflightStats().inflight, 0);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await runSingleflightTests();
|
await runSingleflightTests();
|
||||||
@@ -1340,23 +1398,12 @@ test("streamStringAsSSE empty content: role + stop + [DONE] only", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── Suite: TUI transcript reader ────────────────────────────────────────
|
// ── Suite: TUI transcript reader ────────────────────────────────────────
|
||||||
import { encodeCwd, transcriptPath, findTranscriptPath, parseTranscriptLines, isTerminalLine, extractLatestAssistantText, verifyEntrypoint, detectTuiUpstreamError } from "./lib/tui/transcript.mjs";
|
import { findTranscriptPath, parseTranscriptLines, isTerminalLine, extractLatestAssistantText, verifyEntrypoint, detectTuiUpstreamError } from "./lib/tui/transcript.mjs";
|
||||||
import { readFileSync as tuiReadFileSync, mkdtempSync as tuiMkdtemp0, mkdirSync as tuiMkdir0, writeFileSync as tuiWrite0 } from "node:fs";
|
import { readFileSync as tuiReadFileSync, mkdtempSync as tuiMkdtemp0, mkdirSync as tuiMkdir0, writeFileSync as tuiWrite0 } from "node:fs";
|
||||||
import { tmpdir as tuiTmp0 } from "node:os";
|
import { tmpdir as tuiTmp0 } from "node:os";
|
||||||
|
|
||||||
console.log("\nTUI transcript — path formula:");
|
console.log("\nTUI transcript — path formula:");
|
||||||
|
|
||||||
test("encodeCwd replaces every slash AND every dot with dash", () => {
|
|
||||||
// Verified live (claude v2.1.158): /home/u/.ocp-tui/work -> -home-u--ocp-tui-work
|
|
||||||
assert.equal(encodeCwd("/home/u/.ocp-tui/work"), "-home-u--ocp-tui-work");
|
|
||||||
assert.equal(encodeCwd("/tmp/tui-test"), "-tmp-tui-test"); // dot-free path still correct
|
|
||||||
});
|
|
||||||
test("transcriptPath composes HOME/.claude/projects/<enc>/<sid>.jsonl", () => {
|
|
||||||
assert.equal(
|
|
||||||
transcriptPath("/home/u", "/home/u/.ocp-tui/work", "abc-123"),
|
|
||||||
"/home/u/.claude/projects/-home-u--ocp-tui-work/abc-123.jsonl"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
test("findTranscriptPath locates <sid>.jsonl across projects subdirs by UUID", () => {
|
test("findTranscriptPath locates <sid>.jsonl across projects subdirs by UUID", () => {
|
||||||
const home = tuiMkdtemp0(`${tuiTmp0()}/tui-home-`);
|
const home = tuiMkdtemp0(`${tuiTmp0()}/tui-home-`);
|
||||||
const sid = "11111111-2222-3333-4444-555555555555";
|
const sid = "11111111-2222-3333-4444-555555555555";
|
||||||
@@ -1663,12 +1710,23 @@ await asyncTest("readTuiTranscript throws when no text and cap elapses", async (
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── TUI session reaper ───────────────────────────────────────────────────
|
// ── TUI session reaper ───────────────────────────────────────────────────
|
||||||
import { reapStaleTuiSessions, SESSION_PREFIX, buildTuiCmd } from "./lib/tui/session.mjs";
|
import { reapStaleTuiSessions, sessionPrefixForPort, LEGACY_SESSION_PREFIX, LEGACY_SESSION_NAME_RE, buildTuiCmd } from "./lib/tui/session.mjs";
|
||||||
|
|
||||||
console.log("\nTUI session reaper:");
|
console.log("\nTUI session reaper:");
|
||||||
|
|
||||||
test("SESSION_PREFIX is ocp-tui-", () => {
|
// F7 fix: the session prefix is instance-scoped by listen port so a second OCP
|
||||||
assert.equal(SESSION_PREFIX, "ocp-tui-");
|
// instance on the same host (different port) is never mistaken for "ours".
|
||||||
|
test("sessionPrefixForPort embeds the port (F7 instance scoping)", () => {
|
||||||
|
assert.equal(sessionPrefixForPort(3456), "ocp-tui-3456-");
|
||||||
|
assert.equal(sessionPrefixForPort(4000), "ocp-tui-4000-");
|
||||||
|
assert.notEqual(sessionPrefixForPort(3456), sessionPrefixForPort(4000));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("LEGACY_SESSION_NAME_RE matches only the exact old bare-prefix shape, never the new shape", () => {
|
||||||
|
assert.ok(LEGACY_SESSION_NAME_RE.test(`${LEGACY_SESSION_PREFIX}a1b2c3d4`), "legacy 8-hex shape matches");
|
||||||
|
assert.ok(!LEGACY_SESSION_NAME_RE.test("ocp-tui-3456-a1b2c3d4"), "new port-scoped shape must NOT match legacy regex");
|
||||||
|
assert.ok(!LEGACY_SESSION_NAME_RE.test("ocp-tui-a1b2c3"), "too-short suffix must not match");
|
||||||
|
assert.ok(!LEGACY_SESSION_NAME_RE.test("ocp-tui-a1b2c3d4extra"), "trailing extra chars must not match");
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("\nTUI command construction (proxy-purity / #4):");
|
console.log("\nTUI command construction (proxy-purity / #4):");
|
||||||
@@ -1691,10 +1749,115 @@ test("buildTuiCmd keeps version pin + entrypoint label + MCP wall", () => {
|
|||||||
assert.ok(/-u CLAUDE_CODE_ENTRYPOINT/.test(auto), "auto mode unsets any inherited entrypoint");
|
assert.ok(/-u CLAUDE_CODE_ENTRYPOINT/.test(auto), "auto mode unsets any inherited entrypoint");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// CLAUDE_CODE_OAUTH_TOKEN passthrough (PI231 401 incident): tmux doesn't forward the parent
|
||||||
|
// env to the pane, so the token must be set explicitly on the pane command or the TUI claude
|
||||||
|
// falls back to credentials.json (whose refresh token gets corrupted by the spawn/kill cycle).
|
||||||
|
test("buildTuiCmd passes CLAUDE_CODE_OAUTH_TOKEN when the env is set (shq-escaped)", () => {
|
||||||
|
const save = process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||||
|
try {
|
||||||
|
process.env.CLAUDE_CODE_OAUTH_TOKEN = "sk-ant-oat01-abc123";
|
||||||
|
const cmd = buildTuiCmd("/usr/bin/claude", "m", "sid-tok", "/home/u", "cli");
|
||||||
|
// shq wraps in single quotes; a plain token renders as 'token'.
|
||||||
|
assert.ok(cmd.includes("CLAUDE_CODE_OAUTH_TOKEN='sk-ant-oat01-abc123'"),
|
||||||
|
"token must be set on the pane command, shq-escaped");
|
||||||
|
} finally {
|
||||||
|
if (save === undefined) delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||||
|
else process.env.CLAUDE_CODE_OAUTH_TOKEN = save;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildTuiCmd does NOT add CLAUDE_CODE_OAUTH_TOKEN when the env is unset", () => {
|
||||||
|
const save = process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||||
|
try {
|
||||||
|
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||||
|
const cmd = buildTuiCmd("/usr/bin/claude", "m", "sid-notok", "/home/u", "cli");
|
||||||
|
assert.ok(!/CLAUDE_CODE_OAUTH_TOKEN/.test(cmd),
|
||||||
|
"no token added when env unset (credentials.json-only hosts unaffected)");
|
||||||
|
} finally {
|
||||||
|
if (save === undefined) delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||||
|
else process.env.CLAUDE_CODE_OAUTH_TOKEN = save;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildTuiCmd shq-escapes a token containing shell metacharacters (no injection)", () => {
|
||||||
|
const save = process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||||
|
try {
|
||||||
|
// A token with a single quote must be escaped via the '\'' idiom so it can't break out
|
||||||
|
// of the shell string tmux runs via sh -c.
|
||||||
|
process.env.CLAUDE_CODE_OAUTH_TOKEN = "tok'; rm -rf /;'";
|
||||||
|
const cmd = buildTuiCmd("/usr/bin/claude", "m", "sid-inj", "/home/u", "cli");
|
||||||
|
assert.ok(cmd.includes(`CLAUDE_CODE_OAUTH_TOKEN='tok'\\''; rm -rf /;'\\'''`),
|
||||||
|
"single quote must be shq-escaped, not left bare");
|
||||||
|
assert.ok(!/CLAUDE_CODE_OAUTH_TOKEN=tok'; rm/.test(cmd), "raw unescaped token must NOT appear");
|
||||||
|
} finally {
|
||||||
|
if (save === undefined) delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
|
||||||
|
else process.env.CLAUDE_CODE_OAUTH_TOKEN = save;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// OCP_TUI_EFFORT (TUI latency, docs/plans/2026-07-13-tui-latency): the pane's claude
|
||||||
|
// must get an EXPLICIT --effort so its effort never depends on which HOME mode
|
||||||
|
// resolveTuiHome() picked (real-home inherits the operator's settings.json effortLevel;
|
||||||
|
// env-token scratch inherits claude's built-in default).
|
||||||
|
test("buildTuiCmd passes --effort low by default (OCP_TUI_EFFORT unset)", () => {
|
||||||
|
const save = process.env.OCP_TUI_EFFORT;
|
||||||
|
try {
|
||||||
|
delete process.env.OCP_TUI_EFFORT;
|
||||||
|
const cmd = buildTuiCmd("/usr/bin/claude", "m", "sid-eff1", "/home/u", "cli");
|
||||||
|
assert.ok(cmd.includes("--effort low"), "default must pin --effort low");
|
||||||
|
} finally {
|
||||||
|
if (save === undefined) delete process.env.OCP_TUI_EFFORT;
|
||||||
|
else process.env.OCP_TUI_EFFORT = save;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildTuiCmd honors an explicit OCP_TUI_EFFORT level (case/space-normalized)", () => {
|
||||||
|
const save = process.env.OCP_TUI_EFFORT;
|
||||||
|
try {
|
||||||
|
process.env.OCP_TUI_EFFORT = " XHigh ";
|
||||||
|
const cmd = buildTuiCmd("/usr/bin/claude", "m", "sid-eff2", "/home/u", "cli");
|
||||||
|
assert.ok(cmd.includes("--effort xhigh"), "explicit level must be passed, normalized");
|
||||||
|
assert.ok(!cmd.includes("--effort low"), "default must not also appear");
|
||||||
|
} finally {
|
||||||
|
if (save === undefined) delete process.env.OCP_TUI_EFFORT;
|
||||||
|
else process.env.OCP_TUI_EFFORT = save;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildTuiCmd OCP_TUI_EFFORT=inherit omits --effort entirely (pre-flag argv)", () => {
|
||||||
|
const save = process.env.OCP_TUI_EFFORT;
|
||||||
|
try {
|
||||||
|
process.env.OCP_TUI_EFFORT = "inherit";
|
||||||
|
const cmd = buildTuiCmd("/usr/bin/claude", "m", "sid-eff3", "/home/u", "cli");
|
||||||
|
assert.ok(!/--effort/.test(cmd), "inherit must not add --effort");
|
||||||
|
} finally {
|
||||||
|
if (save === undefined) delete process.env.OCP_TUI_EFFORT;
|
||||||
|
else process.env.OCP_TUI_EFFORT = save;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildTuiCmd falls back to --effort low on an invalid OCP_TUI_EFFORT (never reaches argv)", () => {
|
||||||
|
const save = process.env.OCP_TUI_EFFORT;
|
||||||
|
const savedErr = console.error;
|
||||||
|
try {
|
||||||
|
process.env.OCP_TUI_EFFORT = "ludicrous'; rm -rf /;'";
|
||||||
|
let warned = "";
|
||||||
|
console.error = (...a) => { warned = a.join(" "); };
|
||||||
|
const cmd = buildTuiCmd("/usr/bin/claude", "m", "sid-eff4", "/home/u", "cli");
|
||||||
|
assert.ok(cmd.includes("--effort low"), "invalid value must fall back to low");
|
||||||
|
assert.ok(!cmd.includes("ludicrous"), "invalid raw value must NOT reach the shell string");
|
||||||
|
assert.ok(/invalid OCP_TUI_EFFORT/.test(warned), "must log a warning");
|
||||||
|
} finally {
|
||||||
|
console.error = savedErr;
|
||||||
|
if (save === undefined) delete process.env.OCP_TUI_EFFORT;
|
||||||
|
else process.env.OCP_TUI_EFFORT = save;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("buildTuiCmd OCP_TUI_FULL_TOOLS=1 grants -p-equivalent tool surface (single-user opt-in)", () => {
|
test("buildTuiCmd OCP_TUI_FULL_TOOLS=1 grants -p-equivalent tool surface (single-user opt-in)", () => {
|
||||||
const save = { ...process.env };
|
const save = { ...process.env };
|
||||||
const restore = () => {
|
const restore = () => {
|
||||||
for (const k of ["OCP_TUI_FULL_TOOLS", "CLAUDE_SKIP_PERMISSIONS", "CLAUDE_MCP_CONFIG", "CLAUDE_ALLOWED_TOOLS"]) {
|
for (const k of ["OCP_TUI_FULL_TOOLS", "CLAUDE_MCP_CONFIG", "CLAUDE_ALLOWED_TOOLS"]) {
|
||||||
if (k in save) process.env[k] = save[k]; else delete process.env[k];
|
if (k in save) process.env[k] = save[k]; else delete process.env[k];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1706,20 +1869,14 @@ test("buildTuiCmd OCP_TUI_FULL_TOOLS=1 grants -p-equivalent tool surface (single
|
|||||||
|
|
||||||
// gate on: --allowedTools (default set incl Bash), MCP wall dropped
|
// gate on: --allowedTools (default set incl Bash), MCP wall dropped
|
||||||
process.env.OCP_TUI_FULL_TOOLS = "1";
|
process.env.OCP_TUI_FULL_TOOLS = "1";
|
||||||
delete process.env.CLAUDE_SKIP_PERMISSIONS;
|
|
||||||
delete process.env.CLAUDE_MCP_CONFIG;
|
delete process.env.CLAUDE_MCP_CONFIG;
|
||||||
delete process.env.CLAUDE_ALLOWED_TOOLS;
|
delete process.env.CLAUDE_ALLOWED_TOOLS;
|
||||||
const full = buildTuiCmd("/usr/bin/claude", "m", "s", "/home/u", "cli");
|
const full = buildTuiCmd("/usr/bin/claude", "m", "s", "/home/u", "cli");
|
||||||
assert.ok(full.includes("--allowedTools") && full.includes("Bash"), "full-tools grants --allowedTools incl Bash");
|
assert.ok(full.includes("--allowedTools") && full.includes("Bash"), "full-tools grants --allowedTools incl Bash");
|
||||||
assert.ok(!full.includes("--strict-mcp-config") && !/--disallowedTools/.test(full), "full-tools drops the MCP wall");
|
assert.ok(!full.includes("--strict-mcp-config") && !/--disallowedTools/.test(full), "full-tools drops the MCP wall");
|
||||||
|
assert.ok(!full.includes("--dangerously-skip-permissions"), "skip-permissions branch is removed (bricks headless TUI)");
|
||||||
// skip-permissions supersedes --allowedTools
|
|
||||||
process.env.CLAUDE_SKIP_PERMISSIONS = "true";
|
|
||||||
const skip = buildTuiCmd("/usr/bin/claude", "m", "s", "/home/u", "cli");
|
|
||||||
assert.ok(skip.includes("--dangerously-skip-permissions") && !skip.includes("--allowedTools"), "skip-permissions honored");
|
|
||||||
|
|
||||||
// mcp-config threaded through
|
// mcp-config threaded through
|
||||||
delete process.env.CLAUDE_SKIP_PERMISSIONS;
|
|
||||||
process.env.CLAUDE_MCP_CONFIG = "/tmp/mcp.json";
|
process.env.CLAUDE_MCP_CONFIG = "/tmp/mcp.json";
|
||||||
const mcp = buildTuiCmd("/usr/bin/claude", "m", "s", "/home/u", "cli");
|
const mcp = buildTuiCmd("/usr/bin/claude", "m", "s", "/home/u", "cli");
|
||||||
assert.ok(/--mcp-config '\/tmp\/mcp.json'/.test(mcp), "mcp-config passed through (shq'd)");
|
assert.ok(/--mcp-config '\/tmp\/mcp.json'/.test(mcp), "mcp-config passed through (shq'd)");
|
||||||
@@ -1735,22 +1892,40 @@ test("buildTuiCmd OCP_TUI_FULL_TOOLS=1 grants -p-equivalent tool surface (single
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("reaper kills ONLY ocp-tui- sessions, never olp-tui-", () => {
|
test("reaper kills ONLY this instance's own port-scoped sessions, never olp-tui-", () => {
|
||||||
const killed = [];
|
const killed = [];
|
||||||
const fakeTmux = (args) => {
|
const fakeTmux = (args) => {
|
||||||
if (args[0] === "list-sessions") return { status: 0, stdout: "ocp-tui-aaaa\nolp-tui-bbbb\nmisc\nocp-tui-cccc\n" };
|
if (args[0] === "list-sessions") return { status: 0, stdout: "ocp-tui-3456-aaaa\nolp-tui-bbbb\nmisc\nocp-tui-3456-cccc\n" };
|
||||||
if (args[0] === "kill-session") { killed.push(args[args.indexOf("-t") + 1]); return { status: 0 }; }
|
if (args[0] === "kill-session") { killed.push(args[args.indexOf("-t") + 1]); return { status: 0 }; }
|
||||||
return { status: 0, stdout: "" };
|
return { status: 0, stdout: "" };
|
||||||
};
|
};
|
||||||
const n = reapStaleTuiSessions({ tmux: fakeTmux });
|
const n = reapStaleTuiSessions({ tmux: fakeTmux, port: 3456 });
|
||||||
assert.equal(n, 2);
|
assert.equal(n, 2);
|
||||||
assert.equal(killed.join(","), "ocp-tui-aaaa,ocp-tui-cccc");
|
assert.equal(killed.join(","), "ocp-tui-3456-aaaa,ocp-tui-3456-cccc");
|
||||||
assert.ok(!killed.includes("olp-tui-bbbb"), "olp-tui-bbbb must never be killed");
|
assert.ok(!killed.includes("olp-tui-bbbb"), "olp-tui-bbbb must never be killed");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// F7 fix: a second OCP instance on the same host (different port) must be treated exactly
|
||||||
|
// like a foreign product prefix — never reaped, never allowed to trigger kill-server.
|
||||||
|
test("reaper treats a sibling OCP instance on a DIFFERENT port as foreign (F7)", () => {
|
||||||
|
const killed = [];
|
||||||
|
const calls = [];
|
||||||
|
const fakeTmux = (args) => {
|
||||||
|
calls.push(args.join(" "));
|
||||||
|
if (args[0] === "list-sessions") return { status: 0, stdout: "ocp-tui-3456-aaaa\nocp-tui-9999-bbbb\n" };
|
||||||
|
if (args[0] === "kill-session") { killed.push(args[args.indexOf("-t") + 1]); return { status: 0 }; }
|
||||||
|
return { status: 0, stdout: "" };
|
||||||
|
};
|
||||||
|
const n = reapStaleTuiSessions({ tmux: fakeTmux, port: 3456 });
|
||||||
|
assert.equal(n, 1, "killed only the own-port session");
|
||||||
|
assert.equal(killed.join(","), "ocp-tui-3456-aaaa");
|
||||||
|
assert.ok(!killed.includes("ocp-tui-9999-bbbb"), "sibling instance's session (port 9999) must NEVER be killed");
|
||||||
|
assert.ok(!calls.includes("kill-server"), "kill-server MUST NOT fire — sibling instance's session still live");
|
||||||
|
});
|
||||||
|
|
||||||
test("reaper returns 0 when tmux status !== 0 (no server)", () => {
|
test("reaper returns 0 when tmux status !== 0 (no server)", () => {
|
||||||
const fakeTmux = (_args) => ({ status: 1, stdout: "" });
|
const fakeTmux = (_args) => ({ status: 1, stdout: "" });
|
||||||
const n = reapStaleTuiSessions({ tmux: fakeTmux });
|
const n = reapStaleTuiSessions({ tmux: fakeTmux, port: 3456 });
|
||||||
assert.equal(n, 0);
|
assert.equal(n, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1761,11 +1936,97 @@ test("reaper returns 0 for empty session list", () => {
|
|||||||
if (args[0] === "kill-session") { killed.push(args[args.indexOf("-t") + 1]); return { status: 0 }; }
|
if (args[0] === "kill-session") { killed.push(args[args.indexOf("-t") + 1]); return { status: 0 }; }
|
||||||
return { status: 0, stdout: "" };
|
return { status: 0, stdout: "" };
|
||||||
};
|
};
|
||||||
const n = reapStaleTuiSessions({ tmux: fakeTmux });
|
const n = reapStaleTuiSessions({ tmux: fakeTmux, port: 3456 });
|
||||||
assert.equal(n, 0);
|
assert.equal(n, 0);
|
||||||
assert.equal(killed.length, 0);
|
assert.equal(killed.length, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Defunct-zombie reaping (PI231 incident): the pane's claude is a child of the tmux server,
|
||||||
|
// so only kill-server actually reaps it. We kill-server ONLY when no foreign session remains.
|
||||||
|
console.log("\nTUI defunct-zombie reaping (kill-server):");
|
||||||
|
|
||||||
|
test("reaper kill-servers when the server is ours-only (flush defunct claude zombies)", () => {
|
||||||
|
const calls = [];
|
||||||
|
const fakeTmux = (args) => {
|
||||||
|
calls.push(args.join(" "));
|
||||||
|
if (args[0] === "list-sessions") return { status: 0, stdout: "ocp-tui-3456-aaaa\nocp-tui-3456-bbbb\n" };
|
||||||
|
return { status: 0, stdout: "" };
|
||||||
|
};
|
||||||
|
const n = reapStaleTuiSessions({ tmux: fakeTmux, port: 3456 });
|
||||||
|
assert.equal(n, 2, "killed both of our sessions");
|
||||||
|
assert.ok(calls.includes("kill-server"), "kill-server fired — reaps the defunct backlog");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reaper does NOT kill-server when a foreign (non-ocp) session remains (coexistence)", () => {
|
||||||
|
const calls = [];
|
||||||
|
const fakeTmux = (args) => {
|
||||||
|
calls.push(args.join(" "));
|
||||||
|
if (args[0] === "list-sessions") return { status: 0, stdout: "ocp-tui-3456-aaaa\nolp-tui-bbbb\n" };
|
||||||
|
return { status: 0, stdout: "" };
|
||||||
|
};
|
||||||
|
const n = reapStaleTuiSessions({ tmux: fakeTmux, port: 3456 });
|
||||||
|
assert.equal(n, 1, "killed only our own session");
|
||||||
|
assert.ok(!calls.includes("kill-server"), "kill-server MUST NOT fire — would disrupt olp-tui-*");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reaper does NOT kill-server when there is no server (status !== 0)", () => {
|
||||||
|
const calls = [];
|
||||||
|
const fakeTmux = (args) => { calls.push(args.join(" ")); return { status: 1, stdout: "" }; };
|
||||||
|
reapStaleTuiSessions({ tmux: fakeTmux, port: 3456 });
|
||||||
|
assert.ok(!calls.includes("kill-server"), "no server → no kill-server (early return)");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Legacy migration (F7): pre-fix versions created bare-prefix `ocp-tui-<uuid8>` sessions with
|
||||||
|
// no port segment. includeLegacy is the boot-only opt-in that claims these as our own leftover
|
||||||
|
// zombies; the periodic sweep never sets it, so a lingering legacy session cannot trigger
|
||||||
|
// kill-server on a routine 15-minute tick.
|
||||||
|
console.log("\nTUI legacy-prefix migration (boot-only reap, F7):");
|
||||||
|
|
||||||
|
test("reaper leaves legacy bare-prefix sessions untouched by default (includeLegacy unset)", () => {
|
||||||
|
const killed = [];
|
||||||
|
const calls = [];
|
||||||
|
const fakeTmux = (args) => {
|
||||||
|
calls.push(args.join(" "));
|
||||||
|
if (args[0] === "list-sessions") return { status: 0, stdout: "ocp-tui-3456-aaaa\nocp-tui-deadbeef\n" };
|
||||||
|
if (args[0] === "kill-session") { killed.push(args[args.indexOf("-t") + 1]); return { status: 0 }; }
|
||||||
|
return { status: 0, stdout: "" };
|
||||||
|
};
|
||||||
|
const n = reapStaleTuiSessions({ tmux: fakeTmux, port: 3456 });
|
||||||
|
assert.equal(n, 1, "killed only the own-port session");
|
||||||
|
assert.ok(!killed.includes("ocp-tui-deadbeef"), "legacy session must NOT be reaped without includeLegacy");
|
||||||
|
assert.ok(!calls.includes("kill-server"), "legacy session blocks kill-server when not claimed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reaper claims legacy bare-prefix sessions when includeLegacy=true (boot-time migration)", () => {
|
||||||
|
const killed = [];
|
||||||
|
const calls = [];
|
||||||
|
const fakeTmux = (args) => {
|
||||||
|
calls.push(args.join(" "));
|
||||||
|
if (args[0] === "list-sessions") return { status: 0, stdout: "ocp-tui-3456-aaaa\nocp-tui-deadbeef\n" };
|
||||||
|
if (args[0] === "kill-session") { killed.push(args[args.indexOf("-t") + 1]); return { status: 0 }; }
|
||||||
|
return { status: 0, stdout: "" };
|
||||||
|
};
|
||||||
|
const n = reapStaleTuiSessions({ tmux: fakeTmux, port: 3456, includeLegacy: true });
|
||||||
|
assert.equal(n, 2, "both own-port and legacy sessions reaped");
|
||||||
|
assert.ok(killed.includes("ocp-tui-deadbeef"), "legacy session claimed as our own leftover");
|
||||||
|
assert.ok(calls.includes("kill-server"), "kill-server fires once no foreign/unclaimed session remains");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reaper with includeLegacy=true still spares a sibling instance's port-scoped session", () => {
|
||||||
|
const killed = [];
|
||||||
|
const calls = [];
|
||||||
|
const fakeTmux = (args) => {
|
||||||
|
calls.push(args.join(" "));
|
||||||
|
if (args[0] === "list-sessions") return { status: 0, stdout: "ocp-tui-3456-aaaa\nocp-tui-deadbeef\nocp-tui-9999-zzzz\n" };
|
||||||
|
if (args[0] === "kill-session") { killed.push(args[args.indexOf("-t") + 1]); return { status: 0 }; }
|
||||||
|
return { status: 0, stdout: "" };
|
||||||
|
};
|
||||||
|
const n = reapStaleTuiSessions({ tmux: fakeTmux, port: 3456, includeLegacy: true });
|
||||||
|
assert.equal(n, 2, "own-port + legacy reaped, sibling instance untouched");
|
||||||
|
assert.ok(!killed.includes("ocp-tui-9999-zzzz"), "sibling instance session must never be claimed as legacy");
|
||||||
|
assert.ok(!calls.includes("kill-server"), "sibling instance's live session still blocks kill-server");
|
||||||
|
});
|
||||||
|
|
||||||
// ── TUI home preparation (scratch vs real) ───────────────────────────────
|
// ── TUI home preparation (scratch vs real) ───────────────────────────────
|
||||||
import { prepareTuiHome, ensureTuiCwdTrusted } from "./lib/tui/session.mjs";
|
import { prepareTuiHome, ensureTuiCwdTrusted } from "./lib/tui/session.mjs";
|
||||||
import { mkdtempSync as hMkdtemp, mkdirSync as hMkdir, writeFileSync as hWrite, readFileSync as hRead, existsSync as hExists, readlinkSync as hReadlink } from "node:fs";
|
import { mkdtempSync as hMkdtemp, mkdirSync as hMkdir, writeFileSync as hWrite, readFileSync as hRead, existsSync as hExists, readlinkSync as hReadlink } from "node:fs";
|
||||||
@@ -1801,58 +2062,55 @@ test("prepareTuiHome real mode (tuiHome===realHome): no symlink, just trusts cwd
|
|||||||
assert.equal(j.projects[cwd].hasTrustDialogAccepted, true); // cwd trusted in real config
|
assert.equal(j.projects[cwd].hasTrustDialogAccepted, true); // cwd trusted in real config
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── resolveTuiEntrypointEnv ───────────────────────────────────────────────
|
// ── PR-D: env-token-only credential-isolated home (PI231 401 root fix) ──────
|
||||||
import { resolveTuiEntrypointEnv } from "./lib/tui/session.mjs";
|
// 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 +
|
||||||
console.log("\nresolveTuiEntrypointEnv:");
|
// 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
|
||||||
test("mode 'cli' sets CLAUDE_CODE_ENTRYPOINT=cli", () => {
|
// happens → the single-use token can't be corrupted by the spawn+kill cycle).
|
||||||
const env = {};
|
test("prepareTuiHome env-token mode: NO credentials.json (no symlink, no copy), .claude.json seeded", () => {
|
||||||
resolveTuiEntrypointEnv(env, "cli");
|
const realHome = hMkdtemp(`${hTmp()}/realT-`);
|
||||||
assert.equal(env.CLAUDE_CODE_ENTRYPOINT, "cli");
|
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");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("mode 'cli' overwrites an inherited CLAUDE_CODE_ENTRYPOINT value", () => {
|
console.log("\nresolveTuiHome (env-token credential isolation, PR-D):");
|
||||||
const env = { CLAUDE_CODE_ENTRYPOINT: "sdk-cli" };
|
import { resolveTuiHome, DEFAULT_TUI_SCRATCH_HOME } from "./lib/tui/session.mjs";
|
||||||
resolveTuiEntrypointEnv(env, "cli");
|
|
||||||
assert.equal(env.CLAUDE_CODE_ENTRYPOINT, "cli");
|
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("mode 'auto' deletes CLAUDE_CODE_ENTRYPOINT (leaves unset)", () => {
|
test("resolveTuiHome: env token UNSET → real home (legacy credentials.json path, unchanged)", () => {
|
||||||
const env = {};
|
const h = resolveTuiHome({ realHome: "/home/u", configuredHome: undefined, envTokenSet: false });
|
||||||
resolveTuiEntrypointEnv(env, "auto");
|
assert.equal(h, "/home/u", "no env token → real home, byte-for-byte the pre-fix behaviour");
|
||||||
assert.equal(env.CLAUDE_CODE_ENTRYPOINT, undefined);
|
|
||||||
assert.ok(!Object.prototype.hasOwnProperty.call(env, "CLAUDE_CODE_ENTRYPOINT"));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("mode 'auto' deletes an inherited CLAUDE_CODE_ENTRYPOINT value", () => {
|
test("resolveTuiHome: explicit OCP_TUI_HOME wins regardless of env token (back-compat)", () => {
|
||||||
const env = { CLAUDE_CODE_ENTRYPOINT: "sdk-cli" };
|
assert.equal(resolveTuiHome({ realHome: "/home/u", configuredHome: "/custom/home", envTokenSet: true }), "/custom/home");
|
||||||
resolveTuiEntrypointEnv(env, "auto");
|
assert.equal(resolveTuiHome({ realHome: "/home/u", configuredHome: "/custom/home", envTokenSet: false }), "/custom/home");
|
||||||
assert.equal(env.CLAUDE_CODE_ENTRYPOINT, undefined);
|
|
||||||
assert.ok(!Object.prototype.hasOwnProperty.call(env, "CLAUDE_CODE_ENTRYPOINT"));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("mode 'off' leaves an inherited CLAUDE_CODE_ENTRYPOINT value untouched", () => {
|
|
||||||
const env = { CLAUDE_CODE_ENTRYPOINT: "sdk-cli" };
|
|
||||||
resolveTuiEntrypointEnv(env, "off");
|
|
||||||
assert.equal(env.CLAUDE_CODE_ENTRYPOINT, "sdk-cli");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("mode 'off' with no inherited value leaves env unchanged", () => {
|
|
||||||
const env = { OTHER: "x" };
|
|
||||||
resolveTuiEntrypointEnv(env, "off");
|
|
||||||
assert.equal(env.CLAUDE_CODE_ENTRYPOINT, undefined);
|
|
||||||
assert.equal(env.OTHER, "x");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("default mode (no second arg) behaves like 'cli'", () => {
|
|
||||||
const env = { CLAUDE_CODE_ENTRYPOINT: "sdk-cli" };
|
|
||||||
resolveTuiEntrypointEnv(env);
|
|
||||||
assert.equal(env.CLAUDE_CODE_ENTRYPOINT, "cli");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── TUI concurrency limiter + drift observability (PR-B: audit C-4 / C-5) ──
|
// ── TUI concurrency limiter + drift observability (PR-B: audit C-4 / C-5) ──
|
||||||
import { TuiSemaphore, recordTuiEntrypoint, buildTuiHealthBlock } from "./lib/tui/semaphore.mjs";
|
import { TuiSemaphore, SemaphoreAbortError, recordTuiEntrypoint, buildTuiHealthBlock } from "./lib/tui/semaphore.mjs";
|
||||||
|
|
||||||
console.log("\nTUI concurrency limiter (C-4):");
|
console.log("\nTUI concurrency limiter (C-4):");
|
||||||
|
|
||||||
@@ -1918,6 +2176,265 @@ await asyncTest("wait queue is bounded — run() rejects with tui_queue_full whe
|
|||||||
assert.equal(sem.inflight, 0);
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Audit F1 — runtime-lowered/raised limit must actually bite ──────────────
|
||||||
|
// server.mjs reuses this same TuiSemaphore as `claudeSemaphore`; a PATCH /settings
|
||||||
|
// maxConcurrent update now calls `claudeSemaphore.setLimit(value)` (see applySettingUpdate's
|
||||||
|
// "maxConcurrent" case). These tests pin the semaphore-level contract that fix depends on.
|
||||||
|
console.log("\nF1 — runtime concurrency-limit changes (setLimit / release honoring the current limit):");
|
||||||
|
|
||||||
|
await asyncTest("F1: lowering the limit mid-load — release() stops re-granting until inflight drains under the new limit", async () => {
|
||||||
|
const sem = new TuiSemaphore(3, { maxQueue: 16 });
|
||||||
|
const g = [deferred(), deferred(), deferred()];
|
||||||
|
const held = g.map((d) => sem.run(async () => { await d.p; }));
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
assert.equal(sem.inflight, 3, "3 tasks hold the 3 slots");
|
||||||
|
// A 4th arrives while at capacity — it queues.
|
||||||
|
const g4 = deferred();
|
||||||
|
const queued4 = sem.run(async () => { await g4.p; });
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
assert.equal(sem.queued, 1, "4th request queued");
|
||||||
|
|
||||||
|
// Operator lowers maxConcurrent from 3 to 1 while all 3 original slots are still inflight
|
||||||
|
// (mirrors a PATCH /settings maxConcurrent=1 hitting server.mjs mid-burst).
|
||||||
|
sem.setLimit(1);
|
||||||
|
assert.equal(sem.limit, 1);
|
||||||
|
|
||||||
|
// Releasing one of the 3 original holders must NOT hand the freed slot to the queued 4th
|
||||||
|
// request — before the F1 fix, release() handed slots off unconditionally, so inflight
|
||||||
|
// would have stayed pinned at the OLD higher occupancy forever.
|
||||||
|
g[0].resolve();
|
||||||
|
await held[0];
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
assert.equal(sem.inflight, 2, "inflight drains toward the new limit, not re-granted");
|
||||||
|
assert.equal(sem.queued, 1, "4th request is STILL queued — not over-admitted");
|
||||||
|
|
||||||
|
g[1].resolve();
|
||||||
|
await held[1];
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
assert.equal(sem.inflight, 1, "inflight now exactly at the new limit (1)");
|
||||||
|
assert.equal(sem.queued, 1, "still queued — inflight(1) is not < limit(1), so no grant yet");
|
||||||
|
|
||||||
|
// Releasing the LAST original holder finally drops inflight under the new limit — only
|
||||||
|
// now does the queued 4th request get granted.
|
||||||
|
g[2].resolve();
|
||||||
|
await held[2];
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
assert.equal(sem.inflight, 1, "queued 4th request now holds the single slot");
|
||||||
|
assert.equal(sem.queued, 0, "queue drained");
|
||||||
|
g4.resolve();
|
||||||
|
await queued4;
|
||||||
|
assert.equal(sem.inflight, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await asyncTest("F1: raising the limit wakes queued waiters immediately, up to the new headroom", async () => {
|
||||||
|
const sem = new TuiSemaphore(1, { maxQueue: 16 });
|
||||||
|
const g1 = deferred();
|
||||||
|
const t1 = sem.run(async () => { await g1.p; }); // holds the only slot
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
const started = [];
|
||||||
|
const g2 = deferred(), g3 = deferred();
|
||||||
|
const t2 = sem.run(async () => { started.push(2); await g2.p; });
|
||||||
|
const t3 = sem.run(async () => { started.push(3); await g3.p; });
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
assert.equal(sem.queued, 2, "both queue behind the single holder");
|
||||||
|
assert.deepEqual(started, [], "neither queued task has started");
|
||||||
|
|
||||||
|
// Operator raises maxConcurrent from 1 to 3 (2 units of new headroom) — BOTH queued
|
||||||
|
// waiters must be woken immediately, without waiting for t1 to release.
|
||||||
|
sem.setLimit(3);
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
assert.equal(sem.inflight, 3, "t1 + both newly-woken waiters now hold slots");
|
||||||
|
assert.equal(sem.queued, 0, "queue drained by the limit raise");
|
||||||
|
assert.deepEqual(started.sort(), [2, 3], "both queued tasks started without waiting for t1's release");
|
||||||
|
|
||||||
|
g1.resolve(); g2.resolve(); g3.resolve();
|
||||||
|
await Promise.all([t1, t2, t3]);
|
||||||
|
assert.equal(sem.inflight, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await asyncTest("F1: raising the limit wakes only as many waiters as the new headroom allows (FIFO)", async () => {
|
||||||
|
const sem = new TuiSemaphore(1, { maxQueue: 16 });
|
||||||
|
const g1 = deferred();
|
||||||
|
const t1 = sem.run(async () => { await g1.p; });
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
const started = [];
|
||||||
|
const g2 = deferred(), g3 = deferred();
|
||||||
|
const t2 = sem.run(async () => { started.push(2); await g2.p; });
|
||||||
|
const t3 = sem.run(async () => { started.push(3); await g3.p; });
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
assert.equal(sem.queued, 2);
|
||||||
|
|
||||||
|
sem.setLimit(2); // only 1 unit of new headroom (1 -> 2) — exactly one queued waiter wakes
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
assert.equal(sem.inflight, 2);
|
||||||
|
assert.equal(sem.queued, 1, "one waiter still queued — only one slot of headroom existed");
|
||||||
|
assert.deepEqual(started, [2], "FIFO: the earlier-queued waiter (t2) wakes, not t3");
|
||||||
|
|
||||||
|
// Freeing t1's slot afterward still honors the (now current) limit of 2 via release()'s
|
||||||
|
// normal path — the still-queued t3 gets in once a slot actually frees.
|
||||||
|
g1.resolve();
|
||||||
|
await t1;
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
assert.deepEqual(started, [2, 3], "t3 granted once a slot frees, honoring the raised limit");
|
||||||
|
assert.equal(sem.queued, 0);
|
||||||
|
|
||||||
|
g2.resolve(); g3.resolve();
|
||||||
|
await t2; await t3;
|
||||||
|
assert.equal(sem.inflight, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Audit F2 — queued waiters must be cancellable on client disconnect ──────
|
||||||
|
// server.mjs wires an AbortSignal derived from the client's res "close" event into
|
||||||
|
// claudeSemaphore.acquire()/tuiSemaphore.acquire() (see closeSignalFor + acquireClaudeSlot /
|
||||||
|
// callClaudeTui). These tests pin the semaphore-level cancellation contract that depends on.
|
||||||
|
console.log("\nF2 — queued-wait cancellation via AbortSignal (client disconnect while queued):");
|
||||||
|
|
||||||
|
await asyncTest("F2: aborting a QUEUED waiter rejects with SemaphoreAbortError and SPLICES it out (queued drops immediately, not just flagged)", async () => {
|
||||||
|
const sem = new TuiSemaphore(1, { maxQueue: 16 });
|
||||||
|
const g1 = deferred();
|
||||||
|
const t1 = sem.run(async () => { await g1.p; }); // holds the only slot
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
const controller = new AbortController();
|
||||||
|
const acquire2 = sem.acquire(controller.signal); // queues behind t1
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
assert.equal(sem.queued, 1, "second acquire queued");
|
||||||
|
|
||||||
|
controller.abort(); // simulates the client disconnecting while still queued
|
||||||
|
await assert.rejects(acquire2, SemaphoreAbortError, "cancelled waiter rejects with SemaphoreAbortError");
|
||||||
|
assert.equal(sem.queued, 0, "cancelled waiter is REMOVED — queue length drops immediately");
|
||||||
|
assert.equal(sem.inflight, 1, "t1's slot is untouched by the cancellation");
|
||||||
|
|
||||||
|
// Prove the cancelled waiter never later acquires a slot: free t1's slot and confirm
|
||||||
|
// nobody is waiting to receive it (the queue is genuinely empty, not just decremented).
|
||||||
|
g1.resolve();
|
||||||
|
await t1;
|
||||||
|
assert.equal(sem.inflight, 0, "slot freed with nobody queued — the cancelled waiter never got it");
|
||||||
|
});
|
||||||
|
|
||||||
|
await asyncTest("F2: an already-aborted signal rejects acquire() immediately, never touching the wait queue", async () => {
|
||||||
|
const sem = new TuiSemaphore(1, { maxQueue: 16 });
|
||||||
|
const g1 = deferred();
|
||||||
|
const t1 = sem.run(async () => { await g1.p; }); // holds the only slot
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
controller.abort(); // client already gone before this request ever tries to acquire
|
||||||
|
await assert.rejects(sem.acquire(controller.signal), SemaphoreAbortError);
|
||||||
|
assert.equal(sem.queued, 0, "never entered the wait queue at all");
|
||||||
|
|
||||||
|
g1.resolve(); await t1;
|
||||||
|
});
|
||||||
|
|
||||||
|
await asyncTest("F2: cancelling one queued waiter preserves FIFO order for the others", async () => {
|
||||||
|
const sem = new TuiSemaphore(1, { maxQueue: 16 });
|
||||||
|
const g1 = deferred();
|
||||||
|
const t1 = sem.run(async () => { await g1.p; });
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
|
||||||
|
const started = [];
|
||||||
|
const cA = new AbortController();
|
||||||
|
const cB = new AbortController();
|
||||||
|
const accA = sem.acquire(cA.signal).then(() => started.push("A"));
|
||||||
|
const accB = sem.acquire(cB.signal).then(() => started.push("B"));
|
||||||
|
const g3 = deferred();
|
||||||
|
const t3 = sem.run(async () => { started.push("C"); await g3.p; });
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
assert.equal(sem.queued, 3, "A, B, C all queued behind t1");
|
||||||
|
|
||||||
|
cB.abort(); // B (the middle waiter) disconnects
|
||||||
|
await assert.rejects(accB, SemaphoreAbortError);
|
||||||
|
assert.equal(sem.queued, 2, "B removed; A and C remain, in original relative order");
|
||||||
|
|
||||||
|
g1.resolve();
|
||||||
|
await t1;
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
assert.deepEqual(started, ["A"], "A (queued first, still present) is granted next — FIFO preserved after B's removal");
|
||||||
|
assert.equal(sem.inflight, 1);
|
||||||
|
assert.equal(sem.queued, 1, "C still waiting");
|
||||||
|
|
||||||
|
sem.release(); // A was acquired directly (not via run()) — free its slot manually
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
assert.deepEqual(started, ["A", "C"], "C granted next");
|
||||||
|
g3.resolve();
|
||||||
|
await t3;
|
||||||
|
assert.equal(sem.inflight, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await asyncTest("F2/L2: abort AFTER grant is a no-op — waiter keeps its slot, no rejection, slot released exactly once", async () => {
|
||||||
|
const sem = new TuiSemaphore(1, { maxQueue: 16 });
|
||||||
|
const g1 = deferred();
|
||||||
|
const t1 = sem.run(async () => { await g1.p; }); // holds the only slot
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
let granted = false;
|
||||||
|
const acq = sem.acquire(controller.signal).then(() => { granted = true; });
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
assert.equal(sem.queued, 1, "waiter queued behind t1");
|
||||||
|
|
||||||
|
// t1 finishes → release() shifts the waiter out and grants it the slot (waiter() detaches
|
||||||
|
// the abort listener before resolving).
|
||||||
|
g1.resolve();
|
||||||
|
await t1;
|
||||||
|
await acq;
|
||||||
|
assert.equal(granted, true, "waiter was granted the slot");
|
||||||
|
assert.equal(sem.inflight, 1, "granted waiter holds the slot");
|
||||||
|
assert.equal(sem.queued, 0);
|
||||||
|
|
||||||
|
// The client disconnects AFTER the grant — the abort-after-grant race. onAbort must be a
|
||||||
|
// no-op (the waiter is no longer in _waiters; idx===-1 guard): no rejection materializes,
|
||||||
|
// the queue is untouched, and the slot is still owned by the (already-resolved) acquirer.
|
||||||
|
controller.abort();
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
assert.equal(sem.inflight, 1, "abort after grant did NOT revoke or double-free the slot");
|
||||||
|
assert.equal(sem.queued, 0, "abort after grant did not corrupt queue accounting");
|
||||||
|
|
||||||
|
// The slot is released exactly once via the normal path and is immediately reusable.
|
||||||
|
sem.release();
|
||||||
|
assert.equal(sem.inflight, 0, "slot released exactly once via the normal path");
|
||||||
|
await sem.run(async () => {}); // prove the semaphore is fully healthy afterward
|
||||||
|
assert.equal(sem.inflight, 0);
|
||||||
|
});
|
||||||
|
|
||||||
console.log("\nTUI drift observability (C-5):");
|
console.log("\nTUI drift observability (C-5):");
|
||||||
|
|
||||||
test("recordTuiEntrypoint: observed 'cli' is NOT a mismatch and sets lastEntrypoint", () => {
|
test("recordTuiEntrypoint: observed 'cli' is NOT a mismatch and sets lastEntrypoint", () => {
|
||||||
@@ -2298,8 +2815,131 @@ test("isLoopbackBind: '100.64.0.1' → false (Tailscale IP)", () => {
|
|||||||
assert.equal(isLoopbackBind("100.64.0.1"), false);
|
assert.equal(isLoopbackBind("100.64.0.1"), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Cleanup ──
|
// ── Spawn-auth primitives (F3 / F5 / F6, lib/spawn-auth.mjs) ──
|
||||||
closeDb();
|
// Pure, dependency-injected primitives extracted from server.mjs so the spawn-token concurrency /
|
||||||
|
// caching / expiry logic is testable without booting the server or mocking execFileSync/spawn.
|
||||||
|
console.log("\nSpawn-auth (F3 mutex / F5 TTL cache + label memo / F6 expiry gate):");
|
||||||
|
|
||||||
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===\n`);
|
// F5: expiry gate — the load-bearing invariant that lets a short-TTL keychain cache stay safe.
|
||||||
process.exit(failed > 0 ? 1 : 0);
|
test("isTokenExpiring: creds within 5-min buffer → true", () => {
|
||||||
|
assert.equal(isTokenExpiring({ expiresAt: 1000 }, 1000 - 300000, 300000), true); // exactly at buffer edge
|
||||||
|
assert.equal(isTokenExpiring({ expiresAt: 1000 }, 900, 300000), true); // past the edge
|
||||||
|
});
|
||||||
|
test("isTokenExpiring: creds well beyond buffer → false", () => {
|
||||||
|
assert.equal(isTokenExpiring({ expiresAt: 10_000_000 }, 0, 300000), false);
|
||||||
|
});
|
||||||
|
test("isTokenExpiring: no expiresAt (long-lived env token) → never expiring", () => {
|
||||||
|
assert.equal(isTokenExpiring({ accessToken: "x" }, Date.now(), 300000), false);
|
||||||
|
assert.equal(isTokenExpiring(null, Date.now(), 300000), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// F5: last-good label ordering — one exec instead of two on the steady-state keychain path.
|
||||||
|
test("orderLabelsLastGoodFirst: last-good label is tried first", () => {
|
||||||
|
const labels = ["A", "B"];
|
||||||
|
assert.deepEqual(orderLabelsLastGoodFirst(labels, "B"), ["B", "A"]);
|
||||||
|
});
|
||||||
|
test("orderLabelsLastGoodFirst: null/unknown last-good → original order, fresh array", () => {
|
||||||
|
const labels = ["A", "B"];
|
||||||
|
assert.deepEqual(orderLabelsLastGoodFirst(labels, null), ["A", "B"]);
|
||||||
|
assert.deepEqual(orderLabelsLastGoodFirst(labels, "Z"), ["A", "B"]);
|
||||||
|
assert.notEqual(orderLabelsLastGoodFirst(labels, null), labels); // does not mutate/alias input
|
||||||
|
});
|
||||||
|
|
||||||
|
// F5: TTL cache — bounds how often we RE-READ the keychain (not how often we re-decide expiry).
|
||||||
|
test("createTtlCache: serves cached value within TTL, re-produces after TTL", () => {
|
||||||
|
const cache = createTtlCache({ ttlMs: 30000 });
|
||||||
|
let calls = 0;
|
||||||
|
const produce = () => { calls++; return `v${calls}`; };
|
||||||
|
assert.equal(cache.get(produce, 0), "v1");
|
||||||
|
assert.equal(cache.get(produce, 10000), "v1"); // within TTL → cached, producer NOT called
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
assert.equal(cache.get(produce, 40000), "v2"); // past TTL → re-produced
|
||||||
|
assert.equal(calls, 2);
|
||||||
|
});
|
||||||
|
test("createTtlCache: caches a null miss (absent source not re-probed within TTL)", () => {
|
||||||
|
const cache = createTtlCache({ ttlMs: 30000 });
|
||||||
|
let calls = 0;
|
||||||
|
const produce = () => { calls++; return null; };
|
||||||
|
assert.equal(cache.get(produce, 0), null);
|
||||||
|
assert.equal(cache.get(produce, 5000), null);
|
||||||
|
assert.equal(calls, 1); // the null was cached, not re-probed
|
||||||
|
});
|
||||||
|
|
||||||
|
// F5 core safety property: a short-TTL cache CANNOT reintroduce the #146 forever-stale bug because
|
||||||
|
// the expiry gate is applied to the CACHED creds on every use. The cache keeps returning the same
|
||||||
|
// creds object, but isTokenExpiring flips to true the moment the clock crosses the expiry buffer.
|
||||||
|
test("TTL cache respects expiry gate: cached creds still rejected once clock passes expiry", () => {
|
||||||
|
const cache = createTtlCache({ ttlMs: 30000 });
|
||||||
|
const creds = { accessToken: "tok", expiresAt: 1_000_000 };
|
||||||
|
// t=980_000: cached AND not yet within the 5-min (300_000) buffer → usable.
|
||||||
|
const c1 = cache.get(() => creds, 980_000 - 300_000 - 1);
|
||||||
|
assert.equal(isTokenExpiring(c1, 980_000 - 300_000 - 1, 300000), false);
|
||||||
|
// t=800_000 later: SAME cached object returned (within TTL of the second read window), but now
|
||||||
|
// within the expiry buffer → gate rejects it → caller falls back to real HOME. No forever-stale.
|
||||||
|
const c2 = cache.get(() => creds, 990_000);
|
||||||
|
assert.equal(c2, c1, "cache returns the same creds object");
|
||||||
|
assert.equal(isTokenExpiring(c2, 990_000, 300000), true, "expiry gate still fires on cached creds");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Async: F3 real-HOME fallback serialization mutex ──
|
||||||
|
async function runAsyncTests() {
|
||||||
|
await testAsync("createSerialMutex: second waiter blocks until first holder releases", async () => {
|
||||||
|
const mutex = createSerialMutex();
|
||||||
|
const order = [];
|
||||||
|
const rel1 = await mutex.acquire();
|
||||||
|
order.push("h1-enter");
|
||||||
|
let secondEntered = false;
|
||||||
|
const p2 = mutex.acquire().then((rel2) => { secondEntered = true; order.push("h2-enter"); return rel2; });
|
||||||
|
await new Promise((r) => setTimeout(r, 15));
|
||||||
|
assert.equal(secondEntered, false, "second waiter must NOT enter while first holds the mutex");
|
||||||
|
order.push("h1-release");
|
||||||
|
rel1();
|
||||||
|
const rel2 = await p2;
|
||||||
|
assert.equal(secondEntered, true, "second waiter enters only after release");
|
||||||
|
rel2();
|
||||||
|
assert.deepEqual(order, ["h1-enter", "h1-release", "h2-enter"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
await testAsync("createSerialMutex: N acquires run strictly in FIFO order, never overlapping", async () => {
|
||||||
|
const mutex = createSerialMutex();
|
||||||
|
const events = [];
|
||||||
|
let active = 0;
|
||||||
|
async function critical(id) {
|
||||||
|
const rel = await mutex.acquire();
|
||||||
|
active++;
|
||||||
|
assert.equal(active, 1, `only one holder at a time (id=${id})`);
|
||||||
|
events.push(`start${id}`);
|
||||||
|
await new Promise((r) => setTimeout(r, 5));
|
||||||
|
events.push(`end${id}`);
|
||||||
|
active--;
|
||||||
|
rel();
|
||||||
|
}
|
||||||
|
await Promise.all([critical(1), critical(2), critical(3)]);
|
||||||
|
assert.deepEqual(events, ["start1", "end1", "start2", "end2", "start3", "end3"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
await testAsync("createSerialMutex: release() is idempotent (double-release does not double-admit)", async () => {
|
||||||
|
const mutex = createSerialMutex();
|
||||||
|
const rel1 = await mutex.acquire();
|
||||||
|
rel1();
|
||||||
|
rel1(); // second call must be a no-op
|
||||||
|
const rel2 = await mutex.acquire(); // should acquire cleanly, exactly once
|
||||||
|
let thirdEntered = false;
|
||||||
|
const p3 = mutex.acquire().then((r) => { thirdEntered = true; return r; });
|
||||||
|
await new Promise((r) => setTimeout(r, 15));
|
||||||
|
assert.equal(thirdEntered, false, "double-release must not have leaked an extra admit slot");
|
||||||
|
rel2();
|
||||||
|
(await p3)();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cleanup ──
|
||||||
|
runAsyncTests().then(() => {
|
||||||
|
closeDb();
|
||||||
|
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===\n`);
|
||||||
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
|
}).catch((e) => {
|
||||||
|
console.error("async test runner crashed:", e);
|
||||||
|
closeDb();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user