mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-22 05:25:08 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64150e0408 | ||
|
|
ed9abb19fe |
@@ -1,38 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Changed
|
||||
|
||||
- **Default `sonnet` alias → `claude-sonnet-5`.** The `sonnet` alias (the model used for every `/v1/chat/completions` request that omits `model`, and OpenClaw's OCP primary via `ocp-connect`) now resolves to `claude-sonnet-5` instead of `claude-sonnet-4-6`. `claude-sonnet-4-6` remains available by full ID for pinning. This is a behavior change for clients relying on the default — pin `claude-sonnet-4-6` explicitly to retain the previous model. Split out from the additive `claude-sonnet-5` model entry (#152) per Iron Rule 11.
|
||||
|
||||
## v3.22.1 — 2026-07-17
|
||||
|
||||
Minor release: TUI-mode latency and streaming features — **all opt-in and off by default**, so the default request path (`-p` / `--output-format stream-json`) is byte-for-byte unchanged — plus hardening from an independent (Codex) re-review of the streaming work, Windows `claude.exe` startup resolution, and the Claude Sonnet 5 model entry. No new `cli.js` wire behavior and no new endpoint; the new surface is entirely OCP-owned TUI-mode configuration (env vars), startup binary discovery, model metadata, and `/health` observation. Every code PR carried a fresh-context reviewer (Iron Rule 10). (Version note: v3.22.0 was prepared but never tagged; its contents ship here as v3.22.1 together with the additions below.)
|
||||
|
||||
### Added
|
||||
|
||||
- **Claude Sonnet 5 in the model SPOT (#152, contributed by @vvlasy-openclaw)** — `claude-sonnet-5` added to `models.json` (`contextWindow` 200000 / `maxTokens` 16384 / `reasoning` true, consistent with existing entries), exposed via `/v1/models` and the OpenClaw sync. Purely additive: the `sonnet` alias still resolves to `claude-sonnet-4-6` (the repoint is tracked separately in #168). `ocp-connect`'s model classifier now matches on the model *family* prefix (`claude-sonnet`/`claude-opus`/`claude-haiku`) instead of version-pinned prefixes, so current and future versioned IDs register with correct `reasoning`/`maxTokens` metadata. New referential-integrity tests guard that every alias target exists in `models[]`.
|
||||
- **Windows `claude.exe` startup resolution (#161, contributed by @nyxst4ck, diagnosis credit #147 @Justinsato)** — on Windows, `resolveClaude()` now discovers a native `claude.exe` (`%USERPROFILE%\.local\bin`, WinGet Links, WindowsApps, then `where.exe`) and rejects npm `.cmd`/`.bat`/`.ps1` shims, which cannot be spawned without a shell — previously startup resolved a shim and failed. A non-`.exe` `CLAUDE_BIN` on Windows is a fatal error with an actionable hint. The macOS/Linux path is byte-for-byte unchanged. Note: this is startup binary resolution only — full Windows support is not yet claimed (snapshot-path portability is tracked in #167).
|
||||
|
||||
### Added — TUI mode (all opt-in, default off)
|
||||
|
||||
- **Spawn effort control — `OCP_TUI_EFFORT` (default `low`) (#156)** — the interactive `claude` is now spawned with an explicit `--effort` flag. `low` cuts measured TTFT p50 by ~40% and collapses run-to-run variance ~15× versus an inherited `xhigh`; proxied requests rarely benefit from extended thinking. Set `inherit` to omit the flag and restore the pre-flag HOME-dependent behaviour. Banner-verified to stay on the subscription pool (`· Claude Max`); an invalid value warns and falls back to `low`. README § "Environment Variables".
|
||||
- **Warm pane pool — `OCP_TUI_POOL_SIZE` (default `0` / off) (#158)** — pre-boots up to 4 single-use `claude` panes so a request skips the cold boot: measured end-to-end p50 `10.17s` → `6.00s` (−41%) on a Mac mini (Sonnet 4.6, `--effort low`). Opt-in because each warm pane is a live idle process held whether or not a request ever arrives. Panes are single-use (one turn, then killed and replaced in the background), port-scoped (`ocp-tui-<port>-p<hex>`), and coexist with the zombie reaper by a synchronous drain→reap→resume sweep. README §§ "Environment Variables" + "How It Works".
|
||||
- **Real SSE streaming — `OCP_TUI_STREAM` (default `0` / off) (#159, #160)** — `stream:true` turns emit real `delta.content` chunks as `claude` generates them, sourced from `claude`'s own `MessageDisplay` hook (registered via `--settings` on the ordinary interactive spawn — banner-verified on the subscription pool). Granularity is block-level, and it moves the *first* byte, not the last. The transcript stays authoritative: streamed text is asserted equal to it at end-of-turn, the auth-banner and truncation gates still run before anything is committed, and a turn whose stream cannot be reconciled is **refused** (SSE error frame, not cached) and counted on `/health` (`tui.streamDivergences`; a silent total-hook-failure is counted separately as `tui.streamZeroDeltaTurns`). Tunables: `OCP_TUI_STREAM_HOLDBACK` (default `100`), `OCP_TUI_STREAM_DIR`, `OCP_TUI_STREAM_POLL_MS`. See ADR 0007 (2026-07-13 amendment). README §§ "Environment Variables" + "How It Works".
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Streaming auth-banner guard: a null `message_id` on the first hook fire (#160)** — a first `MessageDisplay` fire with a null `message_id` could disarm the auth-banner guard; re-landed after a #159 squash dropped it (`lib/tui/stream.mjs`).
|
||||
- **Test suite wrote live, unrevoked API keys into the operator's real key store (#163)** — `npm test` had been opening `~/.ocp/ocp.db` (the running server's DB) and writing two junk `api_keys` rows per run (737 accumulated on the maintainer's host), because the isolation the comments claimed was never wired (ESM import hoisting). `keys.mjs` now honors `OCP_DIR_OVERRIDE` under `NODE_ENV=test` and the suite points at a scratch dir; a child-process probe verifies a production process (no `NODE_ENV`) cannot be redirected.
|
||||
- **Streaming holdback floor + billing-pool observation on failed turns (#164)** — (A1) `OCP_TUI_STREAM_HOLDBACK` now clamps up to the safe floor (`100`) with a boot warning, closing a latent auth-banner leak when an operator set a sub-floor value. (A3) the `cc_entrypoint` (billing-pool) observation is now recorded before the honesty gates that throw, so `/health` no longer goes blind to exactly the failed turns most likely to signal a silent degrade to the metered Agent SDK pool.
|
||||
- **Test-only key-store redirection vars can no longer reach a server OCP launches (#165)** — (A4) `NODE_ENV`/`OCP_DIR_OVERRIDE` are stripped from every service unit `setup.mjs` writes (`plist-merge`'s `NEVER_PRESERVE`) and from the `ocp restart` manual nohup fallback (`env -u`); #163's overstated "a prod server can NEVER be redirected" comments were softened to name the one residual hand-launch path and the loud `getDb()` "NOT the default" backstop.
|
||||
|
||||
### Docs
|
||||
|
||||
- **README billing honesty (#162, closes #136)** — removed a feature bullet that promised what the § "honest limits" section forbids.
|
||||
- **TUI latency plans + streaming-achievability spike (#155, #157)** — measured latency decomposition, backlog, and the `MessageDisplay`-hook streaming prereq spike under `docs/plans/2026-07-13-tui-latency/`.
|
||||
|
||||
## 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).
|
||||
|
||||
@@ -24,8 +24,8 @@ One proxy. Multiple IDEs. All models. **$0 API cost.**
|
||||
|
||||
There are several Claude proxy projects. OCP picks a specific lane: **align tightly with what `cli.js` actually does, observe + multiplex what's already there, don't extend the protocol.** What you get:
|
||||
|
||||
- **LAN multi-user keys** (v3.7.0) — reach one Claude Pro/Max subscription from your own devices across the LAN. Each device gets a per-key API token (no OAuth session leak), with independent usage tracking and one-line revocation. Pro/Max are **per-user** accounts — see [Sharing with family / a team — honest limits](#deployment-model--security-read-this) before extending access to other **people**.
|
||||
- **`ocp-connect` one-shot client setup** — one command on the client machine auto-configures OpenClaw, and detects Cursor, Cline, Continue.dev, and opencode to print ready-to-paste setup hints for each. No hunting for where each tool keeps its `OPENAI_BASE_URL`.
|
||||
- **LAN multi-user keys** (v3.7.0) — share one Claude Pro/Max subscription with family, friends, or your own devices. Each user gets a per-key API token (no OAuth session leak), with independent usage tracking and one-line revocation.
|
||||
- **`ocp-connect` one-shot IDE setup** — one command on the client machine detects and configures Claude Code, Cursor, Cline, Continue.dev, OpenCode, and OpenClaw. No pasting `OPENAI_BASE_URL` six times.
|
||||
- **Response cache with per-key isolation + singleflight** (v3.13.0). Optional SHA-256 prompt cache, isolated per API key (cross-user pollution is impossible by hash construction, not by application logic), with stampede protection on concurrent identical prompts. Off by default. ([PR #65](https://github.com/dtzp555-max/ocp/pull/65), [PR #66](https://github.com/dtzp555-max/ocp/pull/66))
|
||||
- **Per-key request quotas** (v3.8.0). Daily / weekly / monthly limits per key — set a kid's iPad to 20/day, a partner's laptop to 100/week. ([PR #18](https://github.com/dtzp555-max/ocp/pull/18))
|
||||
- **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))
|
||||
@@ -49,7 +49,7 @@ OCP and the alternatives serve adjacent but distinct needs. Pick the one that fi
|
||||
| GitHub stars / ecosystem size | small | large | mid |
|
||||
| Governance discipline (CI-enforced alignment with cli.js) | yes | n/a | n/a |
|
||||
|
||||
**Plain English**: `claude-code-router` is the routing-and-switching power tool — pick it if you want to mix Anthropic, OpenAI, Gemini, and local models behind one endpoint. `anthropic-proxy` is the minimal forwarder. **OCP focuses on disciplined `cli.js`-aligned forwarding plus subscription multiplexing** — pick it if you want to reach one Claude Pro/Max subscription from your own IDEs and devices, with LAN auth, quotas, and a governance contract that prevents endpoint drift.
|
||||
**Plain English**: `claude-code-router` is the routing-and-switching power tool — pick it if you want to mix Anthropic, OpenAI, Gemini, and local models behind one endpoint. `anthropic-proxy` is the minimal forwarder. **OCP focuses on disciplined `cli.js`-aligned forwarding plus subscription multiplexing** — pick it if you want to share one Claude Pro/Max subscription across IDEs, devices, and people, with LAN auth, quotas, and a governance contract that prevents endpoint drift.
|
||||
|
||||
### Related: OLP — Open LLM Proxy
|
||||
|
||||
@@ -121,7 +121,7 @@ Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md
|
||||
installed and logged in (`claude auth status`). Install missing pieces
|
||||
using my system's package manager.
|
||||
2. git clone the repo, cd in, and run `node setup.mjs`.
|
||||
3. Verify with `curl http://127.0.0.1:3456/v1/models` (should list 6 models).
|
||||
3. Verify with `curl http://127.0.0.1:3456/v1/models` (should list 5 models).
|
||||
4. Add `export OPENAI_BASE_URL=http://127.0.0.1:3456/v1` to my shell rc.
|
||||
5. Tell me to reload my shell and try a tool like Cline / Continue / Cursor.
|
||||
|
||||
@@ -148,7 +148,7 @@ Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md
|
||||
5. Add OCP_ADMIN_KEY to my shell rc (~/.zshrc or ~/.bashrc).
|
||||
6. Run `ocp lan` to show me the LAN IP and connect command.
|
||||
7. Optionally create example keys: `ocp keys add laptop`, `ocp keys add tablet`.
|
||||
8. Verify: `curl http://127.0.0.1:3456/v1/models` returns 6 models.
|
||||
8. Verify: `curl http://127.0.0.1:3456/v1/models` returns 5 models.
|
||||
|
||||
Tell me each step before running it. On error, diagnose before retrying.
|
||||
```
|
||||
@@ -157,7 +157,8 @@ Tell me each step before running it. On error, diagnose before retrying.
|
||||
|
||||
```text
|
||||
There's an OCP server at <SERVER_IP> on my LAN. Configure this machine to
|
||||
use it for any local IDEs (Cursor, Cline, Continue.dev, OpenCode, OpenClaw).
|
||||
use it for any local IDEs (Cursor, Cline, Continue.dev, OpenCode, Claude
|
||||
Code, OpenClaw).
|
||||
|
||||
Server IP: <SERVER_IP>
|
||||
API key (leave blank if the server has anonymous mode enabled): <OPTIONAL_KEY>
|
||||
@@ -170,7 +171,7 @@ Please follow https://github.com/dtzp555-max/ocp/blob/main/README.md
|
||||
chmod +x ocp-connect
|
||||
2. Run `./ocp-connect <SERVER_IP>` (add `--key <KEY>` if you have one).
|
||||
3. Follow any IDE-specific manual hints it prints.
|
||||
4. Verify: `curl http://<SERVER_IP>:3456/v1/models` returns 6 models.
|
||||
4. Verify: `curl http://<SERVER_IP>:3456/v1/models` returns 5 models.
|
||||
5. Tell me to reload my shell + restart any IDE that was already running.
|
||||
|
||||
Don't auto-retry on error. Tell me the failure mode first.
|
||||
@@ -214,7 +215,7 @@ After install the `ocp` CLI lives at `~/ocp/ocp`. To put it on your PATH, either
|
||||
export OPENAI_BASE_URL=http://127.0.0.1:3456/v1
|
||||
```
|
||||
|
||||
**LAN mode** — reach OCP from your own devices on the network (Claude Pro/Max are per-user accounts — see [Sharing with family / a team — honest limits](#deployment-model--security-read-this) before extending access to other people):
|
||||
**LAN mode** — share with other devices on your network:
|
||||
```bash
|
||||
# Enable LAN access with per-user auth (recommended)
|
||||
node setup.mjs --bind 0.0.0.0 --auth-mode multi
|
||||
@@ -240,7 +241,7 @@ Run `ocp lan` to see your IP and ready-to-share instructions.
|
||||
**Verify:**
|
||||
```bash
|
||||
curl http://127.0.0.1:3456/v1/models
|
||||
# Returns: claude-opus-4-8, claude-opus-4-7, claude-opus-4-6, claude-sonnet-5, claude-sonnet-4-6, claude-haiku-4-5-20251001
|
||||
# Returns: claude-opus-4-8, claude-opus-4-7, claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4-5-20251001
|
||||
```
|
||||
|
||||
#### Headless install notes
|
||||
@@ -319,7 +320,7 @@ OCP Connect v1.3.0
|
||||
(set by admin via PROXY_ANONYMOUS_KEY; see issue #12 §14 Path A)
|
||||
|
||||
Testing API access...
|
||||
✓ API accessible (6 models available)
|
||||
✓ API accessible (5 models available)
|
||||
|
||||
Shell config:
|
||||
✓ .bashrc
|
||||
@@ -352,7 +353,6 @@ OCP Connect v1.3.0
|
||||
• ocp/claude-opus-4-8
|
||||
• ocp/claude-opus-4-7
|
||||
• ocp/claude-opus-4-6
|
||||
• ocp/claude-sonnet-5
|
||||
• ocp/claude-sonnet-4-6
|
||||
• ocp/claude-haiku-4-5-20251001
|
||||
Priority: PRIMARY (default model)
|
||||
@@ -577,9 +577,9 @@ The simplest path: ask your AI.
|
||||
|
||||
What `ocp update` does:
|
||||
|
||||
- **Patch bump** (e.g. `v3.21.0 → v3.21.1`):
|
||||
- **Patch bump** (e.g. `v3.14.0 → v3.14.1`):
|
||||
light path (git pull + npm install + restart).
|
||||
- **Cross-minor** (e.g. `v3.18 → v3.22`):
|
||||
- **Cross-minor** (e.g. `v3.10 → v3.14`):
|
||||
full path: pre-flight check, snapshot, `setup.mjs` (with plist env-merge),
|
||||
service restart, post-flight `/health` and `/v1/models` verification.
|
||||
- **Old version** (< v3.4.0):
|
||||
@@ -701,8 +701,6 @@ 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.
|
||||
|
||||
> **Billing-policy status (as of 2026-07).** Anthropic announced (2026-05-14) that from 2026-06-15 the `claude -p` / Agent SDK path would move to a separate metered credit pool — then **paused the change on its effective date**: *"For now, nothing has changed: Claude Agent SDK, `claude -p`, and third-party app usage still draw from your subscription's usage limits"* ([official help article](https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan)). So the default path above currently bills your subscription. Anthropic has said it will give notice before any future change; if the split re-lands, OCP's opt-in [subscription-pool (TUI) mode](#subscription-pool-tui-mode) is the ready-made hedge — see the billing table there.
|
||||
|
||||
### 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.
|
||||
@@ -711,8 +709,6 @@ Any tool use happens server-side, under the `--allowedTools` set configured on t
|
||||
|
||||
**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).
|
||||
|
||||
**What this means for choosing OCP (workload fit).** LAN/multi-device OCP is built for **chat-class** workloads — Q&A, translation, scripting against the API, chat frontends, home-automation backends — where text in/text out is the whole job. It is **not** the right tool for a coding agent running on a *client* machine that needs the AI to read and edit *that machine's* files: tools execute on the OCP host, so the model can never touch the client's filesystem. For that workload, run `claude` (or a local OCP) directly on the machine where the code lives.
|
||||
|
||||
## Available Models
|
||||
|
||||
| Model ID | Notes |
|
||||
@@ -720,8 +716,7 @@ Any tool use happens server-side, under the `--allowedTools` set configured on t
|
||||
| `claude-opus-4-8` | Most capable (default for `opus` alias) |
|
||||
| `claude-opus-4-7` | Previous Opus, retained for pinning |
|
||||
| `claude-opus-4-6` | Older Opus, retained for pinning |
|
||||
| `claude-sonnet-5` | Latest Sonnet (default for `sonnet` alias) |
|
||||
| `claude-sonnet-4-6` | Previous Sonnet, retained for pinning |
|
||||
| `claude-sonnet-4-6` | Good balance of speed/quality (default for `sonnet` alias) |
|
||||
| `claude-haiku-4-5-20251001` | Fastest, lightweight (default for `haiku` alias) |
|
||||
|
||||
The canonical list lives in [`models.json`](./models.json) — the single source of truth as of v3.11.0. Both `server.mjs` (the `/v1/models` endpoint) and `setup.mjs` (the OpenClaw registration) derive from it. Adding a new model is now a one-file edit:
|
||||
@@ -831,12 +826,11 @@ lsof -nP -iTCP:3456 -sTCP:LISTEN
|
||||
If it's an old OCP process, stop it before re-running setup:
|
||||
|
||||
```bash
|
||||
launchctl bootout gui/$(id -u)/dev.ocp.proxy # macOS launchd
|
||||
systemctl --user stop ocp-proxy # Linux systemd (installed as a --user unit)
|
||||
ocp stop # if the CLI is on PATH
|
||||
launchctl bootout gui/$(id -u)/dev.ocp.proxy # macOS launchd fallback
|
||||
sudo systemctl stop ocp-proxy # Linux systemd fallback
|
||||
```
|
||||
|
||||
(There is no `ocp stop` subcommand — the proxy runs as a service, so stopping it goes through the service manager above. `ocp restart` exists for the bounce case.)
|
||||
|
||||
### Setup fails with "node: command not found" or version error
|
||||
|
||||
OCP requires Node.js 22.5+. Install:
|
||||
@@ -953,7 +947,6 @@ See [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) and ADR 0007 PR-
|
||||
| `CLAUDE_CACHE_TTL` | `0` | Response cache TTL (ms, 0 = disabled). Set to e.g. `300000` for 5-min cache |
|
||||
| `CLAUDE_ALLOWED_TOOLS` | `Bash,Read,...,Agent` | Comma-separated tools to pre-approve |
|
||||
| `CLAUDE_SKIP_PERMISSIONS` | `false` | Bypass all permission checks |
|
||||
| `CLAUDE_MCP_CONFIG` | *(unset)* | Path to an MCP server config JSON, passed to the spawned `claude` as `--mcp-config` (both the `-p` path and TUI `OCP_TUI_FULL_TOOLS` panes) |
|
||||
| `CLAUDE_NO_CONTEXT` | `false` | Suppress CLAUDE.md and auto-memory injection (pure API mode) |
|
||||
| `PROXY_API_KEY` | *(unset)* | Bearer token for shared-mode authentication |
|
||||
| `PROXY_ANONYMOUS_KEY` | *(unset)* | Well-known anonymous key allowlist (multi mode). When set, this exact string bypasses `validateKey()` and grants public access. Exposed via `/health.anonymousKey` only to localhost, or to all callers when `PROXY_ADVERTISE_ANON_KEY=1`. See [Anonymous Access](#anonymous-access-optional). |
|
||||
@@ -966,13 +959,9 @@ See [Subscription-pool (TUI) mode](#subscription-pool-tui-mode) and ADR 0007 PR-
|
||||
| `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_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_STREAM` | `0` (off) | (TUI-mode) When `=1`, `stream:true` requests emit **real SSE `delta.content` chunks as `claude` generates them**, instead of buffering the turn and replaying it. Deltas come from `claude`'s own `MessageDisplay` hook (registered with `--settings` on the ordinary interactive spawn — banner-verified to stay on the subscription pool, `· Claude Max`). Granularity is **block-level**, not token-level. The transcript remains authoritative: the streamed text is asserted equal to it at end-of-turn, the auth-banner and truncation gates still run before anything is committed, and only the transcript text is cached. A turn whose stream cannot be reconciled with the transcript is **refused** (SSE error frame, not cached) and counted as `tui.streamDivergences` on `/health`. A total hook failure (e.g. `--settings` stops registering it after a `claude` version bump) is a *different, silent* failure mode — every streamed turn still succeeds, fully buffered, with no divergence and no error — so it is counted separately as `tui.streamZeroDeltaTurns` (streamed turns where the hook fired **zero** times) and logged as `tui_stream_zero_deltas`; watch it alongside `streamDivergences`. Default off — the buffered path is unchanged and remains the stable default. ⚠️ **Tool-using turns:** the transcript keeps only the model's **last** assistant message, so if the model narrates before calling a tool ("I'll check that file…") and that narration exceeds `OCP_TUI_STREAM_HOLDBACK`, it has already been streamed and cannot be retracted — the turn is then **refused** rather than served (measured live: Opus narrated 475 chars before a `Bash` call). If your deployment lets the model use tools (the TUI default, and anything with `OCP_TUI_FULL_TOOLS=1`), either raise `OCP_TUI_STREAM_HOLDBACK` above the typical narration length — the narration then stays held back and is correctly discarded, at the cost of a later first chunk — or leave streaming off. Streaming is best suited to tool-light chat proxying. See ADR 0007 (2026-07-13 amendment). |
|
||||
| `OCP_TUI_STREAM_HOLDBACK` | `100` | (TUI-mode, streaming) Characters withheld before the first chunk reaches the client. Two jobs. (1) It keeps the **auth-banner gate** alive under streaming, via a guarantee with two required halves: (i) nothing is emitted for a message until its trimmed accumulation exceeds 100 chars — past the default banner detector's reach, since real banners are ≤100 chars — and (ii) once a message boundary follows an emit, nothing further is ever emitted for the rest of the turn, and the turn is refused outright. Half (i) alone only covers a turn's first message; half (ii) is what covers an error banner rendered as a *later* message (e.g. after tool-using prose). Raise the holdback if you replace the detector via `CLAUDE_TUI_ERROR_PATTERNS` with patterns that can match longer messages — that only affects half (i); OCP warns at boot if you do. (2) It is the knob for **tool-using turns** — see the `OCP_TUI_STREAM` caveat below. Answers shorter than the holdback are simply delivered whole at end-of-turn, exactly as the buffered path does. |
|
||||
| `OCP_TUI_STREAM_DIR` | `$HOME/.ocp-tui/stream` | (TUI-mode, streaming) Directory holding the static `MessageDisplay` hook script + settings file, and the per-session delta sink (`<session-id>.jsonl`, removed at turn teardown). One sink **per session-id** — this is what keeps concurrent TUI turns (`OCP_TUI_MAX_CONCURRENT` ≥ 2) from interleaving one client's deltas into another's stream. |
|
||||
| `OCP_TUI_STREAM_POLL_MS` | `100` | (TUI-mode, streaming) Interval at which OCP drains the delta sink. The hook fires at block granularity (seconds apart), so a finer poll buys nothing. |
|
||||
| `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_POOL_SIZE` | `0` (off) | (TUI-mode) Number of **pre-booted warm `claude` panes** kept ready, so a request does not pay the cold boot. `0` disables the pool entirely — the request path is then exactly the cold-boot path. Max `4`; an unparseable value disables it rather than guessing. **Measured on a Mac mini (Sonnet 4.6, `--effort low`): end-to-end p50 `10.17s` (n=6, pool off) → `6.00s` (n=12 warm hits) — −4.2 s / −41%** — the pool recovers both the ~1.2 s boot *and* ~2.9 s of post-input-bar init that a pane which has been idle a moment has already finished. **Cost:** each warm pane is a *live idle `claude` process* held whether or not a request ever arrives (peak processes ≈ pool size + `OCP_TUI_MAX_CONCURRENT` + 1 booting replacement) — which is why it is opt-in. Panes are **single-use**: one turn, then killed and replaced in the background. The **first request after start (and after any model switch) is always a cold miss** — the pool warms the most recently requested model, since OCP cannot know which model the next caller wants. See `docs/plans/2026-07-13-tui-latency/`. |
|
||||
| `OCP_SKIP_AUTH_TEST` | *(unset)* | When `=1`, skip the `claude -p` auth probe during `setup.mjs`. Under the announced (currently **paused**) 2026-06-15 billing split this probe would draw from the metered Agent SDK credit pool; set this to avoid burning a probe 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`, 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
|
||||
@@ -992,16 +981,14 @@ OCP also sends `X-Accel-Buffering: no` on SSE responses so nginx-default proxy b
|
||||
|
||||
### What it is and why
|
||||
|
||||
> **⚠️ Status (as of 2026-07): the billing split below is PAUSED.** Anthropic announced it for 2026-06-15, then paused it on the effective date — *"For now, nothing has changed: Claude Agent SDK, `claude -p`, and third-party app usage still draw from your subscription's usage limits"* ([official help article](https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan)). While the pause holds, OCP's default `-p` path bills the subscription and **TUI-mode is a hedge, not a necessity**. The table describes the *announced* regime, kept here because Anthropic says a reworked change will return (with advance notice) — everything in this section is ready to flip on that day.
|
||||
From 2026-06-15 Anthropic routes `claude` invocations by `cc_entrypoint`:
|
||||
|
||||
The announced routing keys `claude` invocations by `cc_entrypoint`:
|
||||
|
||||
| Launch method | `cc_entrypoint` | Billing pool (announced regime, currently paused) |
|
||||
| Launch method | `cc_entrypoint` | Billing pool |
|
||||
|---------------|-----------------|-------------|
|
||||
| `claude -p` / `--output-format` (OCP default) | `sdk-cli` | Agent SDK credit pool (~$20/mo on Pro) |
|
||||
| Interactive `claude` (no flags) | `cli` | Pro/Max subscription pool |
|
||||
|
||||
TUI-mode lets OCP serve requests via the interactive path so they bill against the subscription pool under that regime. The response is read from claude's native JSONL session transcript once the turn is complete, then replayed to the caller as a normal OpenAI completion or chunked SSE response.
|
||||
TUI-mode lets OCP serve requests via the interactive path so they bill against the subscription pool. The response is read from claude's native JSONL session transcript once the turn is complete, then replayed to the caller as a normal OpenAI completion or chunked SSE response.
|
||||
|
||||
### Billing-classifier labeling (`OCP_TUI_ENTRYPOINT`)
|
||||
|
||||
@@ -1047,7 +1034,7 @@ Then restart OCP. At boot you will see (with the env token set, isolated home au
|
||||
### What changes / what doesn't
|
||||
|
||||
- **Callers see no API change.** The response is a normal OpenAI completion object or chunked SSE — identical wire format.
|
||||
- **Real streaming is opt-in (`OCP_TUI_STREAM=1`), and off by default.** By default TUI-mode buffers the full response and replays it as chunked SSE — you see a delay, then the complete response. Set `OCP_TUI_STREAM=1` and `stream:true` turns emit real SSE `delta.content` chunks as `claude` renders them, sourced from `claude`'s own `MessageDisplay` hook (byte-faithful raw markdown, on the subscription pool, no `-p`). Two honest caveats: granularity is **block-level** — the hook fires once per rendered block, so a handful of chunks per answer, scaling with length, not token-by-token; and it moves the **first** byte, not the last, so a consumer that must parse a complete reply gains nothing. The transcript stays authoritative: every streamed turn is asserted against it at the end, and a turn whose stream disagrees is **failed rather than served** (watch `tui.streamDivergences` on `/health`). Evidence: [`docs/plans/2026-07-13-tui-latency/streaming-spike.md`](docs/plans/2026-07-13-tui-latency/streaming-spike.md).
|
||||
- **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.
|
||||
- **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.
|
||||
@@ -1087,7 +1074,7 @@ a sub-5-second budget. Full measurements and methodology:
|
||||
|
||||
### Monitoring drift via `/health`
|
||||
|
||||
`GET /health` includes a `tui` block so you can poll for a silent billing-pool drift (the top risk under the announced split, if it re-lands — a lost TTY flipping `cc_entrypoint` from `cli` to `sdk-cli` would still return answers but land in the metered pool). 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):
|
||||
|
||||
```jsonc
|
||||
"tui": {
|
||||
@@ -1126,11 +1113,9 @@ unset CLAUDE_TUI_MODE
|
||||
|
||||
The stream-json path is restored immediately. No other change is needed.
|
||||
|
||||
### Operator checklist for the (paused) billing split
|
||||
### 2026-06-15 operator checklist
|
||||
|
||||
> **Status:** the 2026-06-15 split never took effect — Anthropic paused it on the effective date (see the status note at the top of this section). **Nothing needs flipping while the pause holds.** The checklist is retained verbatim as the runbook for if/when a reworked change lands (Anthropic has promised advance notice).
|
||||
|
||||
Under the announced regime, every host serving traffic must be flipped to TUI-mode **and** canary-verified before the effective date, or it will bill the metered Agent SDK credit pool instead of the subscription.
|
||||
Every host serving traffic must be flipped to TUI-mode **and** canary-verified before 2026-06-15, or it will bill the metered Agent SDK credit pool instead of the subscription.
|
||||
|
||||
- **[Flip/rollback runbook](docs/runbooks/tui-flip-rollback.md)** — how to set `CLAUDE_TUI_MODE=true` on systemd (Linux) and launchd (macOS) hosts. Covers the `daemon-reload` requirement (systemd) and the `bootout`+`bootstrap` cycle requirement (launchd — `launchctl kickstart -k` does not reload plist env).
|
||||
- **[615-canary runbook](docs/runbooks/615-canary.md)** — after each flip, run one quiesced request and compare the Agent SDK credit balance before and after. `entrypoint:cli` in the transcript (the `cc_entrypoint` billing classifier) is necessary but not sufficient — only a stable credit balance confirms the subscription pool is being used. Balance check is a manual step (no known programmatic API for the Agent SDK credit pool balance).
|
||||
|
||||
@@ -56,7 +56,7 @@ Add `CLAUDE_TUI_MODE=true` as an opt-in flag in `server.mjs`.
|
||||
3. The serialized prompt (from `messagesToPrompt`) is pasted via `tmux send-keys … "$(cat file)"` + a separate `Enter` key event.
|
||||
4. The answer is read from claude's native JSONL transcript at `<HOME>/.claude/projects/<encoded-cwd>/<session-id>.jsonl`, polling until a `turn_duration` system event or the wall-clock cap (`CLAUDE_TUI_WALLCLOCK_MS`, default 120 s).
|
||||
5. The string answer is returned to OCP's existing downstream (singleflight → cache write-back → `completionResponse` / `streamStringAsSSE`) — **same contract as `callClaude`**.
|
||||
6. Streaming requests are buffered then replayed as chunked SSE (no real token streaming — deliberate; "don't build fragile features"). **Superseded for `stream:true` when `OCP_TUI_STREAM=1` — see the 2026-07-13 amendment below. The buffered path remains the default and is unchanged.**
|
||||
6. Streaming requests are buffered then replayed as chunked SSE (no real token streaming — deliberate; "don't build fragile features").
|
||||
|
||||
### Billing-classifier labeling (`OCP_TUI_ENTRYPOINT`, PR-4)
|
||||
|
||||
@@ -333,61 +333,6 @@ The original "Home strategy" section and PR-C's `prepareTuiHome` comment warned
|
||||
|
||||
---
|
||||
|
||||
## Amendment (2026-07-13) — real SSE streaming via the `MessageDisplay` hook (`OCP_TUI_STREAM`)
|
||||
|
||||
**Supersedes**: Request-flow step 6 above ("no real token streaming — deliberate"), for `stream:true`
|
||||
requests when `OCP_TUI_STREAM=1`. The buffered path stays the default and is byte-for-byte unchanged.
|
||||
|
||||
**Context.** Step 6 was written when the interactive CLI appeared to expose no byte-faithful
|
||||
incremental source. A prereq spike (`docs/plans/2026-07-13-tui-latency/streaming-spike.md`) confirmed
|
||||
three obvious sources are dead ends — the transcript JSONL grows one *whole event* at a time (the
|
||||
answer lands as a single line ~0.3 s before the terminal marker); `tmux capture-pane` yields a
|
||||
*rendered* view whose markdown source is unrecoverable (an H2 and a bold span produce identical ANSI);
|
||||
`--debug-file` logs stream *timing*, never stream *content*. Every interface that does emit
|
||||
`text_delta` (`--output-format stream-json`) requires `-p`, which moves the request to the **metered**
|
||||
`sdk-cli` pool — precisely what TUI-mode exists to avoid.
|
||||
|
||||
**Decision.** Consume `claude`'s own **`MessageDisplay`** hook, registered via `--settings` on the
|
||||
ordinary interactive spawn (no `-p`, no `--bare`). Each fire delivers the **raw markdown source** of an
|
||||
incremental `delta` on the hook's stdin. Verified live (claude 2.1.207, sonnet-4-6): banner stays
|
||||
`· Claude Max` and the transcript `entrypoint` stays `cli` (subscription pool); `concat(deltas) === T`
|
||||
byte-exactly; `T.startsWith(concat(deltas[0..n]))` at every *n*. This is **forwarding, not inventing**
|
||||
— ALIGNMENT.md **Class B**. No `cli.js` citation applies: the TUI spawn is OCP-owned surface (this
|
||||
ADR), the hook payload is claude's own published contract, and the SSE wire shapes are the OpenAI
|
||||
chat/completions streaming spec adopted by **ADR 0006** (the emitters are literally the `-p` path's).
|
||||
|
||||
**The transcript remains authoritative.** It is still the terminal-turn signal, still the source of the
|
||||
returned/cached text `T`, and still the input to the honesty gates (auth-banner detection C-1,
|
||||
`truncated` C-2). The delta stream is a low-latency **mirror**, never a replacement. At end of turn OCP
|
||||
asserts the streamed bytes against `T`: equal → serve; a strict *prefix* of `T` → top up from the
|
||||
transcript (client still receives exactly `T`); **not** a prefix → **refuse the turn** (SSE error frame,
|
||||
no cache, `tui.streamDivergences++`). Serving text the transcript disagrees with is the failure class
|
||||
ALIGNMENT.md exists to prevent, so streaming fails loud rather than degrading quietly.
|
||||
|
||||
**Consequences / constraints recorded for future authors:**
|
||||
|
||||
- **Opt-in, default OFF.** The buffered path is stable production; streaming does not change it.
|
||||
- **Per-`session_id` sink is mandatory, not an optimization.** `OCP_TUI_MAX_CONCURRENT` defaults to
|
||||
**2** — two `claude` panes already run concurrently. A single shared sink would interleave one
|
||||
client's deltas into another's stream. The hook writes to `<dir>/<session_id>.jsonl`, the path
|
||||
delivered through the *pane's own env* (`OCP_TUI_STREAM_FILE`); OCP reads only its own turn's file.
|
||||
Verified with two concurrent streamed turns (ALPHA/BRAVO): zero cross-contamination.
|
||||
- **Warm-pool compatible (a separate in-flight PR depends on this).** The hook script and the settings
|
||||
file are **static** — nothing request-specific is baked in at spawn time. The sink path derives from
|
||||
the session-id, which for a pre-booted pane is fixed at boot.
|
||||
- **The hook is synchronous** (`forceSyncExecution: true` — `claude` *blocks* on it). The hook script
|
||||
must write and exit; it does one `cat` append and nothing else. Measured: p50 **7.2 ms** per fire,
|
||||
~50 ms across a whole turn — noise against a 6–10 s turn. Do not add work to it.
|
||||
- **Thinking blocks do not fire the hook** — verified on a substantive Opus/`xhigh` reasoning turn (see
|
||||
the PR evidence), not merely inferred from the `final:true` call site. This must be **re-verified** if
|
||||
the hook is ever pointed at a new model/effort tier: a thinking delta reaching a client would be
|
||||
unretractable, and the `concat === T` assertion can only *detect* that after the fact, never prevent
|
||||
it. The first-bytes **holdback** (`OCP_TUI_STREAM_HOLDBACK`, default 100 chars) is the same
|
||||
prevention-not-detection reasoning applied to the auth-banner gate.
|
||||
- **Block-level granularity**, scaling with answer length — not token-level. Do not promise otherwise.
|
||||
- **It moves the first byte, not the last.** Only a progressively-rendering consumer benefits; it does
|
||||
not move TUI-mode's ~6 s TTFT floor.
|
||||
|
||||
## Provenance
|
||||
|
||||
TUI-mode originated in a prototype contributed via PR #101 (see the PR for author attribution). The productionization design is in `docs/superpowers/specs/2026-05-30-tui-mode-production-design.md`. Spikes S1–S6 / T1–T6 were validated live on the test host against `claude v2.1.158`.
|
||||
|
||||
@@ -6,74 +6,26 @@ import { join } from "node:path";
|
||||
import { mkdirSync, chmodSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
// Resolved LAZILY, on first getDb() — not at module top-level. Two reasons, and the second is
|
||||
// the bug this fixes:
|
||||
//
|
||||
// 1. Merely IMPORTING keys.mjs should not, as a side effect, create directories in the
|
||||
// operator's home.
|
||||
// 2. OCP_DIR_OVERRIDE exists so the test suite can point the key store at a scratch dir — and
|
||||
// because ESM hoists imports, a top-level `const OCP_DIR = ...` here would be evaluated
|
||||
// BEFORE an importing module's body could set the env var. Eager resolution made the
|
||||
// override unsettable in the one place that needs it. (test-features.mjs carried a comment
|
||||
// claiming it could "set env before the first getDb() call" — it could not, because nothing
|
||||
// here ever read an env var. So `npm test` wrote real, UNREVOKED api_keys rows into the
|
||||
// operator's live ~/.ocp/ocp.db: two per run, unbounded — 737 junk keys against 12 real ones
|
||||
// on the maintainer's host — and two concurrent runs raced one file, which is the ~1-in-6
|
||||
// flake in `listKeys includes quota fields`.)
|
||||
//
|
||||
// The override is gated on NODE_ENV === "test", and that gate is the ACTUAL guard. An earlier
|
||||
// cut of this fix relied on the variable merely having an awkward name — i.e. a naming convention
|
||||
// plus a comment — which is precisely the failure mode this whole change exists to indict (a
|
||||
// comment describing an intention that nothing enforces). The two-key gate means NEITHER var
|
||||
// alone does anything: a stray OCP_DIR_OVERRIDE with no NODE_ENV is inert, and NODE_ENV=test with
|
||||
// no override just resolves the default dir.
|
||||
//
|
||||
// This gate does NOT, by itself, prove a production daemon can't be redirected — an earlier
|
||||
// version of this comment overclaimed that ("a production server runs without NODE_ENV, so it
|
||||
// CANNOT honor the override no matter how the variable got in"). That is only true while the
|
||||
// daemon's env actually lacks NODE_ENV=test, which is an assumption, not something this file can
|
||||
// enforce. What makes it hold in the shipped configuration is defense-in-depth in OCP's launchers:
|
||||
// the plist/systemd units strip both vars on every (re)install (scripts/lib/plist-merge.mjs
|
||||
// NEVER_PRESERVE), and `ocp` restart's manual nohup fallback strips them (`env -u`). So a server
|
||||
// OCP itself started cannot carry the test-only redirection. The one residual path is an operator
|
||||
// who hand-launches `node server.mjs` with BOTH vars explicitly exported, bypassing every
|
||||
// launcher — a case no library-level gate can catch. The loud getDb() log below ("NOT the default
|
||||
// ~/.ocp/ocp.db") is the backstop there: a wrong key store is at least never silent (in
|
||||
// AUTH_MODE=multi that would otherwise be a total auth outage with nothing on /health to show it).
|
||||
function resolveOcpDir() {
|
||||
const override = process.env.NODE_ENV === "test" ? process.env.OCP_DIR_OVERRIDE : null;
|
||||
const dir = override || join(homedir(), ".ocp");
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
// Tighten the directory mode in case it already existed with broader permissions.
|
||||
try { chmodSync(dir, 0o700); } catch { /* ignore EPERM on pre-existing dirs */ }
|
||||
return dir;
|
||||
}
|
||||
const OCP_DIR = join(homedir(), ".ocp");
|
||||
mkdirSync(OCP_DIR, { recursive: true, mode: 0o700 });
|
||||
// Tighten the directory mode in case it already existed with broader permissions.
|
||||
try { chmodSync(OCP_DIR, 0o700); } catch { /* ignore EPERM on pre-existing dirs */ }
|
||||
const DB_PATH = join(OCP_DIR, "ocp.db");
|
||||
|
||||
let db;
|
||||
let dbPath; // resolved on first open, alongside the db handle
|
||||
|
||||
export function getDb() {
|
||||
if (!db) {
|
||||
dbPath = join(resolveOcpDir(), "ocp.db");
|
||||
// Say which store we opened. Silence was the other half of the bug: a server on the wrong
|
||||
// key store looks exactly like a server on the right one until every request 401s.
|
||||
if (dbPath !== join(homedir(), ".ocp", "ocp.db")) {
|
||||
console.error(`[keys] key store: ${dbPath} (NOT the default ~/.ocp/ocp.db)`);
|
||||
}
|
||||
db = new DatabaseSync(dbPath);
|
||||
db = new DatabaseSync(DB_PATH);
|
||||
db.exec("PRAGMA journal_mode = WAL");
|
||||
db.exec("PRAGMA foreign_keys = ON");
|
||||
initSchema();
|
||||
// Tighten mode on the DB file (0600) after creation / first open.
|
||||
try { chmodSync(dbPath, 0o600); } catch { /* ignore — same-user access still works */ }
|
||||
try { chmodSync(DB_PATH, 0o600); } catch { /* ignore — same-user access still works */ }
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
// Which file the key store actually opened. Exported so a test can ASSERT it is not the
|
||||
// operator's real db — the bug this replaced was invisible precisely because nothing checked.
|
||||
export function getDbPath() { return dbPath; }
|
||||
|
||||
function initSchema() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
@@ -474,5 +426,5 @@ export function findKey(idOrName) {
|
||||
}
|
||||
|
||||
export function closeDb() {
|
||||
if (db) { db.close(); db = null; dbPath = undefined; } // clear both — a path to a closed db is a footgun
|
||||
if (db) { db.close(); db = null; }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { rmSync } from "node:fs";
|
||||
|
||||
// TUI warm pane pool (docs/plans/2026-07-13-tui-latency backlog #3).
|
||||
//
|
||||
// WHAT IT IS: a small set of PRE-BOOTED `claude` panes, each already sitting at its
|
||||
@@ -306,16 +304,6 @@ export class TuiPanePool {
|
||||
_drop(pane, reason) {
|
||||
this.dropped++;
|
||||
try { this._killPane(pane.name); } catch { /* already gone */ }
|
||||
// F5: every drop path (expired / unhealthy / model_switch / drain / cancelled_late /
|
||||
// stale_boot) ends up here, and the reap tick drains the WHOLE pool on every tick — so
|
||||
// without this, every warm pane's sink orphans in streamDir with no GC path (killPane only
|
||||
// reaches the tmux session, never the pane's OWN files). Best-effort: pane.streamFile is
|
||||
// undefined for a still-booting identity (the sink path is only known once bootPane
|
||||
// resolves) and rmSync(force:true) is already a no-op on a missing file, so this never
|
||||
// throws into the reaper regardless of which drop path got here.
|
||||
if (pane.streamFile) {
|
||||
try { rmSync(pane.streamFile, { force: true }); } catch { /* best-effort GC */ }
|
||||
}
|
||||
this._log("info", "tui_pool_pane_dropped", { name: pane.name, reason });
|
||||
}
|
||||
}
|
||||
|
||||
+1
-35
@@ -144,35 +144,7 @@ export function recordTuiEntrypoint(tuiStats, observed, expectedMode = "cli") {
|
||||
// when the pool is off (the default). Reported as `pool: null` when off so the block's
|
||||
// shape stays stable, and as the pool's stats (size / warm / hits / misses / …) when on —
|
||||
// the operator's window onto both the hit rate and the standing idle-process cost.
|
||||
//
|
||||
// Streaming fields (backlog #2, OCP_TUI_STREAM) are ADDITIVE too:
|
||||
// streamEnabled — is real (MessageDisplay-hook) SSE streaming on for TUI turns?
|
||||
// streamTurns — streamed turns ATTEMPTED, counted before the truncation/auth-banner
|
||||
// gates run (F6) — so a turn REFUSED by those gates still shows up
|
||||
// here, which is exactly the turn an operator most wants visible.
|
||||
// Counting only turns that survived the gates would silently exclude
|
||||
// a turn's worst-case outcome from its own denominator.
|
||||
// streamDeltas — MessageDisplay hook fires OBSERVED, including held-back ones (F6) —
|
||||
// NOT only the ones forwarded to a client. This is what makes
|
||||
// streamZeroDeltaTurns meaningful: a turn can have streamDeltas
|
||||
// incrementing while still emitting nothing to the client (fully held
|
||||
// back, e.g. a short answer), which is healthy, vs. a hook that fired
|
||||
// zero times at all, which is not (see streamZeroDeltaTurns).
|
||||
// streamTopUps — turns where the delta stream was a safe PREFIX of the transcript but
|
||||
// not equal to it; OCP topped up from the transcript and served T.
|
||||
// Benign but worth watching — a persistent rate means the hook is
|
||||
// losing fires.
|
||||
// streamDivergences — turns REFUSED because emitted bytes were not a prefix of the
|
||||
// transcript. THE field to alert on for CORRECTNESS: it means the hook
|
||||
// and the transcript disagreed and OCP chose to fail rather than serve
|
||||
// unverifiable text.
|
||||
// streamZeroDeltaTurns — streamed turns where the hook fired ZERO times (F7). THE field to
|
||||
// alert on for AVAILABILITY: streamTopUps climbing is one fire dropped
|
||||
// here and there (benign); this climbing means the hook is not firing
|
||||
// AT ALL — e.g. `--settings` silently stopped registering it (a claude
|
||||
// version bump), or F3's truncated-script failure mode — and every
|
||||
// streamed turn is quietly degrading to fully-buffered with no error.
|
||||
export function buildTuiHealthBlock({ enabled, entrypointMode, maxConcurrent, streamEnabled = false }, tuiStats, semaphore, pool = null) {
|
||||
export function buildTuiHealthBlock({ enabled, entrypointMode, maxConcurrent }, tuiStats, semaphore, pool = null) {
|
||||
return {
|
||||
enabled,
|
||||
entrypointMode, // cli | auto | off
|
||||
@@ -182,11 +154,5 @@ export function buildTuiHealthBlock({ enabled, entrypointMode, maxConcurrent, st
|
||||
queued: semaphore.queued, // turns waiting for a slot
|
||||
maxConcurrent,
|
||||
pool: pool ? pool.stats() : null, // warm pane pool, or null when disabled
|
||||
streamEnabled,
|
||||
streamTurns: tuiStats.streamTurns ?? 0,
|
||||
streamDeltas: tuiStats.streamDeltas ?? 0,
|
||||
streamTopUps: tuiStats.streamTopUps ?? 0,
|
||||
streamDivergences: tuiStats.streamDivergences ?? 0,
|
||||
streamZeroDeltaTurns: tuiStats.streamZeroDeltaTurns ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
+9
-135
@@ -14,7 +14,6 @@ import { mkdtempSync, writeFileSync, readFileSync, mkdirSync, existsSync, rmSync
|
||||
import { tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readTuiTranscript } from "./transcript.mjs";
|
||||
import { prepareStreamHook, streamFilePath, parseDeltaChunk } from "./stream.mjs";
|
||||
|
||||
// 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
|
||||
@@ -168,10 +167,6 @@ const BOOT_MS = parseInt(process.env.OCP_TUI_BOOT_MS || "4000", 10);
|
||||
export const POOL_BOOT_MS = BOOT_MS * 5;
|
||||
const READY_POLL_MS = parseInt(process.env.OCP_TUI_READY_POLL_MS || "400", 10); // readiness / paste-verify poll interval
|
||||
const PASTE_VERIFY_MS = parseInt(process.env.OCP_TUI_PASTE_VERIFY_MS || "5000", 10); // max wait for pasted prompt to render
|
||||
// Hook-sink drain interval when streaming. 100ms: the hook fires at BLOCK granularity
|
||||
// (~5-7 fires per answer, seconds apart), so a finer poll buys nothing and a coarser one
|
||||
// would add visible lag to the first delta. Cheap — one readFileSync of a small file.
|
||||
const STREAM_POLL_MS = parseInt(process.env.OCP_TUI_STREAM_POLL_MS || "100", 10);
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
@@ -353,25 +348,7 @@ export function prepareTuiHome(realHome, tuiHome, cwd, { envTokenMode = false }
|
||||
// A-PATH ONLY: built-in tools are left enabled (acceptable single-user). Deployment B
|
||||
// (guest keys) MUST additionally pass --tools "" per spec §5.2(2) as the credential
|
||||
// wall before this argv is reachable for owner_tier=guest — guard that in PR-3 wiring.
|
||||
//
|
||||
// `stream` (optional, OCP_TUI_STREAM): { file, settings } — when present, the pane gets
|
||||
// (a) OCP_TUI_STREAM_FILE in its env — read by the static MessageDisplay hook script to
|
||||
// decide WHERE to append this pane's deltas. Delivered as env (not baked into the
|
||||
// settings file) so the settings file stays STATIC and a pre-booted warm pane works.
|
||||
// Verified live: a claude hook inherits the pane's environment.
|
||||
// (b) --settings <file> — registers the MessageDisplay hook.
|
||||
// VERIFIED LIVE (claude 2.1.207, this host) before shipping, because both were spawn-level
|
||||
// risks:
|
||||
// - the startup banner is UNCHANGED with --settings: "Sonnet 4.6 with low effort ·
|
||||
// Claude Max" (subscription pool). --settings is NOT a --bare-class flag — it does not
|
||||
// silently drop the subscription pool. Transcript entrypoint stayed "cli".
|
||||
// - --settings MERGES into the settings hierarchy, it does NOT clobber <HOME>/.claude/
|
||||
// settings.json: with --settings passed, the user-level settings.json's `env` block was
|
||||
// still applied to the hook's environment. So the isolated-HOME settings story the TUI
|
||||
// already relies on (permissions / additionalDirectories — see prepareTuiHome and the
|
||||
// OCP_TUI_FULL_TOOLS note above) survives intact.
|
||||
// When absent, the argv is byte-for-byte the pre-streaming argv.
|
||||
export function buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode, stream = null) {
|
||||
export function buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode) {
|
||||
// Deliver claude's env via an `env` prefix on the PANE COMMAND — tmux does NOT forward the
|
||||
// spawning process's environment to the pane, and `new-session -e` needs tmux ≥3.2 (the cloud
|
||||
// host runs 2.7), so this is the only portable, reliable mechanism (verified live 2026-06-01:
|
||||
@@ -415,8 +392,6 @@ export function buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode,
|
||||
if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
|
||||
sets.push(`CLAUDE_CODE_OAUTH_TOKEN=${shq(process.env.CLAUDE_CODE_OAUTH_TOKEN)}`);
|
||||
}
|
||||
// Streaming sink: the pane's own per-session delta file (see the `stream` note above).
|
||||
if (stream && stream.file) sets.push(`OCP_TUI_STREAM_FILE=${shq(stream.file)}`);
|
||||
const unset = ["CLAUDECODE", "ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN"];
|
||||
if (entrypointMode === "cli") sets.push("CLAUDE_CODE_ENTRYPOINT=cli");
|
||||
else if (entrypointMode === "auto") unset.push("CLAUDE_CODE_ENTRYPOINT"); // let claude self-classify via TTY
|
||||
@@ -473,10 +448,6 @@ export function buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode,
|
||||
effortArgs = ["--effort", "low"];
|
||||
}
|
||||
|
||||
// --settings registers the MessageDisplay hook. Omitted entirely when streaming is off,
|
||||
// so the OFF argv is byte-for-byte the pre-streaming argv.
|
||||
const settingsArgs = stream && stream.settings ? ["--settings", shq(stream.settings)] : [];
|
||||
|
||||
return [
|
||||
envPrefix,
|
||||
shq(claudeBin),
|
||||
@@ -484,7 +455,6 @@ export function buildTuiCmd(claudeBin, model, sessionId, ehome, entrypointMode,
|
||||
"--session-id", sessionId,
|
||||
...toolArgs,
|
||||
...effortArgs,
|
||||
...settingsArgs,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
@@ -534,16 +504,9 @@ export function poolPaneName(port, sessionId) {
|
||||
// readiness wait returns, so a pool that only learned the name on resolve could neither spare
|
||||
// the session from the reaper nor kill it on shutdown. Supplying BOTH also keeps the name's
|
||||
// hex suffix equal to the session-id's, so `tmux ls` correlates to the transcript file.
|
||||
// `streamDir` (optional, OCP_TUI_STREAM): install claude's MessageDisplay hook on this pane.
|
||||
// Done HERE, at boot — not at turn time — and that is the whole reason streaming survives the
|
||||
// WARM POOL: the hook script + settings file are STATIC (one pair per streamDir), and the only
|
||||
// per-turn thing, the sink path, is derived from the pane's own --session-id, which is fixed
|
||||
// right here. So a pre-booted pane already carries its hook and its own sink and streams exactly
|
||||
// like a cold-booted one; nothing request-specific is ever baked into the spawn.
|
||||
export async function bootTuiPane({
|
||||
model, claudeBin, home, realHome, cwd, port, entrypointMode = "cli",
|
||||
tmux = defaultTmux, sessionId = null, name = null, requireReady = false, bootMs = BOOT_MS,
|
||||
streamDir = null,
|
||||
}) {
|
||||
const sid = sessionId || randomUUID();
|
||||
// Port-scoped session name (F7 fix) — see sessionPrefixForPort / reapStaleTuiSessions
|
||||
@@ -565,15 +528,6 @@ export async function bootTuiPane({
|
||||
if (!existsSync(cwd)) mkdirSync(cwd, { recursive: true });
|
||||
prepareTuiHome(rhome, ehome, cwd, { envTokenMode });
|
||||
|
||||
// Streaming sink for THIS pane (see the streamDir note above). rmSync first so a
|
||||
// re-used session-id can never replay a previous turn's deltas.
|
||||
let streamFile = null, streamSettings = null;
|
||||
if (streamDir) {
|
||||
streamFile = streamFilePath(streamDir, sid);
|
||||
streamSettings = prepareStreamHook(streamDir);
|
||||
try { rmSync(streamFile, { force: true }); } catch { /* start from a fresh sink */ }
|
||||
}
|
||||
|
||||
// Minimal env for spawnSync (tmux itself). The pane's claude env comes exclusively
|
||||
// from the `env` prefix string built inside buildTuiCmd — tmux does NOT forward the
|
||||
// spawning process's env to the pane, so the {env} here is intentionally minimal.
|
||||
@@ -586,8 +540,7 @@ export async function bootTuiPane({
|
||||
// session or issue a billing request without a verified interactive context.
|
||||
const spawnResult = tmux(
|
||||
["new-session", "-d", "-s", tmuxName, "-x", "220", "-y", "50", "-c", cwd,
|
||||
buildTuiCmd(claudeBin, model, sid, ehome, entrypointMode,
|
||||
streamFile ? { file: streamFile, settings: streamSettings } : null)],
|
||||
buildTuiCmd(claudeBin, model, sid, ehome, entrypointMode)],
|
||||
{ env },
|
||||
);
|
||||
if (!spawnResult || spawnResult.status !== 0) {
|
||||
@@ -606,7 +559,7 @@ export async function bootTuiPane({
|
||||
// Cold path (pre-existing behaviour): readiness timed out; rely on paste-verify.
|
||||
console.error("[tui] input_not_ready", tmuxName);
|
||||
}
|
||||
return { name: tmuxName, sessionId: sid, model, ehome, streamFile, bootedAt: Date.now() };
|
||||
return { name: tmuxName, sessionId: sid, model, ehome, bootedAt: Date.now() };
|
||||
}
|
||||
|
||||
// Full per-request TUI lifecycle:
|
||||
@@ -629,33 +582,6 @@ export async function bootTuiPane({
|
||||
// pool refill so the next request finds a warm pane.
|
||||
// Returns { text, entrypoint } from readTuiTranscript (entrypoint is the billing-pool
|
||||
// classifier, e.g. "cli", or null if the transcript did not include a turn_duration).
|
||||
//
|
||||
// STREAMING (OCP_TUI_STREAM, default off). Pass `onDelta` and `streamDir`, and the pane's
|
||||
// MessageDisplay hook (installed by bootTuiPane; see lib/tui/stream.mjs) appends each raw
|
||||
// delta payload to the pane's own sink. This driver polls that sink and invokes onDelta(payload)
|
||||
// per fire while the turn is still generating. A WARM pane already carries its sink from boot
|
||||
// (pane.streamFile), so the pooled and cold paths stream identically.
|
||||
//
|
||||
// `streamDir` IS PASSED TO THE COLD BOOT UNCONDITIONALLY (not gated on `onDelta`) — F4 fix. The
|
||||
// spawn argv is this project's billing-classification surface: a caller with OCP_TUI_STREAM on
|
||||
// but THIS particular request non-streaming (stream:false) must still get the SAME argv whether
|
||||
// it lands on a pool HIT or a cold-boot MISS, because a pre-booted pool pane cannot know in
|
||||
// advance whether the request it will eventually serve wants streaming — it installs the hook
|
||||
// unconditionally whenever the pool is warming at all (see server.mjs's bootPane closure). Gating
|
||||
// the cold boot's hook install on `onDelta` made a stream:false request's argv depend on whether
|
||||
// it happened to hit the pool or miss it — the exact drift this surface cannot tolerate. Whether
|
||||
// the hook is actually POLLED is a separate, correctly-scoped decision: see `streaming` below,
|
||||
// gated on onDelta && streamFile, so a non-streaming turn never reads its own sink even though
|
||||
// the hook is running.
|
||||
//
|
||||
// The transcript stays AUTHORITATIVE regardless: it is still the terminal-turn signal, still the
|
||||
// source of the returned `text`, and still the input to the caller's honesty gates. The delta
|
||||
// stream is a low-latency MIRROR of it, never a replacement, and the caller asserts the two
|
||||
// agree. With onDelta AND streamDir both omitted, nothing here changes: no poll, no hook.
|
||||
//
|
||||
// `abortSignal` (optional): aborts the transcript wait, so a client that disconnects mid-turn
|
||||
// tears the pane down NOW (the finally below) instead of holding the pane — and therefore the
|
||||
// caller's semaphore slot — until the turn or the wallclock cap ends.
|
||||
export async function runTuiTurn({
|
||||
prompt,
|
||||
model,
|
||||
@@ -669,10 +595,6 @@ export async function runTuiTurn({
|
||||
tmux = defaultTmux,
|
||||
pool = null, // TuiPanePool | null — null (default) === today's cold-boot-only path
|
||||
onPane = null, // optional observer: ({ warm }) => void, for logging/metrics
|
||||
onDelta = null, // (payload) => void — invoked per MessageDisplay hook fire, mid-turn
|
||||
streamDir = null, // hook sink dir, passed to the COLD boot UNCONDITIONALLY (F4 — see above);
|
||||
// a warm pane brings its own, fixed at its own boot
|
||||
abortSignal = null,
|
||||
}) {
|
||||
// 1. Warm pane, or cold boot. A MISS is never an error — it is exactly today's path.
|
||||
let pane = pool ? pool.acquire(model) : null;
|
||||
@@ -685,36 +607,12 @@ export async function runTuiTurn({
|
||||
if (pool) pool.refill();
|
||||
if (onPane) { try { onPane({ warm }); } catch { /* observer must never break a turn */ } }
|
||||
if (!pane) {
|
||||
// streamDir passed AS-IS (not gated on onDelta) — F4: see the STREAMING comment above.
|
||||
pane = await bootTuiPane({ model, claudeBin, home, realHome, cwd, port, entrypointMode, tmux,
|
||||
streamDir });
|
||||
pane = await bootTuiPane({ model, claudeBin, home, realHome, cwd, port, entrypointMode, tmux });
|
||||
}
|
||||
const tmuxName = pane.name;
|
||||
const sessionId = pane.sessionId; // THIS pane's own session-id — one session, one turn
|
||||
const ehome = pane.ehome || home || process.env.HOME;
|
||||
|
||||
// Streaming state is read off the PANE, not recomputed here — a warm pane fixed its sink at
|
||||
// boot, and a cold one just did the same above. If the pool was booted WITHOUT a streamDir
|
||||
// while onDelta is set, streamFile is null and the turn degrades to buffered: correct, just
|
||||
// not fast. (server.mjs wires the same streamDir into both paths so that cannot happen.)
|
||||
const streamFile = pane.streamFile || null;
|
||||
const streaming = !!(onDelta && streamFile);
|
||||
const streamCursor = { consumed: 0 };
|
||||
let streamStopped = false;
|
||||
let pollTimer = null;
|
||||
// Drain every complete line appended since the last drain. Never throws into the turn: a
|
||||
// malformed line is skipped by parseDeltaChunk, and an onDelta that throws is contained.
|
||||
const drainDeltas = () => {
|
||||
if (!streaming) return;
|
||||
let text;
|
||||
try { text = readFileSync(streamFile, "utf8"); } catch { return; } // absent until the first fire
|
||||
const { deltas, consumed } = parseDeltaChunk(text, streamCursor.consumed);
|
||||
streamCursor.consumed = consumed;
|
||||
for (const d of deltas) {
|
||||
try { onDelta(d); } catch { /* a sink error must never abort the turn */ }
|
||||
}
|
||||
};
|
||||
|
||||
// Write prompt to a temp file (mode 0600) so the content never touches argv.
|
||||
const tmpDir = mkdtempSync(`${tmpdir()}/ocp-tui-`);
|
||||
const promptFile = `${tmpDir}/prompt.txt`;
|
||||
@@ -746,37 +644,13 @@ export async function runTuiTurn({
|
||||
// Submit (separate Enter key event).
|
||||
tmux(["send-keys", "-t", tmuxName, "Enter"]);
|
||||
|
||||
// 5a. Streaming only: start polling the hook sink. Runs CONCURRENTLY with the
|
||||
// transcript wait below — the deltas are what make the answer visible while the
|
||||
// turn is still generating; the transcript is what makes it authoritative.
|
||||
if (streaming) {
|
||||
const loop = () => {
|
||||
if (streamStopped) return;
|
||||
drainDeltas();
|
||||
pollTimer = setTimeout(loop, STREAM_POLL_MS);
|
||||
};
|
||||
pollTimer = setTimeout(loop, STREAM_POLL_MS);
|
||||
}
|
||||
|
||||
// 5b. Block on the native transcript (resolved by THIS pane's session-id) until terminal.
|
||||
// Returns { text, entrypoint, truncated } from readTuiTranscript.
|
||||
const result = await readTuiTranscript({ home: ehome, sessionId, wallclockMs, abortSignal });
|
||||
|
||||
// 5c. FINAL drain. The terminal marker can land between two poll ticks, so the last
|
||||
// delta(s) may still be unread — without this the tail would be missing from the
|
||||
// stream and every turn would need a transcript top-up.
|
||||
streamStopped = true;
|
||||
if (pollTimer) clearTimeout(pollTimer);
|
||||
drainDeltas();
|
||||
return result;
|
||||
// 5. Block on the native transcript (resolved by THIS pane's session-id) until terminal.
|
||||
// Returns { text, entrypoint } from readTuiTranscript.
|
||||
return await readTuiTranscript({ home: ehome, sessionId, wallclockMs });
|
||||
} finally {
|
||||
// 6. Teardown — always, even on throw (including an abortSignal disconnect, which is
|
||||
// exactly why the pane cannot outlive a client that walked away). A pooled pane is
|
||||
// torn down here exactly like a cold-booted one: SINGLE-USE, never returned (pool.mjs).
|
||||
streamStopped = true;
|
||||
if (pollTimer) clearTimeout(pollTimer);
|
||||
// 6. Teardown — always, even on throw. A pooled pane is torn down here exactly like a
|
||||
// cold-booted one: SINGLE-USE, never returned to the pool (see pool.mjs).
|
||||
try { tmux(["kill-session", "-t", tmuxName]); } catch { /* already gone */ }
|
||||
try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }
|
||||
if (streamFile) { try { rmSync(streamFile, { force: true }); } catch { /* best effort */ } }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,288 +0,0 @@
|
||||
// TUI-mode real SSE streaming — the `MessageDisplay` hook sink.
|
||||
//
|
||||
// WHAT THIS IS. `claude` fires a **MessageDisplay** hook per rendered block of the
|
||||
// assistant's reply, handing the hook the RAW MARKDOWN SOURCE of an incremental
|
||||
// `delta` on stdin. Registered via `--settings` on the ordinary interactive TUI spawn
|
||||
// (NO -p, NO --bare — the billing pool is untouched), it is the only byte-faithful
|
||||
// incremental source the interactive CLI exposes. Everything here consumes that hook
|
||||
// surface AS EMITTED — forwarding, not inventing.
|
||||
//
|
||||
// ALIGNMENT.md: **Class B**. We consume claude's own hook payload and re-emit it in the
|
||||
// OpenAI chat/completions streaming shapes OCP already speaks (ADR 0006). There is no
|
||||
// `cli.js` citation because no `cli.js` function is being mirrored: the TUI spawn is
|
||||
// OCP-owned surface (ADR 0007), and the hook payload is claude's own published contract.
|
||||
//
|
||||
// THE VERIFIED CONTRACT (docs/plans/2026-07-13-tui-latency/streaming-spike.md, and
|
||||
// independently reproduced on claude 2.1.207 / sonnet-4-6 / banner `· Claude Max`):
|
||||
//
|
||||
// payload (stdin, one JSON object per fire):
|
||||
// { hook_event_name:"MessageDisplay", session_id, transcript_path, prompt_id, cwd,
|
||||
// turn_id, message_id, index, final, delta }
|
||||
//
|
||||
// - deltas carry the raw markdown source (`## `, `**`, ```javascript all present)
|
||||
// - concat(deltas of one message) === T, byte-exactly (T = extractLatestAssistantText)
|
||||
// - T.startsWith(concat(deltas[0..n])) at EVERY n (prefix-stable)
|
||||
// - block-level granularity (~5-7 fires per answer), NOT token-level
|
||||
// - only `text` blocks fire it — thinking blocks are excluded (what OCP wants)
|
||||
//
|
||||
// ⚠️ THE HOOK IS SYNCHRONOUS. The hook's source sets `forceSyncExecution: true` —
|
||||
// `claude` BLOCKS on every fire. The hook script must therefore write and exit, doing
|
||||
// NO work inline. Measured cost of the script below: p50 7.2 ms / p90 14.7 ms per fire,
|
||||
// i.e. ~50 ms added blocking across a whole ~7-delta turn against a 6-10 s turn. That is
|
||||
// noise, so a plain append is the right sink — a FIFO would be faster on paper but a FIFO
|
||||
// blocks its writer until a reader attaches, which would hand `claude` a way to hang.
|
||||
//
|
||||
// WARM-POOL COMPATIBILITY (load-bearing — a warm pane pool is a separate in-flight PR).
|
||||
// The hook script and the settings file are BOTH STATIC: one copy per stream dir, written
|
||||
// once, never per-request. The per-turn destination is carried in the PANE'S OWN ENV as
|
||||
// `OCP_TUI_STREAM_FILE` (verified live: a hook inherits the pane's environment), and the
|
||||
// path is derived from the session-id — which for a pre-booted pane is fixed at BOOT.
|
||||
// Nothing about a request is baked into the settings file at spawn time, so a pane booted
|
||||
// before its request arrives streams exactly the same way.
|
||||
import { writeFileSync, mkdirSync, renameSync } from "node:fs";
|
||||
import { detectTuiUpstreamError } from "./transcript.mjs";
|
||||
|
||||
// Default holdback before the first byte is released to the client. See TuiDeltaAssembler.
|
||||
export const DEFAULT_HOLDBACK_CHARS = 100;
|
||||
|
||||
// Resolve OCP_TUI_STREAM_HOLDBACK to a SAFE value. The whole C-1 auth-banner guarantee rests
|
||||
// on the holdback being at least the default banner detector's max message length — which is
|
||||
// exactly DEFAULT_HOLDBACK_CHARS. So this is a FLOOR, not a hint: a smaller value (or a NaN
|
||||
// typo like "unlimited"/"5MB") would let a real banner fragment release before the terminal
|
||||
// detector could classify the whole message, silently reopening the leak the assembler exists
|
||||
// to prevent. The env var's own doc says "Only raise it"; this enforces that instead of trusting
|
||||
// it. Returns { value, clamped } so the caller can warn when it had to clamp — a silent floor is
|
||||
// less honest than a noticed one.
|
||||
export function resolveStreamHoldback(raw, floor = DEFAULT_HOLDBACK_CHARS) {
|
||||
const parsed = parseInt(raw ?? "", 10);
|
||||
if (!Number.isFinite(parsed)) return { value: floor, clamped: raw != null && String(raw).trim() !== "" };
|
||||
if (parsed < floor) return { value: floor, clamped: true };
|
||||
return { value: parsed, clamped: false };
|
||||
}
|
||||
|
||||
// The hook script. POSIX sh, no interpreter startup beyond /bin/sh, one fork (`cat`).
|
||||
//
|
||||
// - `printf` is a shell BUILTIN in sh/dash/bash, so the newline costs no fork.
|
||||
// - the `{ cat; printf '\n'; } >>` group opens the file ONCE and appends both writes
|
||||
// through the same O_APPEND fd, so a payload and its terminator can never be split
|
||||
// by another writer. (They never race anyway: one file per pane, and MessageDisplay
|
||||
// is synchronous within a pane.)
|
||||
// - a payload JSON can never contain a literal newline — JSON.stringify escapes them —
|
||||
// so "one line == one payload" holds, and a torn write is always a trailing partial
|
||||
// line, which parseDeltaChunk() leaves unconsumed until it completes.
|
||||
// - NO OCP_TUI_STREAM_FILE (e.g. a pane booted with streaming off, or any other claude
|
||||
// session that happens to load this settings file) => swallow stdin and exit 0. The
|
||||
// hook must NEVER fail or block: claude is waiting on it.
|
||||
export const HOOK_SCRIPT = `#!/bin/sh
|
||||
# OCP TUI streaming sink — claude fires this per MessageDisplay block and BLOCKS on it.
|
||||
# Write and exit. Never do work here.
|
||||
[ -n "\$OCP_TUI_STREAM_FILE" ] || exec cat >/dev/null
|
||||
{ cat; printf '\\n'; } >> "\$OCP_TUI_STREAM_FILE"
|
||||
`;
|
||||
|
||||
// The --settings payload registering the hook. Static: no per-request data.
|
||||
export function buildStreamSettings(hookScriptPath) {
|
||||
return { hooks: { MessageDisplay: [{ hooks: [{ type: "command", command: hookScriptPath }] }] } };
|
||||
}
|
||||
|
||||
export const hookScriptPath = (streamDir) => `${streamDir}/md-hook.sh`;
|
||||
export const streamSettingsPath = (streamDir) => `${streamDir}/settings.json`;
|
||||
// One file per session-id. For a pre-booted (warm) pane the session-id is fixed at boot,
|
||||
// so this path is knowable at boot — which is what keeps the pool compatible.
|
||||
export const streamFilePath = (streamDir, sessionId) => `${streamDir}/${sessionId}.jsonl`;
|
||||
|
||||
// Atomic write: temp file + rename (same-directory, same-filesystem, so rename is atomic on
|
||||
// POSIX). A process killed mid-`writeFileSync` leaves the TEMP file half-written, never the
|
||||
// real path — `path` always names either the old complete content or the new complete
|
||||
// content, never a torn one. That matters specifically for md-hook.sh: it is SYNCHRONOUS
|
||||
// (claude blocks on every fire), so a truncated script would still pass `existsSync`, still
|
||||
// get exec'd, and fail/hang on every single MessageDisplay fire with no operator-visible
|
||||
// symptom short of streaming going silently dead (F7's streamZeroDeltaTurns is the backstop
|
||||
// for exactly that). Mirrors ensureTuiCwdTrusted's tmp+renameSync pattern in session.mjs.
|
||||
function writeFileAtomic(path, content, mode) {
|
||||
const tmp = `${path}.${process.pid}.tmp`;
|
||||
writeFileSync(tmp, content, { mode });
|
||||
renameSync(tmp, path);
|
||||
}
|
||||
|
||||
// Write the static hook script + settings file into `streamDir`. UNCONDITIONAL, not
|
||||
// write-if-missing: these files persist across OCP restarts at `streamDir`, so a host that
|
||||
// booted once under an older version and never had its stream dir cleared would otherwise be
|
||||
// silently stuck on a stale HOOK_SCRIPT / buildStreamSettings() forever — no future OCP
|
||||
// upgrade could ever reach it. Safe to call every boot: the content is static (no per-request
|
||||
// data), so a same-content rewrite is the overwhelmingly common case and costs two tiny
|
||||
// atomic writes, not a per-turn expense. Returns the settings path to hand to `claude
|
||||
// --settings`.
|
||||
export function prepareStreamHook(streamDir) {
|
||||
mkdirSync(streamDir, { recursive: true });
|
||||
const script = hookScriptPath(streamDir);
|
||||
const settings = streamSettingsPath(streamDir);
|
||||
writeFileAtomic(script, HOOK_SCRIPT, 0o700);
|
||||
writeFileAtomic(settings, JSON.stringify(buildStreamSettings(script), null, 2), 0o600);
|
||||
return settings;
|
||||
}
|
||||
|
||||
// Parse newly-appended sink lines. `consumed` is the number of COMPLETE lines already
|
||||
// taken; only lines terminated by "\n" are complete, so a payload caught mid-write stays
|
||||
// unconsumed until its terminator lands. Returns the fresh MessageDisplay payloads plus
|
||||
// the new consumed count. Pure — the caller owns the cursor.
|
||||
export function parseDeltaChunk(text, consumed = 0) {
|
||||
const lines = String(text ?? "").split("\n");
|
||||
const complete = lines.slice(0, -1); // the tail after the last "\n" is a partial line
|
||||
const deltas = [];
|
||||
for (const line of complete.slice(consumed)) {
|
||||
const t = line.trim();
|
||||
if (!t) continue;
|
||||
try {
|
||||
const o = JSON.parse(t);
|
||||
if (o && o.hook_event_name === "MessageDisplay" && typeof o.delta === "string") deltas.push(o);
|
||||
} catch { /* not ours / not parseable — skip, never throw into the request path */ }
|
||||
}
|
||||
return { deltas, consumed: complete.length };
|
||||
}
|
||||
|
||||
// ── The assembler: hook deltas → client bytes, with the honesty gates intact ──
|
||||
//
|
||||
// Two jobs, both load-bearing.
|
||||
//
|
||||
// 1. THE AUTH-BANNER HOLDBACK (C-1 / issue #133 must survive streaming).
|
||||
// The interactive CLI renders an auth failure as ordinary assistant TEXT — so an
|
||||
// expired-credential turn fires MessageDisplay with the BANNER as its delta, and a
|
||||
// naive forwarder would stream "Please run /login · API Error: 401 …" to the client as
|
||||
// a normal answer, exactly the silent-error case C-1 exists to prevent.
|
||||
// detectTuiUpstreamError() classifies a WHOLE message, so it cannot be run per-delta.
|
||||
// Instead we HOLD BACK the first `holdbackChars` characters. The default detector only
|
||||
// ever fires on a message of <= 100 chars (TUI_ERR_MAX_LEN — real banners are 69 and 73),
|
||||
// so once the TRIMMED accumulation EXCEEDS 100 chars the final text cannot be a banner by
|
||||
// that detector's own length rule, and releasing is safe. An answer that never exceeds the
|
||||
// holdback is simply delivered whole at terminal — i.e. exactly today's buffered
|
||||
// behaviour, gates and all.
|
||||
// THE GUARANTEE HAS TWO HALVES, both required — neither alone is sufficient:
|
||||
// (i) Nothing is emitted for a message until its trimmed accumulation exceeds the
|
||||
// detector's max banner length. This is what keeps the FIRST message of a turn
|
||||
// safe: a banner-length message can never clear the holdback.
|
||||
// (ii) Once a message boundary follows an emit (`restartedAfterEmit`), push() stops
|
||||
// emitting ENTIRELY for the rest of the turn — a SECOND message (e.g. an
|
||||
// auth-failure banner rendered mid-turn, after tool-using prose already streamed)
|
||||
// gets zero bytes forwarded, not just a fresh holdback of its own. finalize() then
|
||||
// refuses the whole turn (SSE error frame, no cache) precisely because the first
|
||||
// message's bytes are unretractable and unverifiable against T. Without this half,
|
||||
// (i) alone only protects the FIRST message per turn — see F1.
|
||||
// ⚠️ Soundness is w.r.t. the DEFAULT detector. An operator who REPLACES it via
|
||||
// CLAUDE_TUI_ERROR_PATTERNS with a pattern that can match a longer message must raise
|
||||
// OCP_TUI_STREAM_HOLDBACK past their longest banner; server.mjs warns at boot. That is the
|
||||
// one case (i) does not cover — (ii) still applies regardless. Even past both, the
|
||||
// terminal gate still refuses to cache a banner and still ends the stream on an SSE error
|
||||
// frame rather than finish_reason:"stop" — the holdback is the first of two layers, not
|
||||
// the only one.
|
||||
//
|
||||
// 2. MESSAGE SCOPING (keeps `concat === T` the RIGHT assertion).
|
||||
// The transcript's T is extractLatestAssistantText() — the LAST text-bearing assistant
|
||||
// entry, not every assistant entry. A tool-using turn therefore has TWO messages
|
||||
// (prose → tool_use → answer) and T is only the second. So the assembler scopes to the
|
||||
// CURRENT message_id: when a new message_id appears and NOTHING has been emitted yet,
|
||||
// the held text is DISCARDED — the transcript is about to discard it too, so this keeps
|
||||
// us byte-identical to the buffered path instead of streaming prose the buffered path
|
||||
// would have dropped. When a new message_id appears AFTER we have already emitted, the
|
||||
// bytes are gone and cannot be retracted: finalize() then reports !ok and the caller
|
||||
// fails the turn loudly (SSE error frame, no cache, counted on /health). Fail-loud is
|
||||
// the correct posture — a proxy that silently serves text the transcript disagrees with
|
||||
// is the exact class of bug ALIGNMENT.md exists to prevent.
|
||||
// Sentinel for "no message seen yet". Deliberately not null/undefined — see the constructor.
|
||||
const NO_MESSAGE_YET = Symbol("no-message-yet");
|
||||
|
||||
export class TuiDeltaAssembler {
|
||||
constructor({ holdbackChars = DEFAULT_HOLDBACK_CHARS, detectError = detectTuiUpstreamError } = {}) {
|
||||
this.holdbackChars = holdbackChars;
|
||||
this.detectError = detectError;
|
||||
this.emitted = ""; // bytes ALREADY written to the client — unretractable
|
||||
this.pending = ""; // held back, not yet written
|
||||
this.released = false;
|
||||
// NOT null: a payload may legitimately carry message_id === null, and if the sentinel were
|
||||
// also null the FIRST such payload would compare equal to it, register no boundary, and
|
||||
// leave `messages` at 0 — which used to disarm the restartedAfterEmit guard below entirely.
|
||||
// A unique object is === to nothing a JSON payload can produce, so the first fire ALWAYS
|
||||
// registers as message 1, whatever its message_id is (or isn't).
|
||||
this.messageId = NO_MESSAGE_YET;
|
||||
this.deltas = 0; // hook fires seen
|
||||
this.messages = 0; // distinct message_ids seen
|
||||
this.restartedAfterEmit = false;
|
||||
}
|
||||
|
||||
// All hook bytes for the CURRENT message (emitted + still held).
|
||||
get full() { return this.emitted + this.pending; }
|
||||
|
||||
// Feed one MessageDisplay payload. Returns the text to emit NOW, or null (held back).
|
||||
push(payload) {
|
||||
const delta = payload && typeof payload.delta === "string" ? payload.delta : "";
|
||||
const mid = payload ? payload.message_id : null;
|
||||
if (mid !== this.messageId) {
|
||||
this.messageId = mid;
|
||||
this.messages++;
|
||||
if (this.emitted === "") {
|
||||
this.pending = ""; // safe: the transcript will drop this message too
|
||||
} else {
|
||||
// A boundary while bytes are ALREADY out is unrecoverable, full stop — the count of
|
||||
// messages seen so far is irrelevant. The old `else if (this.messages > 1)` guard was
|
||||
// the sole reason a null-message_id first payload could disarm F1: it left `messages`
|
||||
// at 0, so the real boundary evaluated 1 > 1 === false and never armed. The invariant
|
||||
// is "a boundary occurred while emitted !== ''", and that is exactly what this says.
|
||||
this.restartedAfterEmit = true; // unrecoverable — finalize() will refuse the turn
|
||||
}
|
||||
}
|
||||
this.deltas++;
|
||||
// F1: once a message boundary has followed an emit, the turn is ALREADY unrecoverable —
|
||||
// finalize() will refuse it (see restartedAfterEmit above). `this.released` stays true
|
||||
// from the FIRST message's release and, uncorrected, lets every later message's deltas
|
||||
// stream straight through unfiltered — exactly the auth-banner-mid-turn leak this class
|
||||
// exists to prevent. Stop emitting HERE, permanently, for the rest of the turn: there is
|
||||
// nothing left to gain from continuing to forward bytes for a turn that will be refused,
|
||||
// and every byte forwarded now is one more the client cannot be told to un-see.
|
||||
if (this.restartedAfterEmit) return null;
|
||||
if (!delta) return null;
|
||||
|
||||
if (this.released) {
|
||||
this.emitted += delta;
|
||||
return delta;
|
||||
}
|
||||
this.pending += delta;
|
||||
// Release only once the TRIMMED accumulation is past the banner detector's reach.
|
||||
// detectTuiUpstreamError() trims before measuring length (TUI_ERR_MAX_LEN is a trimmed-
|
||||
// length bound), so gating release on the UNTRIMMED pending.length let a run of >
|
||||
// holdbackChars whitespace trim down to "" — detectError("") sees nothing to classify,
|
||||
// returns null, and release fires with the holdback never having actually screened
|
||||
// anything. Trimming here keeps both sides of the check talking about the same string.
|
||||
if (this.pending.trim().length > this.holdbackChars && this.detectError(this.pending) == null) {
|
||||
const out = this.pending;
|
||||
this.pending = "";
|
||||
this.released = true;
|
||||
this.emitted += out;
|
||||
return out;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Reconcile against the AUTHORITATIVE transcript text T. Call only AFTER the truncation
|
||||
// and auth-banner gates have passed. Returns:
|
||||
// { ok:true, tail, exact } — tail is the remaining text to emit (may be ""). `exact`
|
||||
// is concat(deltas) === T; when false we still serve exactly
|
||||
// T, having topped up from the transcript, and the caller
|
||||
// counts a topUp.
|
||||
// { ok:false, ... } — what we already emitted is NOT a prefix of T. The client
|
||||
// holds bytes the transcript disagrees with; the caller must
|
||||
// NOT cache and must end the stream on an SSE error frame.
|
||||
finalize(T) {
|
||||
const text = typeof T === "string" ? T : "";
|
||||
const full = this.full;
|
||||
if (!text.startsWith(this.emitted)) {
|
||||
return { ok: false, tail: null, exact: false, emitted: this.emitted.length, transcript: text.length };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
tail: text.slice(this.emitted.length),
|
||||
exact: full === text,
|
||||
emitted: this.emitted.length,
|
||||
transcript: text.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
+1
-11
@@ -267,21 +267,11 @@ export function detectTuiUpstreamError(text, patternsRaw = process.env.CLAUDE_TU
|
||||
// Resolution: pass an explicit `transcriptPath` (used by unit tests), OR pass
|
||||
// `home` + `sessionId` to resolve by glob each poll (production) — the transcript
|
||||
// file does not exist until the turn starts, so resolution happens inside the loop.
|
||||
// `abortSignal` (optional): when it fires, stop waiting and throw TuiAbortError. The one
|
||||
// caller that passes it is the STREAMING TUI path, which ties it to the client's socket:
|
||||
// a client that disconnects mid-turn should not leave the pane running (and the caller's
|
||||
// concurrency slot held) until the turn or the 120s cap ends. runTuiTurn's finally does the
|
||||
// teardown. Omitted => the loop is byte-for-byte the pre-streaming loop.
|
||||
export async function readTuiTranscript({ transcriptPath: p, home, sessionId, wallclockMs = 120000, pollMs = 250, abortSignal = null }) {
|
||||
export async function readTuiTranscript({ transcriptPath: p, home, sessionId, wallclockMs = 120000, pollMs = 250 }) {
|
||||
const deadline = Date.now() + wallclockMs;
|
||||
let lastText = "";
|
||||
let lastEntrypoint = null;
|
||||
while (Date.now() < deadline) {
|
||||
if (abortSignal && abortSignal.aborted) {
|
||||
const err = new Error("tui_aborted: client disconnected before the turn completed");
|
||||
err.name = "TuiAbortError";
|
||||
throw err;
|
||||
}
|
||||
const resolved = p || findTranscriptPath(home, sessionId);
|
||||
if (resolved && existsSync(resolved)) {
|
||||
const events = parseTranscriptLines(readFileSync(resolved, "utf8"));
|
||||
|
||||
+1
-9
@@ -26,14 +26,6 @@
|
||||
"contextWindow": 200000,
|
||||
"maxTokens": 16384
|
||||
},
|
||||
{
|
||||
"id": "claude-sonnet-5",
|
||||
"displayName": "Claude Sonnet 5",
|
||||
"openclawName": "Claude Sonnet 5 (via CLI)",
|
||||
"reasoning": true,
|
||||
"contextWindow": 200000,
|
||||
"maxTokens": 16384
|
||||
},
|
||||
{
|
||||
"id": "claude-sonnet-4-6",
|
||||
"displayName": "Claude Sonnet 4.6",
|
||||
@@ -53,7 +45,7 @@
|
||||
],
|
||||
"aliases": {
|
||||
"opus": "claude-opus-4-8",
|
||||
"sonnet": "claude-sonnet-5",
|
||||
"sonnet": "claude-sonnet-4-6",
|
||||
"haiku": "claude-haiku-4-5-20251001"
|
||||
},
|
||||
"legacyAliases": {
|
||||
|
||||
@@ -622,12 +622,7 @@ cmd_restart() {
|
||||
self_r="${BASH_SOURCE[0]}"
|
||||
while [[ -L "$self_r" ]]; do self_r="$(readlink "$self_r")"; done
|
||||
script_dir="$(cd "$(dirname "$self_r")" && pwd)"
|
||||
# env -u strips test-only key-store redirection vars (A4): if the invoking shell had
|
||||
# NODE_ENV=test + OCP_DIR_OVERRIDE exported (e.g. from a debugging session), this manual
|
||||
# fallback would otherwise inherit them and start the daemon against a scratch/empty key
|
||||
# store — a silent auth outage in AUTH_MODE=multi. The plist/systemd paths strip these via
|
||||
# plist-merge's NEVER_PRESERVE; this covers the one direct-launch path OCP controls.
|
||||
DISABLE_AUTOUPDATER=1 env -u NODE_ENV -u OCP_DIR_OVERRIDE nohup node "$script_dir/server.mjs" >> "$HOME/.ocp/logs/proxy.log" 2>&1 &
|
||||
DISABLE_AUTOUPDATER=1 nohup node "$script_dir/server.mjs" >> "$HOME/.ocp/logs/proxy.log" 2>&1 &
|
||||
fi
|
||||
sleep 3
|
||||
if curl -sf --max-time 5 "$PROXY/health" > /dev/null 2>&1; then
|
||||
|
||||
+10
-21
@@ -122,17 +122,11 @@ provider = {
|
||||
"models": []
|
||||
}
|
||||
|
||||
# Model metadata mapping. Prefix match on the model FAMILY (claude-opus / -sonnet /
|
||||
# -haiku), not a pinned version. A version-pinned prefix like "claude-sonnet-4"
|
||||
# silently misses "claude-sonnet-5" and falls through to the non-reasoning /
|
||||
# 8k-output default (PR #152 review) — every future Sonnet/Opus/Haiku bump would
|
||||
# re-trip it. Family prefixes classify any versioned ID correctly with no per-model
|
||||
# edit. (ADR 0003: models.json is the SPOT for model existence; /v1/models does not
|
||||
# expose reasoning/maxTokens, so family classification stays here.)
|
||||
# Model metadata mapping (prefix match for versioned IDs like claude-haiku-4-5-20251001)
|
||||
model_meta = {
|
||||
"claude-opus": {"name": "Claude Opus (OCP)", "reasoning": True, "maxTokens": 16384},
|
||||
"claude-sonnet": {"name": "Claude Sonnet (OCP)", "reasoning": True, "maxTokens": 16384},
|
||||
"claude-haiku": {"name": "Claude Haiku (OCP)", "reasoning": False, "maxTokens": 8192},
|
||||
"claude-opus-4": {"name": "Claude Opus (OCP)", "reasoning": True, "maxTokens": 16384},
|
||||
"claude-sonnet-4": {"name": "Claude Sonnet (OCP)", "reasoning": True, "maxTokens": 16384},
|
||||
"claude-haiku-4": {"name": "Claude Haiku (OCP)", "reasoning": False, "maxTokens": 8192},
|
||||
}
|
||||
|
||||
def get_model_meta(mid):
|
||||
@@ -184,11 +178,11 @@ config.setdefault("agents", {})
|
||||
config["agents"].setdefault("defaults", {})
|
||||
config["agents"]["defaults"].setdefault("models", {})
|
||||
|
||||
# Build alias map (family prefix match — version-agnostic, see model_meta note)
|
||||
# Build alias map (prefix match)
|
||||
alias_prefixes = {
|
||||
"claude-opus": "Claude Opus",
|
||||
"claude-sonnet": "Claude Sonnet",
|
||||
"claude-haiku": "Claude Haiku",
|
||||
"claude-opus-4": "Claude Opus",
|
||||
"claude-sonnet-4": "Claude Sonnet",
|
||||
"claude-haiku-4": "Claude Haiku",
|
||||
}
|
||||
|
||||
for mid in model_ids:
|
||||
@@ -202,13 +196,8 @@ for mid in model_ids:
|
||||
|
||||
# Handle primary/backup
|
||||
if priority == "1":
|
||||
# OCP as primary — pick the best model (prefer the latest Sonnet for daily use,
|
||||
# tracking the `sonnet` alias default in models.json; fall back across versions).
|
||||
_sonnet_pref = ["claude-sonnet-5", "claude-sonnet-4-6"]
|
||||
primary_model = next(
|
||||
(provider_name + "/" + m for m in _sonnet_pref if m in model_ids),
|
||||
provider_name + "/" + model_ids[0],
|
||||
)
|
||||
# OCP as primary — pick the best model (prefer sonnet for daily use)
|
||||
primary_model = provider_name + "/claude-sonnet-4-6" if "claude-sonnet-4-6" in model_ids else provider_name + "/" + model_ids[0]
|
||||
config["agents"]["defaults"].setdefault("model", {})
|
||||
config["agents"]["defaults"]["model"]["primary"] = primary_model
|
||||
# Keep existing fallbacks
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "open-claude-proxy",
|
||||
"version": "3.22.1",
|
||||
"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.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -8,19 +8,6 @@
|
||||
//
|
||||
// No new dependencies — regex-based, plist <key>X</key><string>Y</string> shape
|
||||
// is stable enough for our hand-written templates in setup.mjs.
|
||||
//
|
||||
// SECURITY DENYLIST (A4): keys that must NEVER be carried into a service unit, even when a
|
||||
// prior unit already contained them. OCP's key store honors OCP_DIR_OVERRIDE only when
|
||||
// NODE_ENV === "test" (keys.mjs). If BOTH somehow reached a daemon's environment, the server
|
||||
// would open a scratch/empty key store instead of ~/.ocp/ocp.db — in AUTH_MODE=multi a silent
|
||||
// total auth outage. The preservation rule below ("keys only in EXISTING are kept verbatim")
|
||||
// is exactly a vector for that: a unit that once carried these test-only vars would otherwise
|
||||
// survive every setup re-run. So we strip them from the preserved set unconditionally. This is
|
||||
// defense-in-depth: setup.mjs's own template never injects them, so the only way they enter is
|
||||
// preservation, and this closes it. (The residual path — a hand-rolled `node server.mjs` with
|
||||
// both vars exported — is out of any launcher's reach; keys.mjs's loud "NOT the default" log is
|
||||
// the backstop there.)
|
||||
export const NEVER_PRESERVE = new Set(["NODE_ENV", "OCP_DIR_OVERRIDE"]);
|
||||
|
||||
// Note: setup.mjs XML-escapes all injected values before writing (via xmlEscape()),
|
||||
// so raw `<` / `>` / `&` never appear in plist <string> bodies — the [^<]* regex below is safe.
|
||||
@@ -49,7 +36,7 @@ export function mergePlistEnv(existing, template) {
|
||||
|
||||
const preserved = {};
|
||||
for (const [k, v] of Object.entries(existingEnv)) {
|
||||
if (!KNOWN.has(k) && !NEVER_PRESERVE.has(k)) preserved[k] = v;
|
||||
if (!KNOWN.has(k)) preserved[k] = v;
|
||||
}
|
||||
if (Object.keys(preserved).length === 0) return template;
|
||||
|
||||
@@ -85,7 +72,7 @@ export function mergeSystemdEnv(existing, template) {
|
||||
const KNOWN = new Set(Object.keys(templateEnv));
|
||||
|
||||
const preservedLines = Object.entries(existingEnv)
|
||||
.filter(([k]) => !KNOWN.has(k) && !NEVER_PRESERVE.has(k))
|
||||
.filter(([k]) => !KNOWN.has(k))
|
||||
.map(([k, v]) => `Environment=${k}=${v}`);
|
||||
if (preservedLines.length === 0) return template;
|
||||
|
||||
|
||||
+29
-349
@@ -47,7 +47,6 @@ import { runTuiTurn, reapStaleTuiSessions, resolveTuiHome, bootTuiPane, tuiPaneH
|
||||
import { detectTuiUpstreamError } from "./lib/tui/transcript.mjs";
|
||||
import { TuiSemaphore, SemaphoreAbortError, recordTuiEntrypoint, buildTuiHealthBlock } from "./lib/tui/semaphore.mjs";
|
||||
import { TuiPanePool, resolvePoolSize, POOL_MAX_SIZE } from "./lib/tui/pool.mjs";
|
||||
import { TuiDeltaAssembler, DEFAULT_HOLDBACK_CHARS, resolveStreamHoldback } from "./lib/tui/stream.mjs";
|
||||
import { createSerialMutex, createTtlCache, isTokenExpiring, orderLabelsLastGoodFirst } from "./lib/spawn-auth.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
@@ -97,40 +96,8 @@ function _collectNodeManagerCandidates(home) {
|
||||
|
||||
return out;
|
||||
}
|
||||
function _joinIfBase(base, ...parts) {
|
||||
return base ? join(base, ...parts) : null;
|
||||
}
|
||||
function _collectWindowsClaudeCandidates() {
|
||||
const userProfile = process.env.USERPROFILE || process.env.HOME || "";
|
||||
const localAppData = process.env.LOCALAPPDATA || "";
|
||||
return [
|
||||
_joinIfBase(userProfile, ".local", "bin", "claude.exe"),
|
||||
_joinIfBase(localAppData, "Microsoft", "WinGet", "Links", "claude.exe"),
|
||||
_joinIfBase(localAppData, "Microsoft", "WindowsApps", "claude.exe"),
|
||||
].filter(Boolean);
|
||||
}
|
||||
function _isWindowsSpawnableBinary(path) {
|
||||
return /\.exe$/i.test(path);
|
||||
}
|
||||
function _lookupLines(out) {
|
||||
return out.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
|
||||
}
|
||||
function _warnUnspawnableWindowsMatches(lines) {
|
||||
const unspawnable = lines.filter(p => !/\.exe$/i.test(p));
|
||||
if (unspawnable.length > 0) {
|
||||
console.warn(`[init] Ignoring non-exe Windows claude command(s): ${unspawnable.join(", ")}`);
|
||||
}
|
||||
}
|
||||
function resolveClaude() {
|
||||
const isWin = process.platform === "win32";
|
||||
if (process.env.CLAUDE_BIN) {
|
||||
if (isWin && !_isWindowsSpawnableBinary(process.env.CLAUDE_BIN)) {
|
||||
console.error(
|
||||
`FATAL: CLAUDE_BIN="${process.env.CLAUDE_BIN}" is not a native Windows executable.\n` +
|
||||
" Set CLAUDE_BIN to claude.exe; shell shims cannot be spawned without a shell."
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
accessSync(process.env.CLAUDE_BIN, constants.X_OK);
|
||||
return process.env.CLAUDE_BIN;
|
||||
@@ -140,43 +107,28 @@ function resolveClaude() {
|
||||
}
|
||||
}
|
||||
|
||||
const home = process.env.HOME || process.env.USERPROFILE || "";
|
||||
const candidates = isWin
|
||||
? _collectWindowsClaudeCandidates()
|
||||
: [
|
||||
"/opt/homebrew/bin/claude",
|
||||
"/usr/local/bin/claude",
|
||||
"/usr/bin/claude",
|
||||
join(home, ".local/bin/claude"),
|
||||
..._collectNodeManagerCandidates(home),
|
||||
];
|
||||
const home = process.env.HOME || "";
|
||||
const candidates = [
|
||||
"/opt/homebrew/bin/claude",
|
||||
"/usr/local/bin/claude",
|
||||
"/usr/bin/claude",
|
||||
join(home, ".local/bin/claude"),
|
||||
..._collectNodeManagerCandidates(home),
|
||||
];
|
||||
for (const p of candidates) {
|
||||
try { accessSync(p, constants.X_OK); console.warn(`[init] CLAUDE_BIN not set, resolved to ${p}`); return p; } catch {}
|
||||
}
|
||||
|
||||
if (isWin) {
|
||||
try {
|
||||
const lines = _lookupLines(execFileSync("where.exe", ["claude"], { encoding: "utf8", timeout: 5000 }));
|
||||
const resolved = lines.find(_isWindowsSpawnableBinary);
|
||||
if (resolved) { console.warn(`[init] CLAUDE_BIN not set, resolved via where.exe: ${resolved}`); return resolved; }
|
||||
_warnUnspawnableWindowsMatches(lines);
|
||||
} catch {}
|
||||
} else {
|
||||
try {
|
||||
const resolved = execFileSync("which", ["claude"], { encoding: "utf8", timeout: 5000 }).trim();
|
||||
if (resolved) { console.warn(`[init] CLAUDE_BIN not set, resolved via which: ${resolved}`); return resolved; }
|
||||
} catch {}
|
||||
}
|
||||
try {
|
||||
const resolved = execFileSync("which", ["claude"], { encoding: "utf8", timeout: 5000 }).trim();
|
||||
if (resolved) { console.warn(`[init] CLAUDE_BIN not set, resolved via which: ${resolved}`); return resolved; }
|
||||
} catch {}
|
||||
|
||||
console.error(
|
||||
"FATAL: claude binary not found.\n" +
|
||||
(isWin
|
||||
? " Set CLAUDE_BIN to the absolute path of claude.exe or ensure claude.exe is in PATH.\n" +
|
||||
" Hint: npm .cmd/.bat/.ps1 shims cannot be spawned without a shell.\n" +
|
||||
" The .exe requirement is an intentional allow-list for shell-less spawning.\n"
|
||||
: " Set CLAUDE_BIN=/path/to/claude or ensure claude is in PATH.\n" +
|
||||
" Hint: if you use nvm/fnm/asdf, set CLAUDE_BIN to the absolute path\n" +
|
||||
" shown by `which claude` in your interactive shell.\n") +
|
||||
" Set CLAUDE_BIN=/path/to/claude or ensure claude is in PATH.\n" +
|
||||
" Hint: if you use nvm/fnm/asdf, set CLAUDE_BIN to the absolute path\n" +
|
||||
" shown by `which claude` in your interactive shell.\n" +
|
||||
" Checked: " + candidates.join(", ")
|
||||
);
|
||||
process.exit(1);
|
||||
@@ -400,61 +352,8 @@ const tuiSemaphore = new TuiSemaphore(TUI_MAX_CONCURRENT);
|
||||
const tuiStats = {
|
||||
lastEntrypoint: null, // last observed cc_entrypoint from the transcript ("cli" | "sdk-cli" | null)
|
||||
entrypointMismatches: 0, // count of cli-expected-but-got-other turns
|
||||
streamTurns: 0, // streamed TUI turns ATTEMPTED (counted before the honesty gates — F6)
|
||||
streamDeltas: 0, // MessageDisplay hook fires OBSERVED (forwarded + held-back — F6)
|
||||
streamTopUps: 0, // turns where the delta stream != T but was a safe PREFIX of it
|
||||
streamDivergences: 0, // turns REFUSED: emitted bytes were not a prefix of T
|
||||
streamZeroDeltaTurns: 0, // streamed turns where the hook fired ZERO times (F7 — the hook is
|
||||
// dead, not just one fire dropped; distinct from streamTopUps)
|
||||
};
|
||||
|
||||
// ── TUI real streaming (backlog #2) — opt-in; default OFF ────────────────
|
||||
// When ON *and* TUI_MODE is on *and* the client asked for stream:true, the turn is emitted
|
||||
// as real SSE delta.content chunks as claude renders them, sourced from claude's own
|
||||
// MessageDisplay hook (lib/tui/stream.mjs). When OFF, the buffered
|
||||
// callClaudeTui → streamStringAsSSE path below is byte-for-byte unchanged — the spawn does
|
||||
// not even get --settings. Opt-in is deliberate: the buffered path is stable production.
|
||||
//
|
||||
// Honest expectation (docs/plans/2026-07-13-tui-latency/streaming-spike.md): this moves the
|
||||
// FIRST byte, not the last. A consumer that must parse a complete reply gains nothing; a
|
||||
// progressively-rendering chat UI gains the ~4s between first delta and last. It does not
|
||||
// move the ~6s TTFT floor of TUI mode.
|
||||
const TUI_STREAM = process.env.OCP_TUI_STREAM === "1";
|
||||
const TUI_STREAM_DIR = process.env.OCP_TUI_STREAM_DIR || `${process.env.HOME}/.ocp-tui/stream`;
|
||||
// First-bytes holdback — the auth-banner gate's (C-1) survival mechanism under streaming.
|
||||
// See TuiDeltaAssembler: nothing is emitted for a message until its TRIMMED accumulation
|
||||
// exceeds this, which puts it out of the default banner detector's <=100-char reach — the
|
||||
// FIRST of the two halves of the guarantee (see the assembler's class comment for the second:
|
||||
// no further emission at all once a message boundary follows an emit). Only raise it.
|
||||
// resolveStreamHoldback enforces the DEFAULT_HOLDBACK_CHARS floor: the "Only raise it" comment
|
||||
// above is now load-bearing, not advisory. A sub-floor value (or garbage) is clamped UP to the
|
||||
// floor and reported via `_holdback.clamped`, because a holdback below the default banner
|
||||
// detector's 100-char reach would let the first chars of a real auth banner stream before the
|
||||
// end-of-turn gate rejects the turn (the A1 leak). We can only ever raise the guarantee, never
|
||||
// weaken it below the detector's bound.
|
||||
const _holdback = resolveStreamHoldback(process.env.OCP_TUI_STREAM_HOLDBACK);
|
||||
const TUI_STREAM_HOLDBACK = _holdback.value;
|
||||
if (TUI_MODE && TUI_STREAM && _holdback.clamped) {
|
||||
console.error(
|
||||
`[tui] WARNING: OCP_TUI_STREAM_HOLDBACK=${JSON.stringify(process.env.OCP_TUI_STREAM_HOLDBACK)} is below the\n` +
|
||||
` safe floor (${DEFAULT_HOLDBACK_CHARS}) or not a number; clamped up to ${DEFAULT_HOLDBACK_CHARS}. The holdback can only be raised.`
|
||||
);
|
||||
}
|
||||
if (TUI_MODE && TUI_STREAM && process.env.CLAUDE_TUI_ERROR_PATTERNS != null && TUI_STREAM_HOLDBACK <= DEFAULT_HOLDBACK_CHARS) {
|
||||
// The holdback's FIRST-MESSAGE half (see TuiDeltaAssembler) is sound for the DEFAULT
|
||||
// auth-banner detector (which cannot match a message longer than 100 chars). An
|
||||
// operator-supplied pattern set has no such bound, so a banner longer than the holdback
|
||||
// could reach the client before the terminal gate rejects the turn. (The second half — no
|
||||
// further emission once a message boundary follows an emit — holds regardless of the
|
||||
// detector; this warning is only about the first-message case.)
|
||||
console.error(
|
||||
`[tui] WARNING: OCP_TUI_STREAM=1 with a custom CLAUDE_TUI_ERROR_PATTERNS and holdback=${TUI_STREAM_HOLDBACK}.\n` +
|
||||
" The streaming holdback's first-message coverage is sound only against the DEFAULT banner\n" +
|
||||
" detector (<=100 chars). Raise OCP_TUI_STREAM_HOLDBACK above your longest custom banner, or\n" +
|
||||
" the first chars of one could be streamed before the end-of-turn gate refuses the turn."
|
||||
);
|
||||
}
|
||||
|
||||
// ── Warm pane pool (docs/plans/2026-07-13-tui-latency #3) — opt-in; default OFF ─────────
|
||||
// OCP_TUI_POOL_SIZE=0 (default) => tuiPool is null => runTuiTurn's cold-boot path is
|
||||
// byte-for-byte unchanged. Set it to N (clamped to POOL_MAX_SIZE) to keep N pre-booted
|
||||
@@ -490,14 +389,6 @@ const tuiPool = TUI_POOL_SIZE > 0
|
||||
name: ident.name,
|
||||
requireReady: true, // a pane that never reached its input bar must not be enlisted
|
||||
bootMs: POOL_BOOT_MS, // background pre-boot — no client is blocked, so be patient
|
||||
// Warm panes must carry the MessageDisplay hook too, or every pool HIT would
|
||||
// silently fall back to buffered while every MISS streamed — the two paths have to
|
||||
// spawn identically (F4). Gated on TUI_STREAM, the deployment-wide switch — NOT on any
|
||||
// particular request's stream:true/false, which does not exist yet at pre-boot time.
|
||||
// The runTuiTurn cold-boot call site (callClaudeTui, below) mirrors this exact gate for
|
||||
// the same reason. bootTuiPane derives the sink from the pane's own session-id, which is
|
||||
// minted above, so nothing request-specific is baked in at pre-boot time.
|
||||
streamDir: TUI_STREAM ? TUI_STREAM_DIR : null,
|
||||
}),
|
||||
killPane: (name) => { try { spawnSync(process.env.OCP_TUI_TMUX_BIN || "tmux", ["kill-session", "-t", name]); } catch { /* already gone */ } },
|
||||
paneHealthy: (name) => tuiPaneHealthy((args) => spawnSync(process.env.OCP_TUI_TMUX_BIN || "tmux", args, { encoding: "utf8" }), name),
|
||||
@@ -534,12 +425,7 @@ const SPAWN_HOME_DIR = `${process.env.HOME}/.ocp/spawn-home`;
|
||||
// erroring loudly — never a silent auth/credential corruption (there are no credentials here).
|
||||
function prepareSpawnHome(dir = SPAWN_HOME_DIR) {
|
||||
try {
|
||||
// mode 0700, and it matters for the PARENT: with `recursive`, this call can create ~/.ocp
|
||||
// itself on a fresh install (spawn homes live under it), and without an explicit mode that
|
||||
// parent lands at the umask default — world-listable 0755. keys.mjs used to pre-create it
|
||||
// 0700 as an import side effect; it no longer does (it resolves its dir lazily), so the
|
||||
// 0700 guarantee has to be stated here rather than inherited by luck.
|
||||
mkdirSync(`${dir}/.claude`, { recursive: true, mode: 0o700 });
|
||||
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 */ }
|
||||
@@ -1468,14 +1354,7 @@ async function callClaude(model, messages, conversationId, keyName, res) {
|
||||
// Authority: claude CLI v2.1.158 interactive mode (cc_entrypoint=cli).
|
||||
// SECURITY: A-path single-user ONLY — home is NOT isolation (see ADR 0007).
|
||||
// `res` (optional, F2) is the client's http.ServerResponse — see closeSignalFor.
|
||||
//
|
||||
// `streamCtx` (optional, OCP_TUI_STREAM): { emit(text), signal } — when present the turn is
|
||||
// ALSO streamed live via claude's MessageDisplay hook. The contract is unchanged: this still
|
||||
// returns the TRANSCRIPT's text (T), the honesty gates still run on T before anything is
|
||||
// committed, and the cache still stores T — never the concatenated deltas. streamCtx.emit is
|
||||
// the SSE sink; streamCtx.signal is the client's disconnect signal, which tears the pane down
|
||||
// mid-turn instead of holding the semaphore slot for a dead socket.
|
||||
async function callClaudeTui(model, messages, _conversationId, _keyName, res, streamCtx = null) {
|
||||
async function callClaudeTui(model, messages, _conversationId, _keyName, res) {
|
||||
const cliModel = MODEL_MAP[model] || model;
|
||||
const prompt = messagesToPrompt(messages); // includes system as [System] inline
|
||||
recordModelRequest(cliModel, prompt.length);
|
||||
@@ -1503,23 +1382,6 @@ async function callClaudeTui(model, messages, _conversationId, _keyName, res, st
|
||||
// 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.
|
||||
// Streaming assembler (null when OCP_TUI_STREAM is off — then runTuiTurn gets no onDelta,
|
||||
// spawns no hook, and behaves byte-for-byte as before). It owns the auth-banner holdback
|
||||
// and the message scoping; see lib/tui/stream.mjs.
|
||||
const assembler = streamCtx ? new TuiDeltaAssembler({ holdbackChars: TUI_STREAM_HOLDBACK }) : null;
|
||||
// F6: counted here — the moment a streamed turn is ATTEMPTED — not after the honesty gates
|
||||
// below. A turn refused by the truncation or auth-banner gate is exactly the turn an operator
|
||||
// most wants visible in streamTurns; counting only turns that reached the gates made
|
||||
// streamDivergences/streamTurns silently exclude its own worst cases from the denominator.
|
||||
if (assembler) tuiStats.streamTurns++;
|
||||
const onDelta = assembler
|
||||
? (payload) => {
|
||||
const out = assembler.push(payload);
|
||||
tuiStats.streamDeltas++; // every hook fire OBSERVED, not just forwarded ones — see the
|
||||
// /health field doc in lib/tui/semaphore.mjs (F6)
|
||||
if (out) streamCtx.emit(out); // released past the holdback — safe to show the client
|
||||
}
|
||||
: null;
|
||||
try {
|
||||
const { text, entrypoint, truncated } = await runTuiTurn({
|
||||
prompt,
|
||||
@@ -1541,30 +1403,7 @@ async function callClaudeTui(model, messages, _conversationId, _keyName, res, st
|
||||
? ({ warm }) => logEvent("info", warm ? "tui_pool_hit" : "tui_pool_miss",
|
||||
{ model: cliModel, warmRemaining: tuiPool.warm })
|
||||
: null,
|
||||
onDelta,
|
||||
// Gated on TUI_STREAM (the deployment-wide switch), NOT on `assembler` (this REQUEST's
|
||||
// stream:true/false) — F4 fix. The pool's bootPane closure above installs the hook on
|
||||
// every warm pane whenever TUI_STREAM is on, regardless of what any given future request
|
||||
// asks for (a pre-booted pane cannot know that yet); the cold path must match, or a
|
||||
// stream:false request gets --settings on a pool HIT and not on a pool MISS — two
|
||||
// different spawn argvs for the identical request, which this project's alignment/billing
|
||||
// posture cannot tolerate. Whether the hook's OUTPUT is actually consumed for THIS turn is
|
||||
// decided downstream by `onDelta` (null when assembler is null), so a non-streaming
|
||||
// request still never polls or emits — it just spawns identically either way.
|
||||
streamDir: TUI_STREAM ? TUI_STREAM_DIR : null,
|
||||
abortSignal: streamCtx ? streamCtx.signal : null,
|
||||
});
|
||||
// ── Billing-pool observation (issue #115, #133) — A3 fix: record the entrypoint the moment
|
||||
// runTuiTurn returns, BEFORE the honesty gates below that can throw. The entrypoint (cli vs
|
||||
// sdk-cli) is which BILLING POOL the turn consumed; a turn that then fails a gate (wall-clock
|
||||
// truncation, auth banner, stream divergence) STILL spent that pool — and those failed turns
|
||||
// are exactly the ones most likely to signal a silent degrade to the metered Agent SDK pool.
|
||||
// Recording only on the success path (the old placement) blinded /health's entrypointMismatches
|
||||
// and lastEntrypoint to every failed turn. recordModelSuccess still runs later, only on success.
|
||||
if (recordTuiEntrypoint(tuiStats, entrypoint, TUI_ENTRYPOINT)) {
|
||||
logEvent("warn", "tui_entrypoint_mismatch", { expected: "cli", got: entrypoint, model: cliModel });
|
||||
}
|
||||
|
||||
// ── Honesty gates (issue #133) ─ run BEFORE recordModelSuccess / cache write-back.
|
||||
// A throw here propagates to the catch below (recordModelError + reject), so the
|
||||
// result never reaches the downstream setCachedResponse / singleflight / SUCCESS path.
|
||||
@@ -1588,73 +1427,20 @@ async function callClaudeTui(model, messages, _conversationId, _keyName, res, st
|
||||
throw new Error("tui_upstream_error: claude CLI returned an in-session error banner instead of an answer");
|
||||
}
|
||||
|
||||
// ── Streaming safety net — the transcript is the authority, the deltas are the mirror.
|
||||
// Runs AFTER the two gates above (so a truncated turn or an auth banner is never
|
||||
// reconciled, let alone flushed) and BEFORE recordModelSuccess / the caller's cache
|
||||
// write. Three outcomes:
|
||||
// exact — concat(deltas) === T. The invariant held; emit whatever is still held
|
||||
// back (a short answer never passes the holdback, so this is its whole text).
|
||||
// top-up — what we emitted is a strict PREFIX of T but the deltas did not add up to
|
||||
// it (a dropped/late fire). We serve exactly T by emitting the missing tail;
|
||||
// the client still gets the right answer. Counted, and visible on /health.
|
||||
// divergence— we already emitted bytes that are NOT a prefix of T. The client is holding
|
||||
// text the transcript disagrees with and it cannot be retracted. REFUSE the
|
||||
// turn: throw → SSE error frame, no cache, no success. Serving on would be
|
||||
// exactly the "silently serve wrong text" failure this gate exists to stop.
|
||||
// (Known trigger: a tool-using turn whose pre-tool prose exceeded the
|
||||
// holdback — the transcript keeps only the LAST assistant message, so the
|
||||
// prose we streamed is text T does not contain.)
|
||||
if (assembler) {
|
||||
// F7: a total hook failure (a claude version bump stops honoring --settings, or a
|
||||
// truncated md-hook.sh per F3) produces zero fires for every turn, finalize() still
|
||||
// reports ok:true/exact:false (the transcript alone carries the whole answer), and the
|
||||
// turn succeeds NORMALLY — degrading to buffered with no error, no divergence, nothing
|
||||
// but streamTopUps climbing (which the comment above calls "benign"). That is
|
||||
// indistinguishable from one late fire dropped unless it is counted separately.
|
||||
if (assembler.deltas === 0) {
|
||||
tuiStats.streamZeroDeltaTurns++;
|
||||
logEvent("warn", "tui_stream_zero_deltas", { model: cliModel });
|
||||
}
|
||||
const rec = assembler.finalize(text);
|
||||
if (!rec.ok) {
|
||||
tuiStats.streamDivergences++;
|
||||
logEvent("error", "tui_stream_divergence", {
|
||||
model: cliModel,
|
||||
// The dominant cause in practice: a TOOL-USING turn whose pre-tool prose exceeded the
|
||||
// holdback and was already streamed. The transcript keeps only the LAST assistant
|
||||
// message, so that prose is text T does not contain. Remedy for such a deployment:
|
||||
// raise OCP_TUI_STREAM_HOLDBACK above the model's typical narration length (later first
|
||||
// chunk, but the prose stays held back and is then correctly discarded), or leave
|
||||
// OCP_TUI_STREAM off. See README + ADR 0007 (2026-07-13 amendment).
|
||||
reason: assembler.restartedAfterEmit ? "multi_message_after_emit (tool-use turn?)" : "delta_transcript_mismatch",
|
||||
emittedChars: rec.emitted, transcriptChars: rec.transcript,
|
||||
deltas: assembler.deltas, messages: assembler.messages,
|
||||
});
|
||||
throw new Error("tui_stream_divergence: streamed text is not a prefix of the transcript; refusing to serve it");
|
||||
}
|
||||
if (!rec.exact) {
|
||||
tuiStats.streamTopUps++;
|
||||
logEvent("warn", "tui_stream_topup", {
|
||||
model: cliModel, emittedChars: rec.emitted, transcriptChars: rec.transcript, deltas: assembler.deltas,
|
||||
});
|
||||
}
|
||||
if (rec.tail) streamCtx.emit(rec.tail);
|
||||
}
|
||||
|
||||
recordModelSuccess(cliModel, 0); // elapsed not measurable here; wallclock at reader level
|
||||
// Entrypoint/billing-pool observation was already recorded above, right after runTuiTurn
|
||||
// returned — see the A3-fix comment there (it must cover failed turns too, so it cannot live
|
||||
// on this success-only path).
|
||||
// Assert the subscription-pool classification. TUI exists to keep cc_entrypoint=cli
|
||||
// (subscription pool); a silent degrade to sdk-cli (metered Agent SDK pool) would still
|
||||
// return text but cost money — warn loudly so it's visible. (issue #115)
|
||||
// C-5: also surface the observation on /health. recordTuiEntrypoint sets lastEntrypoint
|
||||
// unconditionally (operators can poll it to confirm cli) and increments
|
||||
// entrypointMismatches when expected=cli but observed≠cli — the same condition the
|
||||
// journald warning already covers — so a silent metered-pool drift is visible on /health
|
||||
// without tailing logs.
|
||||
if (recordTuiEntrypoint(tuiStats, entrypoint, TUI_ENTRYPOINT)) {
|
||||
logEvent("warn", "tui_entrypoint_mismatch", { expected: "cli", got: entrypoint, model: cliModel });
|
||||
}
|
||||
return text;
|
||||
} catch (err) {
|
||||
// A mid-turn client disconnect (streaming path only — abortSignal) is NOT an upstream
|
||||
// failure: runTuiTurn's finally already tore the pane down, and this finally releases the
|
||||
// slot. Mirror the queued-disconnect handling above (info, no recordModelError, no
|
||||
// response) rather than booking a phantom model error against the socket going away.
|
||||
if (err && err.name === "TuiAbortError") {
|
||||
logEvent("info", "tui_turn_aborted", { reason: "client_disconnected", model: cliModel });
|
||||
throw new RequestDisconnectedError("client disconnected mid-turn; TUI pane torn down");
|
||||
}
|
||||
recordModelError(cliModel, false);
|
||||
throw err;
|
||||
} finally {
|
||||
@@ -1662,102 +1448,6 @@ async function callClaudeTui(model, messages, _conversationId, _keyName, res, st
|
||||
}
|
||||
}
|
||||
|
||||
// ── TUI-mode REAL streaming (OCP_TUI_STREAM=1) ──────────────────────────
|
||||
// The stream:true + TUI_MODE + OCP_TUI_STREAM=1 path. Emits the turn as it is generated,
|
||||
// from claude's own MessageDisplay hook, instead of buffering it and replaying it with
|
||||
// streamStringAsSSE.
|
||||
//
|
||||
// WIRE SHAPES: every frame below is COPIED from callClaudeStreaming (the -p path) — the role
|
||||
// chunk, the content-delta chunk, the stop chunk, `[DONE]`, and the post-header
|
||||
// {error:{message,type}} frame. No new fields, no new shapes. (ALIGNMENT.md Rule 2 / Class B:
|
||||
// the authority for the wire format is the OpenAI chat/completions streaming spec, adopted by
|
||||
// ADR 0006; the authority for the TUI spawn is ADR 0007. No cli.js citation applies — see the
|
||||
// commit body.)
|
||||
//
|
||||
// HEADERS ARE SENT EAGERLY, exactly as the -p path does, so the existing heartbeat
|
||||
// (CLAUDE_HEARTBEAT_INTERVAL) covers the ~6s of silence before the first delta. The cost is
|
||||
// the same one the -p path already pays: after the headers are out, an upstream failure can
|
||||
// no longer be a JSON 500, so it is surfaced as the SSE error frame instead (issue #110).
|
||||
async function callClaudeTuiStreaming(model, messages, conversationId, res, authInfo = {}) {
|
||||
const id = `chatcmpl-${randomUUID()}`;
|
||||
const created = Math.floor(Date.now() / 1000);
|
||||
const t0 = Date.now();
|
||||
const promptChars = messages.reduce((a, m) => a + contentToText(m.content).length, 0);
|
||||
let headersSent = false;
|
||||
|
||||
function ensureHeaders() {
|
||||
if (res.writableEnded || res.destroyed) return false;
|
||||
if (headersSent) return true;
|
||||
headersSent = true;
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
});
|
||||
sendSSE(res, {
|
||||
id, object: "chat.completion.chunk", created, model,
|
||||
choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }],
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
ensureHeaders();
|
||||
const hb = startHeartbeat(res, HEARTBEAT_INTERVAL, conversationId);
|
||||
// Held for the WHOLE turn (not just the queue wait): a disconnect must abort the transcript
|
||||
// wait so runTuiTurn tears the pane down and callClaudeTui's finally frees the slot.
|
||||
const { signal, detach } = closeSignalFor(res);
|
||||
|
||||
const streamCtx = {
|
||||
signal,
|
||||
emit(text) {
|
||||
if (!text) return;
|
||||
if (!ensureHeaders()) return; // client vanished — drop the write, the turn still unwinds
|
||||
sendSSE(res, {
|
||||
id, object: "chat.completion.chunk", created, model,
|
||||
choices: [{ index: 0, delta: { content: text }, finish_reason: null }],
|
||||
}, hb);
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
// callClaudeTui returns the TRANSCRIPT text T after its honesty gates + the streaming
|
||||
// reconciliation. Everything the client should see has been emitted by then.
|
||||
const content = await callClaudeTui(model, messages, conversationId, authInfo.keyName, res, streamCtx);
|
||||
// Cache T — never the concatenated deltas (mirrors the buffered TUI path).
|
||||
if (CACHE_TTL > 0 && authInfo.cacheHash) {
|
||||
try { setCachedResponse(authInfo.cacheHash, model, content); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); }
|
||||
}
|
||||
if (!res.writableEnded && !res.destroyed) {
|
||||
sendSSE(res, {
|
||||
id, object: "chat.completion.chunk", created, model,
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||
}, hb);
|
||||
res.write("data: [DONE]\n\n");
|
||||
res.end();
|
||||
}
|
||||
try { recordUsage({ keyId: authInfo.keyId, keyName: authInfo.keyName, model, promptChars, responseChars: content.length, elapsedMs: Date.now() - t0, success: true }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
|
||||
} catch (err) {
|
||||
// Client walked away (queued OR mid-turn): nothing to write to, nothing to record —
|
||||
// same quiet outcome as every other disconnect path (L1 / F2).
|
||||
if (err instanceof RequestDisconnectedError) { try { res.end(); } catch {} return; }
|
||||
try { recordUsage({ keyId: authInfo.keyId, keyName: authInfo.keyName, model, promptChars, responseChars: 0, elapsedMs: Date.now() - t0, success: false }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
|
||||
console.error(`[proxy] error: ${err.message}`);
|
||||
// Headers are already out (eager, above), so — exactly like the -p path — the failure is
|
||||
// surfaced as an SSE error frame, NOT a success-looking finish_reason:"stop". This is what
|
||||
// keeps a truncated turn, an auth banner, or a stream divergence from being served as an
|
||||
// answer: the client sees an error, and nothing was cached.
|
||||
if (!res.writableEnded && !res.destroyed) {
|
||||
sendSSE(res, { error: { message: sanitizeError(err.message), type: "provider_error" } }, hb);
|
||||
res.write("data: [DONE]\n\n");
|
||||
res.end();
|
||||
}
|
||||
} finally {
|
||||
hb.stop();
|
||||
detach();
|
||||
}
|
||||
}
|
||||
|
||||
// ── SSE heartbeat (opt-in idle watchdog) ────────────────────────────────
|
||||
// Emits `: keepalive\n\n` SSE comment frames during silent windows on the
|
||||
// streaming response. Design: docs/superpowers/specs/2026-04-25-47-sse-heartbeat-design.md
|
||||
@@ -2650,11 +2340,6 @@ async function handleChatCompletions(req, res) {
|
||||
}
|
||||
|
||||
if (stream) {
|
||||
if (TUI_MODE && TUI_STREAM) {
|
||||
// TUI-mode REAL streaming (opt-in): emit delta.content chunks as claude renders them,
|
||||
// via its MessageDisplay hook. The transcript remains authoritative (gates + cache).
|
||||
return callClaudeTuiStreaming(model, messages, conversationId, res, { keyId: req._authKeyId, keyName: req._authKeyName, cacheHash: req._cacheHash });
|
||||
}
|
||||
if (TUI_MODE) {
|
||||
// TUI-mode: no real token stream — buffer the full turn via callClaudeTui,
|
||||
// optionally write-back to cache, then replay as chunked SSE.
|
||||
@@ -2956,13 +2641,8 @@ const server = createServer(async (req, res) => {
|
||||
// `pool` is a NEW nested field inside the (already additive) tui block: null when the
|
||||
// warm pool is off (the default), so the disabled shape is unchanged apart from one
|
||||
// explicit null. Lets the operator confirm hit rate + standing process cost.
|
||||
//
|
||||
// streamEnabled + the stream* counters are likewise ADDITIVE (new fields only, same
|
||||
// grandfathered B.2 rationale — ADR 0006). streamDivergences is the one an operator
|
||||
// must watch: a non-zero value means a streamed turn was REFUSED because the deltas
|
||||
// disagreed with the transcript, which is the streaming path's only correctness risk.
|
||||
tui: buildTuiHealthBlock(
|
||||
{ enabled: TUI_MODE, entrypointMode: TUI_ENTRYPOINT, maxConcurrent: TUI_MAX_CONCURRENT, streamEnabled: TUI_MODE && TUI_STREAM },
|
||||
{ enabled: TUI_MODE, entrypointMode: TUI_ENTRYPOINT, maxConcurrent: TUI_MAX_CONCURRENT },
|
||||
tuiStats, tuiSemaphore, tuiPool,
|
||||
),
|
||||
});
|
||||
|
||||
@@ -390,9 +390,7 @@ if (!DRY_RUN) {
|
||||
// and "ocp-proxy" keeps the proxy invisible to that heuristic.
|
||||
const OCP_HOME = join(HOME, ".ocp");
|
||||
const ocpLogsDir = join(OCP_HOME, "logs");
|
||||
// mode 0700: with `recursive`, this call can create ~/.ocp ITSELF on a fresh install, and
|
||||
// without an explicit mode that parent lands at the umask default (world-listable 0755).
|
||||
if (!existsSync(ocpLogsDir)) mkdirSync(ocpLogsDir, { recursive: true, mode: 0o700 });
|
||||
if (!existsSync(ocpLogsDir)) mkdirSync(ocpLogsDir, { recursive: true });
|
||||
|
||||
// Uninstall legacy service names if present (upgrade path)
|
||||
if (platform === "darwin") {
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
// Imported FIRST by test-features.mjs, before keys.mjs, so this runs before anything can open
|
||||
// the key store. ESM hoists imports and evaluates them in order, so a `process.env.X = ...`
|
||||
// statement in the test's own body would run too late — hence a separate module.
|
||||
//
|
||||
// Why this exists: `npm test` used to write real, UNREVOKED api_keys rows into the operator's
|
||||
// live ~/.ocp/ocp.db (the same database the running server reads) — two per run, unbounded.
|
||||
// It also made the suite racy: two concurrent runs (e.g. review worktrees) shared one file, so
|
||||
// `listKeys()` could miss "test-user-1" and the `in` check would throw on undefined.
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
export const TEST_OCP_DIR = mkdtempSync(join(tmpdir(), "ocp-test-"));
|
||||
|
||||
// BOTH are required. keys.mjs honors OCP_DIR_OVERRIDE only when NODE_ENV === "test", so neither
|
||||
// var alone redirects anything — a stray OCP_DIR_OVERRIDE in a production env is inert without
|
||||
// NODE_ENV=test alongside it. (A daemon OCP launches never carries either: the service units and
|
||||
// the `ocp` restart fallback strip both — see plist-merge NEVER_PRESERVE / keys.mjs's comment.)
|
||||
process.env.NODE_ENV = "test";
|
||||
process.env.OCP_DIR_OVERRIDE = TEST_OCP_DIR;
|
||||
|
||||
// Remove the scratch store on exit. Without this the fix would trade unbounded growth in
|
||||
// ~/.ocp/ocp.db for unbounded growth in $TMPDIR — better, but still litter.
|
||||
process.on("exit", () => {
|
||||
try { rmSync(TEST_OCP_DIR, { recursive: true, force: true }); } catch { /* best effort */ }
|
||||
});
|
||||
+17
-572
@@ -3,24 +3,22 @@
|
||||
* Integration test for Quota + Cache features.
|
||||
* Tests database layer functions directly — no server needed.
|
||||
*/
|
||||
// MUST come before keys.mjs: redirects the key store to a scratch dir (see test-env.mjs).
|
||||
import { TEST_OCP_DIR } from "./test-env.mjs";
|
||||
import { getDb, getDbPath, 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 { createSerialMutex, createTtlCache, isTokenExpiring, orderLabelsLastGoodFirst } from "./lib/spawn-auth.mjs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { strict as assert } from "node:assert";
|
||||
import { unlinkSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
process.env.HOME = homedir(); // normalize HOME so homedir()-derived paths are stable across shells
|
||||
// Use a test database to avoid corrupting real data
|
||||
const TEST_DB = join(homedir(), ".ocp", "ocp-test.db");
|
||||
try { unlinkSync(TEST_DB); } catch {}
|
||||
|
||||
// The scaffolding that used to live here CLAIMED to use "a test database to avoid corrupting
|
||||
// real data" by setting an env var before the first getDb(). It never worked: keys.mjs read no
|
||||
// env var, and ESM hoisting meant the assignment ran after the import anyway. The redirect is
|
||||
// now real, and lives in test-env.mjs (imported above, before keys.mjs). This test proves it.
|
||||
// Monkey-patch DB_PATH for testing (override the module-level variable)
|
||||
// Since keys.mjs uses lazy init, we can set env before first getDb() call
|
||||
process.env.HOME = homedir(); // ensure consistent
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -537,7 +535,7 @@ async function runSingleflightTests() {
|
||||
await runSingleflightTests();
|
||||
|
||||
// ── Plist Env Merge Tests ──
|
||||
import { mergePlistEnv, mergeSystemdEnv, NEVER_PRESERVE } from "./scripts/lib/plist-merge.mjs";
|
||||
import { mergePlistEnv, mergeSystemdEnv } from "./scripts/lib/plist-merge.mjs";
|
||||
|
||||
console.log("\nPlist env merge:");
|
||||
|
||||
@@ -651,78 +649,6 @@ test("mergePlistEnv is idempotent", () => {
|
||||
assert.equal(mergePlistEnv(r1, SAMPLE_TEMPLATE_PLIST), r1);
|
||||
});
|
||||
|
||||
// ── A4: security denylist — test-only key-store redirection vars must NEVER survive a setup
|
||||
// re-run, even when a prior unit already carried them. Mutation-proof: drop the
|
||||
// `!NEVER_PRESERVE.has(k)` guard in either merge fn and these fail (the vars get preserved).
|
||||
test("NEVER_PRESERVE denylists exactly the two key-store redirection vars", () => {
|
||||
assert.ok(NEVER_PRESERVE.has("NODE_ENV") && NEVER_PRESERVE.has("OCP_DIR_OVERRIDE"));
|
||||
assert.equal(NEVER_PRESERVE.size, 2, "exactly two — a new entry needs its own rationale + test");
|
||||
});
|
||||
|
||||
const PLIST_EXISTING_WITH_TEST_VARS = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>dev.ocp.proxy</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>CLAUDE_PROXY_PORT</key>
|
||||
<string>3456</string>
|
||||
<key>CLAUDE_CACHE_TTL</key>
|
||||
<string>600</string>
|
||||
<key>NODE_ENV</key>
|
||||
<string>test</string>
|
||||
<key>OCP_DIR_OVERRIDE</key>
|
||||
<string>/tmp/scratch-store</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>`;
|
||||
|
||||
test("mergePlistEnv strips test-only redirection vars (A4) but keeps legit user keys", () => {
|
||||
const merged = mergePlistEnv(PLIST_EXISTING_WITH_TEST_VARS, SAMPLE_TEMPLATE_PLIST);
|
||||
assert.match(merged, /<key>CLAUDE_CACHE_TTL<\/key>\s*<string>600<\/string>/, "a legit user key is still preserved");
|
||||
assert.doesNotMatch(merged, /<key>NODE_ENV<\/key>/, "NODE_ENV must never reach a service unit");
|
||||
assert.doesNotMatch(merged, /OCP_DIR_OVERRIDE/, "OCP_DIR_OVERRIDE must never reach a service unit (key or value)");
|
||||
});
|
||||
|
||||
test("mergePlistEnv: an existing unit whose ONLY extras are denylisted → template unchanged", () => {
|
||||
const existing = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>CLAUDE_PROXY_PORT</key>
|
||||
<string>3456</string>
|
||||
<key>NODE_ENV</key>
|
||||
<string>test</string>
|
||||
<key>OCP_DIR_OVERRIDE</key>
|
||||
<string>/tmp/scratch-store</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>`;
|
||||
assert.equal(mergePlistEnv(existing, SAMPLE_TEMPLATE_PLIST), SAMPLE_TEMPLATE_PLIST, "nothing left to preserve → clean template");
|
||||
});
|
||||
|
||||
const SYSTEMD_EXISTING_WITH_TEST_VARS = `[Unit]
|
||||
Description=OCP — Open Claude Proxy
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/bin/node /home/u/ocp/server.mjs
|
||||
Environment=CLAUDE_PROXY_PORT=3456
|
||||
Environment=CLAUDE_CACHE_TTL=600
|
||||
Environment=NODE_ENV=test
|
||||
Environment=OCP_DIR_OVERRIDE=/tmp/scratch-store
|
||||
Restart=always
|
||||
`;
|
||||
|
||||
test("mergeSystemdEnv strips test-only redirection vars (A4) but keeps legit user keys", () => {
|
||||
const merged = mergeSystemdEnv(SYSTEMD_EXISTING_WITH_TEST_VARS, SAMPLE_TEMPLATE_SYSTEMD);
|
||||
assert.match(merged, /Environment=CLAUDE_CACHE_TTL=600/, "a legit user key is still preserved");
|
||||
assert.doesNotMatch(merged, /Environment=NODE_ENV=/, "NODE_ENV must never reach a service unit");
|
||||
assert.doesNotMatch(merged, /OCP_DIR_OVERRIDE/, "OCP_DIR_OVERRIDE must never reach a service unit");
|
||||
});
|
||||
|
||||
test("mergeSystemdEnv is idempotent", () => {
|
||||
const r1 = mergeSystemdEnv(SAMPLE_EXISTING_SYSTEMD, SAMPLE_TEMPLATE_SYSTEMD);
|
||||
assert.equal(mergeSystemdEnv(r1, SAMPLE_TEMPLATE_SYSTEMD), r1);
|
||||
@@ -3052,18 +2978,12 @@ test("buildTuiHealthBlock: shape + live counters (the additive /health tui block
|
||||
const ts = { lastEntrypoint: "cli", entrypointMismatches: 3 };
|
||||
const block = buildTuiHealthBlock(
|
||||
{ enabled: true, entrypointMode: "cli", maxConcurrent: 2 }, ts, sem);
|
||||
// Shape is ADDITIVE-only: the seven original keys must all still be present (existing
|
||||
// /health consumers are grandfathered, ADR 0006), plus `pool` (warm pane pool) and the
|
||||
// stream* fields (backlog #2). Asserting CONTAINMENT plus an exact added-set — rather than
|
||||
// one flat deepEqual — is what makes "additive" itself the thing under test: a future field
|
||||
// that silently REPLACED an original key would pass a flat equality check that was updated
|
||||
// alongside it, but cannot pass this one.
|
||||
const ORIGINAL_KEYS = ["enabled", "entrypointMismatches", "entrypointMode", "inflight", "lastEntrypoint", "maxConcurrent", "queued"];
|
||||
const keys = Object.keys(block);
|
||||
for (const k of ORIGINAL_KEYS) assert.ok(keys.includes(k), `original /health key must survive: ${k}`);
|
||||
assert.deepEqual(keys.filter((k) => !ORIGINAL_KEYS.includes(k)).sort(),
|
||||
["pool", "streamDeltas", "streamDivergences", "streamEnabled", "streamTopUps", "streamTurns", "streamZeroDeltaTurns"],
|
||||
"only the documented pool + streaming fields may be added");
|
||||
// `pool` joined this key set with the warm pane pool. The tui block is ADR-0007-owned (it
|
||||
// did not exist at v3.16.4, so it is outside ADR 0006's grandfather freeze), and the
|
||||
// addition is purely additive: every pre-existing key below still carries a byte-identical
|
||||
// value, and `pool` is null unless the operator opts in via OCP_TUI_POOL_SIZE.
|
||||
assert.deepEqual(Object.keys(block).sort(),
|
||||
["enabled", "entrypointMismatches", "entrypointMode", "inflight", "lastEntrypoint", "maxConcurrent", "pool", "queued"]);
|
||||
assert.equal(block.pool, null, "no pool passed → null (the default, pool disabled)");
|
||||
assert.equal(block.enabled, true);
|
||||
assert.equal(block.entrypointMode, "cli");
|
||||
@@ -3322,33 +3242,8 @@ test("models.json aliases.haiku === 'claude-haiku-4-5-20251001' (usage-probe SPO
|
||||
assert.equal(_spotModels.aliases.haiku, "claude-haiku-4-5-20251001");
|
||||
});
|
||||
|
||||
test("models.json aliases.sonnet === 'claude-sonnet-5' (default-request-model SPOT)", () => {
|
||||
assert.equal(_spotModels.aliases.sonnet, "claude-sonnet-5");
|
||||
});
|
||||
|
||||
// ── Referential integrity (PR #152 review) ──────────────────────────────────
|
||||
// The value-mirror assertions above only prove the alias equals a string literal —
|
||||
// they pass even if that literal points at a model that does not exist in
|
||||
// models[]. A one-line slip (edit an alias, forget the models[] entry) would leave
|
||||
// /v1/models missing the model while every `model: "<alias>"` request passes
|
||||
// validation and then fails at CLI spawn. VALID_MODELS keys on alias *names*, so
|
||||
// nothing else checks alias *targets*. This is the guard with teeth.
|
||||
const _spotModelIds = new Set(_spotModels.models.map(m => m.id));
|
||||
|
||||
test("models.json: claude-sonnet-5 is present in models[] (the entry this PR adds)", () => {
|
||||
assert.ok(_spotModelIds.has("claude-sonnet-5"), "claude-sonnet-5 must exist as a models[].id");
|
||||
});
|
||||
|
||||
test("models.json: every aliases value resolves to a real models[].id (referential integrity)", () => {
|
||||
for (const [name, target] of Object.entries(_spotModels.aliases)) {
|
||||
assert.ok(_spotModelIds.has(target), `aliases.${name} -> '${target}' is a dangling alias (no matching models[].id)`);
|
||||
}
|
||||
});
|
||||
|
||||
test("models.json: every legacyAliases value resolves to a real models[].id (referential integrity)", () => {
|
||||
for (const [name, target] of Object.entries(_spotModels.legacyAliases || {})) {
|
||||
assert.ok(_spotModelIds.has(target), `legacyAliases.${name} -> '${target}' is a dangling alias (no matching models[].id)`);
|
||||
}
|
||||
test("models.json aliases.sonnet === 'claude-sonnet-4-6' (default-request-model SPOT)", () => {
|
||||
assert.equal(_spotModels.aliases.sonnet, "claude-sonnet-4-6");
|
||||
});
|
||||
|
||||
// ── escapeHtml + key-name validator (issue #114) ────────────────────────────
|
||||
@@ -3547,459 +3442,9 @@ async function runAsyncTests() {
|
||||
});
|
||||
}
|
||||
|
||||
// ── TUI real streaming: MessageDisplay hook sink (backlog #2) ───────────────
|
||||
// Pure-logic coverage for lib/tui/stream.mjs: sink parsing, the concat===T assertion,
|
||||
// prefix-stability, the auth-banner holdback, message scoping, and the error paths.
|
||||
import { TuiDeltaAssembler, parseDeltaChunk, buildStreamSettings, streamFilePath, HOOK_SCRIPT, prepareStreamHook, resolveStreamHoldback, DEFAULT_HOLDBACK_CHARS } from "./lib/tui/stream.mjs";
|
||||
|
||||
test("stream: parseDeltaChunk consumes only COMPLETE lines (a torn write stays unread)", () => {
|
||||
const p = (i, d, final = false) => JSON.stringify({ hook_event_name: "MessageDisplay", session_id: "s", message_id: "m", index: i, final, delta: d });
|
||||
// second payload is mid-write — no trailing newline yet
|
||||
const partial = `${p(0, "## A\n\n")}\n${p(1, "body").slice(0, 20)}`;
|
||||
const r1 = parseDeltaChunk(partial, 0);
|
||||
assert.equal(r1.deltas.length, 1, "only the terminated line is consumed");
|
||||
assert.equal(r1.consumed, 1);
|
||||
// now it lands complete
|
||||
const whole = `${p(0, "## A\n\n")}\n${p(1, "body")}\n`;
|
||||
const r2 = parseDeltaChunk(whole, r1.consumed);
|
||||
assert.equal(r2.deltas.length, 1, "the once-partial line is picked up exactly once");
|
||||
assert.equal(r2.deltas[0].delta, "body");
|
||||
assert.equal(r2.consumed, 2);
|
||||
// idempotent: nothing new
|
||||
assert.equal(parseDeltaChunk(whole, r2.consumed).deltas.length, 0);
|
||||
});
|
||||
|
||||
test("stream: parseDeltaChunk skips blank/garbage lines and foreign hook events", () => {
|
||||
const md = JSON.stringify({ hook_event_name: "MessageDisplay", message_id: "m", index: 0, final: true, delta: "ok" });
|
||||
const other = JSON.stringify({ hook_event_name: "Stop", message_id: "m", delta: "nope" });
|
||||
const text = `\n{not json\n${other}\n${md}\n`;
|
||||
const { deltas } = parseDeltaChunk(text, 0);
|
||||
assert.equal(deltas.length, 1);
|
||||
assert.equal(deltas[0].delta, "ok");
|
||||
});
|
||||
|
||||
// The live-verified contract (claude 2.1.207): deltas are the raw markdown source and
|
||||
// concat(deltas) === extractLatestAssistantText(transcript), byte-exactly.
|
||||
const mdFire = (i, delta, { final = false, mid = "m1" } = {}) =>
|
||||
({ hook_event_name: "MessageDisplay", session_id: "s1", message_id: mid, index: i, final, delta });
|
||||
|
||||
test("stream: concat(deltas) === T → exact, no top-up, prefix-stable at every n", () => {
|
||||
const chunks = ["## Mutex\n\n", "A **mutual exclusion lock** prevents concurrent access.\n\n", "```javascript\nconst m = new Mutex();\n```"];
|
||||
const T = chunks.join("");
|
||||
const a = new TuiDeltaAssembler({ holdbackChars: 10 });
|
||||
let acc = "";
|
||||
chunks.forEach((c, i) => {
|
||||
const out = a.push(mdFire(i, c, { final: i === chunks.length - 1 }));
|
||||
if (out) acc += out;
|
||||
assert.ok(T.startsWith(a.full), `prefix-stable at n=${i}`);
|
||||
});
|
||||
const rec = a.finalize(T);
|
||||
assert.equal(rec.ok, true);
|
||||
assert.equal(rec.exact, true, "concat(deltas) === T");
|
||||
assert.equal(acc + rec.tail, T, "client's assembled stream === T");
|
||||
assert.equal(a.deltas, 3);
|
||||
});
|
||||
|
||||
test("stream: holdback withholds the first chars so the auth-banner gate can still fire", () => {
|
||||
const banner = "Please run /login · API Error: 401 Invalid authentication credentials"; // 69 chars, a real one
|
||||
const a = new TuiDeltaAssembler(); // default holdback 100
|
||||
const out = a.push(mdFire(0, banner, { final: true }));
|
||||
assert.equal(out, null, "a banner-length message must NEVER reach the client");
|
||||
assert.equal(a.emitted, "", "nothing emitted");
|
||||
// and the whole-message detector still classifies it — the gate runs on T, before any flush
|
||||
assert.ok(detectTuiUpstreamError(a.full) !== null, "banner still detected at terminal");
|
||||
});
|
||||
|
||||
test("stream: holdback releases once past the detector's reach, and only then", () => {
|
||||
const a = new TuiDeltaAssembler({ holdbackChars: 100 });
|
||||
assert.equal(a.push(mdFire(0, "x".repeat(80))), null, "80 chars: still held");
|
||||
const out = a.push(mdFire(1, "y".repeat(40)));
|
||||
assert.equal(out, "x".repeat(80) + "y".repeat(40), "released as one chunk once >100");
|
||||
assert.equal(a.push(mdFire(2, "tail")), "tail", "subsequent deltas stream straight through");
|
||||
});
|
||||
|
||||
// ── resolveStreamHoldback: the FLOOR under OCP_TUI_STREAM_HOLDBACK (A1 fix) ────────────
|
||||
// The C-1 auth-banner guarantee holds only while the holdback >= the default detector's
|
||||
// 100-char reach. These tests pin that the resolver CLAMPS UP to the floor. They are
|
||||
// mutation-proof: delete the `parsed < floor` branch and the sub-floor cases below fail
|
||||
// (a 50 would pass straight through, reopening the leak). The clamped flag drives the boot
|
||||
// warning in server.mjs, so its truthiness is asserted alongside every value.
|
||||
test("holdback: a sub-floor value is clamped UP to the floor and flagged", () => {
|
||||
assert.deepEqual(resolveStreamHoldback("50"), { value: DEFAULT_HOLDBACK_CHARS, clamped: true });
|
||||
assert.deepEqual(resolveStreamHoldback("0"), { value: DEFAULT_HOLDBACK_CHARS, clamped: true });
|
||||
assert.deepEqual(resolveStreamHoldback("-5"), { value: DEFAULT_HOLDBACK_CHARS, clamped: true });
|
||||
assert.deepEqual(resolveStreamHoldback("99"), { value: DEFAULT_HOLDBACK_CHARS, clamped: true });
|
||||
});
|
||||
|
||||
test("holdback: garbage / NaN falls back to the floor and is flagged (not silently 0)", () => {
|
||||
assert.deepEqual(resolveStreamHoldback("unlimited"), { value: DEFAULT_HOLDBACK_CHARS, clamped: true });
|
||||
assert.deepEqual(resolveStreamHoldback("5MB"), { value: DEFAULT_HOLDBACK_CHARS, clamped: true });
|
||||
});
|
||||
|
||||
test("holdback: an above-floor value passes through unchanged and is NOT flagged", () => {
|
||||
assert.deepEqual(resolveStreamHoldback("200"), { value: 200, clamped: false });
|
||||
assert.deepEqual(resolveStreamHoldback("101"), { value: 101, clamped: false });
|
||||
assert.deepEqual(resolveStreamHoldback(String(DEFAULT_HOLDBACK_CHARS)), { value: DEFAULT_HOLDBACK_CHARS, clamped: false });
|
||||
});
|
||||
|
||||
test("holdback: an unset env var takes the floor WITHOUT flagging (no spurious boot warning)", () => {
|
||||
assert.deepEqual(resolveStreamHoldback(undefined), { value: DEFAULT_HOLDBACK_CHARS, clamped: false });
|
||||
assert.deepEqual(resolveStreamHoldback(null), { value: DEFAULT_HOLDBACK_CHARS, clamped: false });
|
||||
assert.deepEqual(resolveStreamHoldback(""), { value: DEFAULT_HOLDBACK_CHARS, clamped: false });
|
||||
assert.deepEqual(resolveStreamHoldback(" "), { value: DEFAULT_HOLDBACK_CHARS, clamped: false });
|
||||
});
|
||||
|
||||
test("holdback: the floor is a parameter, so a deployment can raise (never lower) it", () => {
|
||||
assert.deepEqual(resolveStreamHoldback("150", 200), { value: 200, clamped: true }, "custom floor still clamps up");
|
||||
assert.deepEqual(resolveStreamHoldback("300", 200), { value: 300, clamped: false });
|
||||
});
|
||||
|
||||
test("stream: a short answer never passes the holdback and is delivered whole at terminal", () => {
|
||||
const T = "The capital of France is Paris.";
|
||||
const a = new TuiDeltaAssembler();
|
||||
assert.equal(a.push(mdFire(0, T, { final: true })), null);
|
||||
const rec = a.finalize(T);
|
||||
assert.equal(rec.ok, true);
|
||||
assert.equal(rec.exact, true);
|
||||
assert.equal(rec.tail, T, "the whole short answer is flushed at terminal (buffered semantics)");
|
||||
});
|
||||
|
||||
test("stream: a DROPPED delta is a safe prefix → top-up from the transcript, exact=false", () => {
|
||||
const a = new TuiDeltaAssembler({ holdbackChars: 5 });
|
||||
a.push(mdFire(0, "Hello world, this is the first block. "));
|
||||
const T = "Hello world, this is the first block. And the tail the hook never delivered.";
|
||||
const rec = a.finalize(T);
|
||||
assert.equal(rec.ok, true, "prefix → recoverable");
|
||||
assert.equal(rec.exact, false, "flagged: concat(deltas) !== T");
|
||||
assert.equal(rec.tail, "And the tail the hook never delivered.");
|
||||
assert.equal(a.emitted + rec.tail, T, "client still receives exactly T");
|
||||
});
|
||||
|
||||
test("stream: emitted bytes NOT a prefix of T → divergence, refuse the turn", () => {
|
||||
const a = new TuiDeltaAssembler({ holdbackChars: 5 });
|
||||
a.push(mdFire(0, "Let me go and read that file for you first."));
|
||||
const rec = a.finalize("A completely different final answer.");
|
||||
assert.equal(rec.ok, false, "must NOT serve text the transcript disagrees with");
|
||||
assert.equal(rec.tail, null);
|
||||
});
|
||||
|
||||
// Message scoping: the transcript keeps only the LAST assistant message, so the assembler
|
||||
// must too. Discarding is safe while nothing has been emitted; after that it is a divergence.
|
||||
test("stream: new message_id BEFORE any emit → held text discarded, stays exact vs T", () => {
|
||||
const a = new TuiDeltaAssembler({ holdbackChars: 100 });
|
||||
a.push(mdFire(0, "I'll check the file.", { mid: "m1" })); // short pre-tool prose, held back
|
||||
assert.equal(a.emitted, "");
|
||||
const answer = "The file defines a Mutex class with acquire and release, and " + "z".repeat(90);
|
||||
const out = a.push(mdFire(0, answer, { mid: "m2", final: true }));
|
||||
assert.equal(out, answer, "only the FINAL message's text is emitted");
|
||||
const rec = a.finalize(answer); // T = extractLatestAssistantText = the last message only
|
||||
assert.equal(rec.ok, true);
|
||||
assert.equal(rec.exact, true, "scoping to the last message_id keeps concat === T true");
|
||||
assert.equal(a.messages, 2);
|
||||
});
|
||||
|
||||
test("stream: new message_id AFTER an emit → unretractable, flagged and refused", () => {
|
||||
const a = new TuiDeltaAssembler({ holdbackChars: 10 });
|
||||
a.push(mdFire(0, "Long pre-tool prose that already went out to the client.", { mid: "m1" }));
|
||||
assert.notEqual(a.emitted, "");
|
||||
// Assert what push() RETURNS, not merely that finalize() refuses. This test used to check
|
||||
// only restartedAfterEmit + finalize().ok, which left it passing while F1 was live: the
|
||||
// second message's bytes were still being handed to the client. "The turn is refused" and
|
||||
// "the client got the bytes anyway" were both true at once.
|
||||
const out = a.push(mdFire(0, "The real answer.", { mid: "m2", final: true }));
|
||||
assert.equal(out, null, "after a message boundary follows an emit, NOTHING more may be emitted");
|
||||
assert.equal(a.restartedAfterEmit, true);
|
||||
assert.equal(a.finalize("The real answer.").ok, false, "must refuse: prose already emitted is not in T");
|
||||
});
|
||||
|
||||
test("F1: an auth banner rendered as a LATER message is never forwarded to the client", () => {
|
||||
// The leak this class exists to prevent, in the shape production actually runs
|
||||
// (OCP_TUI_FULL_TOOLS=1 → multi-message tool-using turns are the norm):
|
||||
// 1. the model narrates past the holdback before a tool call → released, emitted != ""
|
||||
// 2. credentials expire mid-turn → claude renders the 401 as ordinary assistant TEXT,
|
||||
// as a NEW message
|
||||
// 3. pre-fix: push() took the `if (this.released)` branch — `released` was never reset at
|
||||
// a message boundary — and returned the BANNER verbatim, straight to the client.
|
||||
// The holdback protected only the FIRST message of a turn. This asserts it protects the rest.
|
||||
const a = new TuiDeltaAssembler({ holdbackChars: 100 });
|
||||
const narration = "I'll check that file for you and then report back with what I find inside it.";
|
||||
a.push(mdFire(0, narration + narration, { mid: "m1" })); // > holdback → released
|
||||
assert.notEqual(a.emitted, "", "precondition: the narration really did reach the client");
|
||||
|
||||
const BANNER = "Please run /login · API Error: 401 Invalid authentication credentials";
|
||||
const out = a.push(mdFire(1, BANNER, { mid: "m2", final: true }));
|
||||
assert.equal(out, null, "the auth banner must NOT be forwarded once a later message begins");
|
||||
assert.ok(!a.emitted.includes("401"), "no byte of the banner may have reached the client");
|
||||
assert.equal(a.finalize(BANNER).ok, false, "and the turn is refused, not served");
|
||||
});
|
||||
|
||||
test("F1: a first payload with message_id:null cannot disarm the guard", () => {
|
||||
// The residual bypass the reviewer found by probing. `this.messageId` used to be initialized
|
||||
// to null, so a first payload carrying message_id:null compared EQUAL to it → no boundary
|
||||
// registered → `messages` stayed 0 → when the REAL boundary arrived, `messages > 1` evaluated
|
||||
// 1 > 1 === false → restartedAfterEmit never armed → the released branch forwarded the banner.
|
||||
// The whole F1 guard was disarmed by a single null field. parseDeltaChunk does not validate
|
||||
// message_id, so such a payload does reach push().
|
||||
const a = new TuiDeltaAssembler({ holdbackChars: 100 });
|
||||
const narration = "I'll check that file for you and then report back with what I find inside it.";
|
||||
a.push({ hook_event_name: "MessageDisplay", message_id: null, delta: narration + narration });
|
||||
assert.notEqual(a.emitted, "", "precondition: the narration released to the client");
|
||||
assert.equal(a.messages, 1, "a null message_id is still a MESSAGE — it must register as one");
|
||||
|
||||
const BANNER = "Please run /login · API Error: 401 Invalid authentication credentials";
|
||||
const out = a.push({ hook_event_name: "MessageDisplay", message_id: "m2", delta: BANNER });
|
||||
assert.equal(out, null, "the banner must not be forwarded — the guard must arm regardless");
|
||||
assert.equal(a.restartedAfterEmit, true);
|
||||
assert.ok(!a.emitted.includes("401"));
|
||||
});
|
||||
|
||||
test("F1: whitespace cannot buy a release — the holdback screens TRIMMED length", () => {
|
||||
// detectTuiUpstreamError() TRIMS before applying its <=100-char rule, so gating release on
|
||||
// the UNTRIMMED pending.length let 101 spaces trim to "" → the detector has nothing to
|
||||
// classify → returns null → release fires having screened nothing, and every subsequent
|
||||
// delta of that message (a banner included) streams unfiltered.
|
||||
const a = new TuiDeltaAssembler({ holdbackChars: 100 });
|
||||
assert.equal(a.push(mdFire(0, " ".repeat(101), { mid: "m1" })), null,
|
||||
"101 chars of whitespace must not clear a 100-char holdback");
|
||||
assert.equal(a.released, false, "…and must not flip the assembler into released state");
|
||||
const BANNER = "Please run /login · API Error: 401 Invalid authentication credentials";
|
||||
assert.equal(a.push(mdFire(1, BANNER, { mid: "m1" })), null, "so the banner stays held back");
|
||||
assert.ok(!a.emitted.includes("401"));
|
||||
});
|
||||
|
||||
test("F3: a STALE or truncated hook script is overwritten, not trusted because it exists", () => {
|
||||
// ~/.ocp-tui/stream/{md-hook.sh,settings.json} persist across OCP restarts. The old
|
||||
// write-if-missing guard meant a host that booted once under an older version was stuck on
|
||||
// that version's HOOK_SCRIPT forever — no upgrade could reach it. Worse, a non-atomic write
|
||||
// interrupted mid-flight leaves a TRUNCATED md-hook.sh that existsSync() calls fine, and
|
||||
// claude BLOCKS on that hook synchronously on every fire.
|
||||
const dir = mkdtemp2(`${tmpdir2()}/ocp-hook-`);
|
||||
mkdir2(dir, { recursive: true });
|
||||
writeFile2(`${dir}/md-hook.sh`, "#!/bin/sh\n# stale, truncated leftov", { mode: 0o700 });
|
||||
writeFile2(`${dir}/settings.json`, "{ TRUNCATED", { mode: 0o600 });
|
||||
|
||||
const settings = prepareStreamHook(dir);
|
||||
|
||||
assert.equal(readFile2(`${dir}/md-hook.sh`, "utf8"), HOOK_SCRIPT,
|
||||
"the stale script must be replaced with the current one, not left because it existed");
|
||||
assert.deepEqual(JSON.parse(readFile2(settings, "utf8")), buildStreamSettings(`${dir}/md-hook.sh`),
|
||||
"…and so must the stale settings file");
|
||||
});
|
||||
|
||||
test("stream: hook script is a write-and-exit sh script and tolerates a missing sink var", () => {
|
||||
// forceSyncExecution: claude BLOCKS on this hook, so it must do no work inline.
|
||||
assert.ok(HOOK_SCRIPT.startsWith("#!/bin/sh"));
|
||||
assert.ok(HOOK_SCRIPT.includes('[ -n "$OCP_TUI_STREAM_FILE" ] || exec cat >/dev/null'),
|
||||
"no sink configured => swallow stdin and exit 0; never fail, never block claude");
|
||||
assert.ok(!/curl|node |python/.test(HOOK_SCRIPT), "no interpreter/network work in a blocking hook");
|
||||
});
|
||||
|
||||
test("stream: settings registers exactly one MessageDisplay command hook (static, no per-request data)", () => {
|
||||
const s = buildStreamSettings("/x/md-hook.sh");
|
||||
assert.deepEqual(Object.keys(s.hooks), ["MessageDisplay"]);
|
||||
assert.equal(s.hooks.MessageDisplay[0].hooks[0].type, "command");
|
||||
assert.equal(s.hooks.MessageDisplay[0].hooks[0].command, "/x/md-hook.sh");
|
||||
// Warm-pool compatibility: the settings file must NOT carry a session/request-specific path.
|
||||
assert.ok(!JSON.stringify(s).includes(".jsonl"), "sink path comes from the pane env, not the settings file");
|
||||
});
|
||||
|
||||
test("stream: sink path is keyed by session_id (concurrent panes cannot interleave)", () => {
|
||||
// OCP_TUI_MAX_CONCURRENT defaults to 2 — two claude panes DO run at once. A shared sink
|
||||
// would splice request A's deltas into request B's stream.
|
||||
const A = streamFilePath("/d", "aaaa-1111");
|
||||
const B = streamFilePath("/d", "bbbb-2222");
|
||||
assert.notEqual(A, B, "one sink per session-id");
|
||||
assert.ok(A.endsWith("/aaaa-1111.jsonl"));
|
||||
});
|
||||
|
||||
test("stream: buildTuiCmd — OFF is byte-for-byte the pre-streaming argv; ON adds only env + --settings", () => {
|
||||
const off = buildTuiCmd("/bin/claude", "m", "SID", "/h", "cli");
|
||||
assert.ok(!off.includes("--settings"), "no --settings when streaming is off");
|
||||
assert.ok(!off.includes("OCP_TUI_STREAM_FILE"), "no sink env when streaming is off");
|
||||
const on = buildTuiCmd("/bin/claude", "m", "SID", "/h", "cli", { file: "/d/SID.jsonl", settings: "/d/s.json" });
|
||||
assert.ok(on.includes("OCP_TUI_STREAM_FILE='/d/SID.jsonl'"), "sink delivered via the pane env");
|
||||
assert.ok(on.includes("--settings '/d/s.json'"));
|
||||
// must not regress the MCP wall or the pinned effort (#156)
|
||||
assert.ok(on.includes("--strict-mcp-config") && on.includes("--disallowedTools 'mcp__*'"), "MCP wall intact");
|
||||
assert.ok(on.includes("--effort low"), "OCP_TUI_EFFORT default intact");
|
||||
assert.ok(!on.includes(" -p ") && !on.includes("--bare"), "still a plain interactive TUI spawn");
|
||||
});
|
||||
|
||||
test("stream: /health block is additive and exposes the divergence counter", () => {
|
||||
const stats = { lastEntrypoint: "cli", entrypointMismatches: 0, streamTurns: 3, streamDeltas: 21, streamTopUps: 1, streamDivergences: 0 };
|
||||
const sem = { inflight: 0, queued: 0 };
|
||||
const b = buildTuiHealthBlock({ enabled: true, entrypointMode: "cli", maxConcurrent: 2, streamEnabled: true }, stats, sem);
|
||||
assert.equal(b.streamEnabled, true);
|
||||
assert.equal(b.streamTurns, 3);
|
||||
assert.equal(b.streamDivergences, 0);
|
||||
// existing fields unchanged (grandfathered /health consumers)
|
||||
assert.equal(b.enabled, true);
|
||||
assert.equal(b.entrypointMode, "cli");
|
||||
assert.equal(b.maxConcurrent, 2);
|
||||
// a pre-streaming tuiStats (no stream* keys) must not produce undefined/NaN
|
||||
const legacy = buildTuiHealthBlock({ enabled: false, entrypointMode: "cli", maxConcurrent: 2 }, { lastEntrypoint: null, entrypointMismatches: 0 }, sem);
|
||||
assert.equal(legacy.streamEnabled, false);
|
||||
assert.equal(legacy.streamDivergences, 0);
|
||||
});
|
||||
|
||||
// ── Cleanup ──
|
||||
// Settle the async-bodied tests registered through the sync `test()` helper BEFORE summarizing —
|
||||
// otherwise their pass/fail is not reflected in the counts (see the `pendingAsync` comment above).
|
||||
// ─── TUI streaming × warm pool: the INTEGRATION seam (backlog #2 rebased onto #158) ───
|
||||
//
|
||||
// The hook is installed by bootTuiPane at BOOT, and runTuiTurn reads the sink off the PANE
|
||||
// (pane.streamFile). That indirection is the entire reason a POOLED pane streams: the pool
|
||||
// pre-boots panes long before a request exists, so anything derived at turn time would leave
|
||||
// every pool HIT silently buffered while every MISS streamed — a perf regression with no
|
||||
// failing test and no error, visible only as "streaming mysteriously does nothing in prod".
|
||||
// These three guard that seam.
|
||||
console.log("\nTUI streaming × warm pane pool integration:");
|
||||
|
||||
import { bootTuiPane as bootPaneUnderTest, runTuiTurn as runTurnUnderTest } from "./lib/tui/session.mjs";
|
||||
import { mkdtempSync as mkdtemp2, writeFileSync as writeFile2, mkdirSync as mkdir2, readFileSync as readFile2 } from "node:fs";
|
||||
import { tmpdir as tmpdir2 } from "node:os";
|
||||
|
||||
// Fake tmux that records the spawned pane command and always looks ready + pasted.
|
||||
function makeTmuxRecorder() {
|
||||
const cmds = [];
|
||||
const tmux = (args) => {
|
||||
cmds.push(args);
|
||||
if (args[0] === "capture-pane") {
|
||||
// input bar present AND the prompt visibly landed → both polls pass immediately
|
||||
return { status: 0, stdout: "[Pasted text #1 +2 lines]\n ? for shortcuts" };
|
||||
}
|
||||
return { status: 0, stdout: "" };
|
||||
};
|
||||
return { tmux, cmds, paneCmd: () => (cmds.find((a) => a[0] === "new-session") || []).slice(-1)[0] || "" };
|
||||
}
|
||||
|
||||
// A HOME with one already-terminal transcript for `sid`, so readTuiTranscript returns at once.
|
||||
function seedTranscript(home, sid, text) {
|
||||
const dir = `${home}/.claude/projects/x`;
|
||||
mkdir2(dir, { recursive: true });
|
||||
writeFile2(`${dir}/${sid}.jsonl`, JSON.stringify({
|
||||
type: "assistant",
|
||||
message: { role: "assistant", content: [{ type: "text", text }], stop_reason: "end_turn" },
|
||||
turn_duration: 1234, cc_entrypoint: "cli",
|
||||
}) + "\n");
|
||||
}
|
||||
|
||||
test("bootTuiPane with a streamDir installs the hook AT BOOT and hands the sink back on the pane", async () => {
|
||||
const home = mkdtemp2(`${tmpdir2()}/ocp-t-`);
|
||||
const streamDir = mkdtemp2(`${tmpdir2()}/ocp-s-`);
|
||||
const rec = makeTmuxRecorder();
|
||||
const pane = await bootPaneUnderTest({
|
||||
model: "sonnet", claudeBin: "claude", home, realHome: home,
|
||||
cwd: `${home}/wk`, port: 3456, tmux: rec.tmux, streamDir,
|
||||
});
|
||||
// The pane carries its OWN sink, named from its OWN session-id — which is what a pre-booted
|
||||
// pool pane needs, since it is minted with no knowledge of the request it will eventually serve.
|
||||
assert.ok(pane.streamFile, "a streamDir must yield a per-pane sink on the returned pane");
|
||||
assert.ok(pane.streamFile.includes(pane.sessionId), "the sink is keyed by the pane's own session-id");
|
||||
const cmd = rec.paneCmd();
|
||||
assert.ok(cmd.includes("OCP_TUI_STREAM_FILE="), "the pane's env must carry its sink path");
|
||||
assert.ok(cmd.includes(pane.streamFile), "…and it must be THIS pane's sink, not a shared one");
|
||||
assert.ok(cmd.includes("--settings"), "the MessageDisplay hook must be registered at spawn");
|
||||
});
|
||||
|
||||
test("bootTuiPane WITHOUT a streamDir spawns exactly today's pane — no hook, no --settings", async () => {
|
||||
const home = mkdtemp2(`${tmpdir2()}/ocp-t-`);
|
||||
const rec = makeTmuxRecorder();
|
||||
const pane = await bootPaneUnderTest({
|
||||
model: "sonnet", claudeBin: "claude", home, realHome: home,
|
||||
cwd: `${home}/wk`, port: 3456, tmux: rec.tmux,
|
||||
});
|
||||
assert.equal(pane.streamFile, null, "no streamDir → no sink (streaming is opt-in, default OFF)");
|
||||
const cmd = rec.paneCmd();
|
||||
assert.ok(!cmd.includes("--settings"), "the default spawn must not gain --settings");
|
||||
assert.ok(!cmd.includes("OCP_TUI_STREAM_FILE"), "the default spawn must not gain the hook env");
|
||||
});
|
||||
|
||||
test("REGRESSION: a WARM (pooled) pane streams — the sink comes off the pane, not the turn", async () => {
|
||||
const home = mkdtemp2(`${tmpdir2()}/ocp-t-`);
|
||||
const streamDir = mkdtemp2(`${tmpdir2()}/ocp-s-`);
|
||||
const rec = makeTmuxRecorder();
|
||||
|
||||
// Pre-boot a pane the way the POOL does (its own session-id + sink, fixed at boot).
|
||||
const warm = await bootPaneUnderTest({
|
||||
model: "sonnet", claudeBin: "claude", home, realHome: home,
|
||||
cwd: `${home}/wk`, port: 3456, tmux: rec.tmux, streamDir,
|
||||
});
|
||||
// Its hook has already fired twice by the time the turn's transcript goes terminal.
|
||||
writeFile2(warm.streamFile,
|
||||
JSON.stringify({ hook_event_name: "MessageDisplay", delta: "Hello " }) + "\n" +
|
||||
JSON.stringify({ hook_event_name: "MessageDisplay", delta: "world" }) + "\n");
|
||||
seedTranscript(home, warm.sessionId, "Hello world");
|
||||
|
||||
const seen = [];
|
||||
const pool = { acquire: () => warm, refill: () => {}, warm: 0 };
|
||||
const out = await runTurnUnderTest({
|
||||
prompt: "say hello", model: "sonnet", claudeBin: "claude", home, realHome: home,
|
||||
cwd: `${home}/wk`, port: 3456, tmux: rec.tmux, pool,
|
||||
onDelta: (d) => seen.push(d.delta),
|
||||
// streamDir is deliberately NOT passed: on a pool HIT runTuiTurn never cold-boots, so if it
|
||||
// recomputed the sink from a turn-time streamDir (the pre-rebase shape) this turn would emit
|
||||
// ZERO deltas and silently serve buffered. Reading pane.streamFile is what makes it stream.
|
||||
streamDir: null,
|
||||
});
|
||||
assert.deepEqual(seen, ["Hello ", "world"], "the pooled pane's deltas must reach the client");
|
||||
assert.equal(out.text, "Hello world", "and the transcript stays authoritative for the final text");
|
||||
});
|
||||
|
||||
console.log("\nTest isolation (the suite must never touch the operator's live key store):");
|
||||
|
||||
test("the key store under test is a scratch db, NOT the operator's real ~/.ocp/ocp.db", () => {
|
||||
// The guard that was missing. `npm test` wrote live, UNREVOKED api_keys rows straight into the
|
||||
// operator's real ~/.ocp/ocp.db — the same database the running server reads — two per run,
|
||||
// unbounded (737 junk keys vs 12 real ones on the maintainer's host before this landed). It
|
||||
// went unnoticed for so long precisely because NOTHING asserted where the store actually was.
|
||||
const real = join(homedir(), ".ocp", "ocp.db");
|
||||
const used = getDbPath();
|
||||
assert.ok(used, "getDb() must have opened something by now");
|
||||
assert.notEqual(used, real, "the suite must NOT open the operator's live key database");
|
||||
assert.ok(used.startsWith(TEST_OCP_DIR), `expected a scratch db under ${TEST_OCP_DIR}, got ${used}`);
|
||||
});
|
||||
|
||||
test("a PRODUCTION process (no NODE_ENV) must IGNORE OCP_DIR_OVERRIDE", () => {
|
||||
// Must run OUT OF PROCESS. The parent is irreversibly NODE_ENV=test by the time any test runs
|
||||
// (test-env.mjs set it before keys.mjs was imported), so the production path is unreachable
|
||||
// from in here — and an in-process test can only ever RE-IMPLEMENT the predicate, which is
|
||||
// worthless: the first cut of this test did exactly that, and deleting the whole NODE_ENV gate
|
||||
// from keys.mjs still left the suite at 320 passed / 0 failed. A copy of the predicate is not
|
||||
// the predicate. So: spawn a child with no NODE_ENV, the override set, and HOME redirected to
|
||||
// a temp dir (so the real key store is never opened), and assert what the REAL keys.mjs did.
|
||||
const home = mkdtempSync(join(tmpdir(), "ocp-prodsim-"));
|
||||
const evil = mkdtempSync(join(tmpdir(), "ocp-evil-"));
|
||||
try {
|
||||
const keysUrl = pathToFileURL(join(import.meta.dirname, "keys.mjs")).href;
|
||||
// The child prints the override it SAW, then the store it actually opened. Printing both is
|
||||
// the negative control: without it, a future refactor that renamed the env var and missed
|
||||
// this test's `env` object would leave the child with no override at all — and "prod opened
|
||||
// the right store" would pass for the wrong reason. Asserting the child saw it and ignored
|
||||
// it anyway is the claim we actually want to make.
|
||||
const probe = `import { getDb, getDbPath, closeDb } from ${JSON.stringify(keysUrl)};
|
||||
getDb(); process.stdout.write(process.env.OCP_DIR_OVERRIDE + "\\n" + getDbPath()); closeDb();`;
|
||||
const env = { ...process.env, HOME: home, OCP_DIR_OVERRIDE: evil };
|
||||
delete env.NODE_ENV; // a production server has no NODE_ENV
|
||||
const [seen, opened] = execFileSync(process.execPath, ["--input-type=module", "-e", probe],
|
||||
{ env, encoding: "utf8" }).trim().split("\n");
|
||||
assert.equal(seen, evil, "precondition: the child must actually SEE the override");
|
||||
assert.equal(opened, join(home, ".ocp", "ocp.db"), "a prod process must open HOME/.ocp/ocp.db");
|
||||
assert.ok(!opened.startsWith(evil), "…having seen the override, a prod process must IGNORE it");
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
rmSync(evil, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("listKeys does not depend on rows left behind by an earlier or concurrent run", () => {
|
||||
// The ~1-in-6 flake: two runs sharing one db file. keys.find() returned undefined and the
|
||||
// caller's `in` check threw a TypeError instead of failing cleanly. With a per-run scratch db
|
||||
// the store starts empty, so the count is exactly what THIS run created.
|
||||
const mine = listKeys().filter((k) => k.name === "test-user-1");
|
||||
assert.equal(mine.length, 1, "exactly one test-user-1 — a shared store would accumulate duplicates");
|
||||
});
|
||||
|
||||
runAsyncTests().then(() => Promise.all(pendingAsync)).then(() => {
|
||||
closeDb();
|
||||
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===\n`);
|
||||
|
||||
Reference in New Issue
Block a user